Files
2026-07-13 12:08:54 +08:00

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")
}