Fix k3s CNPG image registry resolution and import flows

- In k3s mode, resolve CNPG images to a cluster-reachable internal registry and ignore k3d/localhost-ish registry values.

- Avoid any k3d cluster/containerd interactions when PROLE_MODE=k3s; add a preflight-image action.

- Extend init scripts and Ansible k3s/mariadb import tasks/playbooks for the updated k3s flow.

- Add/extend installer UI + tests around services and action/milestone helpers.

- Add merlin MariaDB provisioning playbook, services init-script test, and a k3s datastore SQL snapshot.

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-03-07 00:23:00 -08:00
parent d2295f4c2b
commit 125da5669b
28 changed files with 830 additions and 141 deletions

View File

@ -1,5 +1,5 @@
; Prole Master Configuration File
; Generated by install.py on 2026-03-06 00:36:32
; Generated by install.py on 2026-03-06 23:44:26
; This file is used as input for Ansible deployment and k8s cluster creation.
[User]
@ -110,7 +110,7 @@ ANSIBLE_DOMAIN = prole.org
ANSIBLE_INFRASTRUCTURE = ${PROLE_HOME}/infrastructure
ANSIBLE_INVENTORY = ${PROLE_HOME}/infrastructure/inventory
ANSIBLE_REALM = PROLE.ORG
ANSIBLE_TOPOLOGY = {"domain":"prole.org","realm":"PROLE.ORG","internal_records":{"aventage.prole.org":"10.0.0.206","fairyland.prole.org":"10.0.0.208","loghost.prole.org":"10.0.0.3","merlin.prole.org":"10.0.0.3","morana.prole.org":"10.0.0.66","morgoth.prole.org":"10.0.0.204","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207","synology.prole.org":"10.0.0.203","zinfandel.prole.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":true},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_hosts":["myrddin.prole.org","pi.prole.org","retropie.prole.org","merlin.prole.org"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"]},"hosts":{"merlin":"10.0.0.3","merlin.prole.org":"10.0.0.3","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207"},"unmapped_hosts":["mariadb_primary","mariadb_replica"]}
ANSIBLE_TOPOLOGY = {"domain":"prole.org","realm":"PROLE.ORG","internal_records":{"aventage.prole.org":"10.0.0.206","fairyland.prole.org":"10.0.0.208","loghost.prole.org":"10.0.0.3","merlin.prole.org":"10.0.0.3","morana.prole.org":"10.0.0.66","morgoth.prole.org":"10.0.0.204","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207","synology.prole.org":"10.0.0.203","zinfandel.prole.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"","server_host":"","token_present":true},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org","pi.prole.org","merlin.prole.org"],"k3s_agents":[],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"]},"hosts":{"merlin":"10.0.0.3","merlin.prole.org":"10.0.0.3","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207"},"unmapped_hosts":["k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]}
KDC_ANSIBLE_DETECTED = 10.0.0.3
KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True

View File

