chore: update jeannie toolchain and add ai-model-check utility
This commit is contained in:
parent
087a65fdb8
commit
d189428faf
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue