Fix installer ctx apply flow and registry namespace

- Add explicit Apply button for kubectl context switching to avoid half-applied changes

- Allow kubeconfig/context-based auth without requiring K3S_TOKEN when kubeconfig is valid

- Prompt cleanup/reset of previous Common Core services namespace to prevent resource collisions

- Remove hardcoded 'common-services' registry namespace; default registry deploy/check to SERVICE_NAMESPACE/REGISTRY_NAMESPACE

- Update mocks and add regression tests for namespace resolution and installer behavior
This commit is contained in:
chrisfu 2026-03-07 10:35:27 -08:00
parent 436c6214df
commit d397b3000e
23 changed files with 596 additions and 83 deletions

View File

@ -1,5 +1,5 @@
; Prole Master Configuration File
; Generated by install.py on 2026-03-07 03:07:50
; Generated by install.py on 2026-03-07 10:32:42
; This file is used as input for Ansible deployment and k8s cluster creation.
[User]
@ -10,7 +10,7 @@ PROLE_DATA = /Users/chrisfu/.prole/data
PROLE_HOME = /Users/chrisfu/dev/prole
PROLE_LOGS = /opt/prole/logs/chrisfu
PROLE_SERVICE = ${PROLE_HOME}/etc
SERVICE_NAMESPACE = default
SERVICE_NAMESPACE = knoe-system
[Inputs]
; Screen-scoped inputs used for unattended replays (-S)
@ -42,7 +42,7 @@ dependencies.opentofu.install = true
dependencies.python.install = true
dependencies.verify_all = false
disk_selection.disk_type = local
disk_selection.local_path = /Users/chrisfu
disk_selection.local_path = ${PROLE_HOME}/prole-tools-app/dist
disk_selection.removable_mount =
env_setup.NAMESPACE = ${NAMESPACE}
env_setup.PROLE_CONF = ${PROLE_CONF}
@ -51,13 +51,13 @@ env_setup.PROLE_HOME = ${PROLE_HOME}
env_setup.PROLE_LOGS = ${PROLE_LOGS}
env_setup.PROLE_SERVICE = ${PROLE_SERVICE}
init_cluster.at_rest_encryption_enabled = true
init_cluster.cluster_env = dev
init_cluster.deployment_target = prole-dev-cluster
init_cluster.cluster_env = service
init_cluster.deployment_target = prole-service-cluster
init_cluster.gitops_enabled = false
init_cluster.k3s_server_url =
init_cluster.k3s_token =
init_cluster.kerberos_enabled = false
init_cluster.mode = k3d
init_cluster.k3s_token = ${PROLE_SECRET:v1:38WOjNObQUt_S1W_:q7cZ6C5FcsiNPfxtmUI5RtsuJ4iaBAdvLbw_1hpOx87Cvv2KL-HjbdE7s5J4aPegFw02IrwWuPP5WXe5nGkg7uTXreCsvg5uWYXc1vxjWHUzKEwVfcP2nPEKBjJrRuai1VxD8WaPbwcUMQfXEtqNrFrkBSIICYltM1hmmA==}
init_cluster.kerberos_enabled = true
init_cluster.mode = k3s
init_cluster.start_cluster = true
init_cluster.supabase_enabled = false
init_cnpg_deploy.force_rollout = false
@ -70,10 +70,10 @@ init_password.db_password_confirm =
init_password.db_username = root
init_password.generate_ssh_key = true
init_scripts.run_scripts = true
kerberos_config.enabled = false
kerberos_config.kdc =
kerberos_config.password =
kerberos_config.realm =
kerberos_config.enabled = true
kerberos_config.kdc = 10.0.0.3
kerberos_config.password = ${PROLE_SECRET:v1:q3AVwS1x8Xr8V6n9:IRDXFhRi8uBOZvDmLoT-zKVjwGsiiDQvB4c430CqJS1PWz3X}
kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = false
kerberos_config.user = administrator
network_scan.run = true
@ -83,18 +83,18 @@ ollama_config.server_port = 11434
[Global]
; Variables used by name in more than one place or assumed global scope
CLUSTER_ENV = dev
CLUSTER_ENV = service
DB_HOST_PORT = 5432
DB_PASSWORD =
DEPLOYMENT_MODE = k3d
DEPLOYMENT_TARGET = prole-dev-cluster
DEPLOYMENT_MODE = k3s
DEPLOYMENT_TARGET = prole-service-cluster
NAMESPACE = ${NAMESPACE}
PROLE_DB_USER = root
PROLE_HOME = ${PROLE_HOME}
PROLE_K3S_SERVER =
PROLE_K3S_TOKEN =
PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:38WOjNObQUt_S1W_:q7cZ6C5FcsiNPfxtmUI5RtsuJ4iaBAdvLbw_1hpOx87Cvv2KL-HjbdE7s5J4aPegFw02IrwWuPP5WXe5nGkg7uTXreCsvg5uWYXc1vxjWHUzKEwVfcP2nPEKBjJrRuai1VxD8WaPbwcUMQfXEtqNrFrkBSIICYltM1hmmA==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080
SERVICE_NAMESPACE = default
SERVICE_NAMESPACE = knoe-system
[Welcome]
; No configuration values captured yet for this section.
@ -111,6 +111,8 @@ 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.6","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"],"k3s_agents":["pi.prole.org","merlin.prole.org"],"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.6","merlin.prole.org":"10.0.0.6","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
[Port Forwards]
; No configuration values captured yet for this section.
@ -132,7 +134,10 @@ PROLE_SERVICE = ${PROLE_SERVICE}
; No configuration values captured yet for this section.
[Optional Features]
; No configuration values captured yet for this section.
AT_REST_ENCRYPTION_ENABLED = True
GITOPS_ENABLED = False
KERBEROS_ENABLED = True
SUPABASE_ENABLED = False
[GitOps]
; No configuration values captured yet for this section.
@ -141,24 +146,26 @@ PROLE_SERVICE = ${PROLE_SERVICE}
NAMESPACE = ${NAMESPACE}
[Initialize Cluster]
; No configuration values captured yet for this section.
ENVIRONMENT = service
K3S_SERVER_URL =
K3S_TOKEN = ${PROLE_SECRET:v1:38WOjNObQUt_S1W_:q7cZ6C5FcsiNPfxtmUI5RtsuJ4iaBAdvLbw_1hpOx87Cvv2KL-HjbdE7s5J4aPegFw02IrwWuPP5WXe5nGkg7uTXreCsvg5uWYXc1vxjWHUzKEwVfcP2nPEKBjJrRuai1VxD8WaPbwcUMQfXEtqNrFrkBSIICYltM1hmmA==}
[Dev Cluster (k3d)]
CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT =
KUBECTL_CONTEXT = prole-k3s
MODE = k3d
[Service Cluster (k3s)]
CLUSTER_ENV = prole-service-cluster
DISPLAY_NAME = prole-service-cluster
K3S_SERVER_URL =
K3S_TOKEN =
K3S_TOKEN = ${PROLE_SECRET:v1:38WOjNObQUt_S1W_:q7cZ6C5FcsiNPfxtmUI5RtsuJ4iaBAdvLbw_1hpOx87Cvv2KL-HjbdE7s5J4aPegFw02IrwWuPP5WXe5nGkg7uTXreCsvg5uWYXc1vxjWHUzKEwVfcP2nPEKBjJrRuai1VxD8WaPbwcUMQfXEtqNrFrkBSIICYltM1hmmA==}
MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080
[Prod Cluster (k8s)]
ARTIFACTS_DIR = /private/var/folders/rt/pywlnmxj3dn7t5552vwdcpp80000gn/T/pytest-of-chrisfu/pytest-132/test_encryption_toggle0/data/staging
ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster
MODE = k8s
@ -171,8 +178,8 @@ PIPELINE_URL = http://127.0.0.1:8080
; No configuration values captured yet for this section.
[Deployment]
MODE = k3d
TARGET = prole-dev-cluster
MODE = k3s
TARGET = prole-service-cluster
[Install]
; No configuration values captured yet for this section.

