314 lines
12 KiB
Bash
314 lines
12 KiB
Bash
#!/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
|
|
}
|
|
|
|
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
|
|
}
|
|
|