2380 lines
68 KiB
Bash
2380 lines
68 KiB
Bash
# File operations for interactive setup.
|
|
|
|
# Registry of temp files created during this session; cleaned up on exit.
|
|
_FILE_OPS_CLEANUP_TMP=()
|
|
declare -A _FILE_OPS_VOLUME_BLOCKS=()
|
|
declare -a _FILE_OPS_VOLUME_ORDER=()
|
|
_WIZARD_MANAGED_SERVICES_MARKER="# __WIZARD_MANAGED_SERVICES__"
|
|
_file_ops_cleanup() {
|
|
local f
|
|
for f in "${_FILE_OPS_CLEANUP_TMP[@]:-}"; do
|
|
rm -f "$f" 2>/dev/null || true
|
|
done
|
|
}
|
|
trap '_file_ops_cleanup' EXIT INT TERM
|
|
|
|
format_env_value() {
|
|
local value="$1"
|
|
local escaped
|
|
|
|
if [[ -z "$value" ]]; then
|
|
printf ''
|
|
return
|
|
fi
|
|
|
|
if [[ "$value" =~ [[:space:]] || "$value" == *"\""* || "$value" == *"$"* || "$value" == *"#"* ]]; then
|
|
# Prefer single quotes when quoting is required so generated .env values
|
|
# match env.example style and remain Compose-friendly. Fall back to
|
|
# double quotes only when the value itself contains a single quote.
|
|
if [[ "$value" != *"'"* ]]; then
|
|
printf "'%s'" "$value"
|
|
return
|
|
fi
|
|
|
|
# Double-quoted .env values only need escaping for backslash and double quote.
|
|
# Do not escape '$': python-dotenv preserves plain '$' literally, while '\$'
|
|
# changes the loaded value.
|
|
# '#' in unquoted values is treated as a comment by python-dotenv, so any
|
|
# value containing '#' must be quoted.
|
|
escaped="${value//\\/\\\\}"
|
|
escaped="${escaped//\"/\\\"}"
|
|
printf '"%s"' "$escaped"
|
|
return
|
|
fi
|
|
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
backup_env_file() {
|
|
local env_file="${1:-${REPO_ROOT:-.}/.env}"
|
|
local backup_file=""
|
|
|
|
if [[ -f "$env_file" ]]; then
|
|
backup_file="${env_file}.backup.$(date +%Y%m%d_%H%M%S)"
|
|
if ! cp "$env_file" "$backup_file"; then
|
|
format_error "Failed to back up ${env_file} to ${backup_file}." "Check disk space and file permissions."
|
|
return 1
|
|
fi
|
|
printf '%s' "$backup_file"
|
|
fi
|
|
}
|
|
|
|
backup_compose_file() {
|
|
local compose_file="${1:-}"
|
|
local repo_root="${REPO_ROOT:-.}"
|
|
local backup_file=""
|
|
|
|
if [[ -z "$compose_file" ]]; then
|
|
compose_file="$(find_generated_compose_file)"
|
|
fi
|
|
|
|
if [[ -z "$compose_file" || ! -f "$compose_file" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
backup_file="${repo_root}/docker-compose.backup$(date +%Y%m%d_%H%M%S).yml"
|
|
if ! cp "$compose_file" "$backup_file"; then
|
|
format_error "Failed to back up ${compose_file} to ${backup_file}." \
|
|
"Check disk space and file permissions."
|
|
return 1
|
|
fi
|
|
|
|
printf '%s' "$backup_file"
|
|
}
|
|
|
|
stage_ssl_assets() {
|
|
local cert_source="$1"
|
|
local key_source="$2"
|
|
local certs_dir="${REPO_ROOT:-.}/data/certs"
|
|
local cert_target=""
|
|
local key_target=""
|
|
|
|
mkdir -p "$certs_dir"
|
|
|
|
if [[ -n "$cert_source" ]]; then
|
|
cert_target="${certs_dir}/$(resolve_staged_ssl_basename "cert" "$cert_source" "$key_source")"
|
|
if [[ ! -e "$cert_target" || ! "$cert_source" -ef "$cert_target" ]]; then
|
|
cp "$cert_source" "$cert_target"
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$key_source" ]]; then
|
|
key_target="${certs_dir}/$(resolve_staged_ssl_basename "key" "$key_source" "$cert_source")"
|
|
if [[ ! -e "$key_target" || ! "$key_source" -ef "$key_target" ]]; then
|
|
cp "$key_source" "$key_target"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
stage_redis_config_asset() {
|
|
local template_path="${TEMPLATES_DIR:-}/redis.conf.template"
|
|
local config_dir="${REPO_ROOT:-.}/data/config"
|
|
local config_target="${config_dir}/redis.conf"
|
|
|
|
if [[ -z "$template_path" || ! -f "$template_path" ]]; then
|
|
format_error "Missing Redis config template: ${template_path}" \
|
|
"Restore scripts/setup/templates/redis.conf.template before rerunning setup."
|
|
return 1
|
|
fi
|
|
|
|
mkdir -p "$config_dir"
|
|
|
|
if [[ -e "$config_target" ]]; then
|
|
log_info "Preserving existing Redis config at ${config_target}"
|
|
return 0
|
|
fi
|
|
|
|
if ! cp "$template_path" "$config_target"; then
|
|
format_error "Failed to stage Redis config at ${config_target}" \
|
|
"Check file permissions and available disk space, then rerun setup."
|
|
return 1
|
|
fi
|
|
|
|
log_success "Staged Redis config at ${config_target}"
|
|
}
|
|
|
|
resolve_staged_ssl_basename() {
|
|
local asset_type="$1"
|
|
local source_path="$2"
|
|
local peer_path="${3:-}"
|
|
local basename_value=""
|
|
local peer_basename=""
|
|
|
|
basename_value="$(basename "$source_path")"
|
|
if [[ -n "$peer_path" ]]; then
|
|
peer_basename="$(basename "$peer_path")"
|
|
if [[ "$basename_value" == "$peer_basename" ]]; then
|
|
printf '%s-%s' "$asset_type" "$basename_value"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
printf '%s' "$basename_value"
|
|
}
|
|
|
|
append_preserved_non_template_env_lines() {
|
|
local template_file="$1"
|
|
local existing_env_file="$2"
|
|
local output_file="$3"
|
|
local line key
|
|
local in_preserved_section="no"
|
|
local line_is_commented_env="no"
|
|
local template_in_preserved_section="no"
|
|
local template_has_preserved_section="no"
|
|
local old_has_preserved_section="no"
|
|
local preserved_header="### ----- Preserved custom environment variables from previous .env -----"
|
|
local preserved_notice="### ----- Comments in this session will persist across regenerations -----"
|
|
local -a pending_lines=()
|
|
local -a preserved_payload=()
|
|
local -a discovered_payload=()
|
|
local -a template_preserved_payload=()
|
|
local -a effective_preserved_payload=()
|
|
local -A ignored_keys=(
|
|
["LIGHTRAG_SETUP_PROFILE"]=1
|
|
)
|
|
local -A template_keys=()
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "$preserved_header" ]]; then
|
|
template_has_preserved_section="yes"
|
|
template_in_preserved_section="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$template_in_preserved_section" == "yes" && "$line" == "$preserved_notice" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$template_in_preserved_section" == "yes" ]]; then
|
|
template_preserved_payload+=("$line")
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[A-Za-z0-9_]+= ]]; then
|
|
template_keys["${line%%=*}"]=1
|
|
elif [[ "$line" =~ ^#[[:space:]]*([A-Za-z0-9_]+)=(.*)$ ]]; then
|
|
template_keys["${BASH_REMATCH[1]}"]=1
|
|
fi
|
|
done < "$template_file"
|
|
|
|
if [[ -f "$existing_env_file" ]]; then
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "$preserved_header" ]]; then
|
|
in_preserved_section="yes"
|
|
old_has_preserved_section="yes"
|
|
pending_lines=()
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_preserved_section" == "yes" && "$line" == "$preserved_notice" ]]; then
|
|
continue
|
|
fi
|
|
|
|
key=""
|
|
line_is_commented_env="no"
|
|
|
|
if [[ "$line" =~ ^([A-Za-z0-9_]+)= ]]; then
|
|
key="${BASH_REMATCH[1]}"
|
|
elif [[ "$line" =~ ^#[[:space:]]*([A-Za-z0-9_]+)=(.*)$ ]]; then
|
|
key="${BASH_REMATCH[1]}"
|
|
line_is_commented_env="yes"
|
|
fi
|
|
|
|
if [[ -z "$key" ]]; then
|
|
if [[ "$in_preserved_section" == "yes" ]]; then
|
|
pending_lines+=("$line")
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
if [[ -n "${ignored_keys[$key]+set}" ]]; then
|
|
if [[ "$in_preserved_section" == "yes" ]] && ((${#pending_lines[@]} > 0)); then
|
|
preserved_payload+=("${pending_lines[@]}")
|
|
fi
|
|
pending_lines=()
|
|
continue
|
|
fi
|
|
|
|
if [[ -z "${template_keys[$key]+set}" || \
|
|
("$in_preserved_section" == "yes" && "$line_is_commented_env" == "yes") ]]; then
|
|
if ((${#pending_lines[@]} > 0)); then
|
|
if [[ "$in_preserved_section" == "yes" ]]; then
|
|
preserved_payload+=("${pending_lines[@]}")
|
|
fi
|
|
fi
|
|
|
|
if [[ "$in_preserved_section" == "yes" ]]; then
|
|
preserved_payload+=("$line")
|
|
else
|
|
discovered_payload+=("$line")
|
|
fi
|
|
elif [[ "$in_preserved_section" == "yes" && ${#pending_lines[@]} -gt 0 ]]; then
|
|
preserved_payload+=("${pending_lines[@]}")
|
|
fi
|
|
|
|
pending_lines=()
|
|
done < "$existing_env_file"
|
|
|
|
if ((${#pending_lines[@]} > 0)); then
|
|
preserved_payload+=("${pending_lines[@]}")
|
|
fi
|
|
fi
|
|
|
|
effective_preserved_payload=("${preserved_payload[@]}")
|
|
|
|
if [[ "$template_has_preserved_section" == "yes" ]]; then
|
|
printf '%s\n%s\n' "$preserved_header" "$preserved_notice" >> "$output_file"
|
|
|
|
if [[ "$old_has_preserved_section" != "yes" ]] && ((${#template_preserved_payload[@]} > 0)); then
|
|
printf '%s\n' "${template_preserved_payload[@]}" >> "$output_file"
|
|
fi
|
|
|
|
if ((${#effective_preserved_payload[@]} > 0)); then
|
|
printf '%s\n' "${effective_preserved_payload[@]}" >> "$output_file"
|
|
fi
|
|
|
|
if ((${#discovered_payload[@]} > 0)); then
|
|
printf '%s\n' "${discovered_payload[@]}" >> "$output_file"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
if ((${#effective_preserved_payload[@]} == 0 && ${#discovered_payload[@]} == 0)); then
|
|
return 0
|
|
fi
|
|
|
|
printf '\n%s\n%s\n' "$preserved_header" "$preserved_notice" >> "$output_file"
|
|
|
|
if ((${#effective_preserved_payload[@]} > 0)); then
|
|
printf '%s\n' "${effective_preserved_payload[@]}" >> "$output_file"
|
|
fi
|
|
|
|
if ((${#discovered_payload[@]} > 0)); then
|
|
printf '%s\n' "${discovered_payload[@]}" >> "$output_file"
|
|
fi
|
|
}
|
|
|
|
generate_env_file() {
|
|
local template_file="${1:-${REPO_ROOT:-.}/env.example}"
|
|
local output_file="${2:-${REPO_ROOT:-.}/.env}"
|
|
local tmp_file="${output_file}.tmp"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line key value
|
|
local preserved_header="### ----- Preserved custom environment variables from previous .env -----"
|
|
local in_template_preserved_section="no"
|
|
local -A written_keys=()
|
|
local -A match_write_keys=()
|
|
|
|
if [[ ! -f "$template_file" ]]; then
|
|
echo "env.example not found at $template_file" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Pre-scan: identify commented keys whose value exactly matches the ENV_VALUE.
|
|
# When a match exists, the active value is written only at that matching line,
|
|
# leaving all other commented examples intact.
|
|
local _prescan_key _prescan_val _prescan_env_val _prescan_fmt
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "$preserved_header" ]]; then
|
|
in_template_preserved_section="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_template_preserved_section" == "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^#[[:space:]]*([A-Za-z0-9_]+)=(.*)$ ]]; then
|
|
_prescan_key="${BASH_REMATCH[1]}"
|
|
_prescan_val="${BASH_REMATCH[2]}"
|
|
if [[ -z "${match_write_keys[$_prescan_key]+set}" && -n "${ENV_VALUES[$_prescan_key]+set}" ]]; then
|
|
_prescan_env_val="${ENV_VALUES[$_prescan_key]}"
|
|
_prescan_fmt="$(format_env_value "$_prescan_env_val" "$_prescan_key")"
|
|
if [[ "$_prescan_val" == "$_prescan_env_val" || "$_prescan_val" == "$_prescan_fmt" ]]; then
|
|
match_write_keys["$_prescan_key"]=1
|
|
fi
|
|
fi
|
|
fi
|
|
done < "$template_file"
|
|
|
|
: > "$tmp_file"
|
|
|
|
in_template_preserved_section="no"
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "$preserved_header" ]]; then
|
|
in_template_preserved_section="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_template_preserved_section" == "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[A-Za-z0-9_]+= ]]; then
|
|
key="${line%%=*}"
|
|
if [[ -z "${written_keys[$key]+set}" ]]; then
|
|
if [[ -n "${ENV_VALUES[$key]+set}" ]]; then
|
|
value="${ENV_VALUES[$key]}"
|
|
local _fmt_active_val
|
|
_fmt_active_val="$(format_env_value "$value" "$key")"
|
|
printf '%s=%s\n' "$key" "$_fmt_active_val" >> "$tmp_file"
|
|
local _orig_tmpl_val="${line#*=}"
|
|
if [[ "$_orig_tmpl_val" != "$value" && "$_orig_tmpl_val" != "$_fmt_active_val" ]]; then
|
|
printf '# %s\n' "$line" >> "$tmp_file"
|
|
fi
|
|
else
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
fi
|
|
written_keys["$key"]=1
|
|
else
|
|
if [[ -n "${ENV_VALUES[$key]+set}" ]]; then
|
|
printf '# %s\n' "$line" >> "$tmp_file"
|
|
else
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
fi
|
|
fi
|
|
elif [[ "$line" =~ ^#[[:space:]]*([A-Za-z0-9_]+)=(.*)$ ]]; then
|
|
key="${BASH_REMATCH[1]}"
|
|
local _commented_val="${BASH_REMATCH[2]}"
|
|
if [[ -z "${written_keys[$key]+set}" && -n "${ENV_VALUES[$key]+set}" ]]; then
|
|
value="${ENV_VALUES[$key]}"
|
|
if [[ -n "${match_write_keys[$key]+set}" ]]; then
|
|
# A commented line matching the ENV value exists; only activate at that line.
|
|
local _fmt_val
|
|
_fmt_val="$(format_env_value "$value" "$key")"
|
|
if [[ "$_commented_val" == "$value" || "$_commented_val" == "$_fmt_val" ]]; then
|
|
printf '%s=%s\n' "$key" "$_fmt_val" >> "$tmp_file"
|
|
written_keys["$key"]=1
|
|
else
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
fi
|
|
else
|
|
# No matching commented line; fall back to activating at first occurrence.
|
|
printf '%s=%s\n' "$key" "$(format_env_value "$value" "$key")" >> "$tmp_file"
|
|
written_keys["$key"]=1
|
|
fi
|
|
else
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
fi
|
|
else
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
fi
|
|
done < "$template_file"
|
|
|
|
append_preserved_non_template_env_lines "$template_file" "$output_file" "$tmp_file"
|
|
|
|
mv "$tmp_file" "$output_file"
|
|
}
|
|
|
|
# All environment keys the wizard may inject into the lightrag service via
|
|
# COMPOSE_ENV_OVERRIDES. Used to remove stale entries before re-injection so
|
|
# keys no longer needed are not left behind in the compose file.
|
|
_WIZARD_COMPOSE_LIGHTRAG_KEYS=(
|
|
"EMBEDDING_BINDING_HOST" "RERANK_BINDING_HOST" "LLM_BINDING_HOST"
|
|
"REDIS_URI" "MONGO_URI" "NEO4J_URI" "MILVUS_URI" "QDRANT_URL" "MEMGRAPH_URI" "OPENSEARCH_HOSTS"
|
|
"POSTGRES_HOST" "POSTGRES_PORT" "PORT" "HOST" "SSL_CERTFILE" "SSL_KEYFILE"
|
|
"WORKING_DIR" "INPUT_DIR" "PROMPT_DIR"
|
|
)
|
|
|
|
_managed_service_root_name() {
|
|
local service_name="$1"
|
|
|
|
case "$service_name" in
|
|
postgres|neo4j|mongodb|redis|qdrant|memgraph|vllm-embed|vllm-rerank)
|
|
printf '%s' "$service_name"
|
|
;;
|
|
opensearch|dashboards)
|
|
printf 'opensearch'
|
|
;;
|
|
milvus|milvus-etcd|milvus-minio)
|
|
printf 'milvus'
|
|
;;
|
|
*)
|
|
printf ''
|
|
;;
|
|
esac
|
|
}
|
|
|
|
_managed_volume_root_name() {
|
|
local volume_name="$1"
|
|
|
|
case "$volume_name" in
|
|
postgres_data)
|
|
printf 'postgres'
|
|
;;
|
|
neo4j_data)
|
|
printf 'neo4j'
|
|
;;
|
|
mongo_data|mongo_config_data|mongo_mongot_data)
|
|
printf 'mongodb'
|
|
;;
|
|
redis_data)
|
|
printf 'redis'
|
|
;;
|
|
milvus_data|milvus-etcd_data|milvus-minio_data)
|
|
printf 'milvus'
|
|
;;
|
|
qdrant_data)
|
|
printf 'qdrant'
|
|
;;
|
|
memgraph_data)
|
|
printf 'memgraph'
|
|
;;
|
|
opensearch_data)
|
|
printf 'opensearch'
|
|
;;
|
|
vllm_rerank_cache)
|
|
printf 'vllm-rerank'
|
|
;;
|
|
vllm_embed_cache)
|
|
printf 'vllm-embed'
|
|
;;
|
|
*)
|
|
printf ''
|
|
;;
|
|
esac
|
|
}
|
|
|
|
_should_rewrite_wizard_managed_root_service() {
|
|
local root_service="$1"
|
|
|
|
if [[ -z "$root_service" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${FORCE_REWRITE_COMPOSE:-no}" == "yes" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${DOCKER_SERVICE_SET[$root_service]+set}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ -n "${COMPOSE_REWRITE_SERVICE_SET[$root_service]+set}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
_should_preserve_wizard_managed_root_service() {
|
|
local root_service="$1"
|
|
|
|
if [[ -z "$root_service" || "${FORCE_REWRITE_COMPOSE:-no}" == "yes" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ -z "${DOCKER_SERVICE_SET[$root_service]+set}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ -n "${COMPOSE_REWRITE_SERVICE_SET[$root_service]+set}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
_existing_managed_root_service_present() {
|
|
local root_service="$1"
|
|
|
|
[[ -n "$root_service" && -n "${EXISTING_MANAGED_ROOT_SERVICE_SET[$root_service]+set}" ]]
|
|
}
|
|
|
|
_refresh_existing_managed_root_service_set_from_compose() {
|
|
local compose_file="$1"
|
|
local service_name
|
|
|
|
EXISTING_MANAGED_ROOT_SERVICE_SET=()
|
|
|
|
if [[ -z "$compose_file" || ! -f "$compose_file" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
while IFS= read -r service_name; do
|
|
EXISTING_MANAGED_ROOT_SERVICE_SET["$service_name"]=1
|
|
done < <(detect_managed_root_services "$compose_file")
|
|
}
|
|
|
|
_is_wizard_managed_root_service_name() {
|
|
local service_name="$1"
|
|
|
|
[[ -n "$(_managed_service_root_name "$service_name")" ]]
|
|
}
|
|
|
|
_is_wizard_managed_service_name() {
|
|
local service_name="$1"
|
|
|
|
[[ -n "$(_managed_service_root_name "$service_name")" ]]
|
|
}
|
|
|
|
_is_wizard_managed_volume_name() {
|
|
local volume_name="$1"
|
|
|
|
[[ -n "$(_managed_volume_root_name "$volume_name")" ]]
|
|
}
|
|
|
|
# Remove wizard-managed keys from the lightrag service's environment block,
|
|
# leaving any user-added keys intact.
|
|
_strip_lightrag_wizard_environment_keys() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.strip-wizard-keys"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line key wk list_entry
|
|
local in_lightrag="no"
|
|
local in_environment="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_environment="no"
|
|
elif [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]]; then
|
|
in_lightrag="no"
|
|
in_environment="no"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" == " environment:" ]]; then
|
|
in_environment="yes"
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$in_environment" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6}([A-Z0-9_]+): ]]; then
|
|
key="${BASH_REMATCH[1]}"
|
|
for wk in "${_WIZARD_COMPOSE_LIGHTRAG_KEYS[@]}"; do
|
|
if [[ "$key" == "$wk" ]]; then
|
|
continue 2 # skip this wizard-managed key
|
|
fi
|
|
done
|
|
elif [[ "$line" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then
|
|
list_entry="$(_strip_wrapping_quotes "${BASH_REMATCH[1]}")"
|
|
key="${list_entry%%=*}"
|
|
if [[ "$key" =~ ^[A-Z0-9_]+$ ]]; then
|
|
for wk in "${_WIZARD_COMPOSE_LIGHTRAG_KEYS[@]}"; do
|
|
if [[ "$key" == "$wk" ]]; then
|
|
continue 2 # skip this wizard-managed key
|
|
fi
|
|
done
|
|
fi
|
|
elif [[ -z "$line" ]]; then
|
|
continue # skip blank lines inside the environment block
|
|
elif [[ ! "$line" =~ ^[[:space:]]{6} ]]; then
|
|
in_environment="no"
|
|
fi
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
done < "$compose_file"
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
_write_service_environment_entries() {
|
|
local tmp_file="$1"
|
|
local style="$2"
|
|
shift 2
|
|
local entries=("$@")
|
|
local key value
|
|
|
|
for entry in "${entries[@]}"; do
|
|
key="${entry%%=*}"
|
|
value="${entry#*=}"
|
|
if [[ "$style" == "list" ]]; then
|
|
if [[ "$value" == "${_COMPOSE_RAW_VALUE_PREFIX}"* ]]; then
|
|
printf ' - %s=%s\n' "$key" "${value#${_COMPOSE_RAW_VALUE_PREFIX}}" >> "$tmp_file"
|
|
else
|
|
printf ' - %s\n' "$(format_yaml_value "${key}=${value}")" >> "$tmp_file"
|
|
fi
|
|
else
|
|
printf ' %s: %s\n' "$key" "$(format_compose_environment_value "$value")" >> "$tmp_file"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Capture top-level named volume blocks so user-managed definitions can be
|
|
# re-emitted when still referenced by preserved services.
|
|
_collect_top_level_volume_blocks() {
|
|
local compose_file="$1"
|
|
local line
|
|
local in_top_volumes="no"
|
|
local current_volume=""
|
|
local current_block=""
|
|
|
|
_FILE_OPS_VOLUME_BLOCKS=()
|
|
_FILE_OPS_VOLUME_ORDER=()
|
|
|
|
if [[ ! -f "$compose_file" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" =~ ^[A-Za-z] ]]; then
|
|
if [[ "$in_top_volumes" == "yes" && -n "$current_volume" ]]; then
|
|
_FILE_OPS_VOLUME_BLOCKS["$current_volume"]="$current_block"
|
|
_FILE_OPS_VOLUME_ORDER+=("$current_volume")
|
|
fi
|
|
|
|
current_volume=""
|
|
current_block=""
|
|
if [[ "$line" == "volumes:" ]]; then
|
|
in_top_volumes="yes"
|
|
else
|
|
in_top_volumes="no"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_top_volumes" != "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{2}([A-Za-z0-9_.-]+):[[:space:]]*$ ]]; then
|
|
if [[ -n "$current_volume" ]]; then
|
|
_FILE_OPS_VOLUME_BLOCKS["$current_volume"]="$current_block"
|
|
_FILE_OPS_VOLUME_ORDER+=("$current_volume")
|
|
fi
|
|
|
|
current_volume="${BASH_REMATCH[1]}"
|
|
current_block="${line}"$'\n'
|
|
continue
|
|
fi
|
|
|
|
if [[ -n "$current_volume" ]]; then
|
|
current_block+="${line}"$'\n'
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_top_volumes" == "yes" && -n "$current_volume" ]]; then
|
|
_FILE_OPS_VOLUME_BLOCKS["$current_volume"]="$current_block"
|
|
_FILE_OPS_VOLUME_ORDER+=("$current_volume")
|
|
fi
|
|
}
|
|
|
|
# Remove wizard-managed services and the top-level volumes block from a compose
|
|
# file. Non-managed services are preserved verbatim.
|
|
_strip_wizard_managed_services_and_top_level_volumes() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.strip-svc"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line current_service="" current_root_service=""
|
|
local in_services="no"
|
|
local in_top_volumes="no"
|
|
local inserted_marker="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "$_WIZARD_MANAGED_SERVICES_MARKER" ]]; then
|
|
continue
|
|
fi
|
|
|
|
# Detect top-level (non-indented) keys.
|
|
if [[ "$line" =~ ^[A-Za-z] ]]; then
|
|
if [[ "$in_services" == "yes" && "$line" != "services:" && "$inserted_marker" != "yes" ]]; then
|
|
printf '%s\n' "$_WIZARD_MANAGED_SERVICES_MARKER" >> "$tmp_file"
|
|
inserted_marker="yes"
|
|
fi
|
|
in_top_volumes="no"
|
|
if [[ "$line" == "services:" ]]; then
|
|
in_services="yes"
|
|
current_service=""
|
|
elif [[ "$line" =~ ^volumes:[[:space:]]*$ ]]; then
|
|
in_top_volumes="yes"
|
|
in_services="no"
|
|
current_service=""
|
|
continue # skip volumes: header; regenerated at end of generate_docker_compose
|
|
else
|
|
in_services="no"
|
|
current_service=""
|
|
fi
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
continue
|
|
fi
|
|
|
|
# Skip top-level volumes block content.
|
|
if [[ "$in_top_volumes" == "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
# Track current service inside the services: block.
|
|
if [[ "$in_services" == "yes" && "$line" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]]; then
|
|
current_service="${BASH_REMATCH[1]}"
|
|
current_root_service="$(_managed_service_root_name "$current_service")"
|
|
fi
|
|
|
|
# Skip managed services that are being removed or regenerated. Preserve
|
|
# lightrag, user-added services, and unchanged managed service groups.
|
|
if [[ "$in_services" == "yes" && -n "$current_service" ]] && \
|
|
[[ "$current_service" != "lightrag" ]] && \
|
|
[[ -n "$current_root_service" ]] && \
|
|
_should_rewrite_wizard_managed_root_service "$current_root_service"; then
|
|
continue
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_services" == "yes" && "$inserted_marker" != "yes" ]]; then
|
|
printf '%s\n' "$_WIZARD_MANAGED_SERVICES_MARKER" >> "$tmp_file"
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
_merge_managed_service_blocks() {
|
|
local compose_file="$1"
|
|
local service_blocks_file="$2"
|
|
local tmp_file="${compose_file}.merge-services"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local inserted="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "$_WIZARD_MANAGED_SERVICES_MARKER" ]]; then
|
|
if [[ -s "$service_blocks_file" ]]; then
|
|
cat "$service_blocks_file" >> "$tmp_file"
|
|
inserted="yes"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
done < "$compose_file"
|
|
|
|
if [[ -s "$service_blocks_file" && "$inserted" != "yes" ]]; then
|
|
cat "$service_blocks_file" >> "$tmp_file"
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
_normalize_services_section_spacing() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.normalize-services"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_services="no"
|
|
local pending_blank="no"
|
|
local saw_service_content="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_services" == "yes" ]]; then
|
|
if [[ "$line" == "$_WIZARD_MANAGED_SERVICES_MARKER" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ -z "$line" ]]; then
|
|
pending_blank="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ ! "$line" =~ ^[[:space:]] ]]; then
|
|
pending_blank="no"
|
|
if [[ "$saw_service_content" == "yes" ]]; then
|
|
printf '\n' >> "$tmp_file"
|
|
fi
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
in_services="no"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$pending_blank" == "yes" && "$saw_service_content" == "yes" ]]; then
|
|
printf '\n' >> "$tmp_file"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
pending_blank="no"
|
|
saw_service_content="yes"
|
|
continue
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == "services:" ]]; then
|
|
in_services="yes"
|
|
pending_blank="no"
|
|
saw_service_content="no"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
_strip_wrapping_quotes() {
|
|
local value="$1"
|
|
|
|
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
|
|
value="${value#\"}"
|
|
value="${value%\"}"
|
|
elif [[ "$value" == \'*\' ]]; then
|
|
value="${value#\'}"
|
|
value="${value%\'}"
|
|
fi
|
|
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
read_service_environment_value() {
|
|
local compose_file="$1"
|
|
local service_name="$2"
|
|
local wanted_key="$3"
|
|
local line
|
|
local entry_key=""
|
|
local entry_value=""
|
|
local service_header=" ${service_name}:"
|
|
local in_service="no"
|
|
local in_environment="no"
|
|
|
|
if [[ ! -f "$compose_file" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_service" == "yes" && "$in_environment" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6}([A-Z0-9_]+):[[:space:]]*(.+)$ ]]; then
|
|
entry_key="${BASH_REMATCH[1]}"
|
|
if [[ "$entry_key" == "$wanted_key" ]]; then
|
|
printf '%s' "$(_strip_wrapping_quotes "${BASH_REMATCH[2]}")"
|
|
return 0
|
|
fi
|
|
elif [[ "$line" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then
|
|
entry_value="$(_strip_wrapping_quotes "${BASH_REMATCH[1]}")"
|
|
entry_key="${entry_value%%=*}"
|
|
if [[ "$entry_key" == "$wanted_key" && "$entry_value" == *=* ]]; then
|
|
printf '%s' "${entry_value#*=}"
|
|
return 0
|
|
fi
|
|
elif [[ ! "$line" =~ ^[[:space:]]{6} ]]; then
|
|
in_environment="no"
|
|
fi
|
|
elif [[ "$in_service" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != "$service_header" ]]; then
|
|
in_service="no"
|
|
fi
|
|
|
|
if [[ "$line" == "$service_header" ]]; then
|
|
in_service="yes"
|
|
in_environment="no"
|
|
elif [[ "$in_service" == "yes" && "$line" == " environment:" ]]; then
|
|
in_environment="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
return 1
|
|
}
|
|
|
|
# Parse the image: value for a named service from a compose file.
|
|
# Assumes wizard-standard indentation: services at 2 spaces, properties at 4 spaces.
|
|
read_service_image_value() {
|
|
local compose_file="$1"
|
|
local service_name="$2"
|
|
local line
|
|
local service_header=" ${service_name}:"
|
|
local in_service="no"
|
|
|
|
if [[ ! -f "$compose_file" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_service" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{4}image:[[:space:]]*(.+)$ ]]; then
|
|
printf '%s' "${BASH_REMATCH[1]}"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != "$service_header" ]]; then
|
|
in_service="no"
|
|
elif [[ "$line" =~ ^[^[:space:]] ]]; then
|
|
in_service="no"
|
|
fi
|
|
fi
|
|
|
|
if [[ "$line" == "$service_header" ]]; then
|
|
in_service="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
return 1
|
|
}
|
|
|
|
_extract_named_volume_name() {
|
|
local mount_spec="$1"
|
|
local source=""
|
|
|
|
mount_spec="$(_strip_wrapping_quotes "$mount_spec")"
|
|
source="${mount_spec%%:*}"
|
|
|
|
if [[ "$source" == "$mount_spec" || -z "$source" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ "$source" == .* || "$source" == /* || "$source" == "~"* ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ "$source" == *"/"* || "$source" == *'$'* ]]; then
|
|
return 1
|
|
fi
|
|
|
|
printf '%s' "$source"
|
|
}
|
|
|
|
_collect_referenced_named_volumes() {
|
|
local compose_file="$1"
|
|
local line
|
|
local in_services="no"
|
|
local current_service=""
|
|
local in_volumes="no"
|
|
local in_long_volume_entry="no"
|
|
local long_volume_type=""
|
|
local long_volume_source=""
|
|
local volume_name=""
|
|
local long_entry_key=""
|
|
local long_entry_value=""
|
|
local -A seen=()
|
|
|
|
if [[ ! -f "$compose_file" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "services:" ]]; then
|
|
in_services="yes"
|
|
current_service=""
|
|
in_volumes="no"
|
|
in_long_volume_entry="no"
|
|
long_volume_type=""
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_services" == "yes" && "$line" =~ ^[A-Za-z] && "$line" != "services:" ]]; then
|
|
in_services="no"
|
|
in_volumes="no"
|
|
in_long_volume_entry="no"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_services" != "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]]; then
|
|
current_service="${BASH_REMATCH[1]}"
|
|
in_volumes="no"
|
|
in_long_volume_entry="no"
|
|
long_volume_type=""
|
|
long_volume_source=""
|
|
continue
|
|
fi
|
|
|
|
if [[ -z "$current_service" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" == " volumes:" ]]; then
|
|
in_volumes="yes"
|
|
in_long_volume_entry="no"
|
|
long_volume_type=""
|
|
long_volume_source=""
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_volumes" != "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{4}[^[:space:]-] || "$line" =~ ^[[:space:]]{2}[^[:space:]] ]]; then
|
|
in_volumes="no"
|
|
in_long_volume_entry="no"
|
|
long_volume_type=""
|
|
long_volume_source=""
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then
|
|
local volume_entry="${BASH_REMATCH[1]}"
|
|
volume_entry="$(_strip_wrapping_quotes "$volume_entry")"
|
|
in_long_volume_entry="no"
|
|
long_volume_type=""
|
|
long_volume_source=""
|
|
|
|
if [[ "$volume_entry" =~ ^([A-Za-z_][A-Za-z0-9_-]*):[[:space:]]*(.*)$ ]]; then
|
|
long_entry_key="${BASH_REMATCH[1]}"
|
|
long_entry_value="$(_strip_wrapping_quotes "${BASH_REMATCH[2]}")"
|
|
case "$long_entry_key" in
|
|
type|source|target|read_only|bind|volume|tmpfs|consistency|nocopy|subpath)
|
|
in_long_volume_entry="yes"
|
|
case "$long_entry_key" in
|
|
type)
|
|
if [[ "$long_entry_value" == "volume" ]]; then
|
|
long_volume_type="volume"
|
|
else
|
|
long_volume_type="other"
|
|
fi
|
|
;;
|
|
source)
|
|
long_volume_source="$long_entry_value"
|
|
;;
|
|
esac
|
|
if [[ "$long_volume_type" == "volume" && -n "$long_volume_source" && -z "${seen[$long_volume_source]+set}" ]]; then
|
|
seen["$long_volume_source"]=1
|
|
printf '%s\n' "$long_volume_source"
|
|
fi
|
|
continue
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
volume_name="$(_extract_named_volume_name "$volume_entry")" || continue
|
|
if [[ -z "${seen[$volume_name]+set}" ]]; then
|
|
seen["$volume_name"]=1
|
|
printf '%s\n' "$volume_name"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_long_volume_entry" == "yes" ]] && \
|
|
[[ "$line" =~ ^[[:space:]]{8}([A-Za-z_][A-Za-z0-9_-]*):[[:space:]]*(.+)$ ]]; then
|
|
long_entry_key="${BASH_REMATCH[1]}"
|
|
long_entry_value="$(_strip_wrapping_quotes "${BASH_REMATCH[2]}")"
|
|
case "$long_entry_key" in
|
|
type)
|
|
if [[ "$long_entry_value" == "volume" ]]; then
|
|
long_volume_type="volume"
|
|
else
|
|
long_volume_type="other"
|
|
fi
|
|
;;
|
|
source)
|
|
long_volume_source="$long_entry_value"
|
|
;;
|
|
esac
|
|
|
|
if [[ "$long_volume_type" == "volume" && -n "$long_volume_source" && -z "${seen[$long_volume_source]+set}" ]]; then
|
|
seen["$long_volume_source"]=1
|
|
printf '%s\n' "$long_volume_source"
|
|
fi
|
|
fi
|
|
done < "$compose_file"
|
|
}
|
|
|
|
_trim_trailing_blank_lines_in_file() {
|
|
local file="$1"
|
|
local trim_file="${file}.trim-tail"
|
|
_FILE_OPS_CLEANUP_TMP+=("$trim_file")
|
|
|
|
awk '
|
|
{ lines[NR] = $0 }
|
|
END {
|
|
last = NR
|
|
while (last > 0 && lines[last] == "") {
|
|
last--
|
|
}
|
|
for (i = 1; i <= last; i++) {
|
|
print lines[i]
|
|
}
|
|
}
|
|
' "$file" > "$trim_file"
|
|
|
|
mv "$trim_file" "$file"
|
|
}
|
|
|
|
_append_referenced_volume_blocks() {
|
|
local compose_file="$1"
|
|
local -a referenced_volumes=()
|
|
local volume_name
|
|
local root_service
|
|
|
|
while IFS= read -r volume_name; do
|
|
if [[ -n "$volume_name" ]]; then
|
|
referenced_volumes+=("$volume_name")
|
|
fi
|
|
done < <(_collect_referenced_named_volumes "$compose_file")
|
|
|
|
if ((${#referenced_volumes[@]} == 0)); then
|
|
return 0
|
|
fi
|
|
|
|
_trim_trailing_blank_lines_in_file "$compose_file"
|
|
printf '\nvolumes:\n' >> "$compose_file"
|
|
for volume_name in "${referenced_volumes[@]}"; do
|
|
if _is_wizard_managed_volume_name "$volume_name"; then
|
|
root_service="$(_managed_volume_root_name "$volume_name")"
|
|
if [[ -n "${_FILE_OPS_VOLUME_BLOCKS[$volume_name]+set}" ]] && \
|
|
_should_preserve_wizard_managed_root_service "$root_service"; then
|
|
printf '%s' "${_FILE_OPS_VOLUME_BLOCKS[$volume_name]}" >> "$compose_file"
|
|
else
|
|
printf ' %s:\n' "$volume_name" >> "$compose_file"
|
|
fi
|
|
elif [[ -n "${_FILE_OPS_VOLUME_BLOCKS[$volume_name]+set}" ]]; then
|
|
printf '%s' "${_FILE_OPS_VOLUME_BLOCKS[$volume_name]}" >> "$compose_file"
|
|
else
|
|
printf ' %s:\n' "$volume_name" >> "$compose_file"
|
|
fi
|
|
done
|
|
}
|
|
|
|
generate_docker_compose() {
|
|
local output_file="${1:-${REPO_ROOT:-.}/docker-compose.yml}"
|
|
local base_file="${REPO_ROOT:-.}/docker-compose.yml"
|
|
local tmp_file="${output_file}.tmp"
|
|
local service_blocks_file="${output_file}.services"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
_FILE_OPS_CLEANUP_TMP+=("$service_blocks_file")
|
|
local template_file
|
|
local lightrag_mounts=()
|
|
local lightrag_env_entries=()
|
|
local key
|
|
local root_service
|
|
|
|
# Prefer the existing generated compose as the starting point to preserve
|
|
# any user customisations to the lightrag service. Fall back to the base
|
|
# docker-compose.yml when the output file doesn't exist yet.
|
|
if [[ -f "$output_file" && "$output_file" != "$base_file" ]]; then
|
|
_refresh_existing_managed_root_service_set_from_compose "$output_file"
|
|
_collect_top_level_volume_blocks "$output_file"
|
|
cp "$output_file" "$tmp_file"
|
|
# Strip wizard-managed services and top-level volumes. User-managed
|
|
# services are preserved, while volumes are rebuilt from final service
|
|
# references after managed templates are appended.
|
|
_strip_wizard_managed_services_and_top_level_volumes "$tmp_file"
|
|
elif [[ -f "$base_file" ]]; then
|
|
_refresh_existing_managed_root_service_set_from_compose "$base_file"
|
|
_collect_top_level_volume_blocks "$base_file"
|
|
cp "$base_file" "$tmp_file"
|
|
_strip_wizard_managed_services_and_top_level_volumes "$tmp_file"
|
|
else
|
|
EXISTING_MANAGED_ROOT_SERVICE_SET=()
|
|
_FILE_OPS_VOLUME_BLOCKS=()
|
|
_FILE_OPS_VOLUME_ORDER=()
|
|
printf 'services:\n' > "$tmp_file"
|
|
fi
|
|
|
|
prepare_lightrag_service_for_generated_compose "$tmp_file"
|
|
normalize_lightrag_restart_policy "$tmp_file"
|
|
# Remove stale wizard-managed keys from lightrag's environment so that
|
|
# keys no longer in COMPOSE_ENV_OVERRIDES are not left behind.
|
|
_strip_lightrag_wizard_environment_keys "$tmp_file"
|
|
|
|
# Remove stale wizard-managed bind mounts from lightrag's volumes so that
|
|
# mounts no longer needed (e.g. after SSL removal) are not left behind.
|
|
_strip_lightrag_wizard_bind_mounts "$tmp_file"
|
|
|
|
if [[ -n "${LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING:-}" ]]; then
|
|
_strip_lightrag_wizard_ports "$tmp_file"
|
|
inject_lightrag_port_mapping "$tmp_file" "$LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING"
|
|
fi
|
|
|
|
# Ensure the optional prompts directory mount exists so users can supply
|
|
# custom entity-type prompt profiles without rebuilding the image. Mirrors
|
|
# the ./data/inputs and ./data/rag_storage bind layout.
|
|
if ! _lightrag_volumes_have_container_target "$tmp_file" "/app/data/prompts"; then
|
|
lightrag_mounts+=("./data/prompts:/app/data/prompts")
|
|
fi
|
|
|
|
append_lightrag_ssl_mount lightrag_mounts "${COMPOSE_ENV_OVERRIDES[SSL_CERTFILE]:-}" || return 1
|
|
append_lightrag_ssl_mount lightrag_mounts "${COMPOSE_ENV_OVERRIDES[SSL_KEYFILE]:-}" || return 1
|
|
if ((${#lightrag_mounts[@]} > 0)); then
|
|
inject_lightrag_bind_mounts "$tmp_file" "${lightrag_mounts[@]}"
|
|
fi
|
|
|
|
for key in "${!COMPOSE_ENV_OVERRIDES[@]}"; do
|
|
lightrag_env_entries+=("${key}=${COMPOSE_ENV_OVERRIDES[$key]}")
|
|
done
|
|
if ((${#lightrag_env_entries[@]} > 0)); then
|
|
inject_lightrag_environment_overrides "$tmp_file" "${lightrag_env_entries[@]}"
|
|
fi
|
|
|
|
repair_misplaced_lightrag_depends_on "$tmp_file"
|
|
inject_lightrag_depends_on "$tmp_file" "${DOCKER_SERVICES[@]}"
|
|
|
|
: > "$service_blocks_file"
|
|
for service in "${DOCKER_SERVICES[@]}"; do
|
|
root_service="$(_managed_service_root_name "$service")"
|
|
if _should_preserve_wizard_managed_root_service "$root_service" && \
|
|
_existing_managed_root_service_present "$root_service"; then
|
|
continue
|
|
fi
|
|
|
|
template_file="$TEMPLATES_DIR/${service}.yml"
|
|
if [[ "$service" == "milvus" ]]; then
|
|
if [[ "${ENV_VALUES[MILVUS_DEVICE]:-cpu}" == "cuda" ]]; then
|
|
if [[ -f "$TEMPLATES_DIR/${service}-gpu.yml" ]]; then
|
|
template_file="$TEMPLATES_DIR/${service}-gpu.yml"
|
|
fi
|
|
fi
|
|
fi
|
|
if [[ "$service" == "qdrant" ]]; then
|
|
if [[ "${ENV_VALUES[QDRANT_DEVICE]:-cpu}" == "cuda" ]]; then
|
|
if [[ -f "$TEMPLATES_DIR/${service}-gpu.yml" ]]; then
|
|
template_file="$TEMPLATES_DIR/${service}-gpu.yml"
|
|
fi
|
|
fi
|
|
fi
|
|
if [[ "$service" == "vllm-rerank" ]]; then
|
|
if [[ "${ENV_VALUES[VLLM_RERANK_DEVICE]:-cpu}" == "cuda" ]]; then
|
|
if [[ -f "$TEMPLATES_DIR/${service}-gpu.yml" ]]; then
|
|
template_file="$TEMPLATES_DIR/${service}-gpu.yml"
|
|
fi
|
|
fi
|
|
fi
|
|
if [[ "$service" == "vllm-embed" ]]; then
|
|
if [[ "${ENV_VALUES[VLLM_EMBED_DEVICE]:-cpu}" == "cuda" ]]; then
|
|
if [[ -f "$TEMPLATES_DIR/${service}-gpu.yml" ]]; then
|
|
template_file="$TEMPLATES_DIR/${service}-gpu.yml"
|
|
fi
|
|
fi
|
|
fi
|
|
if [[ ! -f "$template_file" ]]; then
|
|
format_error "Missing docker template: $template_file" "Reinstall the setup scripts."
|
|
return 1
|
|
fi
|
|
|
|
printf '\n' >> "$service_blocks_file"
|
|
cat "$template_file" >> "$service_blocks_file"
|
|
|
|
case "$service" in
|
|
postgres)
|
|
inject_service_environment_overrides "$service_blocks_file" "postgres" \
|
|
"POSTGRES_USER=${ENV_VALUES[POSTGRES_USER]:-}" \
|
|
"POSTGRES_PASSWORD=${ENV_VALUES[POSTGRES_PASSWORD]:-}" \
|
|
"POSTGRES_DB=${ENV_VALUES[POSTGRES_DATABASE]:-}"
|
|
;;
|
|
neo4j)
|
|
inject_service_environment_overrides "$service_blocks_file" "neo4j" \
|
|
"NEO4J_AUTH=${_COMPOSE_RAW_VALUE_PREFIX}\${NEO4J_USERNAME:?missing}/\${NEO4J_PASSWORD:?missing}" \
|
|
"NEO4J_dbms_default__database=${ENV_VALUES[NEO4J_DATABASE]:-neo4j}"
|
|
;;
|
|
mongodb)
|
|
;;
|
|
redis)
|
|
;;
|
|
milvus)
|
|
;;
|
|
qdrant)
|
|
;;
|
|
memgraph)
|
|
;;
|
|
opensearch)
|
|
;;
|
|
vllm-rerank)
|
|
;;
|
|
vllm-embed)
|
|
;;
|
|
esac
|
|
|
|
if [[ -n "${COMPOSE_SERVICE_IMAGE_OVERRIDES[$service]+set}" ]]; then
|
|
inject_service_image_override \
|
|
"$service_blocks_file" \
|
|
"$service" \
|
|
"${COMPOSE_SERVICE_IMAGE_OVERRIDES[$service]}"
|
|
fi
|
|
done
|
|
|
|
_merge_managed_service_blocks "$tmp_file" "$service_blocks_file"
|
|
_normalize_services_section_spacing "$tmp_file"
|
|
_append_referenced_volume_blocks "$tmp_file"
|
|
|
|
mv "$tmp_file" "$output_file"
|
|
}
|
|
|
|
prepare_lightrag_service_for_generated_compose() {
|
|
# Let the containerized app read the mounted .env itself. Keeping env_file
|
|
# here would make Docker Compose re-parse the same secrets and expand '$'.
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.strip-env-file"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_lightrag="no"
|
|
local in_env_file="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_env_file" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6}-[[:space:]] ]]; then
|
|
continue
|
|
fi
|
|
in_env_file="no"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]]; then
|
|
in_lightrag="no"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" == " env_file:" ]]; then
|
|
in_env_file="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{4}container_name: ]]; then
|
|
continue
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_env_file="no"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
normalize_lightrag_restart_policy() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.normalize-lightrag-restart"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_lightrag="no"
|
|
local in_deploy="no"
|
|
local deploy_seen="no"
|
|
local insert_blank_after_deploy="no"
|
|
local skip_blank_after_removed_restart="no"
|
|
local -a deploy_lines=()
|
|
|
|
_trim_trailing_blank_lines() {
|
|
local file="$1"
|
|
local trim_file="${file}.trim"
|
|
_FILE_OPS_CLEANUP_TMP+=("$trim_file")
|
|
|
|
awk '
|
|
{ lines[NR] = $0 }
|
|
END {
|
|
last = NR
|
|
while (last > 0 && lines[last] == "") {
|
|
last--
|
|
}
|
|
for (i = 1; i <= last; i++) {
|
|
print lines[i]
|
|
}
|
|
}
|
|
' "$file" > "$trim_file"
|
|
|
|
mv "$trim_file" "$file"
|
|
}
|
|
|
|
_write_normalized_lightrag_deploy_block() {
|
|
local deploy_line
|
|
local skipping_restart_policy="no"
|
|
|
|
printf ' deploy:\n' >> "$tmp_file"
|
|
for deploy_line in "${deploy_lines[@]}"; do
|
|
if [[ -z "$deploy_line" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$skipping_restart_policy" == "yes" ]]; then
|
|
if [[ "$deploy_line" =~ ^[[:space:]]{8} ]]; then
|
|
continue
|
|
fi
|
|
skipping_restart_policy="no"
|
|
fi
|
|
|
|
if [[ "$deploy_line" == " restart_policy:" ]]; then
|
|
skipping_restart_policy="yes"
|
|
continue
|
|
fi
|
|
|
|
printf '%s\n' "$deploy_line" >> "$tmp_file"
|
|
done
|
|
|
|
printf ' restart_policy:\n' >> "$tmp_file"
|
|
printf ' condition: on-failure\n' >> "$tmp_file"
|
|
printf ' max_attempts: 10\n' >> "$tmp_file"
|
|
}
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_deploy" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6} || -z "$line" ]]; then
|
|
deploy_lines+=("$line")
|
|
continue
|
|
fi
|
|
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_normalized_lightrag_deploy_block
|
|
deploy_lines=()
|
|
in_deploy="no"
|
|
if [[ "$line" =~ ^[[:space:]]{2}[^[:space:]] || "$line" =~ ^[^[:space:]] ]]; then
|
|
insert_blank_after_deploy="yes"
|
|
fi
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]] || \
|
|
[[ "$in_lightrag" == "yes" && "$line" =~ ^[^[:space:]] ]]; then
|
|
if [[ "$deploy_seen" != "yes" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_normalized_lightrag_deploy_block
|
|
insert_blank_after_deploy="yes"
|
|
fi
|
|
in_lightrag="no"
|
|
deploy_seen="no"
|
|
skip_blank_after_removed_restart="no"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" == " deploy:" ]]; then
|
|
in_deploy="yes"
|
|
deploy_seen="yes"
|
|
deploy_lines=()
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{4}restart: ]]; then
|
|
skip_blank_after_removed_restart="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$skip_blank_after_removed_restart" == "yes" && "$in_lightrag" == "yes" ]]; then
|
|
if [[ -z "$line" ]]; then
|
|
continue
|
|
fi
|
|
skip_blank_after_removed_restart="no"
|
|
fi
|
|
|
|
if [[ "$insert_blank_after_deploy" == "yes" ]]; then
|
|
printf '\n' >> "$tmp_file"
|
|
insert_blank_after_deploy="no"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_deploy="no"
|
|
deploy_seen="no"
|
|
insert_blank_after_deploy="no"
|
|
skip_blank_after_removed_restart="no"
|
|
deploy_lines=()
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_deploy" == "yes" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_normalized_lightrag_deploy_block
|
|
deploy_seen="yes"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$deploy_seen" != "yes" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_normalized_lightrag_deploy_block
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
append_lightrag_ssl_mount() {
|
|
local array_name="$1"
|
|
local container_path="$2"
|
|
local relative_host_path=""
|
|
local mount_entry=""
|
|
|
|
if [[ -z "$container_path" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$container_path" != /app/data/* ]]; then
|
|
format_error "Unsupported SSL path: ${container_path}" "Use paths staged under /app/data."
|
|
return 1
|
|
fi
|
|
|
|
relative_host_path="./data/${container_path#/app/data/}"
|
|
mount_entry="${relative_host_path}:${container_path}:ro"
|
|
local -n _arr_ref="$array_name"
|
|
_arr_ref+=("$mount_entry")
|
|
}
|
|
|
|
format_yaml_value() {
|
|
local value="$1"
|
|
local escaped="${value//\\/\\\\}"
|
|
|
|
escaped="${escaped//\"/\\\"}"
|
|
escaped="${escaped//\$/\$\$}"
|
|
printf '"%s"' "$escaped"
|
|
}
|
|
|
|
_COMPOSE_RAW_VALUE_PREFIX="__LIGHTRAG_RAW_COMPOSE__:"
|
|
|
|
format_compose_environment_value() {
|
|
local value="$1"
|
|
|
|
if [[ "$value" == "${_COMPOSE_RAW_VALUE_PREFIX}"* ]]; then
|
|
printf '%s' "${value#${_COMPOSE_RAW_VALUE_PREFIX}}"
|
|
return 0
|
|
fi
|
|
|
|
format_yaml_value "$value"
|
|
}
|
|
|
|
inject_service_environment_overrides() {
|
|
local compose_file="$1"
|
|
local service_name="$2"
|
|
shift 2
|
|
local entries=("$@")
|
|
local tmp_file="${compose_file}.${service_name}.env"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line key value
|
|
local in_service="no"
|
|
local in_environment="no"
|
|
local environment_style="mapping"
|
|
local inserted="no"
|
|
local service_header=" ${service_name}:"
|
|
|
|
if ((${#entries[@]} == 0)); then
|
|
return 0
|
|
fi
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_service" == "yes" && "$in_environment" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6}-[[:space:]] ]]; then
|
|
environment_style="list"
|
|
elif [[ "$line" =~ ^[[:space:]]{6}[A-Z0-9_]+: ]]; then
|
|
environment_style="mapping"
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{4}[^[:space:]] || "$line" =~ ^[[:space:]]{2}[^[:space:]] || "$line" =~ ^[^[:space:]] ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
_write_service_environment_entries "$tmp_file" "$environment_style" "${entries[@]}"
|
|
inserted="yes"
|
|
fi
|
|
in_environment="no"
|
|
fi
|
|
elif [[ "$in_service" == "yes" && \
|
|
( "$line" =~ ^[[:space:]]{2}[^[:space:]] || "$line" =~ ^[^[:space:]] ) && \
|
|
"$line" != "$service_header" ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
printf ' environment:\n' >> "$tmp_file"
|
|
_write_service_environment_entries "$tmp_file" "mapping" "${entries[@]}"
|
|
inserted="yes"
|
|
fi
|
|
in_service="no"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == "$service_header" ]]; then
|
|
in_service="yes"
|
|
in_environment="no"
|
|
environment_style="mapping"
|
|
elif [[ "$in_service" == "yes" && "$line" == " environment:" ]]; then
|
|
in_environment="yes"
|
|
environment_style="mapping"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_service" == "yes" && "$inserted" == "no" ]]; then
|
|
if [[ "$in_environment" != "yes" ]]; then
|
|
printf ' environment:\n' >> "$tmp_file"
|
|
fi
|
|
_write_service_environment_entries "$tmp_file" "$environment_style" "${entries[@]}"
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
inject_service_image_override() {
|
|
local compose_file="$1"
|
|
local service_name="$2"
|
|
local image_value="$3"
|
|
local tmp_file="${compose_file}.${service_name}.image"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_service="no"
|
|
local inserted="no"
|
|
local service_header=" ${service_name}:"
|
|
|
|
if [[ -z "$image_value" || ! -f "$compose_file" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_service" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{4}image:[[:space:]]*.+$ ]]; then
|
|
printf ' image: %s\n' "$image_value" >> "$tmp_file"
|
|
inserted="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$inserted" == "no" && "$line" =~ ^[[:space:]]{4}[^[:space:]] ]]; then
|
|
printf ' image: %s\n' "$image_value" >> "$tmp_file"
|
|
inserted="yes"
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != "$service_header" ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
printf ' image: %s\n' "$image_value" >> "$tmp_file"
|
|
inserted="yes"
|
|
fi
|
|
in_service="no"
|
|
elif [[ "$line" =~ ^[^[:space:]] ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
printf ' image: %s\n' "$image_value" >> "$tmp_file"
|
|
inserted="yes"
|
|
fi
|
|
in_service="no"
|
|
fi
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == "$service_header" ]]; then
|
|
in_service="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_service" == "yes" && "$inserted" == "no" ]]; then
|
|
printf ' image: %s\n' "$image_value" >> "$tmp_file"
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
# Return success when the lightrag service already has a volume mount whose
|
|
# container target matches the supplied path. Used to make idempotent mount
|
|
# injections that should not duplicate existing user/wizard entries.
|
|
_lightrag_volumes_have_container_target() {
|
|
local compose_file="$1"
|
|
local target_path="$2"
|
|
local line
|
|
local in_lightrag="no"
|
|
local in_volumes="no"
|
|
local mount_spec=""
|
|
local remainder=""
|
|
local container_path=""
|
|
|
|
if [[ ! -f "$compose_file" || -z "$target_path" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_lightrag" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]] || \
|
|
[[ "$line" =~ ^[^[:space:]] ]]; then
|
|
in_lightrag="no"
|
|
in_volumes="no"
|
|
elif [[ "$line" == " volumes:" ]]; then
|
|
in_volumes="yes"
|
|
elif [[ "$in_volumes" == "yes" && "$line" =~ ^[[:space:]]{4}[^[:space:]-] ]]; then
|
|
in_volumes="no"
|
|
elif [[ "$in_volumes" == "yes" && "$line" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then
|
|
mount_spec="$(_strip_wrapping_quotes "${BASH_REMATCH[1]}")"
|
|
remainder="${mount_spec#*:}"
|
|
if [[ "$remainder" == "$mount_spec" ]]; then
|
|
continue
|
|
fi
|
|
container_path="${remainder%%:*}"
|
|
if [[ "$container_path" == "$target_path" ]]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_volumes="no"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
return 1
|
|
}
|
|
|
|
# Return success when a volume mount entry is a wizard-managed SSL cert/key
|
|
# bind mount (./data/certs/* -> /app/data/certs/*, optional :ro suffix).
|
|
_is_wizard_ssl_bind_mount() {
|
|
local mount_spec="$1"
|
|
local host_path=""
|
|
local remainder=""
|
|
local container_path=""
|
|
local mode=""
|
|
local host_suffix=""
|
|
local container_suffix=""
|
|
|
|
host_path="${mount_spec%%:*}"
|
|
remainder="${mount_spec#*:}"
|
|
if [[ "$remainder" == "$mount_spec" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
container_path="${remainder%%:*}"
|
|
if [[ "$container_path" == "$remainder" ]]; then
|
|
mode=""
|
|
else
|
|
mode="${remainder#${container_path}:}"
|
|
fi
|
|
|
|
if [[ "$host_path" != ./data/certs/* ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ "$container_path" != /app/data/certs/* ]]; then
|
|
return 1
|
|
fi
|
|
|
|
# Wizard-generated SSL mounts are read-only. Keep non-read-only mounts so
|
|
# user-defined overrides under /app/data/certs are not stripped.
|
|
if [[ -n "$mode" && "$mode" != "ro" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
host_suffix="${host_path#./data/certs/}"
|
|
container_suffix="${container_path#/app/data/certs/}"
|
|
[[ "$host_suffix" == "$container_suffix" ]]
|
|
}
|
|
|
|
# Remove wizard-managed SSL bind mounts from the lightrag service's volumes
|
|
# block, leaving persistent and user-added /app/data/* mounts intact.
|
|
_strip_lightrag_wizard_bind_mounts() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.strip-mounts"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local mount_spec
|
|
local in_lightrag="no"
|
|
local in_volumes="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_lightrag" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]]; then
|
|
in_lightrag="no"
|
|
in_volumes="no"
|
|
elif [[ "$line" == " volumes:" ]]; then
|
|
in_volumes="yes"
|
|
elif [[ "$in_volumes" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{4}[^[:space:]] ]]; then
|
|
in_volumes="no"
|
|
elif [[ "$line" =~ ^[[:space:]]{6}-[[:space:]] ]]; then
|
|
mount_spec="${line# - }"
|
|
mount_spec="${mount_spec%\"}"
|
|
mount_spec="${mount_spec#\"}"
|
|
mount_spec="${mount_spec%\'}"
|
|
mount_spec="${mount_spec#\'}"
|
|
if _is_wizard_ssl_bind_mount "$mount_spec"; then
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_volumes="no"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
_is_wizard_lightrag_port_mapping() {
|
|
local port_spec="$(_strip_wrapping_quotes "$1")"
|
|
|
|
if [[ "$port_spec" == '${HOST:-0.0.0.0}:${PORT:-9621}:9621' || \
|
|
"$port_spec" == '${PORT:-9621}:9621' ]]; then
|
|
return 0
|
|
fi
|
|
|
|
case "$port_spec" in
|
|
9621|9621/tcp|*:9621|*:9621/tcp)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
return 1
|
|
}
|
|
|
|
_strip_lightrag_wizard_ports() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.strip-lightrag-ports"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local port_spec=""
|
|
local in_lightrag="no"
|
|
local in_ports="no"
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_lightrag" == "yes" && "$in_ports" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then
|
|
port_spec="${BASH_REMATCH[1]}"
|
|
if _is_wizard_lightrag_port_mapping "$port_spec"; then
|
|
continue
|
|
fi
|
|
elif [[ ! "$line" =~ ^[[:space:]]{6} ]]; then
|
|
in_ports="no"
|
|
fi
|
|
elif [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]]; then
|
|
in_lightrag="no"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_ports="no"
|
|
elif [[ "$in_lightrag" == "yes" && "$line" == " ports:" ]]; then
|
|
in_ports="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
inject_lightrag_bind_mounts() {
|
|
local compose_file="$1"
|
|
shift
|
|
local mounts=("$@")
|
|
local tmp_file="${compose_file}.mounts"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_lightrag="no"
|
|
local in_volumes="no"
|
|
local inserted="no"
|
|
|
|
if ((${#mounts[@]} == 0)); then
|
|
return 0
|
|
fi
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_lightrag" == "yes" && "$in_volumes" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{4}[^[:space:]-] || "$line" =~ ^[[:space:]]{2}[^[:space:]] || "$line" =~ ^(volumes|networks): ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
for mount in "${mounts[@]}"; do
|
|
printf ' - %s\n' "$mount" >> "$tmp_file"
|
|
done
|
|
inserted="yes"
|
|
fi
|
|
in_volumes="no"
|
|
fi
|
|
elif [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
printf ' volumes:\n' >> "$tmp_file"
|
|
for mount in "${mounts[@]}"; do
|
|
printf ' - %s\n' "$mount" >> "$tmp_file"
|
|
done
|
|
inserted="yes"
|
|
fi
|
|
in_lightrag="no"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_volumes="no"
|
|
elif [[ "$in_lightrag" == "yes" && "$line" == " volumes:" ]]; then
|
|
in_volumes="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$inserted" == "no" ]]; then
|
|
if [[ "$in_volumes" != "yes" ]]; then
|
|
printf ' volumes:\n' >> "$tmp_file"
|
|
fi
|
|
for mount in "${mounts[@]}"; do
|
|
printf ' - %s\n' "$mount" >> "$tmp_file"
|
|
done
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
inject_lightrag_port_mapping() {
|
|
local compose_file="$1"
|
|
local port_mapping="$2"
|
|
local tmp_file="${compose_file}.ports"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_lightrag="no"
|
|
local in_ports="no"
|
|
local inserted="no"
|
|
|
|
if [[ -z "$port_mapping" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_lightrag" == "yes" && "$in_ports" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{4}[^[:space:]-] || "$line" =~ ^[[:space:]]{2}[^[:space:]] || "$line" =~ ^(volumes|networks): ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
printf ' - "%s"\n' "$port_mapping" >> "$tmp_file"
|
|
inserted="yes"
|
|
fi
|
|
in_ports="no"
|
|
fi
|
|
elif [[ "$in_lightrag" == "yes" && "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
printf ' ports:\n' >> "$tmp_file"
|
|
printf ' - "%s"\n' "$port_mapping" >> "$tmp_file"
|
|
inserted="yes"
|
|
fi
|
|
in_lightrag="no"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
in_ports="no"
|
|
elif [[ "$in_lightrag" == "yes" && "$line" == " ports:" ]]; then
|
|
in_ports="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$inserted" == "no" ]]; then
|
|
if [[ "$in_ports" != "yes" ]]; then
|
|
printf ' ports:\n' >> "$tmp_file"
|
|
fi
|
|
printf ' - "%s"\n' "$port_mapping" >> "$tmp_file"
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
repair_misplaced_lightrag_depends_on() {
|
|
local compose_file="$1"
|
|
local tmp_file="${compose_file}.repair-lightrag-depends-on"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_services="no"
|
|
local in_lightrag="no"
|
|
local lightrag_has_depends_on="no"
|
|
local candidate_service=""
|
|
local candidate_root_service=""
|
|
local captured_block=""
|
|
local candidate_header=""
|
|
local inserted="no"
|
|
local in_candidate_service="no"
|
|
local skipping_candidate_depends_on="no"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "services:" ]]; then
|
|
in_services="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_services" == "yes" && "$line" =~ ^[^[:space:]] && "$line" != "services:" ]]; then
|
|
break
|
|
fi
|
|
|
|
if [[ "$in_services" != "yes" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" ]]; then
|
|
if [[ "$line" == " depends_on:" ]]; then
|
|
lightrag_has_depends_on="yes"
|
|
break
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]] && \
|
|
[[ "${BASH_REMATCH[1]}" != "lightrag" ]]; then
|
|
candidate_service="${BASH_REMATCH[1]}"
|
|
candidate_root_service="$(_managed_service_root_name "$candidate_service")"
|
|
if [[ -z "$candidate_root_service" || "$candidate_root_service" == "milvus" ]]; then
|
|
break
|
|
fi
|
|
in_lightrag="no"
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ -n "$candidate_service" ]]; then
|
|
if [[ "$line" == " depends_on:" ]]; then
|
|
captured_block=" depends_on:"$'\n'
|
|
continue
|
|
fi
|
|
|
|
if [[ -n "$captured_block" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6} ]]; then
|
|
captured_block+="${line}"$'\n'
|
|
continue
|
|
fi
|
|
break
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " ${candidate_service}:" ]]; then
|
|
break
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[^[:space:]] ]]; then
|
|
break
|
|
fi
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$lightrag_has_depends_on" == "yes" || -z "$captured_block" || -z "$candidate_service" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
candidate_header=" ${candidate_service}:"
|
|
: > "$tmp_file"
|
|
in_lightrag="no"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$skipping_candidate_depends_on" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[[:space:]]{6} ]]; then
|
|
continue
|
|
fi
|
|
skipping_candidate_depends_on="no"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$inserted" == "no" ]] && \
|
|
[[ ( "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != " lightrag:" ) || "$line" =~ ^[^[:space:]] ]]; then
|
|
printf '%s' "$captured_block" >> "$tmp_file"
|
|
inserted="yes"
|
|
in_lightrag="no"
|
|
fi
|
|
|
|
if [[ "$line" == "$candidate_header" ]]; then
|
|
in_candidate_service="yes"
|
|
elif [[ "$in_candidate_service" == "yes" ]] && \
|
|
[[ ( "$line" =~ ^[[:space:]]{2}[^[:space:]] && "$line" != "$candidate_header" ) || "$line" =~ ^[^[:space:]] ]]; then
|
|
in_candidate_service="no"
|
|
fi
|
|
|
|
if [[ "$in_candidate_service" == "yes" && "$line" == " depends_on:" ]]; then
|
|
skipping_candidate_depends_on="yes"
|
|
continue
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$inserted" == "no" ]]; then
|
|
printf '%s' "$captured_block" >> "$tmp_file"
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
inject_lightrag_environment_overrides() {
|
|
local compose_file="$1"
|
|
shift
|
|
inject_service_environment_overrides "$compose_file" "lightrag" "$@"
|
|
}
|
|
|
|
inject_lightrag_depends_on() {
|
|
local compose_file="$1"
|
|
shift
|
|
local candidate_service
|
|
local managed_services=()
|
|
local tmp_file="${compose_file}.depends-on"
|
|
_FILE_OPS_CLEANUP_TMP+=("$tmp_file")
|
|
local line
|
|
local in_lightrag="no"
|
|
local in_depends_on="no"
|
|
local inserted="no"
|
|
local insert_blank_after_depends_on="no"
|
|
local current_dep_name=""
|
|
local current_dep_block=""
|
|
local dep_name=""
|
|
local dep_tail=""
|
|
local dep_service=""
|
|
declare -A preserved_dep_blocks=()
|
|
declare -A preserved_dep_seen=()
|
|
local -a preserved_dep_order=()
|
|
|
|
for candidate_service in "$@"; do
|
|
if _is_wizard_managed_root_service_name "$candidate_service"; then
|
|
managed_services+=("$candidate_service")
|
|
fi
|
|
done
|
|
|
|
_record_preserved_depends_on_entry() {
|
|
local service_name="$1"
|
|
local block="$2"
|
|
|
|
if [[ -z "$service_name" ]] || _is_wizard_managed_root_service_name "$service_name"; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ -n "${preserved_dep_seen[$service_name]+set}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
preserved_dep_seen["$service_name"]=1
|
|
preserved_dep_order+=("$service_name")
|
|
preserved_dep_blocks["$service_name"]="$block"
|
|
}
|
|
|
|
_flush_current_depends_on_entry() {
|
|
if [[ -z "$current_dep_name" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
_record_preserved_depends_on_entry "$current_dep_name" "$current_dep_block"
|
|
current_dep_name=""
|
|
current_dep_block=""
|
|
}
|
|
|
|
_trim_trailing_blank_lines() {
|
|
local file="$1"
|
|
local trim_file="${file}.trim"
|
|
_FILE_OPS_CLEANUP_TMP+=("$trim_file")
|
|
|
|
awk '
|
|
{ lines[NR] = $0 }
|
|
END {
|
|
last = NR
|
|
while (last > 0 && lines[last] == "") {
|
|
last--
|
|
}
|
|
for (i = 1; i <= last; i++) {
|
|
print lines[i]
|
|
}
|
|
}
|
|
' "$file" > "$trim_file"
|
|
|
|
mv "$trim_file" "$file"
|
|
}
|
|
|
|
_write_lightrag_depends_on_block() {
|
|
local managed_service
|
|
local preserved_service
|
|
|
|
if ((${#preserved_dep_order[@]} == 0 && ${#managed_services[@]} == 0)); then
|
|
inserted="yes"
|
|
return 0
|
|
fi
|
|
|
|
printf ' depends_on:\n' >> "$tmp_file"
|
|
|
|
for preserved_service in "${preserved_dep_order[@]}"; do
|
|
printf '%s' "${preserved_dep_blocks[$preserved_service]}" >> "$tmp_file"
|
|
done
|
|
|
|
for managed_service in "${managed_services[@]}"; do
|
|
printf ' %s:\n' "$managed_service" >> "$tmp_file"
|
|
printf ' condition: service_healthy\n' >> "$tmp_file"
|
|
done
|
|
|
|
inserted="yes"
|
|
}
|
|
|
|
: > "$tmp_file"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$in_depends_on" == "yes" ]]; then
|
|
if [[ -n "$current_dep_name" && "$line" =~ ^[[:space:]]{8} ]]; then
|
|
current_dep_block+="${line}"$'\n'
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then
|
|
_flush_current_depends_on_entry
|
|
dep_service="$(_strip_wrapping_quotes "${BASH_REMATCH[1]}")"
|
|
_record_preserved_depends_on_entry \
|
|
"$dep_service" \
|
|
"$(printf ' %s:\n condition: service_started\n' "$dep_service")"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" =~ ^[[:space:]]{6}([A-Za-z0-9_.-]+):[[:space:]]*(.*)$ ]]; then
|
|
_flush_current_depends_on_entry
|
|
dep_name="${BASH_REMATCH[1]}"
|
|
dep_tail="${BASH_REMATCH[2]}"
|
|
current_dep_name="$(_strip_wrapping_quotes "$dep_name")"
|
|
if [[ -n "$dep_tail" ]]; then
|
|
current_dep_block=" ${current_dep_name}: ${dep_tail}"$'\n'
|
|
else
|
|
current_dep_block=" ${current_dep_name}:"$'\n'
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
_flush_current_depends_on_entry
|
|
if [[ "$inserted" == "no" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_lightrag_depends_on_block
|
|
fi
|
|
in_depends_on="no"
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && "$line" == " depends_on:" ]]; then
|
|
in_depends_on="yes"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$in_lightrag" == "yes" && \
|
|
( "$line" =~ ^[[:space:]]{2}[^[:space:]] || "$line" =~ ^[^[:space:]] ) && \
|
|
"$line" != " lightrag:" ]]; then
|
|
if [[ "$inserted" == "no" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_lightrag_depends_on_block
|
|
insert_blank_after_depends_on="yes"
|
|
fi
|
|
in_lightrag="no"
|
|
fi
|
|
|
|
if [[ "$insert_blank_after_depends_on" == "yes" ]]; then
|
|
printf '\n' >> "$tmp_file"
|
|
insert_blank_after_depends_on="no"
|
|
fi
|
|
|
|
printf '%s\n' "$line" >> "$tmp_file"
|
|
|
|
if [[ "$line" == " lightrag:" ]]; then
|
|
in_lightrag="yes"
|
|
inserted="no"
|
|
insert_blank_after_depends_on="no"
|
|
in_depends_on="no"
|
|
current_dep_name=""
|
|
current_dep_block=""
|
|
preserved_dep_blocks=()
|
|
preserved_dep_seen=()
|
|
preserved_dep_order=()
|
|
fi
|
|
done < "$compose_file"
|
|
|
|
if [[ "$in_depends_on" == "yes" ]]; then
|
|
_flush_current_depends_on_entry
|
|
if [[ "$inserted" == "no" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_lightrag_depends_on_block
|
|
fi
|
|
elif [[ "$in_lightrag" == "yes" && "$inserted" == "no" ]]; then
|
|
_trim_trailing_blank_lines "$tmp_file"
|
|
_write_lightrag_depends_on_block
|
|
fi
|
|
|
|
mv "$tmp_file" "$compose_file"
|
|
}
|
|
|
|
# Find the first generated compose file in priority order.
|
|
# Prints the path if found, empty string if not.
|
|
find_generated_compose_file() {
|
|
local repo_root="${REPO_ROOT:-.}"
|
|
local candidates=(
|
|
"final:$repo_root/docker-compose.final.yml"
|
|
"development:$repo_root/docker-compose.development.yml"
|
|
"production:$repo_root/docker-compose.production.yml"
|
|
"custom:$repo_root/docker-compose.custom.yml"
|
|
"local:$repo_root/docker-compose.local.yml"
|
|
)
|
|
local candidate f
|
|
|
|
for candidate in "${candidates[@]}"; do
|
|
f="${candidate#*:}"
|
|
if [[ -f "$f" ]]; then
|
|
printf '%s' "$f"
|
|
return 0
|
|
fi
|
|
done
|
|
printf ''
|
|
}
|
|
|
|
# Detect service names in a compose file's services: block (excluding lightrag).
|
|
# Prints one service name per line.
|
|
detect_compose_services() {
|
|
local compose_file="$1"
|
|
local in_services="no"
|
|
local line
|
|
|
|
if [[ ! -f "$compose_file" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "services:" ]]; then
|
|
in_services="yes"
|
|
continue
|
|
fi
|
|
if [[ "$in_services" == "yes" ]]; then
|
|
if [[ "$line" =~ ^[^[:space:]] && "$line" != "services:" ]]; then
|
|
in_services="no"
|
|
continue
|
|
fi
|
|
if [[ "$line" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]]; then
|
|
local svc_name="${BASH_REMATCH[1]}"
|
|
if [[ "$svc_name" != "lightrag" ]]; then
|
|
printf '%s\n' "$svc_name"
|
|
fi
|
|
fi
|
|
fi
|
|
done < "$compose_file"
|
|
}
|
|
|
|
detect_managed_root_services() {
|
|
local compose_file="$1"
|
|
local service_name
|
|
local root_service
|
|
declare -A seen_roots=()
|
|
|
|
while IFS= read -r service_name; do
|
|
root_service="$(_managed_service_root_name "$service_name")"
|
|
if [[ -n "$root_service" && -z "${seen_roots[$root_service]+set}" ]]; then
|
|
seen_roots["$root_service"]=1
|
|
printf '%s\n' "$root_service"
|
|
fi
|
|
done < <(detect_compose_services "$compose_file")
|
|
}
|