#!/usr/bin/env bash load_homelab_inventory_defaults() { local inventory_file="${HOMELAB_INVENTORY_FILE:-${REPO_ROOT}/homelab.yml}" local rendered_defaults if [[ ! -s "${inventory_file}" ]] || ! command -v python3 >/dev/null 2>&1; then return 0 fi rendered_defaults="$(python3 - "${inventory_file}" <<'PY' import re import json import shlex import sys inventory_file = sys.argv[1] mapping = { "domain.base": "LAB_DOMAIN", "domain.public_url": "LAB_PUBLIC_URL", "domain.gitea_url": "LAB_GITEA_ROOT_URL", "network.lan_cidr": "LAB_LAN_CIDR", "network.lan_ip_prefix": "LAB_LAN_IP_PREFIX", "network.metallb.traefik_ip": "LAB_TRAEFIK_LB_IP", "hosts.debian.user": "LAB_DEBIAN_USER", "hosts.debian.lan_ip": "LAB_DEBIAN_LAN_IP", "hosts.debian.tailscale_ip": "LAB_DEBIAN_TAILSCALE_IP", "hosts.debian.docker_root": "LAB_DEBIAN_DOCKER_ROOT", "hosts.debian.kubeconfig": "LAB_KUBECONFIG_PATH", "hosts.rpi4.user": "LAB_RPI_USER", "hosts.rpi4.lan_ip": "LAB_RPI_HOST", "hosts.rpi4.tailscale_ip": "LAB_RPI_TAILSCALE_IP", "hosts.rpi4.docker_root": "LAB_RPI_DOCKER_ROOT", "hosts.rpi4.docker_nvme_root": "LAB_RPI_DOCKER_NVME_ROOT", "hosts.rpi4.docker_fallback_root": "LAB_RPI_DOCKER_FALLBACK_ROOT", "hosts.opi5_pimox.user": "LAB_PIMOX_USER", "hosts.opi5_pimox.lan_ip": "LAB_PIMOX_HOST", "hosts.opi5_pimox.bridge": "LAB_PIMOX_BRIDGE", "hosts.opi5_pimox.worker_storage": "LAB_PIMOX_WORKER_STORAGE", "hosts.oci_edge.user": "LAB_EDGE_USER", "hosts.oci_edge.public_ip": "LAB_EDGE_HOST", "hosts.oci_edge.install_dir": "LAB_EDGE_INSTALL_DIR", "services.gitea.http_port": "LAB_GITEA_HTTP_PORT", "services.gitea.ssh_port": "LAB_GITEA_SSH_PORT", "services.gitea.root_url": "LAB_GITEA_ROOT_URL", "services.gitea.ssh_remote": "LAB_GITOPS_REPO_URL", "services.heimdall.install_dir": "LAB_HEIMDALL_INSTALL_DIR", "services.heimdall.http_port": "LAB_HEIMDALL_HTTP_PORT", "services.heimdall.public_url": "LAB_HEIMDALL_PUBLIC_URL", "services.local_registry.endpoint": "LAB_REGISTRY_ENDPOINT", "services.ollama.bind_address": "LAB_OLLAMA_BIND_ADDRESS", "services.ollama.models_dir": "LAB_OLLAMA_MODELS_DIR", "services.ollama.igpu_enable": "LAB_OLLAMA_IGPU_ENABLE", "services.rpi_dns.pihole_web_port": "PIHOLE_WEB_PORT", "services.rpi_dns.uptime_kuma_port": "UPTIME_KUMA_PORT", "ai_gateway.provider": "LAB_AI_GATEWAY_PROVIDER", "ai_gateway.enabled": "LAB_BACKSTAGE_BRAIN_ENABLED", "ai_gateway.url": "LAB_AI_GATEWAY_URL", "ai_gateway.model": "LAB_AI_GATEWAY_MODEL", "ai_gateway.timeout_seconds": "LAB_AI_GATEWAY_TIMEOUT_SECONDS", "ai_gateway.knowledge_index_dir": "LAB_AI_KNOWLEDGE_INDEX_DIR", "pimox.worker_base_vmid": "LAB_PIMOX_WORKER_BASE_VMID", "pimox.default_worker_count": "LAB_PIMOX_WORKER_COUNT", } def flatten(document, prefix=""): values = {} if isinstance(document, dict): for key, value in document.items(): path = f"{prefix}.{key}" if prefix else str(key) if isinstance(value, dict): values.update(flatten(value, path)) elif isinstance(value, list): values[path] = ",".join(str(item) for item in value) elif value is not None: values[path] = str(value) return values def parse_simple_inventory(path): stack = [] values = {} pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$") with open(path, encoding="utf-8") as handle: for raw_line in handle: if not raw_line.strip() or raw_line.lstrip().startswith("#") or raw_line.lstrip().startswith("- "): continue match = pattern.match(raw_line.rstrip("\n")) if not match: continue indent = len(match.group(1)) key = match.group(2) value = (match.group(3) or "").strip() while stack and stack[-1][0] >= indent: stack.pop() current_path = ".".join([item[1] for item in stack] + [key]) if value == "": stack.append((indent, key)) continue if " #" in value: value = value.split(" #", 1)[0].strip() values[current_path] = value.strip("\"'") return values try: import yaml except ImportError: values = parse_simple_inventory(inventory_file) else: with open(inventory_file, encoding="utf-8") as handle: values = flatten(yaml.safe_load(handle) or {}) for path, env_name in mapping.items(): value = values.get(path) if value: print(f": ${{{env_name}:={shlex.quote(value)}}}") debian_ip = values.get("hosts.debian.lan_ip") gitea_http = values.get("services.gitea.http_port") if debian_ip: print(f": ${{LAB_GITEA_HOST:={shlex.quote(debian_ip)}}}") if debian_ip and gitea_http: print(f": ${{LAB_GITEA_LOCAL_URL:={shlex.quote(f'http://{debian_ip}:{gitea_http}/')}}}") gitea_ts = values.get("hosts.debian.tailscale_ip") if gitea_ts: print(f": ${{LAB_GITEA_TAILSCALE_IP:={shlex.quote(gitea_ts)}}}") subdomains = values.get("domain.subdomains") if subdomains: if isinstance(subdomains, str): subdomain_values = [item.strip() for item in subdomains.split(",") if item.strip()] else: subdomain_values = list(subdomains) if subdomain_values: print(f": ${{LAB_ADDITIONAL_SERVER_NAMES_JSON:={shlex.quote(json.dumps(subdomain_values))}}}") PY )" if [[ -n "${rendered_defaults}" ]]; then eval "${rendered_defaults}" fi } export_if_unset() { local name="$1" local value="$2" if [[ -z "${value}" ]]; then return 0 fi if [[ -z "${!name:-}" ]]; then printf -v "${name}" '%s' "${value}" fi # shellcheck disable=SC2163 export "${name?}" } export_homelab_inventory_tf_vars() { export_if_unset TF_VAR_kubeconfig_path "${LAB_KUBECONFIG_PATH:-}" export_if_unset TF_VAR_control_plane_endpoint "${LAB_DEBIAN_LAN_IP:-}" export_if_unset TF_VAR_registry_endpoint "${LAB_REGISTRY_ENDPOINT:-}" export_if_unset TF_VAR_provisioning_host "${LAB_DEBIAN_LAN_IP:-}" export_if_unset TF_VAR_provisioning_user "${LAB_DEBIAN_USER:-}" export_if_unset TF_VAR_http_host "${LAB_DEBIAN_LAN_IP:-}" export_if_unset TF_VAR_pimox_host "${LAB_PIMOX_HOST:-}" export_if_unset TF_VAR_pimox_user "${LAB_PIMOX_USER:-}" export_if_unset TF_VAR_pimox_worker_storage "${LAB_PIMOX_WORKER_STORAGE:-}" export_if_unset TF_VAR_pimox_template_bridge "${LAB_PIMOX_BRIDGE:-}" export_if_unset TF_VAR_pimox_template_build_user "${LAB_DEBIAN_USER:-}" export_if_unset TF_VAR_pimox_template_guest_ip_prefix "${LAB_LAN_IP_PREFIX:-}" export_if_unset TF_VAR_edge_host "${LAB_EDGE_HOST:-}" export_if_unset TF_VAR_edge_user "${LAB_EDGE_USER:-}" export_if_unset TF_VAR_edge_install_dir "${LAB_EDGE_INSTALL_DIR:-}" export_if_unset TF_VAR_server_name "${LAB_DOMAIN:-}" export_if_unset TF_VAR_additional_server_names "${LAB_ADDITIONAL_SERVER_NAMES_JSON:-}" export_if_unset TF_VAR_backend_host "${LAB_TRAEFIK_LB_IP:-}" export_if_unset TF_VAR_gitea_backend_host "${LAB_DEBIAN_TAILSCALE_IP:-}" export_if_unset TF_VAR_gitea_backend_port "${LAB_GITEA_HTTP_PORT:-}" export_if_unset TF_VAR_heimdall_backend_host "${LAB_HEIMDALL_BACKEND_HOST:-${LAB_DEBIAN_TAILSCALE_IP:-}}" export_if_unset TF_VAR_heimdall_backend_port "${LAB_HEIMDALL_HTTP_PORT:-}" export_if_unset TF_VAR_gitops_repo_url "${LAB_GITOPS_REPO_URL:-}" export_if_unset TF_VAR_worker_tailscale_enabled "${LAB_PIMOX_WORKER_TAILSCALE_ENABLED:-}" export_if_unset TF_VAR_worker_tailscale_accept_routes "${LAB_PIMOX_WORKER_TAILSCALE_ACCEPT_ROUTES:-}" export_if_unset TF_VAR_worker_tailscale_pod_egress_snat "${LAB_PIMOX_WORKER_TAILSCALE_POD_EGRESS_SNAT:-}" } sops_available() { command -v sops >/dev/null 2>&1 } sops_load_secrets() { local file="$1" local key_file local decrypted_text local rendered_env if [[ ! -s "${file}" ]]; then echo "Missing encrypted secrets file: ${file}" >&2 return 1 fi if ! sops_available; then echo "sops is required to decrypt ${file}." >&2 return 1 fi key_file="$(sops_age_key_file)" if [[ -s "${key_file}" ]]; then decrypted_text="$(SOPS_AGE_KEY_FILE="${key_file}" sops -d "${file}")" || return 1 else decrypted_text="$(sops -d "${file}")" || return 1 fi rendered_env="$(SECRET_TEXT="${decrypted_text}" python3 - <<'PY' import os import re import shlex try: import yaml except Exception: yaml = None text = os.environ.get("SECRET_TEXT", "") if yaml is not None: document = yaml.safe_load(text) or {} if not isinstance(document, dict): document = {} else: document = {} pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$") for line in text.splitlines(): match = pattern.match(line) if not match: continue key, value = match.groups() if key == "sops": continue document[key] = value.strip().strip('"').strip("'") for key, value in document.items(): if key == "sops" or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", str(key)): continue if value is None: value = "" if isinstance(value, (dict, list)): continue print(f"export {key}={shlex.quote(str(value))}") PY )" if [[ -z "${rendered_env}" ]]; then echo "No exportable secrets found in ${file}." >&2 return 1 fi while IFS= read -r assignment; do local name [[ -n "${assignment}" ]] || continue name="${assignment#export }" name="${name%%=*}" if [[ -n "${!name:-}" ]]; then continue fi eval "${assignment}" done <<<"${rendered_env}" } secrets_init() { local key_file local key_dir local recipient local config_file="${SOPS_CONFIG:-${REPO_ROOT}/.sops.yaml}" local example_file="${REPO_ROOT}/.sops.yaml.example" require_debian_server "secrets-init" ensure_sops_age_tools key_file="$(sops_age_key_file)" key_dir="$(dirname "${key_file}")" mkdir -p "${key_dir}" chmod 700 "${key_dir}" if [[ ! -s "${key_file}" ]]; then echo "Generating age identity at ${key_file}..." age-keygen -o "${key_file}" chmod 600 "${key_file}" else echo "Using existing age identity at ${key_file}." chmod 600 "${key_file}" fi recipient="$(sops_age_recipient "${key_file}")" if [[ -z "${recipient}" ]]; then echo "Could not read the public recipient from ${key_file}." >&2 exit 1 fi if [[ -e "${config_file}" ]]; then if grep -q 'age1replacewithyourpublicrecipient' "${config_file}"; then echo "${config_file} still contains the placeholder age recipient." >&2 exit 1 fi echo "SOPS config already exists: ${config_file}" else if [[ ! -s "${example_file}" ]]; then echo "Missing ${example_file}" >&2 exit 1 fi sed "s/age1replacewithyourpublicrecipient/${recipient}/g" "${example_file}" >"${config_file}" echo "Wrote ${config_file} with Debian host age recipient ${recipient}." fi echo "Private age key: ${key_file}" echo "Public age recipient: ${recipient}" echo "Review and commit ${config_file}; never commit ${key_file}." } secrets_check_file() { local file="$1" local key_file="$2" case "${file}" in *.json) grep -q '"sops"' "${file}" || { echo "${file} does not look SOPS-encrypted." >&2 return 1 } ;; *) grep -q '^sops:' "${file}" || { echo "${file} does not look SOPS-encrypted." >&2 return 1 } ;; esac if [[ -s "${key_file}" ]]; then SOPS_AGE_KEY_FILE="${key_file}" sops -d "${file}" >/dev/null fi } secrets_check() { local config_file="${SOPS_CONFIG:-${REPO_ROOT}/.sops.yaml}" local key_file local recipient="" local failures=0 local found_files=0 local file key_file="$(sops_age_key_file)" if [[ ! -s "${config_file}" ]]; then echo "Missing ${config_file}. Run ./jeannie secrets-init on the Debian host." >&2 failures=$((failures + 1)) elif grep -q 'age1replacewithyourpublicrecipient' "${config_file}"; then echo "${config_file} still contains the placeholder age recipient." >&2 failures=$((failures + 1)) fi if [[ -s "${key_file}" ]]; then recipient="$(sops_age_recipient "${key_file}")" if [[ -n "${recipient}" && -s "${config_file}" ]] && ! grep -q "${recipient}" "${config_file}"; then echo "${config_file} does not include this host's age recipient ${recipient}." >&2 failures=$((failures + 1)) fi else echo "No local age key found at ${key_file}; encrypted file structure will be checked without decrypting." fi if command -v git >/dev/null 2>&1; then while IFS= read -r file; do [[ -n "${file}" ]] || continue found_files=$((found_files + 1)) secrets_check_file "${REPO_ROOT}/${file}" "${key_file}" || failures=$((failures + 1)) done < <(git -C "${REPO_ROOT}" ls-files '*.secret.yaml' '*.secret.yml' '*.secret.json' '*.enc.yaml' '*.enc.yml' '*.enc.json') fi if ((failures > 0)); then echo "Secret checks failed with ${failures} issue(s)." >&2 exit 1 fi if ((found_files == 0)); then echo "SOPS config checks passed. No encrypted secret files are committed yet." else echo "SOPS config checks passed for ${found_files} encrypted secret file(s)." fi }