View File

@ -48,7 +48,7 @@ fi
ACTION="$COMMON_CORE_ACTION"
# cert-manager always runs in its own dedicated namespace; never follows the
# common-services namespace override.
# Registry/service namespace override.
CERTMGR_NAMESPACE="cert-manager"
common_core_apply_namespace "$CERTMGR_NAMESPACE"

View File

@ -724,7 +724,7 @@ _push_to_k3s_registry() {
# 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:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
local reg_ns="${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE:-${NAMESPACE:-default}}}"
local pf_port="${PROLE_REGISTRY_PORT_FORWARD_LOCAL:-55000}"
local pf_log
pf_log="$(mktemp -t prole-registry-pf.XXXXXX)"

View File

@ -87,7 +87,8 @@ if [ -z "$NS" ]; then
fi
ARGOCD_NS="${ARGOCD_NAMESPACE:-argocd}"
REGISTRY_NS="${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
# Registry should live in the common-core/service namespace unless explicitly overridden.
REGISTRY_NS="${REGISTRY_NAMESPACE:-${NS}}"
prole_ensure_kubeconfig >/dev/null 2>&1 || true
echo "DEBUG: prole_ensure_kubeconfig finished" >&2
@ -392,7 +393,7 @@ _preload_docker_images() {
if [[ -z "$registry" ]]; then
echo " [WARN] Could not resolve registry host (set LOCAL_REGISTRY or PROLE_K3S_SERVER)." >&2
echo " Images will be pulled at deploy time." >&2
;;
return 0
fi
_registry_ref_exists() {
@ -413,7 +414,7 @@ _preload_docker_images() {
if ! curl -fsS "http://${registry}/v2/" >/dev/null 2>&1; then
echo " [WARN] Registry not reachable at http://${registry}/v2/." >&2
echo " Images will be pulled at deploy time." >&2
;;
return 0
fi
local tar_file

View File

@ -24,7 +24,7 @@ registry_host_from_url() {
K3S_REGISTRY_HOST=${K3S_REGISTRY_HOST:-$(registry_host_from_url "${PROLE_K3S_SERVER:-${K3S_SERVER_URL:-}}")}
K3S_REGISTRY_PORT=${K3S_REGISTRY_PORT:-5000}
K3S_REGISTRY_NAMESPACE=${K3S_REGISTRY_NAMESPACE:-${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}}
K3S_REGISTRY_NAMESPACE=${K3S_REGISTRY_NAMESPACE:-${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE:-${PROLE_NAMESPACE:-default}}}}
K3S_REGISTRY_FILE=${K3S_REGISTRY_FILE:-/etc/rancher/k3s/registries.yaml}
ensure_root() {

View File

@ -56,15 +56,31 @@ KRB5_KDC=${KRB5_KDC:-}
KRB5_ADMIN=${KRB5_ADMIN:-}
local_registry_enabled() {
case "${PROLE_ENABLE_LOCAL_REGISTRY:-${ENABLE_LOCAL_REGISTRY:-}}" in
1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;;
esac
return 1
local raw="${PROLE_ENABLE_LOCAL_REGISTRY:-${ENABLE_LOCAL_REGISTRY:-}}"
if [[ -n "$raw" ]]; then
case "$raw" in
1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;;
esac
return 1
fi
local mode=""
if command -v prole_normalize_mode >/dev/null 2>&1; then
mode=$(prole_normalize_mode "${PROLE_MODE:-${DEPLOYMENT_MODE:-${CLUSTER_ENV:-}}}")
else
mode="${PROLE_MODE:-${DEPLOYMENT_MODE:-${CLUSTER_ENV:-}}}"
fi
[[ "$mode" == "k3d" || "$mode" == "k3s" ]]
}
if ! local_registry_enabled; then
PROLE_KDC_REGISTRY_HOST=""
# Only clear default local-registry settings; preserve explicit registry host values.
_default_host="${LOCAL_REGISTRY:-localhost:5000}"
if [[ -z "${PROLE_KDC_REGISTRY_HOST:-}" || "${PROLE_KDC_REGISTRY_HOST:-}" == "$_default_host" ]]; then
PROLE_KDC_REGISTRY_HOST=""
fi
PROLE_KDC_REGISTRY_INTERNAL=""
unset _default_host
fi
log() { printf '%s\n' "$*"; }
@ -264,7 +280,7 @@ resolve_prole_kdc_defaults() {
fi
fi
if [[ -z "$PROLE_KDC_REGISTRY_INTERNAL" ]]; then
local reg_ns="${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
local reg_ns="${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE:-${NAMESPACE:-default}}}"
PROLE_KDC_REGISTRY_INTERNAL="registry.${reg_ns}.svc.cluster.local:5000"
unset reg_ns
fi

View File

@ -136,7 +136,8 @@ if [[ -n "$REGISTRY_NAMESPACE_OVERRIDE" ]]; then
elif [[ -n "${REGISTRY_NAMESPACE:-}" ]]; then
REGISTRY_NAMESPACE="$REGISTRY_NAMESPACE"
else
REGISTRY_NAMESPACE="${COMMON_SERVICES_NAMESPACE:-common-services}"
# Default to the configured service namespace (driven by prole.cfg via prole_cfg.sh).
REGISTRY_NAMESPACE="${SERVICE_NAMESPACE:-${PROLE_NAMESPACE:-default}}"
fi
# In k3s mode, the in-cluster registry is a common core service and should live
@ -146,7 +147,7 @@ if declare -F prole_normalize_mode >/dev/null 2>&1; then
_mode_resolved="$(prole_normalize_mode "$_mode_resolved")"
fi
if [[ "$_mode_resolved" == "k3s" && ( -z "${REGISTRY_NAMESPACE:-}" || "${REGISTRY_NAMESPACE}" == "default" ) ]]; then
REGISTRY_NAMESPACE="${COMMON_SERVICES_NAMESPACE:-common-services}"
REGISTRY_NAMESPACE="${SERVICE_NAMESPACE:-${PROLE_NAMESPACE:-default}}"
fi
unset _mode_resolved

View File

@ -116,7 +116,8 @@ fi
SERVICE_LAYER_NAMESPACE="$SERVICE_NAMESPACE"
ARGOCD_NS="${ARGOCD_NAMESPACE:-argocd}"
REGISTRY_NS="${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
# Registry should live in the service-layer namespace unless explicitly overridden.
REGISTRY_NS="${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE}}"
STATE_DIR=""
if [[ -n "${PROLE_SERVICE:-}" && -f "$PROLE_SERVICE/prole_cfg.sh" ]]; then

View File

@ -314,7 +314,8 @@ if [[ -n "${PROLE_NAMESPACE:-}" ]]; then
esac
case "$_prole_mode_resolved" in
k3s|k8s)
_prole_registry_ns="${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
# Registry namespace is driven by config (SERVICE_NAMESPACE) unless explicitly overridden.
_prole_registry_ns="${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE:-${PROLE_NAMESPACE:-}}}"
if [[ -n "${LOCAL_REGISTRY_INTERNAL:-}" ]]; then
:
elif _prole_local_registry_enabled; then

View File

@ -381,7 +381,7 @@
k3s_registry_namespace_resolved: >-
{{ (k3s_registry_namespace | default('')) if (k3s_registry_namespace | default('') | length > 0 and k3s_registry_namespace != '${SERVICE_NAMESPACE}')
else (k3s_prole_service_namespace_cfg | default('')) if (k3s_prole_service_namespace_cfg | default('') | length > 0 and k3s_prole_service_namespace_cfg != '${SERVICE_NAMESPACE}')
else 'common-services' }}
else 'knoe-system' }}
when: k3s_state == "present" and k3s_registry_config_enabled | bool
- name: Validate k3s registry host

View File

@ -73,7 +73,7 @@
k3s_registry_namespace_resolved: >-
{{ (k3s_registry_namespace | default('')) if (k3s_registry_namespace | default('') | length > 0 and k3s_registry_namespace != '${SERVICE_NAMESPACE}')
else (k3s_prole_service_namespace_cfg | default('')) if (k3s_prole_service_namespace_cfg | default('') | length > 0 and k3s_prole_service_namespace_cfg != '${SERVICE_NAMESPACE}')
else 'common-services' }}
else 'knoe-system' }}
when: k3s_registry_config_enabled | bool
- name: Validate k3s registry host

View File

@ -1971,7 +1971,11 @@ class ProleSilentInstaller(ProleInstallerBase):
)
if not ns:
ns = (os.environ.get("REGISTRY_NAMESPACE") or "").strip()
return ns or "common-services"
if ns:
return ns
# Registry is a common-core service; default to the configured service namespace.
return self._service_namespace()
def _ensure_registry_defaults(self, mode: str) -> None:
db = self.prole_cfg_data.get("Docker Build", {}) or {}