@ -156,9 +156,63 @@ resolve_cnpg_image() {
# Using LOCAL_REGISTRY (e.g., localhost:5000) breaks inside cluster and may
# also prefer IPv6 ::1, leading to connection refused. Avoid it.
local registry=""
if [[ -n "${LOCAL_REGISTRY_INTERNAL:-}" ]]; then
registry="${LOCAL_REGISTRY_INTERNAL}"
elif [[ -n "${LOCAL_REGISTRY:-}" ]]; then
_cnpg_registry_looks_like_k3d() {
local r="${1:-}"
[[ -n "$r" ]] || return 1
case "$r" in
k3d-*|*"/k3d-"*) return 0 ;;
esac
return 1
}
_cnpg_registry_is_localhostish() {
local r="${1:-}"
[[ -n "$r" ]] || return 1
case "$r" in
localhost:5000|127.0.0.1:5000|*.localhost|*.localhost:5000) return 0 ;;
esac
return 1
}
_cnpg_k3s_internal_registry() {
# For k3s, never use k3d registry names or localhost-ish endpoints.
local r="${LOCAL_REGISTRY_INTERNAL:-}"
if _cnpg_registry_looks_like_k3d "$r" || _cnpg_registry_is_localhostish "$r"; then
r=""
fi
if [[ -n "$r" ]]; then
printf '%s' "$r"
return 0
fi
local host=""
if command -v _prole_host_from_url >/dev/null 2>&1; then
host=$(_prole_host_from_url "${PROLE_K3S_SERVER:-${K3S_SERVER_URL:-}}")
fi
if [[ -n "${host:-}" ]]; then
printf '%s' "${host}:5000"
return 0
fi
local ns="${SERVICE_NAMESPACE:-${PROLE_NAMESPACE:-default}}"
printf '%s' "registry.${ns}.svc.cluster.local:5000"
}
local _mode
_mode=$(prole_normalize_mode "${PROLE_MODE:-${DEPLOYMENT_MODE:-}}")
case "${_mode}" in
k3s)
registry=$(_cnpg_k3s_internal_registry)
;;
*)
if [[ -n "${LOCAL_REGISTRY_INTERNAL:-}" ]]; then
registry="${LOCAL_REGISTRY_INTERNAL}"
fi
;;
esac
if [[ -z "$registry" && -n "${LOCAL_REGISTRY:-}" ]]; then
# Only fall back to LOCAL_REGISTRY when INTERNAL is not available
# and it's not pointing at localhost (which is invalid for cluster pulls).
if [[ "${LOCAL_REGISTRY}" != "localhost:5000" && "${LOCAL_REGISTRY}" != "127.0.0.1:5000" ]]; then
@ -642,12 +696,141 @@ _push_to_k3s_registry() {
local push_host="${LOCAL_REGISTRY:-${LOCAL_REGISTRY_INTERNAL:-myrddin.prole.org:5000}}"
local plain_image="${image##*/}"
# Normalize push_host (strip scheme if provided)
push_host="${push_host#http://}"
push_host="${push_host#https://}"
_import_image_to_k3s_nodes() {
local import_image="$1"
if [[ "${PROLE_MODE:-}" != "k3s" ]]; then
return 1
fi
if [[ -z "${PROLE_HOME:-}" || ! -d "${PROLE_HOME}/infrastructure/playbooks" ]]; then
return 1
fi
if ! command -v docker >/dev/null 2>&1 || ! command -v ansible-playbook >/dev/null 2>&1; then
return 1
fi
local tar_path vars_path
tar_path="$(mktemp -t prole-db-image.XXXXXX).tar"
vars_path="$(mktemp -t prole-k3s-import.XXXXXX).yml"
echo " Exporting image '$import_image' to '$tar_path' for direct k3s import ..."
if ! docker save "$import_image" -o "$tar_path" >/dev/null 2>&1; then
rm -f "$tar_path" "$vars_path" >/dev/null 2>&1 || true
return 1
fi
cat >"$vars_path" <<EOF
k3s_import_images:
- path: "$tar_path"
image: "$import_image"
EOF
local vault_arg=()
if [[ -f "${PROLE_HOME}/.vault_pass" ]]; then
vault_arg+=(--vault-password-file "${PROLE_HOME}/.vault_pass")
fi
echo " Importing image into k3s nodes via Ansible (playbook: k3s_import_images.yml) ..."
if ANSIBLE_CONFIG="${PROLE_HOME}/ansible.cfg" ansible-playbook "${vault_arg[@]}" \
"${PROLE_HOME}/infrastructure/playbooks/k3s_import_images.yml" \
-l k3s_hosts \
-e "@${vars_path}"; then
echo " ✓ Image imported into k3s nodes via containerd import."
rm -f "$tar_path" "$vars_path" >/dev/null 2>&1 || true
return 0
fi
rm -f "$tar_path" "$vars_path" >/dev/null 2>&1 || true
return 1
}
if command -v skopeo >/dev/null 2>&1; then
# Avoid long partial uploads when the host registry endpoint is unreachable.
if command -v curl >/dev/null 2>&1; then
if ! curl -fsS -m 2 "http://${push_host}/v2/" >/dev/null 2>&1; then
# Prefer direct containerd import in k3s when the registry endpoint isn't reachable.
if _import_image_to_k3s_nodes "$image"; then
return 0
fi
echo " WARN: Registry endpoint 'http://${push_host}/v2/' is not reachable; will try port-forward fallback." >&2
else
echo " Pushing to k3s registry at '${push_host}' using skopeo ..."
if skopeo copy --dest-tls-verify=false docker-daemon:"$image" docker://"${push_host}/${plain_image}"; then
echo " ✓ Image pushed to registry at '${push_host}'."
return 0
fi
fi
else
echo " Pushing to k3s registry at '${push_host}' using skopeo ..."
if skopeo copy --dest-tls-verify=false docker-daemon:"$image" docker://"${push_host}/${plain_image}"; then
echo " ✓ Image pushed to registry at '${push_host}'."
return 0
fi
fi
fi
# Fallback: port-forward the in-cluster registry service and push via localhost.
# This avoids relying on hostPort / firewall rules for ${push_host}.
if command -v kubectl >/dev/null 2>&1 && command -v skopeo >/dev/null 2>&1; then
local reg_ns="${REGISTRY_NAMESPACE:-}"
# In k3s mode, the registry is a service-layer component and should live in
# SERVICE_NAMESPACE; normalize legacy/default config to that namespace.
if [[ "${PROLE_MODE:-}" == "k3s" && ( -z "$reg_ns" || "$reg_ns" == "default" ) ]]; then
reg_ns="${SERVICE_NAMESPACE:-}"
fi
if [[ -z "$reg_ns" ]]; then
reg_ns="${SERVICE_NAMESPACE:-default}"
fi
local pf_port="${PROLE_REGISTRY_PORT_FORWARD_LOCAL:-55000}"
local pf_log
pf_log="$(mktemp -t prole-registry-pf.XXXXXX)"
echo " Trying registry push via kubectl port-forward (namespace='${reg_ns}', local=127.0.0.1:${pf_port} -> svc/registry:5000) ..."
kubectl -n "$reg_ns" port-forward --address 127.0.0.1 svc/registry "${pf_port}:5000" >"$pf_log" 2>&1 &
local pf_pid=$!
local ready=0
if command -v curl >/dev/null 2>&1; then
for _i in {1..40}; do
if curl -fsS -m 1 "http://127.0.0.1:${pf_port}/v2/" >/dev/null 2>&1; then
ready=1
break
fi
if ! kill -0 "$pf_pid" >/dev/null 2>&1; then
break
fi
sleep 0.5
done
else
# Without curl, best-effort short delay before attempting push.
sleep 2
ready=1
fi
local rc=1
if [[ "$ready" == "1" ]]; then
if skopeo copy --dest-tls-verify=false docker-daemon:"$image" docker://"127.0.0.1:${pf_port}/${plain_image}"; then
echo " ✓ Image pushed to registry via port-forward."
rc=0
fi
else
echo " WARN: registry port-forward did not become ready (see $pf_log)." >&2
fi
kill "$pf_pid" >/dev/null 2>&1 || true
wait "$pf_pid" >/dev/null 2>&1 || true
rm -f "$pf_log" >/dev/null 2>&1 || true
if [[ $rc -eq 0 ]]; then
return 0
fi
if _import_image_to_k3s_nodes "$image"; then
return 0
fi
fi
# Fallback to docker push if skopeo is missing or fails (might fail if daemon not configured)
@ -708,34 +891,37 @@ _ensure_prole_db_image() {
fi
fi
image=$(resolve_cnpg_image "$image")
# Auto-detect active k3d cluster name
local cluster_name="${K3D_CLUSTER_NAME:-}"
if [[ -z "$cluster_name" ]]; then
cluster_name=$(k3d cluster list --no-headers 2>/dev/null | awk '{print $1}' | head -1)
fi
cluster_name="${cluster_name:-knoe-dev-cluster}"
local prole_db_dir="${PROLE_HOME:-$SCRIPT_DIR/..}/prole-db"
local plain_image="${image##*/}" # e.g. prole-db:18-088
echo "Pre-flight: verifying image '$image' is available in k3d cluster '$cluster_name' ..."
# Step 1: check if already present in k3d containerd with matching digest
local containerd_digest local_digest containerd_sha
containerd_digest=$(docker exec "k3d-${cluster_name}-server-0" \
ctr images ls -q 2>/dev/null | grep -F "$image" | head -1 || true)
if [[ -n "$containerd_digest" ]]; then
local_digest=$(docker inspect --format='{{index .RepoDigests 0}}' "$image" 2>/dev/null \
| awk -F@ '{print $2}' || true)
containerd_sha=$(docker exec "k3d-${cluster_name}-server-0" \
ctr images ls 2>/dev/null | grep -F "$image" | awk '{print $3}' | head -1 || true)
if [[ -z "$local_digest" || "$containerd_sha" == "$local_digest" ]]; then
echo " ✓ Image '$image' already in k3d containerd (digest match); no import needed."
return 0
if [[ "${PROLE_MODE:-}" == "k3s" ]]; then
echo "Pre-flight: ensuring image '$image' is available in k3s registry/import path ..."
else
# Auto-detect active k3d cluster name
local cluster_name="${K3D_CLUSTER_NAME:-}"
if [[ -z "$cluster_name" ]]; then
cluster_name=$(k3d cluster list --no-headers 2>/dev/null | awk '{print $1}' | head -1)
fi
cluster_name="${cluster_name:-knoe-dev-cluster}"
echo "Pre-flight: verifying image '$image' is available in k3d cluster '$cluster_name' ..."
# Step 1: check if already present in k3d containerd with matching digest
local containerd_digest local_digest containerd_sha
containerd_digest=$(docker exec "k3d-${cluster_name}-server-0" \
ctr images ls -q 2>/dev/null | grep -F "$image" | head -1 || true)
if [[ -n "$containerd_digest" ]]; then
local_digest=$(docker inspect --format='{{index .RepoDigests 0}}' "$image" 2>/dev/null \
| awk -F@ '{print $2}' || true)
containerd_sha=$(docker exec "k3d-${cluster_name}-server-0" \
ctr images ls 2>/dev/null | grep -F "$image" | awk '{print $3}' | head -1 || true)
if [[ -z "$local_digest" || "$containerd_sha" == "$local_digest" ]]; then
echo " ✓ Image '$image' already in k3d containerd (digest match); no import needed."
return 0
fi
echo " Image '$image' in containerd but digest mismatch (local: ${local_digest:-unknown}, containerd: ${containerd_sha:-unknown}); re-importing ..."
docker exec "k3d-${cluster_name}-server-0" ctr images rm "$image" 2>/dev/null || true
fi
echo " Image '$image' in containerd but digest mismatch (local: ${local_digest:-unknown}, containerd: ${containerd_sha:-unknown}); re-importing ..."
docker exec "k3d-${cluster_name}-server-0" ctr images rm "$image" 2>/dev/null || true
fi
# Step 2a: registry-tagged image in local Docker daemon → push + import
@ -795,7 +981,11 @@ _ensure_prole_db_image() {
echo "ERROR: Dockerfile not found in '$prole_db_dir'; cannot build prole-db image." >&2
return 1
fi
echo " Image '$image' not found in k3d, Docker daemon, or docker-import dir."
if [[ "${PROLE_MODE:-}" == "k3s" ]]; then
echo " Image '$image' not found in Docker daemon or docker-import dir."
else
echo " Image '$image' not found in k3d, Docker daemon, or docker-import dir."
fi
echo " Building prole-db image from '$prole_db_dir' ..."
if ! docker build -t "$plain_image" "$prole_db_dir"; then
echo "ERROR: docker build failed for image '$plain_image'." >&2
@ -1003,13 +1193,8 @@ wait_for_cnpg_pods() {
fail_image=$(kubectl -n "$NAMESPACE" get pod "$fail_pod" \
-o jsonpath='{.spec.initContainers[0].image}' 2>/dev/null || true)
fi
echo "WARN: Pod '$fail_pod' cannot pull image '${fail_image:-unknown}' (ErrImagePull/ImagePullBackOff in k3d mode)." >&2
echo "WARN: Pod '$fail_pod' cannot pull image '${fail_image:-unknown}' (ErrImagePull/ImagePullBackOff)." >&2
import_dir="${DOCKER_IMPORT_DIR:-${PROLE_DATA:+${PROLE_DATA}/docker-import}}"
cluster_name="${K3D_CLUSTER_NAME:-}"
if [[ -z "$cluster_name" ]]; then
cluster_name=$(k3d cluster list --no-headers 2>/dev/null | awk '{print $1}' | head -1)
fi
cluster_name="${cluster_name:-knoe-dev-cluster}"
# Re-run full image ensure logic to push/import the failing image
echo " Attempting image remediation via _ensure_prole_db_image ..." >&2
_ensure_prole_db_image >&2 || true
@ -1816,7 +2001,16 @@ deploy_cluster() {
fi
}
preflight_image() {
ensure_tools
ensure_namespace
_ensure_prole_db_image
}
case "$ACTION" in
preflight-image|image-preflight|preflight_image)
preflight_image
;;
recreate)
ensure_tools
"$0" delete "$CNPG_CLUSTER_NAME"

View File

@ -110,13 +110,34 @@ import_image_k3s() {
fi
echo "Importing $DB_MANAGER_IMAGE into k3s nodes ..."
if [[ -f "$ansible_cfg" ]]; then
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { echo "ERROR: Failed to copy image to k3s nodes." >&2; return 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
else
"${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { echo "ERROR: Failed to copy image to k3s nodes." >&2; return 1; }
"${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
if ! command -v ansible-playbook >/dev/null 2>&1; then
echo "ERROR: ansible-playbook not available; cannot import image into k3s nodes." >&2
return 1
fi
local playbook="$PROLE_HOME/infrastructure/playbooks/k3s_import_images.yml"
if [[ ! -f "$playbook" ]]; then
echo "ERROR: Ansible playbook not found: $playbook" >&2
return 1
fi
local images_json
images_json=$(printf '[{"path": "%s", "image": "%s"}]' "$tar_path" "$DB_MANAGER_IMAGE")
local ansible_cmd=(ansible-playbook -f 1)
if [[ -n "$vault_file" ]]; then
ansible_cmd+=("--vault-password-file" "$vault_file")
fi
ansible_cmd+=(-e "{\"k3s_import_images\": $images_json}")
ansible_cmd+=("$playbook")
if [[ -f "$ansible_cfg" ]]; then
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_cmd[@]}" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
else
"${ansible_cmd[@]}" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
fi
echo "Image imported to k3s nodes successfully."
}

View File

@ -384,12 +384,32 @@ build_prole_kdc_image() {
fi
log "Importing authority image into k3s nodes (group: k3s_hosts)"
if ! command -v ansible-playbook >/dev/null 2>&1; then
err "ERROR: ansible-playbook not available; cannot import authority image to k3s nodes."
exit 1
fi
local playbook="${SCRIPT_DIR}/../infrastructure/playbooks/k3s_import_images.yml"
if [[ ! -f "$playbook" ]]; then
err "ERROR: Ansible playbook not found: $playbook"
exit 1
fi
local images_json
images_json=$(printf '[{"path": "%s", "image": "%s"}]' "$tar_path" "$local_tag")
local ansible_cmd=(ansible-playbook -f 1)
if [[ -n "$vault_file" ]]; then
ansible_cmd+=("--vault-password-file" "$vault_file")
fi
ansible_cmd+=(-e "{\"k3s_import_images\": $images_json}")
ansible_cmd+=("$playbook")
if [[ -f "$ansible_cfg" ]]; then
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { err "ERROR: Failed to copy authority image to k3s nodes."; exit 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_cmd[@]}" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
else
"${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { err "ERROR: Failed to copy authority image to k3s nodes."; exit 1; }
"${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
"${ansible_cmd[@]}" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
fi
else
err "ERROR: ansible not available; cannot import authority image to k3s nodes."

View File

@ -25,6 +25,10 @@ unset _has_config _arg
common_core_preparse_config "$@"
# Remove -c/--config from the remaining args now that PROLE_CONF is set.
# `common_core_preparse_config` exposes the stripped args as `COMMON_CORE_ARGS`.
set -- "${COMMON_CORE_ARGS[@]}"
# Load environment and config via prole_cfg.sh
# shellcheck disable=SC1090
source "$SCRIPT_DIR/prole_cfg.sh"
@ -70,15 +74,6 @@ EOF
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--config)
shift
# Already handled by common_core_preparse_config if we use common_core_lib.sh
# but since we don't, we can just skip it here as prole_cfg.sh
# will pick it up if we set PROLE_CONF?
# Actually prole_cfg.sh uses PROLE_CONF.
# Let's just consume it.
shift
;;
-m|--mode)
shift
prole_set_mode "${1:-}"
@ -169,6 +164,7 @@ ARGOCD_SERVER_SERVICE=${ARGOCD_SERVER_SERVICE:-argocd-server}
ARGOCD_PORT_FORWARD_LOCAL=${ARGOCD_PORT_FORWARD_LOCAL:-8081}
ARGOCD_PORT_FORWARD_REMOTE=${ARGOCD_PORT_FORWARD_REMOTE:-80}
ARGOCD_NODE_SELECTOR=${ARGOCD_NODE_SELECTOR:-}
REGISTRY_NODE_SELECTOR=${REGISTRY_NODE_SELECTOR:-}
current_mode() {
if command -v prole_normalize_mode >/dev/null 2>&1; then
@ -196,6 +192,33 @@ resolve_argocd_node_selector() {
fi
}
resolve_registry_node_selector() {
if [[ -n "$REGISTRY_NODE_SELECTOR" ]]; then
printf '%s' "$REGISTRY_NODE_SELECTOR"
return 0
fi
# Default: pin to the control-plane node so hostPort:5000 is reachable via
# the k3s server host (e.g. myrddin.prole.org:5000).
resolve_argocd_node_selector
}
apply_registry_node_selector() {
local selector
selector="$(resolve_registry_node_selector)"
if [[ -z "$selector" ]]; then
return 0
fi
local key="${selector%%=*}"
local val="${selector#*=}"
if [[ -z "$key" || -z "$val" ]]; then
echo "WARN: invalid REGISTRY_NODE_SELECTOR '$selector' (expected key=value); skipping." >&2
return 0
fi
kubectl -n "$REGISTRY_NAMESPACE" patch deployment/registry \
--type merge \
-p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"$key\":\"$val\"}}}}}" >/dev/null 2>&1 || true
}
apply_argocd_node_selector() {
local selector
selector="$(resolve_argocd_node_selector)"
@ -352,6 +375,7 @@ apply_registry() {
echo "Applying registry manifest to namespace '$REGISTRY_NAMESPACE' ..."
render_registry_manifest | kubectl apply --server-side --force-conflicts --field-manager=prole-installer --validate=false -n "$REGISTRY_NAMESPACE" -f -
apply_registry_node_selector
kubectl rollout status deploy/registry -n "$REGISTRY_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
}

View File

@ -4,5 +4,7 @@
# Remote TCP admin user created by `mariadb_primary` for management.
k3s_mariadb_admin_user: prole_admin
# Keep the k3s datastore password vaulted; by default reuse the vaulted admin password.
k3s_datastore_mariadb_password: "{{ k3s_mariadb_admin_password }}"
# Keep the k3s datastore password vaulted.
# For consistency with existing k3s server configuration, reuse the AD DC's vaulted Samba DNS admin password.
# (This ensures k3s can authenticate to the datastore after migration.)
k3s_datastore_mariadb_password: "{{ hostvars[groups['ad_dc'][0]].vault_samba_dns_admin_pass }}"

View File

@ -17,8 +17,9 @@ k3s_enabled: false
k3s_role: server
k3s_cluster_init: false
# Keep the current cluster datastore until the MariaDB cutover to merlin is performed.
k3s_datastore_mariadb_host: synology.prole.org
# k3s datastore now lives on merlin.
# NOTE: Use IP to avoid DNS drift during migration/cutover.
k3s_datastore_mariadb_host: 10.0.0.6
k3s_datastore_mariadb_port: 3306
k3s_datastore_mariadb_db: k3s
k3s_datastore_mariadb_user: prole_k3s

View File

@ -70,7 +70,7 @@ k3s_enabled: true
k3s_cluster_init: false
k3s_role: server
k3s_token: "{{ vault_k3s_token | default('') }}" # store this in vault
k3s_datastore_mariadb_host: synology.prole.org
k3s_datastore_mariadb_host: 10.0.0.6
k3s_datastore_mariadb_port: 3306
k3s_datastore_mariadb_db: k3s
k3s_datastore_mariadb_user: prole_k3s

View File

@ -18,6 +18,10 @@
ansible.builtin.setup:
gather_subset:
- min
- name: Skip import on hosts where k3s is disabled
ansible.builtin.meta: end_host
when: not (k3s_enabled | default(true) | bool)
tasks:
- name: Import image tarballs
ansible.builtin.import_role:

View File

@ -0,0 +1,15 @@
---
- name: Provision Merlin MariaDB primary (users/grants/datadir)
hosts: mariadb_primary
gather_facts: false
become: true
serial: 1
pre_tasks:
- name: Gather minimal facts
ansible.builtin.setup:
gather_subset:
- min
roles:
- mariadb_primary

View File

@ -1,10 +1,28 @@
---
- name: Wait for k3s containerd socket to be present
ansible.builtin.wait_for:
path: /run/k3s/containerd/containerd.sock
state: present
timeout: "{{ k3s_containerd_socket_wait_timeout | default(120) }}"
changed_when: false
- name: Verify k3s container runtime is available
ansible.builtin.command: k3s ctr -n k8s.io version
register: _k3s_ctr_version
changed_when: false
failed_when: false
retries: 12
delay: 5
until: _k3s_ctr_version.rc == 0
- name: Fail if k3s container runtime is not available
ansible.builtin.fail:
msg: >-
k3s/containerd is not ready on {{ inventory_hostname }}.
Expected /run/k3s/containerd/containerd.sock and a working `k3s ctr`.
Check the k3s service status and node networking, then retry the import.
when: _k3s_ctr_version.rc != 0
- name: Import image tarballs into k3s containerd
ansible.builtin.include_tasks: import_image.yml
@ -13,5 +31,4 @@
loop_var: k3s_import_image
label: "{{ k3s_import_image.path | basename }}"
when:
- _k3s_ctr_version.rc == 0
- (k3s_import_images | length) > 0

View File

@ -121,6 +121,7 @@
mysql --protocol=socket --user=root
--execute="
CREATE USER IF NOT EXISTS '{{ k3s_mariadb_admin_user }}'@'%' IDENTIFIED BY '{{ k3s_mariadb_admin_password }}';
ALTER USER '{{ k3s_mariadb_admin_user }}'@'%' IDENTIFIED BY '{{ k3s_mariadb_admin_password }}';
GRANT ALL PRIVILEGES ON *.* TO '{{ k3s_mariadb_admin_user }}'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;"
when: not ansible_check_mode
@ -139,6 +140,7 @@
--execute="
CREATE DATABASE IF NOT EXISTS `{{ k3s_datastore_mariadb_db | default('k3s') }}`;
CREATE USER IF NOT EXISTS '{{ k3s_datastore_mariadb_user | default('prole_k3s') }}'@'%' IDENTIFIED BY '{{ k3s_datastore_mariadb_password }}';
ALTER USER '{{ k3s_datastore_mariadb_user | default('prole_k3s') }}'@'%' IDENTIFIED BY '{{ k3s_datastore_mariadb_password }}';
GRANT ALL PRIVILEGES ON `{{ k3s_datastore_mariadb_db | default('k3s') }}`.* TO '{{ k3s_datastore_mariadb_user | default('prole_k3s') }}'@'%';
FLUSH PRIVILEGES;"
when: not ansible_check_mode

View File

@ -34,32 +34,39 @@
changed_when: false
- name: Refuse dumpfiles that appear to reference other databases
ansible.builtin.command: >-
{{ ansible_playbook_python }} -c
"import re,sys
dump=sys.argv[1]
target_db=sys.argv[2]
bad=set()
with open(dump,'r',encoding='utf-8',errors='ignore') as f:
ansible.builtin.shell: |
set -euo pipefail
python3 - "{{ mariadb_import_dumpfile }}" "{{ k3s_datastore_mariadb_db }}" <<'PY'
import re
import sys
dump = sys.argv[1]
target_db = sys.argv[2]
bad = set()
with open(dump, 'r', encoding='utf-8', errors='ignore') as f:
for ln in f:
if ln.startswith('CREATE DATABASE'):
bad.add('CREATE DATABASE')
if ln.startswith('USE '):
m=re.match(r'^USE\\s+`?([^` ;]+)`?;?', ln.strip())
m = re.match(r'^USE\s+`?([^` ;]+)`?;?', ln.strip())
if m and m.group(1) != target_db:
bad.add(m.group(1))
if bad:
print('Dumpfile references non-target DB(s) or contains CREATE DATABASE:', ','.join(sorted(bad)))
sys.exit(2)
print('Dumpfile scope OK')"
{{ mariadb_import_dumpfile }}
{{ k3s_datastore_mariadb_db }}
print('Dumpfile scope OK')
PY
args:
executable: /bin/bash
changed_when: false
- name: Import dumpfile into k3s datastore database (deliberate operator action)
ansible.builtin.shell: >-
set -euo pipefail;
mysql --protocol=socket --user=root {{ k3s_datastore_mariadb_db }} < {{ mariadb_import_dumpfile }}
mysql --protocol=socket --user=root "{{ k3s_datastore_mariadb_db }}" < "{{ mariadb_import_dumpfile }}"
args:
executable: /bin/bash
changed_when: true

View File

@ -13,7 +13,7 @@ network:
{% if (netplan_static_nameservers_effective | default([])) | length > 0 %}
nameservers:
addresses:
{% for ns in netplan_static_nameservers_effective %}
{% for ns in (netplan_static_nameservers_effective | map('trim') | reject('equalto','') | unique | sort) %}
- {{ ns }}
{% endfor %}
{% endif %}

View File

@ -4,6 +4,7 @@ Installer actions and unattended workflow helpers.
from __future__ import annotations
import json
import logging
import secrets
import shlex
@ -2296,30 +2297,34 @@ class ProleSilentInstaller(ProleInstallerBase):
vault_file = str(candidate)
host_group = "k3s_hosts"
ansible_base = ["ansible", host_group]
if vault_file:
ansible_base += ["--vault-password-file", vault_file]
dest_path = f"/tmp/{tar_path.name}"
self.log(f"[INFO] Importing {image_name} into k3s nodes (group: {host_group})")
rc = self._run_cmd(
ansible_base
+ ["-m", "copy", "-a", f"src={tar_path} dest={dest_path} mode=0644"],
cwd=str(self.project_root),
env=ansible_env,
playbook = (
self.project_root
/ "infrastructure"
/ "playbooks"
/ "k3s_import_images.yml"
)
if rc != 0:
self.err(f"[WARN] Failed to copy {tar_path} to k3s nodes (code {rc})")
if not playbook.exists():
self.err(f"[WARN] Ansible playbook not found: {playbook}")
return False
extra_vars = json.dumps(
{
"k3s_import_images": [
{"path": str(tar_path), "image": image_name}
]
}
)
cmd = ["ansible-playbook", "-f", "1"]
if vault_file:
cmd += ["--vault-password-file", vault_file]
cmd += ["-e", extra_vars, str(playbook)]
self.log(
f"[INFO] Importing {image_name} into k3s nodes via Ansible playbook (group: {host_group})"
)
rc = self._run_cmd(
ansible_base
+ [
"-m",
"shell",
"-a",
f"ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack {dest_path}",
],
cmd,
cwd=str(self.project_root),
env=ansible_env,
)
@ -3563,6 +3568,7 @@ class ProleSilentInstaller(ProleInstallerBase):
svc_args = ["-k", "update"]
steps = [
("init_common_services.sh", svc_args, False),
("init_certmgr.sh", ["initialize"], False),
("init_cloudnative_pg.sh", ["initialize"], False),
]
if kerberos_enabled:
@ -3994,16 +4000,12 @@ class ProleSilentInstaller(ProleInstallerBase):
try:
self.inputs = {**self._default_inputs(), **self._load_inputs_from_cfg()}
# Silent mode: ensure we have a concrete master password before proceeding.
# Requirement: prompt if missing or an unresolvable reference in silent mode.
# Silent mode: ensure we have a master password source before proceeding.
# If the value is an OpenBao reference or an encrypted `${PROLE_SECRET:...}`
# placeholder, allow downstream secret-management steps to resolve it.
# Only prompt/generate when the value is truly missing.
db_pw = self._get_input("init_password.db_password", "")
needs_prompt = False
if not db_pw:
needs_prompt = True
elif _is_openbao_ref(db_pw):
resolved = self._resolve_secret_value(db_pw)
if not resolved or _is_openbao_ref(resolved):
needs_prompt = True
needs_prompt = not bool(db_pw)
if needs_prompt:
# In silent installs, never block on an interactive prompt in environments

View File

@ -369,16 +369,29 @@ class DockerBuildMilestone(Milestone):
self.logger.info("Docker build disabled.")
return
if progress:
progress("Building Prole database image...", 0.1)
tag = state.controller.get_prole_db_version()
# In k3s/service installs, `init_cloudnative_pg.sh` already has a robust
# pre-flight that ensures the `prole-db` image is present/pushed (and can
# build it if missing). Building a multi-platform image here is expensive
# and redundant when initialization scripts are enabled.
from installer.core.env import _normalize_cluster_env
env_key = (
_normalize_cluster_env(state.inputs.get("init_cluster.cluster_env", "dev"))
or "dev"
)
run_init_scripts = self._parse_bool(
state.inputs.get("init_scripts.run_scripts", "True")
)
if env_key != "dev" and run_init_scripts:
self.logger.info(
"Skipping Docker DB image build for service env; init scripts will ensure/push the image."
)
return
if progress:
progress("Building Prole database image...", 0.1)
tag = state.controller.get_prole_db_version()
args = ["--tag", tag]
if env_key != "dev":

View File

@ -156,6 +156,7 @@ class ServicesScreenMixin:
("Kubectl Status", self.KUBECTL_STATUS_TAB),
("Common Services", "init_common_services.sh"),
("Common Services Log", "common_services_log"),
("Cert-Manager", "init_certmgr.sh"),
("CloudNative-PG", "init_cloudnative_pg.sh"),
]
scripts.append(("Prole DB Backup", "init_cnpg_backup.sh"))
@ -318,6 +319,59 @@ class ServicesScreenMixin:
"Skipping common services initialization because previous steps failed.\n"
)
# 2. init_certmgr.sh initialize
if overall_success:
script = "init_certmgr.sh"
_select_tab(script)
self.script_consoles[script].clear()
self.script_consoles[script].write(f"Running {script} initialize...\n")
self.script_consoles[script].write(
"> bash etc/init_certmgr.sh initialize\n"
)
log_path = _log_path_for(script)
self._record_install_log(log_path)
try:
log_fp = log_path.open("a", encoding="utf-8")
except Exception:
log_fp = None
def _certmgr_line(line):
self.script_consoles[script].write(line)
self._process_script_output_line(line)
if log_fp:
try:
log_fp.write(line)
log_fp.flush()
except Exception:
pass
rc_cm = self.controller.run_script(
script,
args=mode_args + ["initialize"],
env=env,
on_line=_certmgr_line,
)
if log_fp:
try:
log_fp.close()
except Exception:
pass
if rc_cm != 0:
self.script_consoles[script].write(
f"\nERROR: {script} initialize failed with code {rc_cm}\n"
)
overall_success = False
else:
self.script_consoles[script].write(
f"\n{script} completed successfully.\n"
)
else:
self.script_consoles["init_certmgr.sh"].write(
"Skipping cert-manager initialization because previous steps failed.\n"
)
# 3. init_cloudnative_pg.sh initialize
if overall_success:
script = "init_cloudnative_pg.sh"

View File

@ -5,7 +5,7 @@ metadata:
name: prole-db
spec:
instances: 3
imageName: prole-db:18-102
imageName: myrddin.prole.org:5000/prole-db:18-105
postgresUID: 100
postgresGID: 101
maxSyncReplicas: 1

View File

@ -4,7 +4,7 @@ metadata:
name: prole-db
spec:
instances: 3
imageName: prole-db:18-102
imageName: myrddin.prole.org:5000/prole-db:18-105
postgresUID: 100
postgresGID: 101
maxSyncReplicas: 1

View File

@ -110,12 +110,31 @@ import_image_k3s() {
fi
echo "Importing $DB_MANAGER_IMAGE into k3s nodes ..."
if ! command -v ansible-playbook >/dev/null 2>&1; then
echo "ERROR: ansible-playbook not available; cannot import image into k3s nodes." >&2
return 1
fi
local playbook="$PROLE_HOME/infrastructure/playbooks/k3s_import_images.yml"
if [[ ! -f "$playbook" ]]; then
echo "ERROR: Ansible playbook not found: $playbook" >&2
return 1
fi
local images_json
images_json=$(printf '[{"path": "%s", "image": "%s"}]' "$tar_path" "$DB_MANAGER_IMAGE")
local ansible_cmd=(ansible-playbook -f 1)
if [[ -n "$vault_file" ]]; then
ansible_cmd+=("--vault-password-file" "$vault_file")
fi
ansible_cmd+=(-e "{\"k3s_import_images\": $images_json}")
ansible_cmd+=("$playbook")
if [[ -f "$ansible_cfg" ]]; then
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { echo "ERROR: Failed to copy image to k3s nodes." >&2; return 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_cmd[@]}" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
else
"${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { echo "ERROR: Failed to copy image to k3s nodes." >&2; return 1; }
"${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
"${ansible_cmd[@]}" || { echo "ERROR: Failed to import image into k3s nodes." >&2; return 1; }
fi
echo "Image imported to k3s nodes successfully."
}

View File

@ -383,12 +383,31 @@ build_prole_kdc_image() {
fi
log "Importing authority image into k3s nodes (group: k3s_hosts)"
if ! command -v ansible-playbook >/dev/null 2>&1; then
err "ERROR: ansible-playbook not available; cannot import authority image to k3s nodes."
exit 1
fi
local playbook="${SCRIPT_DIR}/../infrastructure/playbooks/k3s_import_images.yml"
if [[ ! -f "$playbook" ]]; then
err "ERROR: Ansible playbook not found: $playbook"
exit 1
fi
local images_json
images_json=$(printf '[{"path": "%s", "image": "%s"}]' "$tar_path" "$local_tag")
local ansible_cmd=(ansible-playbook -f 1)
if [[ -n "$vault_file" ]]; then
ansible_cmd+=("--vault-password-file" "$vault_file")
fi
ansible_cmd+=(-e "{\"k3s_import_images\": $images_json}")
ansible_cmd+=("$playbook")
if [[ -f "$ansible_cfg" ]]; then
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { err "ERROR: Failed to copy authority image to k3s nodes."; exit 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
ANSIBLE_CONFIG="$ansible_cfg" "${ansible_cmd[@]}" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
else
"${ansible_args[@]}" -m copy -a "src=$tar_path dest=$dest mode=0644" || { err "ERROR: Failed to copy authority image to k3s nodes."; exit 1; }
"${ansible_args[@]}" -m shell -a "ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack $dest" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
"${ansible_cmd[@]}" || { err "ERROR: Failed to import authority image into k3s nodes."; exit 1; }
fi
else
err "ERROR: ansible not available; cannot import authority image to k3s nodes."

View File

@ -1 +1 @@
104
105

View File

@ -9,43 +9,96 @@ SCRIPT_UNDER_TEST="$ETC_DIR/init_cloudnative_pg.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
# Mock tools
mock_tool() {
cat <<M_EOF > "$TMP_DIR/$1"
cat <<M_EOF > "$BIN_DIR/$1"
#!/usr/bin/env bash
echo "Mocked $1 called with \$@" >> "$TMP_DIR/mock_calls.log"
exit 0
M_EOF
chmod +x "$TMP_DIR/$1"
chmod +x "$BIN_DIR/$1"
}
# Custom kubectl mock: return minimal outputs so initialize() doesn't hang.
cat <<K_EOF > "$BIN_DIR/kubectl"
#!/usr/bin/env bash
_log_file="${TMP_DIR}/mock_calls.log"
echo "Mocked kubectl called with \$@" >> "${_log_file}"
args="\$*"
# CNPG webhook wait: return an endpoint IP so wait passes quickly.
if [[ "${args}" == *"get endpoints"*"cnpg-webhook-service"* ]]; then
echo "10.42.0.10"
exit 0
fi
# CNPG pods listing (no-headers) used to detect pull errors.
if [[ "${args}" == *"get pods"*"--no-headers"*"cnpg.io/cluster="* ]]; then
echo "prole-db-1 1/1 Running 0 1m"
echo "prole-db-2 1/1 Running 0 1m"
echo "prole-db-3 1/1 Running 0 1m"
exit 0
fi
# Ready pod count (jsonpath) used by wait_for_cnpg_pods.
if [[ "${args}" == *"get pods"*"jsonpath="*"Ready"*"True"* || "${args}" == *"get pods"*"jsonpath="*"status.conditions"*"Ready"* ]]; then
printf "True\nTrue\nTrue\n"
exit 0
fi
# Cluster instances; avoid defaulting logic differences.
if [[ "${args}" == *"get cluster"*"jsonpath="*".spec.instances"* ]]; then
echo "3"
exit 0
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
# Create mocks for common tools
mock_tool kubectl
mock_tool curl
mock_tool docker
mock_tool k3d
mock_tool skopeo
mock_tool ansible-playbook
mock_tool tofu
mock_tool terraform
mock_tool ollama
mock_tool jq
# Mock prole.cfg
# Mock prole.cfg (k3s mode, but with a k3d-looking LOCAL_REGISTRY_INTERNAL to reproduce the bug).
mkdir -p "$TMP_DIR/conf"
cat <<C_EOF > "$TMP_DIR/conf/prole.cfg"
[globals]
prole.home = $PROLE_HOME
prole.mode = k3d
[k3s]
server = https://localhost:6443
token = test-token
[User]
NAMESPACE = test-ns
SERVICE_NAMESPACE = test-system
PROLE_HOME = $PROLE_HOME
PROLE_SERVICE = $TMP_DIR/service
[Global]
DEPLOYMENT_MODE = k3s
PROLE_K3S_SERVER = https://myrddin.prole.org:6443
[Docker Build]
LOCAL_REGISTRY = localhost:5000
LOCAL_REGISTRY_INTERNAL = k3d-prole-registry.localhost:5000
C_EOF
export PATH="$TMP_DIR:$PATH"
mkdir -p "$TMP_DIR/service/secrets"
printf '%s' "dummy-private-key" > "$TMP_DIR/service/secrets/admin.key"
printf '%s' "dummy-public-key" > "$TMP_DIR/service/secrets/admin.pub"
export PATH="$BIN_DIR:$PATH"
export PROLE_HOME="$PROLE_HOME"
export PROLE_CONF="$TMP_DIR/conf"
export NAMESPACE="test-ns"
export PROLE_PASSWD="test-password"
export PROLE_SERVICE="$TMP_DIR/service"
# Run the script
if [[ "init_cloudnative_pg.sh" == *.py ]]; then
@ -57,17 +110,26 @@ if [[ "init_cloudnative_pg.sh" == *.py ]]; then
fi
RC=$?
else
# Check for usage() or if it's a script that likely needs arguments
if grep -q "usage()" "$SCRIPT_UNDER_TEST"; then
bash "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr"
RC=$?
else
# Try a few common non-destructive actions
bash "$SCRIPT_UNDER_TEST" status > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" || \
bash "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" || \
bash "$SCRIPT_UNDER_TEST" > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr"
RC=$?
fi
# Run a path that triggers image resolution + preflight without long waits.
bash "$SCRIPT_UNDER_TEST" --mode k3s preflight-image > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr"
RC=$?
fi
# Assertions: in k3s mode we must never touch k3d.
if grep -q "Mocked k3d called" "$TMP_DIR/mock_calls.log"; then
echo "FAILURE: init_cloudnative_pg.sh invoked k3d in k3s mode"
sed -n '1,200p' "$TMP_DIR/mock_calls.log" || true
exit 1
fi
# And we must not produce k3d registry image names.
if grep -q "k3d-prole-registry" "$TMP_DIR/stdout" "$TMP_DIR/stderr" 2>/dev/null; then
echo "FAILURE: init_cloudnative_pg.sh used k3d registry prefix in k3s mode"
echo "--- stdout ---"
sed -n '1,200p' "$TMP_DIR/stdout" || true
echo "--- stderr ---"
sed -n '1,200p' "$TMP_DIR/stderr" || true
exit 1
fi
if [[ $RC -eq 0 || $RC -eq 1 || $RC -eq 2 ]]; then

View File

@ -397,3 +397,34 @@ class TestRunScript:
"args", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else []
)
assert "--mode" not in args_passed
# ---------------------------------------------------------------------------
# _step_init_scripts
# ---------------------------------------------------------------------------
class TestStepInitScripts:
def test_includes_init_certmgr(self, monkeypatch):
inst = _TestableSilentInstaller(
inputs={
"init_cluster.cluster_env": "service",
"init_password.db_namespace": "default",
"init_password.db_password": "pw",
"kerberos_config.enabled": "false",
"init_scripts.run_scripts": "true",
}
)
inst.controller.run_script.return_value = 0
monkeypatch.setattr(inst, "_deployment_mode", lambda: "k3s")
monkeypatch.setattr(inst, "_run_cmd", lambda *a, **k: 0, raising=False)
inst._step_init_scripts()
scripts = [c.args[0] for c in inst.controller.run_script.call_args_list]
assert "init_common_services.sh" in scripts
assert "init_certmgr.sh" in scripts
assert scripts.index("init_certmgr.sh") == scripts.index("init_common_services.sh") + 1
assert inst.prole_cfg_data["Initialization Scripts"]["STATUS"] == "Completed"
assert inst._scripts_success is True

View File

@ -345,6 +345,40 @@ class TestProleController:
assert "supabase" in captured["ids"]
assert captured["ids"].index("supabase_images_preload") < captured["ids"].index("supabase")
def test_silent_installer_does_not_generate_db_password_when_openbao_ref_present(
self, tmp_path
):
c = ProleController(tmp_path)
installer = ProleSilentInstaller(c)
openbao_ref = "${OPENBAO:kv/prole/test/db#password}"
with (
mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": openbao_ref,
"init_password.db_password_confirm": openbao_ref,
},
),
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
):
def fake_run_milestones(milestones, progress_callback=None):
# No-op: we only care about the silent installer preflight logic.
return None
c.run_milestones = fake_run_milestones
rc = installer.run()
assert rc == 0
assert installer.inputs.get("init_password.db_password") == openbao_ref
# No early cfg write for generated password; only the normal writes.
assert write_cfg.call_count == 2
# ===== Milestone concrete subclasses (from core/milestones.py) =====

View File

@ -187,6 +187,9 @@ def test_docker_build_non_dev_env_adds_registry():
"init_db_build.run_build": "True",
"init_cluster.cluster_env": "prod",
"init_cluster.k3s_server_url": "https://myrddin.prole.org:6443",
# When init scripts are disabled, the DB build milestone is responsible
# for pushing the image to the registry in non-dev envs.
"init_scripts.run_scripts": "False",
})
milestone = DockerBuildMilestone()
with patch("installer.core.env._normalize_cluster_env", return_value="prod"):
@ -198,6 +201,19 @@ def test_docker_build_non_dev_env_adds_registry():
assert "--push" in build_args
def test_docker_build_non_dev_env_skips_when_init_scripts_enabled():
state = _make_state(**{
"init_db_build.run_build": "True",
"init_cluster.cluster_env": "prod",
"init_cluster.k3s_server_url": "https://myrddin.prole.org:6443",
"init_scripts.run_scripts": "True",
})
milestone = DockerBuildMilestone()
with patch("installer.core.env._normalize_cluster_env", return_value="prod"):
milestone.execute(state)
state.controller.run_script.assert_not_called()
def test_docker_build_failed_rc():
state = _make_state(**{"init_db_build.run_build": "True"})
state.controller.run_script.return_value = 1

