chore: update jeannie toolchain and add ai-model-check utility
Homelab Main / validate (push) Failing after 12s Details
Homelab Main / deploy (push) Has been skipped Details

This commit is contained in:
jv 2026-09-19 00:54:47 -05:00
parent 087a65fdb8
commit d189428faf
4 changed files with 451 additions and 8308 deletions

8312
jeannie

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,313 @@
#!/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
}

View File

@ -1,2 +1,126 @@
#!/usr/bin/env bash
require_debian_server() {
local command_name="$1"
local os_id=""
if [[ "$(uname -s)" != "Linux" ]]; then
echo "Refusing to run '${command_name}' from this machine. Run it on the Debian homelab server." >&2
exit 1
fi
if [[ -r /etc/os-release ]]; then
os_id="$(awk -F= '$1 == "ID" {gsub(/"/, "", $2); print $2; exit}' /etc/os-release)"
fi
if [[ "${os_id}" != "debian" ]]; then
echo "Refusing to run '${command_name}' on ${os_id:-unknown OS}. Run it on the Debian homelab server." >&2
exit 1
fi
}
tofu_state_has_resource() {
local stack="$1"
local resource_address="$2"
tofu -chdir="${REPO_ROOT}/${stack}" state show "${resource_address}" >/dev/null 2>&1
}
helm_release_secret_exists() {
local namespace="$1"
local release_name="$2"
local secret_name
secret_name="$(kubectl --kubeconfig "${KUBECONFIG_PATH}" -n "${namespace}" get secrets \
-l "owner=helm,name=${release_name}" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)"
[[ -n "${secret_name}" ]]
}
kubernetes_resource_exists() {
local namespace="$1"
local resource_kind="$2"
local resource_name="$3"
if [[ -n "${namespace}" ]]; then
kubectl --kubeconfig "${KUBECONFIG_PATH}" -n "${namespace}" get "${resource_kind}" "${resource_name}" >/dev/null 2>&1
return $?
fi
kubectl --kubeconfig "${KUBECONFIG_PATH}" get "${resource_kind}" "${resource_name}" >/dev/null 2>&1
}
adopt_tofu_helm_release() {
local stack="$1"
local resource_address="$2"
local namespace="$3"
local release_name="$4"
local has_state=false
local has_release_secret=false
if tofu_state_has_resource "${stack}" "${resource_address}"; then
has_state=true
fi
if helm_release_secret_exists "${namespace}" "${release_name}"; then
has_release_secret=true
fi
if [[ "${has_state}" == "true" && "${has_release_secret}" == "false" ]]; then
echo "Removing stale Helm release state for ${namespace}/${release_name} from ${stack} (${resource_address}) because no Helm release secret exists..."
tofu -chdir="${REPO_ROOT}/${stack}" state rm "${resource_address}"
return 0
fi
if [[ "${has_state}" == "true" ]]; then
return 0
fi
if [[ "${has_release_secret}" == "false" ]]; then
return 0
fi
echo "Importing existing Helm release ${namespace}/${release_name} into ${stack} state (${resource_address})..."
tofu -chdir="${REPO_ROOT}/${stack}" import -input=false "${resource_address}" "${namespace}/${release_name}"
}
adopt_tofu_kubernetes_resource() {
local stack="$1"
local resource_address="$2"
local namespace="$3"
local resource_kind="$4"
local resource_name="$5"
local import_id="$6"
if tofu_state_has_resource "${stack}" "${resource_address}"; then
return 0
fi
if ! kubernetes_resource_exists "${namespace}" "${resource_kind}" "${resource_name}"; then
return 0
fi
echo "Importing existing Kubernetes ${resource_kind} ${resource_name} into ${stack} state (${resource_address})..."
tofu -chdir="${REPO_ROOT}/${stack}" import -input=false "${resource_address}" "${import_id}"
}
adopt_tofu_kubernetes_manifest() {
local stack="$1"
local resource_address="$2"
local namespace="$3"
local kubectl_kind="$4"
local api_version="$5"
local manifest_kind="$6"
local resource_name="$7"
local import_id
if tofu_state_has_resource "${stack}" "${resource_address}"; then
return 0
fi
if ! kubernetes_resource_exists "${namespace}" "${kubectl_kind}" "${resource_name}"; then
return 0
fi
import_id="apiVersion=${api_version},kind=${manifest_kind},namespace=${namespace},name=${resource_name}"
echo "Importing existing Kubernetes ${manifest_kind} ${namespace}/${resource_name} into ${stack} state (${resource_address})..."
tofu -chdir="${REPO_ROOT}/${stack}" import -input=false "${resource_address}" "${import_id}"
}

12
scripts/ai-model-check.py Normal file
View File

@ -0,0 +1,12 @@
import json
import sys
import urllib.request
endpoint, model = sys.argv[1:3]
try:
with urllib.request.urlopen("{}/api/tags".format(endpoint.rstrip("/")), timeout=5) as response:
tags = json.loads(response.read().decode("utf-8"))
models = {item.get("name", "") for item in tags.get("models", [])}
sys.exit(0 if model in models or "{}:latest".format(model) in models else 1)
except Exception:
sys.exit(1)