View File

@ -386,10 +386,6 @@ def _verify_k3s_services_status(
mapped to status strings (``Good``, ``Failing``, ``Error``, ).
"""
status = {"registry": "Unknown", "openbao": "Unknown", "opentofu": "Unknown"}
server, token = _resolve_k3s_connection(project_root=project_root)
if not token:
return {k: "Missing token" for k in status}
base_cmd = _kubectl_base_cmd_for_k3s(
managed_kubeconfig=managed_kubeconfig, project_root=project_root
)
@ -400,6 +396,10 @@ def _verify_k3s_services_status(
text=True,
timeout=15,
)
if res.returncode != 0:
# Avoid reporting a misleading "Missing token" when the user is
# authenticated via kubeconfig/context instead of K3S_TOKEN.
return {k: "Error" for k in status}
out = res.stdout.lower() if res.stdout else ""
status["registry"] = "Good" if "registry" in out else "Failing"
status["openbao"] = "Good" if ("openbao" in out or "bao" in out) else "Failing"

View File

@ -215,14 +215,20 @@ class ClusterScreenMixin:
)
combo.bind("<<ComboboxSelected>>", self._on_kubectx_select)
if not self.selected_kubectx.get() and values:
if selected_env_key == "service" and "prole-k3s" in values:
# Default to the current context if it exists in the list; otherwise pick
# a sensible default without switching immediately.
current_ctx = (getattr(self, "_kubectx_applied", "") or "").strip()
if not current_ctx:
current_ctx = (self._current_kubectl_context() or "").strip()
if current_ctx:
self._kubectx_applied = current_ctx
if current_ctx and current_ctx in values:
self.selected_kubectx.set(current_ctx)
elif selected_env_key == "service" and "prole-k3s" in values:
self.selected_kubectx.set("prole-k3s")
# Auto-switch to prole-k3s if we just loaded the page and it's selected
self._switch_kubectx("prole-k3s")
else:
self.selected_kubectx.set(values[0])
# No auto-switch for others to avoid surprises, but we should probably
# if it's the only one.
combo_win = self.bg_canvas.create_window(
x_label + 190, y - 6, window=combo, anchor="nw"
)
@ -239,6 +245,30 @@ class ClusterScreenMixin:
fill="#6e6e73",
font=("SF Pro Text", 10),
)
apply_btn = tk.Button(
self.bg_canvas,
text="Apply",
command=self._on_kubectx_apply,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=10,
state="disabled",
)
apply_win = self.bg_canvas.create_window(
x_label + 670, y - 10, window=apply_btn, anchor="nw"
)
self._canvas_items.append(apply_win)
self._overlay_widgets.append(apply_btn)
# Keep references for UI tests / layout verification.
self._kubectx_apply_btn = apply_btn
self._kubectx_apply_btn_canvas_window = apply_win
self._update_kubectx_apply_button()
y += 34
if selected_env_key == "prod":
@ -542,10 +572,14 @@ class ClusterScreenMixin:
self._apply_k3s_defaults()
except Exception:
pass
# Auto-switch to prole-k3s if it exists
if hasattr(self, "selected_kubectx"):
# Prefer prole-k3s context if present, but require explicit Apply.
try:
values = self._get_kubectx_list()
except Exception:
values = []
if hasattr(self, "selected_kubectx") and "prole-k3s" in (values or []):
self.selected_kubectx.set("prole-k3s")
self._switch_kubectx("prole-k3s")
self._update_kubectx_apply_button()
elif env_key == "dev":
name = self.selected_k3d_cluster.get()
if name:
@ -1128,46 +1162,132 @@ class ClusterScreenMixin:
def _on_kubectx_select(self, *args):
"""Called when a generic kubernetes context is selected from the dropdown."""
ctx = self.selected_kubectx.get()
self._update_kubectx_apply_button()
ctx = (self.selected_kubectx.get() or "").strip()
if ctx:
self._switch_kubectx(ctx)
self._verify_k3s_services()
self.show_page("init_cluster")
self._set_cluster_env_message(
f"Context change pending: {ctx} (click Apply)",
"#ff9500",
clear_after_ms=4000,
)
def _switch_kubectx(self, context_name: str):
def _on_kubectx_apply(self):
ctx = (self.selected_kubectx.get() or "").strip()
if not ctx:
return
ok = self._switch_kubectx(ctx)
if ok:
self._kubectx_applied = ctx
self._update_kubectx_apply_button()
self._set_cluster_env_message(
f"Context applied: {ctx}", "#34c759", clear_after_ms=3000
)
self._verify_k3s_services()
self.show_page("init_cluster")
def _update_kubectx_apply_button(self):
btn = getattr(self, "_kubectx_apply_btn", None)
if not btn:
return
selected = ""
try:
if hasattr(self, "selected_kubectx") and self.selected_kubectx is not None:
selected = (self.selected_kubectx.get() or "").strip()
except Exception:
selected = ""
applied = (getattr(self, "_kubectx_applied", "") or "").strip()
state = "normal" if (selected and selected != applied) else "disabled"
try:
btn.config(state=state)
except Exception:
pass
def _switch_kubectx(self, context_name: str) -> bool:
"""Switch the current kubernetes context using kubectx or kubectl."""
if not context_name:
return
return False
# Determine which kubeconfig to modify.
# Use the same logic as the rest of the application.
kc = _find_kubeconfig_file()
env = os.environ.copy()
if kc:
env["KUBECONFIG"] = kc
try:
# Try kubectx first
res = subprocess.run(
["kubectx", context_name], capture_output=True, text=True, env=env
)
if res.returncode != 0:
ok = res.returncode == 0
err = (res.stderr or "").strip()
if not ok:
# Fallback to kubectl config use-context
subprocess.run(
res2 = subprocess.run(
["kubectl", "config", "use-context", context_name],
capture_output=True,
text=True,
env=env
)
ok = res2.returncode == 0
if not ok:
err2 = (res2.stderr or "").strip()
err = err2 or err
# Update the UI dropdown to match (but don't trigger re-select)
if hasattr(self, "selected_kubectx") and self.selected_kubectx.get() != context_name:
# Check if it's in the current values list
current_values = list(self.kubectx_list.get() or [])
if context_name in current_values:
self.selected_kubectx.set(context_name)
if (
ok
and hasattr(self, "selected_kubectx")
and self.selected_kubectx.get() != context_name
):
try:
# Check if it's in the current values list
current_values = []
if hasattr(self, "kubectx_list") and self.kubectx_list is not None:
current_values = list(self.kubectx_list.get() or [])
if not current_values:
current_values = self._get_kubectx_list()
if context_name in (current_values or []):
self.selected_kubectx.set(context_name)
except Exception:
pass
if not ok:
try:
messagebox.showerror(
"Kubernetes Context",
f"Failed to switch to context '{context_name}'.\n\n{err or 'Unknown error'}",
)
except Exception:
pass
return ok
except Exception as e:
try:
messagebox.showerror(
"Kubernetes Context",
f"Failed to switch to context '{context_name}': {e}",
)
except Exception:
pass
return False
def _current_kubectl_context(self) -> str:
kc = _find_kubeconfig_file()
env = os.environ.copy()
if kc:
env["KUBECONFIG"] = kc
try:
res = subprocess.run(
["kubectl", "config", "current-context"],
capture_output=True,
text=True,
env=env,
)
if res.returncode == 0:
return (res.stdout or "").strip()
except Exception:
pass
return ""
def _get_kubectx_list(self) -> list[str]:
"""Get list of kubernetes contexts."""
@ -1197,6 +1317,15 @@ class ClusterScreenMixin:
def _validate_and_save_cluster_config(self) -> bool:
"""Verify the config then write the values to prole.cfg."""
env_key = self._cluster_env_key()
prev_service_ns = ""
try:
prev_service_ns = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("SERVICE_NAMESPACE", "")
.strip()
)
except Exception:
prev_service_ns = ""
if env_key == "dev":
cluster_val = self.selected_k3d_cluster.get() or "knoe-dev-cluster"
if not cluster_val:
@ -1215,14 +1344,19 @@ class ClusterScreenMixin:
except Exception:
pass
elif env_key == "service":
if not (self.k3s_server_url.get() or "").strip():
# Service deployments can authenticate via an existing kubeconfig/context
# (preferred), or via a token-based generated kubeconfig.
kubeconfig = _find_kubeconfig_file()
has_token_flow = bool((self.k3s_server_url.get() or "").strip()) and bool(
(self.k3s_token.get() or "").strip()
)
if not kubeconfig and not has_token_flow:
messagebox.showwarning(
"Validation", "Please specify the K3s server URL."
"Validation",
"Missing cluster credentials. Please select a working Kubernetes context (kubectx) "
"or provide K3s server URL + token.",
)
return False
if not (self.k3s_token.get() or "").strip():
messagebox.showwarning("Validation", "Please specify the K3s token.")
return False
cluster_val = self.cluster_env.get()
elif env_key == "prod":
path = self.prod_artifacts_path.get().strip()
@ -1255,6 +1389,7 @@ class ClusterScreenMixin:
self.prole_cfg_data["Global"][
"SERVICE_NAMESPACE"
] = self._get_service_namespace()
new_service_ns = self.prole_cfg_data["Global"]["SERVICE_NAMESPACE"]
self.prole_cfg_data["Optional Features"]["SUPABASE_ENABLED"] = str(
self.supabase_enabled.get()
)
@ -1275,6 +1410,62 @@ class ClusterScreenMixin:
# Save config
self._save_prole_cfg()
# Namespace change cleanup (prevents collisions when switching namespaces).
try:
prev = (prev_service_ns or "").strip()
new = (new_service_ns or "").strip()
if prev and new and prev != new:
do_cleanup = False
try:
do_cleanup = messagebox.askyesno(
"Namespace Changed",
(
f"Common Core Services namespace changed from '{prev}' to '{new}'.\n\n"
f"Reset the OLD namespace '{prev}' to avoid resource collisions?\n\n"
"This deletes workloads/services in that namespace (PVCs are preserved)."
),
)
except Exception:
do_cleanup = False
if do_cleanup:
def _cleanup_worker():
try:
self._set_cluster_env_message(
f"Resetting old namespace: {prev}",
"#ff9500",
clear_after_ms=3000,
)
try:
self._ensure_k3s_kubeconfig_merged()
except Exception:
pass
server, token = ("", "")
try:
server, token = self._k3s_connection_info()
except Exception:
server, token = ("", "")
controller = getattr(self, "controller", None)
project_root = getattr(controller, "project_root", None)
if project_root:
_reset_k3s_namespace(project_root, prev, server, token)
self._set_cluster_env_message(
f"Old namespace reset complete: {prev}",
"#34c759",
clear_after_ms=4000,
)
except Exception as e:
self._set_cluster_env_message(
f"Namespace reset failed: {e}",
"#ff3b30",
clear_after_ms=6000,
)
threading.Thread(target=_cleanup_worker, daemon=True).start()
except Exception:
pass
return True
def _cluster_status_snapshot(self) -> dict:

View File

@ -1594,7 +1594,7 @@ class ServicesScreenMixin:
# Pre-pull dependent images into local registry before initialization
if mode == "k3s":
console.write(
"[INFO] Skipping image pre-pull in k3s mode (images are handled by k3s/common-services import).\n"
"[INFO] Skipping image pre-pull in k3s mode (images are handled by the in-cluster registry/import).\n"
)
else:
try:

View File

@ -48,7 +48,7 @@ fi
ACTION="$COMMON_CORE_ACTION"
# cert-manager always runs in its own dedicated namespace; never follows the
# common-services namespace override.
# Registry/service namespace override.
CERTMGR_NAMESPACE="cert-manager"
common_core_apply_namespace "$CERTMGR_NAMESPACE"

View File

@ -86,7 +86,7 @@ if [ -z "$NS" ]; then
fi
ARGOCD_NS="${ARGOCD_NAMESPACE:-argocd}"
REGISTRY_NS="${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
REGISTRY_NS="${REGISTRY_NAMESPACE:-${NS}}"
prole_ensure_kubeconfig >/dev/null 2>&1 || true

View File

@ -263,7 +263,7 @@ resolve_prole_kdc_defaults() {
fi
fi
if [[ -z "$PROLE_KDC_REGISTRY_INTERNAL" ]]; then
local reg_ns="${REGISTRY_NAMESPACE:-${COMMON_SERVICES_NAMESPACE:-common-services}}"
local reg_ns="${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE:-${NAMESPACE:-default}}}"
PROLE_KDC_REGISTRY_INTERNAL="registry.${reg_ns}.svc.cluster.local:5000"
unset reg_ns
fi

View File

@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Regression test: etc/init_kdc.sh should treat local registry as enabled by default in k3s mode
# (matching etc/prole_cfg.sh defaults), so it must not fail with “No registry host configured for k3s”.
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
SOURCE_ETC_DIR="$PROLE_HOME/etc"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
WORK_DIR="$TMP_DIR/work"
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$WORK_DIR/etc" "$WORK_DIR/conf" "$WORK_DIR/authority" "$BIN_DIR"
cp "$SOURCE_ETC_DIR/init_kdc.sh" "$WORK_DIR/etc/init_kdc.sh"
cp "$SOURCE_ETC_DIR/prole_cfg.sh" "$WORK_DIR/etc/prole_cfg.sh"
chmod +x "$WORK_DIR/etc/init_kdc.sh"
cat <<EOF > "$WORK_DIR/env.sh"
#!/usr/bin/env bash
export PROLE_HOME="$WORK_DIR"
export PROLE_CONF="$WORK_DIR/conf"
EOF
cat <<EOF > "$WORK_DIR/conf/prole.cfg"
[globals]
prole.home = $WORK_DIR
prole.mode = k3s
NAMESPACE = prole-db-a0001
EOF
export KUBECTL_LOG="$TMP_DIR/kubectl.log"
export DOCKER_LOG="$TMP_DIR/docker.log"
cat <<'EOF' > "$BIN_DIR/kubectl"
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
printf '%s\n' "${args[*]}" >> "${KUBECTL_LOG}"
sub="${1:-}"
case "$sub" in
config)
case "${2:-}" in
current-context)
printf '%s' "docker-desktop"
exit 0
;;
use-context|get-contexts)
exit 0
;;
esac
exit 0
;;
get)
# For namespace existence checks, succeed.
if [[ "${2:-}" == "namespace" ]]; then
exit 0
fi
# For deployment checks, report missing to exercise the deploy-time timeout path.
if [[ "${2:-}" == "deploy" || "${2:-}" == "deployment" ]]; then
exit 1
fi
exit 0
;;
create)
# init_kdc.sh pipes YAML from create-secret into kubectl apply.
if [[ "${2:-}" == "secret" ]]; then
cat <<YAML
apiVersion: v1
kind: Secret
metadata:
name: dummy
YAML
exit 0
fi
if [[ "${2:-}" == "namespace" ]]; then
exit 0
fi
exit 0
;;
apply)
cat >/dev/null || true
exit 0
;;
rollout|wait|exec|delete|describe|logs|patch)
exit 0
;;
*)
exit 0
;;
esac
EOF
chmod +x "$BIN_DIR/kubectl"
cat <<'EOF' > "$BIN_DIR/docker"
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
printf '%s\n' "${args[*]}" >> "${DOCKER_LOG}"
case "${1:-}" in
info|build|tag|push)
exit 0
;;
esac
exit 0
EOF
chmod +x "$BIN_DIR/docker"
export PATH="$BIN_DIR:$PATH"
export PROLE_HOME="$WORK_DIR"
export PROLE_CONF="$WORK_DIR/conf"
export PROLE_MODE="k3s"
# Avoid secret generation paths (python/openssl) by providing explicit values.
export PROLE_KDC_ADMIN_PASSWORD="test-admin"
export PROLE_KDC_MASTER_PASSWORD="test-master"
export PROLE_KDC_TRUST_PASSWORD="test-trust"
export PROLE_KDC_TRUST_SHARED_PASSWORD="test-shared"
if "$WORK_DIR/etc/init_kdc.sh" update >"$TMP_DIR/stdout" 2>"$TMP_DIR/stderr"; then
:
else
echo "FAILURE: init_kdc.sh update exited non-zero"
cat "$TMP_DIR/stderr"
exit 1
fi
if grep -q "No registry host configured for k3s" "$TMP_DIR/stderr"; then
echo "FAILURE: unexpected missing-registry-host error for k3s mode"
cat "$TMP_DIR/stderr"
exit 1
fi
if ! grep -q "push" "$DOCKER_LOG"; then
echo "FAILURE: expected docker push to be attempted"
cat "$DOCKER_LOG"
exit 1
fi
if ! grep -q "push localhost:5000/prole-authority:latest" "$DOCKER_LOG"; then
echo "FAILURE: expected docker push to default localhost registry in k3s mode"
cat "$DOCKER_LOG"
exit 1
fi
echo "SUCCESS"

View File

@ -324,10 +324,12 @@ class TestNamespaceHelpers:
assert inst._registry_namespace() == "env-reg"
def test_registry_default(self):
inst = _TestableSilentInstaller()
# Registry is a common-core service; default should follow SERVICE_NAMESPACE.
# For k3s/service clusters, SERVICE_NAMESPACE defaults to knoe-system.
inst = _TestableSilentInstaller(inputs={"init_cluster.cluster_env": "service"})
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("REGISTRY_NAMESPACE", None)
assert inst._registry_namespace() == "common-services"
assert inst._registry_namespace() == "knoe-system"
# ---------------------------------------------------------------------------

View File

@ -27,7 +27,7 @@ class _DummyClusterApp(ScreenBaseMixin, ClusterScreenMixin):
self.cluster_env = tk.StringVar(master=root, value="prod")
self.selected_k3d_cluster = tk.StringVar(master=root, value="")
self.selected_kubectx = tk.StringVar(master=root, value="")
self.service_namespace = tk.StringVar(master=root, value="common-services")
self.service_namespace = tk.StringVar(master=root, value="knoe-system")
self.prod_artifacts_path = tk.StringVar(master=root, value="/tmp")
# Legacy variables still referenced by non-UI logic elsewhere
@ -35,6 +35,8 @@ class _DummyClusterApp(ScreenBaseMixin, ClusterScreenMixin):
self.k3s_token = tk.StringVar(master=root, value="")
self._kubectx_values = kubectx_values
# Avoid invoking real `kubectl` during layout tests.
self._kubectx_applied = kubectx_values[0] if kubectx_values else ""
# ---- Stubs for callbacks / helpers invoked by rendering ----
def _on_cluster_env_change(self, *_args):
@ -56,7 +58,10 @@ class _DummyClusterApp(ScreenBaseMixin, ClusterScreenMixin):
return None
def _switch_kubectx(self, _context_name: str):
return None
return True
def _current_kubectl_context(self) -> str:
return self._kubectx_applied
def _get_kubectx_list(self) -> list[str]:
return list(self._kubectx_values)
@ -149,3 +154,80 @@ def test_cluster_environment_layout_large_mock_data(env: str):
assert app._k3s_service_status_console.text.cget("state") == "disabled"
finally:
root.destroy()
def test_kubectx_selection_requires_apply():
root = tk.Tk()
root.withdraw()
try:
canvas = tk.Canvas(root, width=975, height=780, bg="white")
canvas.pack(fill="both", expand=False)
root.update()
class _KubectxApplyApp(ScreenBaseMixin, ClusterScreenMixin):
def __init__(self, root: tk.Tk, canvas: tk.Canvas):
self.root = root
self.bg_canvas = canvas
self._canvas_items: list[int] = []
self._overlay_widgets: list[tk.Widget] = []
self.cluster_env = tk.StringVar(master=root, value="prod")
self.selected_k3d_cluster = tk.StringVar(master=root, value="")
self.selected_kubectx = tk.StringVar(master=root, value="ctx-000")
self.service_namespace = tk.StringVar(master=root, value="knoe-system")
self.prod_artifacts_path = tk.StringVar(master=root, value="/tmp")
self.k3s_server_url = tk.StringVar(master=root, value="")
self.k3s_token = tk.StringVar(master=root, value="")
self._kubectx_applied = "ctx-000"
self._switch_calls: list[str] = []
def _on_cluster_env_change(self, *_args):
return None
def _get_k3d_cluster_list(self) -> list[str]:
return []
def _on_create_k3d_cluster(self):
return None
def _on_delete_k3d_cluster(self):
return None
def _on_k3d_cluster_select(self, *_args):
return None
def _get_kubectx_list(self) -> list[str]:
return ["ctx-000", "ctx-001"]
def _switch_kubectx(self, context_name: str) -> bool:
self._switch_calls.append(context_name)
return True
def _verify_k3s_services(self):
return None
def show_page(self, _page: str):
return None
def _validate_and_save_cluster_config(self):
return True
app = _KubectxApplyApp(root, canvas)
app._render_init_cluster_page()
root.update()
# No pending change initially.
assert app._kubectx_apply_btn.cget("state") == "disabled"
# Selecting a different context should NOT switch immediately.
app.selected_kubectx.set("ctx-001")
app._on_kubectx_select()
assert app._switch_calls == []
assert app._kubectx_apply_btn.cget("state") == "normal"
# Apply triggers the switch.
app._on_kubectx_apply()
assert app._switch_calls == ["ctx-001"]
finally:
root.destroy()

View File

@ -0,0 +1,39 @@
from pathlib import Path
import sys
import subprocess
# Ensure `installer` is importable when tests are invoked directly via `pytest`
# (the project also provides `tests/run_tests.sh` which sets `PYTHONPATH=.`).
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from installer.core import env as core_env
def test_verify_k3s_services_status_does_not_require_token_when_kubeconfig_available(
monkeypatch, tmp_path
):
kubeconfig = tmp_path / "kubeconfig"
kubeconfig.write_text("dummy")
def fake_run(cmd, capture_output=True, text=True, timeout=None, env=None):
return subprocess.CompletedProcess(
args=cmd,
returncode=0,
stdout="registry\nopenbao\nopentofu\n",
stderr="",
)
monkeypatch.setattr(core_env.subprocess, "run", fake_run)
status = core_env._verify_k3s_services_status(
project_root=PROJECT_ROOT,
managed_kubeconfig=str(kubeconfig),
)
assert status["registry"] == "Good"
assert status["openbao"] == "Good"
assert status["opentofu"] == "Good"

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from pathlib import Path
def test_no_common_services_literal_in_etc_init_scripts():
"""Regression: registry namespace must be driven by config; do not hardcode `common-services`."""
init_scripts = sorted(Path("etc").glob("init_*.sh"))
assert init_scripts, "expected init_*.sh scripts under etc/"
for p in init_scripts:
text = p.read_text(encoding="utf-8")
assert "common-services" not in text, f"{p} hardcodes common-services"