View File

@ -0,0 +1,132 @@
from __future__ import annotations
from unittest.mock import MagicMock
def test_tk_init_scripts_runner_invokes_init_certmgr(tmp_path, monkeypatch):
import installer.ui.screens.services as services
from installer.ui.screens.services import ServicesScreenMixin
class _Var:
def __init__(self, value=""):
self._value = value
def get(self):
return self._value
class _Console:
def __init__(self):
self.buf: list[str] = []
def clear(self):
self.buf.clear()
def write(self, content: str):
self.buf.append(content)
class _Widget:
def winfo_exists(self):
return False
def configure(self, **_kwargs):
return None
class _Canvas:
def winfo_exists(self):
return False
def itemconfig(self, *_args, **_kwargs):
return None
class _ImmediateThread:
def __init__(self, target, daemon=True):
self._target = target
def start(self):
self._target()
monkeypatch.setattr(services.threading, "Thread", _ImmediateThread)
class _DummyScreen(ServicesScreenMixin):
KUBECTL_STATUS_TAB = "__kubectl_status__"
def __init__(self):
self._action_flags = {}
self.controller = MagicMock()
self.controller.run_script.return_value = 0
self._init_scripts_button = _Widget()
self.bg_canvas = _Canvas()
self._init_scripts_status_label = 0
self._script_tab_index = {}
self.script_consoles = {
"init_common_services.sh": _Console(),
"common_services_log": _Console(),
"init_certmgr.sh": _Console(),
"init_cloudnative_pg.sh": _Console(),
"init_cnpg_backup.sh": _Console(),
"init_kong.sh": _Console(),
"init_monitoring.sh": _Console(),
"init_nginx_ingress.sh": _Console(),
"init_port_forwards.sh": _Console(),
self.KUBECTL_STATUS_TAB: _Console(),
}
self.db_username = _Var("prole")
self.db_password = _Var("pw")
self.db_namespace = _Var("default")
self.kerberos_enabled = _Var(False)
self.kerberos_realm = _Var("")
self.kerberos_kdc = _Var("")
self.kerberos_user = _Var("")
self.kerberos_password = _Var("")
self.cluster_env = _Var("prole-service-cluster")
self.prole_cfg_data = {"Initialization Scripts": {"STATUS": ""}}
self._scripts_success = False
def safe_after(self, fn, delay: int = 0):
return fn()
def _deployment_mode(self):
return "k3s"
def _resolve_prole_logs_dir(self):
return tmp_path
def _get_service_namespace(self):
return "default"
def _sync_port_forward_mappings(self):
return None
def _update_legacy_port_mapping(self):
return None
def _record_install_log(self, _path):
return None
def _process_script_output_line(self, _line: str):
return None
def _run_cmd_capture(self, _args):
return 0, ""
def _save_prole_cfg(self):
return None
def update_footer(self):
return None
def check_services_status_async(self):
return None
screen = _DummyScreen()
screen.run_init_scripts()
called_scripts = [c.args[0] for c in screen.controller.run_script.call_args_list]
assert "init_certmgr.sh" in called_scripts
assert screen.prole_cfg_data["Initialization Scripts"]["STATUS"] == "Completed"