feat: GCP/GKE CNPG hardening, Artifact Registry traffic light, and knoe-system namespace fixes

UI screens
- database.py: fix mode detection to use env_key priority (prod→k8s, service→k3s) so stale DEPLOYMENT_MODE never overrides the user's chosen environment
- database.py: Registry status reads ARTIFACT_REGISTRY_AVAILABLE persisted by cluster screen; uses SERVICE_NAMESPACE for Artifact Registry repo name
- cluster.py: add Artifact Registry traffic light (amber→green/red) to prod section; _check_artifact_registry_async persists ARTIFACT_REGISTRY_AVAILABLE into Global cfg
- cluster.py: re-trigger Artifact Registry check after GKE cluster selection so the light re-evaluates once region is available from KUBECONTEXT
- cluster_nodes.py: fix TclError on Python 3.14 — pady=(2,0) tuple → pady=2 scalar
- __init__.py: seed knoe-system namespace when saved value is "default", not only when empty
- services.py: replace hardcoded "Prole DB" log string with dynamic cnpg_cluster name

Core ops
- cloudnative_pg.py: replace one-shot Barman plugin retry with 6-attempt loop; first cert-manager/x509 failure triggers rollout restart + 30 s CA propagation wait; subsequent failures back off up to 60 s per attempt
- cloudnative_pg.py: TLS CA CN now uses cluster_name instead of hardcoded "Prole CNPG CA"
- registry.py, garage_store.py: refactored into per-mode modules (k3d/k3s/k8s registry and garage store, shared _garage_common)

Deploy / config
- deploy/gcp/gke/knoe-db.yaml: GKE-specific CNPG cluster manifest (rw/ro/r on separate nodes with premium-rwo storage)
- etc/init_common_services.sh, modes/k8s/knoe-db/.version: updated for current deploy
- kong-deployment.yaml: updated manifest

Tests
- test_cluster_nodes_render_smoke.py: add pack/grid, winfo_children, winfo_reqheight, update_idletasks, grid_slaves to dummy widgets; monkeypatch tk.Label so CNPG placement render completes without a real Tkinter root

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-04-04 19:36:08 -07:00
parent 9e8ebaf36a
commit 3312c39b1f
25 changed files with 2100 additions and 420 deletions

View File

@ -0,0 +1,23 @@
{
"assignments": {
"0": "gk3-knoe-dev-0-pool-1-11c87d92-8qr5",
"1": "gk3-knoe-dev-0-pool-1-88b7392b-62c4",
"2": "gk3-knoe-dev-0-pool-1-88b7392b-d6lb"
},
"cluster_name": "knoe-db",
"desired_instances": 3,
"eligible_nodes": [
"gk3-knoe-dev-0-pool-1-11c87d92-8qr5",
"gk3-knoe-dev-0-pool-1-88b7392b-62c4",
"gk3-knoe-dev-0-pool-1-88b7392b-d6lb"
],
"metadata": {
"prior_plan_present": true,
"reason": "reused",
"regenerated": false,
"reused": true
},
"plan_hash": "334860873593911a",
"plan_id": "cnpg-placement-334860873593911a",
"schema_version": "v1"
}

View File

@ -14,8 +14,8 @@ spec:
affinity:
enablePodAntiAffinity: true
podAntiAffinityType: preferred
topologyKey: topology.kubernetes.io/zone # GKE zone-aware spreading
podAntiAffinityType: required # hard: refuse to co-locate pods on the same node
topologyKey: kubernetes.io/hostname # physical node boundary (not zone)
postgresql:
parameters:

View File

@ -37,6 +37,10 @@ spec:
value: "0.0.0.0:8001"
- name: KONG_STREAM_LISTEN
value: "0.0.0.0:3022"
- name: KONG_NGINX_WORKER_PROCESSES
value: "1"
- name: KONG_MEM_CACHE_SIZE
value: "64m"
ports:
- name: proxy
containerPort: 8000
@ -47,13 +51,25 @@ spec:
volumeMounts:
- name: kong-config
mountPath: /kong/declarative
readinessProbe:
httpGet:
path: /status
port: admin
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /status
port: admin
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
memory: "768Mi"
volumes:
- name: kong-config
configMap:

View File

@ -0,0 +1,157 @@
# IntelliJ AI Assistant — Structural Refactor Prompt
You are performing a focused structural refactor on the **prole** installer project.
Make **ONLY** the changes described below.
Do **NOT** add features, rename identifiers not listed, or change observable behaviour.
After each task, run the existing test suite and stop + report if any tests fail.
---
## TASK A — Split `ops/registry.py` into mode-specific modules
### A1. Create `knoe/core/ops/k3d_registry.py`
- Move `_ensure_k3d_registry()` from `registry.py`
- Implement `initialize`, `start`, `update`, `stop`, `restart`, `status` for **k3d** mode
(extract the k3d branches that currently live inside each function in `registry.py`)
- `update()` calls `_ensure_k3d_registry()`
- `stop()` deletes the k3d registry and the fallback Docker container
- `status()` checks `k3d registry list`
### A2. Create `knoe/core/ops/k3s_registry.py`
- Move `_manifest()` and `_deployment_is_available()` from `registry.py`
- Implement `initialize`, `start`, `update`, `stop`, `restart`, `status` for **k3s** mode
(this is the current default fall-through path in `registry.py`)
- `update()` applies `k8s/registry/deployment.yaml`, reconciles ReplicaSets, waits rollout
### A3. Create `knoe/core/ops/k8s_registry.py`
- `update()` / `stop()` / `restart()`: log a no-op message and return
- `status()`: return `True` (GCP Artifact Registry is externally managed)
- `initialize()` / `start()`: delegate to `update()`
### A4. Rewrite `knoe/core/ops/registry.py` as a pure dispatcher
```python
from ._services_common import _detect_mode
from types import ModuleType
def _module(mode, env) -> ModuleType:
m = _detect_mode(mode, env)
if m == "k3d":
from . import k3d_registry; return k3d_registry
if m == "k8s":
from . import k8s_registry; return k8s_registry
from . import k3s_registry; return k3s_registry
# initialize / start / update / stop / restart / status
# — each delegates to _module(mode, env).<fn>(**kwargs)
# Public signatures are UNCHANGED.
```
---
## TASK B — Split `ops/garage_store.py` into mode-specific modules
### B1. Create `knoe/core/ops/_garage_common.py`
- Move `_garage_namespace()` and `_ensure_garage_secret()` from `garage_store.py`
- These helpers are shared by all three mode variants
### B2. Create `knoe/core/ops/k3s_garage_store.py`
- Move `_repair_released_garage_pvs()` here (k3s / Synology iSCSI only)
- `_manifest_files()` returns:
`storageclass-synology-iscsi.yaml`, `iscsi-pvs.yaml`,
`garage-configmap.yaml`, `garage-statefulset.yaml`, `garage-service.yaml`
- `update()` calls `_repair_released_garage_pvs()` before applying manifests
- Cluster-scoped resources (no `-n` flag): `{"storageclass-synology-iscsi.yaml", "iscsi-pvs.yaml"}`
### B3. Create `knoe/core/ops/k3d_garage_store.py`
- `_manifest_files()` returns:
`garage-configmap.yaml`, `garage-statefulset.yaml`, `garage-service.yaml`
(no Synology storage classes, no static PVs)
- No PV repair
### B4. Create `knoe/core/ops/k8s_garage_store.py`
- `_manifest_files()` returns:
`storageclass-gcp-hdd.yaml`, `garage-configmap.yaml`,
`garage-statefulset-gcp.yaml`, `garage-service.yaml`
- No PV repair
- Cluster-scoped: `{"storageclass-gcp-hdd.yaml"}`
### B5. Rewrite `knoe/core/ops/garage_store.py` as dispatcher
Same `_module()` + delegating-function pattern as Task A.
`stop()` and `restart()` in garage variants take only `namespace`, `env`, `log`
(no `mode` parameter — mode is resolved from `env`).
---
## TASK C — Rename UI nav labels (display text only)
**File: `knoe/ui/screens/__init__.py`**
```
"Kerberos Authentication" → "Knoe Authority" (nav_items; page_id "kerberos_config" unchanged)
"Knoe User Authority" → "Knoe Users" (nav_items; page_id "knoe_users" unchanged)
```
**File: `knoe/ui/screens/security.py`**
- Change the `_render_title("Kerberos Authentication", ...)` call → `_render_title("Knoe Authority", ...)`
- Change `text="Enable Kerberos Authentication"``text="Enable Knoe Authority"`
- **Do NOT** change `self.prole_cfg_data["Kerberos Authentication"]` keys — those are config-file section names
**File: `knoe/ui/screens/knoe_users.py`**
- Update any user-facing title Label text that still reads "Knoe User Authority"
- **Do NOT** change log prefixes or cfg/env key names
---
## TASK D — Add k3d | k3s | k8s mode tab strip to sidebar
**File: `knoe/ui/screens/__init__.py`** — in `__init__`, after `self.nav_widgets = {}`:
```python
self._mode_tab_widgets: dict = {}
self.deployment_mode = tk.StringVar(value=os.environ.get("PROLE_MODE", "k3s"))
```
**File: `knoe/ui/screens/navigation.py`** — in `_create_sidebar_nav()`,
**before** the `"INSTALLER"` Label, insert:
```python
_MODE_COLORS = {"k3d": "#4A90D9", "k3s": "#27AE60", "k8s": "#E67E22"}
mode_frame = tk.Frame(self.sidebar, bg="#F5F5DC")
mode_frame.pack(fill="x", padx=12, pady=(14, 4))
for label, value in [("k3d", "k3d"), ("k3s", "k3s"), ("k8s", "k8s")]:
btn = tk.Label(
mode_frame, text=label,
bg="#D5D5C5", fg="#555",
font=("SF Pro Text", 9, "bold"),
padx=8, pady=3, cursor="hand2",
)
btn.pack(side="left", padx=2)
btn.bind("<Button-1>", lambda e, v=value: self._set_deployment_mode(v))
self._mode_tab_widgets[value] = btn
self._refresh_mode_tabs()
```
Add to the navigation mixin:
```python
def _set_deployment_mode(self, mode: str) -> None:
self.deployment_mode.set(mode)
os.environ["PROLE_MODE"] = mode
self._refresh_mode_tabs()
def _refresh_mode_tabs(self) -> None:
_MODE_COLORS = {"k3d": "#4A90D9", "k3s": "#27AE60", "k8s": "#E67E22"}
active = self.deployment_mode.get()
for value, widget in self._mode_tab_widgets.items():
if value == active:
widget.configure(bg=_MODE_COLORS[value], fg="white")
else:
widget.configure(bg="#D5D5C5", fg="#555")
```
---
## CONSTRAINTS
- All existing import paths must remain valid (`from knoe.core.ops import registry` etc.)
- All public function signatures are unchanged
- Run tests after each task; stop and report failures before continuing
- Do not create README or documentation files (other than this prompt file)
- Do not touch any files not listed above

View File

@ -602,6 +602,53 @@ migrate_common_services() {
kubectl delete -n "$old_ns" svc "$KONG_NAME" --ignore-not-found >/dev/null 2>&1 || true
kubectl delete -n "$old_ns" configmap "$KONG_CONFIG_NAME" --ignore-not-found >/dev/null 2>&1 || true
done
# Same-namespace: remove the alternate Kong deployment name if present.
# In k8s/GKE mode KONG_NAME=knoe-svc-kong; a leftover prole-svc-kong (or vice
# versa) in the same namespace causes duplicate pods that the deployer won't
# clean up on its own.
local kong_alt_name kong_alt_config
case "$KONG_NAME" in
*knoe-svc-kong*) kong_alt_name="${KONG_NAME//knoe-svc-kong/prole-svc-kong}"
kong_alt_config="${KONG_CONFIG_NAME//knoe-svc-kong/prole-svc-kong}" ;;
*prole-svc-kong*) kong_alt_name="${KONG_NAME//prole-svc-kong/knoe-svc-kong}"
kong_alt_config="${KONG_CONFIG_NAME//prole-svc-kong/knoe-svc-kong}" ;;
*) kong_alt_name="" ; kong_alt_config="" ;;
esac
if [[ -n "$kong_alt_name" && "$kong_alt_name" != "$KONG_NAME" ]]; then
if kubectl -n "$NS" get deploy "$kong_alt_name" >/dev/null 2>&1; then
echo "Found stale alternate Kong deployment '$kong_alt_name' in '$NS'; removing ..."
kubectl -n "$NS" delete deploy "$kong_alt_name" --ignore-not-found >/dev/null 2>&1 || true
kubectl -n "$NS" delete svc "$kong_alt_name" --ignore-not-found >/dev/null 2>&1 || true
[[ -n "$kong_alt_config" ]] && \
kubectl -n "$NS" delete configmap "$kong_alt_config" --ignore-not-found >/dev/null 2>&1 || true
fi
fi
# Same-namespace: prune excess unhealthy Kong pods from a stuck rolling update.
# Happens when the old pod (e.g. OOMKilled) does not terminate cleanly before
# the new pod comes up, leaving the deployment with more pods than desired.
if kubectl -n "$NS" get deploy "$KONG_NAME" >/dev/null 2>&1; then
local _kong_desired _kong_pod_count _stale_pod
_kong_desired=$(kubectl -n "$NS" get deploy "$KONG_NAME" \
-o jsonpath='{.spec.replicas}' 2>/dev/null)
_kong_desired="${_kong_desired:-1}"
_kong_pod_count=$(kubectl -n "$NS" get pods \
-l "app=${KONG_NAME}" --no-headers 2>/dev/null | wc -l | tr -d '[:space:]')
if [[ "${_kong_pod_count:-0}" -gt "${_kong_desired:-1}" ]]; then
echo "Kong ($KONG_NAME) has ${_kong_pod_count} pod(s) but desired=${_kong_desired};" \
"removing unhealthy pods ..."
while IFS= read -r _stale_pod; do
[[ -z "$_stale_pod" ]] && continue
echo " Removing unhealthy pod: $_stale_pod"
kubectl -n "$NS" delete pod "$_stale_pod" --force --grace-period=0 \
>/dev/null 2>&1 || true
done < <(
kubectl -n "$NS" get pods -l "app=${KONG_NAME}" --no-headers 2>/dev/null \
| awk '{split($2,r,"/"); ok=($3=="Running" && r[1]==r[2] && r[1]~/^[0-9]+$/); if(!ok) print $1}'
)
fi
fi
}
echo "Deploying common services (namespace=$NS, action=$ACTION)"

View File

@ -0,0 +1,53 @@
"""Shared helpers for Garage object-store ops across all deployment modes."""
from __future__ import annotations
import json
import secrets
from ._services_common import (
_LogFn,
_exists,
_kubectl,
_log,
_to_bool,
)
def _garage_namespace(namespace: str | None, env: dict | None) -> str:
service_ns = str((env or {}).get("SERVICE_NAMESPACE") or "").strip()
if env:
explicit = str(env.get("GARAGE_NAMESPACE") or "").strip()
if explicit:
return service_ns if explicit == "default" and service_ns else explicit
raw = (namespace or "").strip() or service_ns or str((env or {}).get("NAMESPACE") or "").strip() or "knoe-system"
if raw == "default":
return service_ns or "knoe-system"
return raw
def _ensure_garage_secret(*, namespace: str, env: dict | None = None, log: _LogFn | None = None) -> None:
secret_name = str((env or {}).get("GARAGE_SECRET_NAME") or "garage-secrets").strip() or "garage-secrets"
force = _to_bool((env or {}).get("PROLE_GARAGE_FORCE_SECRET"), default=False)
if not force and _exists("secret", secret_name, namespace, env=env):
return
payload = (
"apiVersion: v1\n"
"kind: Secret\n"
"metadata:\n"
f" name: {secret_name}\n"
"type: Opaque\n"
"stringData:\n"
f" rpc_secret: {json.dumps(secrets.token_hex(32))}\n"
f" admin_token: {json.dumps(secrets.token_urlsafe(32))}\n"
f" metrics_token: {json.dumps(secrets.token_urlsafe(32))}\n"
)
_kubectl(
["-n", namespace, "apply", "-f", "-"],
env=env,
input_text=payload,
timeout=90,
check=True,
)
_log(log, f"[GARAGE] Ensured secret/{secret_name} in namespace {namespace}")

View File

@ -206,14 +206,22 @@ def _resolve_cnpg_manifest(project_root: str | Path, env: dict | None) -> Path:
if p.exists():
return p
root = Path(project_root)
candidates = [
mode = str((env or {}).get("PROLE_MODE") or "").strip()
candidates: list[Path] = []
if mode == "k8s":
# GKE/prod: use the GCP-specific manifest (premium-rwo storage, no Synology selectors)
candidates.append(root / "deploy" / "gcp" / "gke" / "knoe-db.yaml")
candidates += [
root / "deploy" / "opentofu" / "k3s" / "manifests" / "prole" / "knoe-db.yaml",
root / "k8s" / "prole" / "knoe-db.yaml",
]
if env:
prole_home = (env.get("PROLE_HOME") or "").strip()
if prole_home:
candidates.insert(0, Path(prole_home) / "k8s" / "prole" / "knoe-db.yaml")
ph = Path(prole_home)
if mode == "k8s":
candidates.insert(0, ph / "deploy" / "gcp" / "gke" / "knoe-db.yaml")
candidates.append(ph / "k8s" / "prole" / "knoe-db.yaml")
for candidate in candidates:
if candidate.exists():
return candidate
@ -709,30 +717,48 @@ def install_barman_plugin(
url = f"https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/{tag}/manifest.yaml"
_log(log, f"Installing Barman Cloud plugin from {url}...")
r = subprocess.run(
["kubectl", "apply", "-f", url],
env=env, capture_output=True, text=True, timeout=120,
_CERT_WEBHOOK_PATTERNS = (
"webhook.cert-manager.io",
"cert-manager",
"x509",
"certificate signed by unknown authority",
"tls:",
)
if r.returncode != 0:
combined = (r.stdout or "") + (r.stderr or "")
if "webhook.cert-manager.io" in combined.lower() or "cert-manager" in combined.lower():
_log(log, "WARN: cert-manager webhook error; ensuring cert-manager and retrying...")
_ensure_cert_manager_for_barman(env=env, log=log)
r2 = subprocess.run(
["kubectl", "apply", "-f", url],
env=env, capture_output=True, text=True, timeout=120,
)
if r2.returncode != 0:
raise RuntimeError(
f"Failed to apply Barman Cloud plugin after cert-manager restart: "
f"{(r2.stderr or r2.stdout or '').strip()}"
)
_cert_manager_recovered = False
_last_error: str = ""
_apply_ok = False
for _attempt in range(1, 7): # up to 6 attempts; first triggers cert-manager recovery
r = subprocess.run(
["kubectl", "apply", "-f", url],
env=env, capture_output=True, text=True, timeout=120,
)
if r.returncode == 0:
if r.stdout.strip():
_log(log, r.stdout.strip())
_apply_ok = True
break
combined = ((r.stdout or "") + (r.stderr or "")).lower()
_last_error = (r.stderr or r.stdout or "").strip()
is_cert_issue = any(pat in combined for pat in _CERT_WEBHOOK_PATTERNS)
if is_cert_issue:
if not _cert_manager_recovered:
_log(log, "WARN: cert-manager webhook error; ensuring cert-manager and retrying...")
_ensure_cert_manager_for_barman(env=env, log=log)
_cert_manager_recovered = True
_log(log, "cert-manager recovered; waiting 30 s for webhook CA bundle to propagate...")
time.sleep(30)
else:
wait_s = min(20 * _attempt, 60)
_log(log, f"Webhook CA not yet trusted (attempt {_attempt}); retrying in {wait_s} s...")
time.sleep(wait_s)
else:
raise RuntimeError(
f"Failed to apply Barman Cloud plugin manifest: {(r.stderr or r.stdout or '').strip()}"
f"Failed to apply Barman Cloud plugin manifest: {_last_error}"
)
elif r.stdout.strip():
_log(log, r.stdout.strip())
if not _apply_ok:
raise RuntimeError(
f"Failed to apply Barman Cloud plugin after cert-manager restart: {_last_error}"
)
# Wait for barman-cloud deployment if it was created
r3 = _kubectl(["-n", "cnpg-system", "get", "deploy", "barman-cloud"], env=env, timeout=15)
@ -903,7 +929,7 @@ def bootstrap_cnpg_tls_secrets(
- ``{cluster_name}-ca`` : Opaque, keys ``ca.crt`` and ``ca.key``
- ``{cluster_name}-tls`` : kubernetes.io/tls, keys ``tls.crt`` and ``tls.key``
The CA uses CN=``Prole CNPG CA`` and is self-signed (EC P-256, SHA-256).
The CA uses CN=``{cluster_name} CNPG CA`` and is self-signed (EC P-256, SHA-256).
The server cert is signed by that CA and carries SANs matching the standard
CNPG service names for the cluster.
"""
@ -926,7 +952,7 @@ def bootstrap_cnpg_tls_secrets(
# --- CA ---
ca_key = generate_private_key(SECP256R1())
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Prole CNPG CA")])
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"{cluster_name} CNPG CA")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)

View File

@ -1,110 +1,30 @@
"""Garage object-store ops — thin dispatcher.
Routes to the mode-specific implementation:
k3d k3d_garage_store (local storage, no Synology)
k3s k3s_garage_store (Synology iSCSI, static PVs)
k8s k8s_garage_store (GCP pd-standard StorageClass)
All public function signatures are unchanged.
"""
from __future__ import annotations
import json
import secrets
from pathlib import Path
from types import ModuleType
from ._services_common import (
_LogFn,
_detect_mode,
_ensure_namespace,
_exists,
_kubectl,
_log,
_manifest_path,
_prune_named_workload_other_namespaces,
_to_bool,
_wait_rollout,
)
from ._services_common import _LogFn, _detect_mode
def _garage_namespace(namespace: str | None, env: dict | None) -> str:
service_ns = str((env or {}).get("SERVICE_NAMESPACE") or "").strip()
if env:
explicit = str(env.get("GARAGE_NAMESPACE") or "").strip()
if explicit:
return service_ns if explicit == "default" and service_ns else explicit
raw = (namespace or "").strip() or service_ns or str((env or {}).get("NAMESPACE") or "").strip() or "knoe-system"
if raw == "default":
return service_ns or "knoe-system"
return raw
def _manifest_files(project_root: str | Path) -> list[Path]:
root = Path(project_root)
return [
_manifest_path(root, "k8s", "prole", "storageclass-synology-iscsi.yaml"),
_manifest_path(root, "k8s", "prole", "iscsi-pvs.yaml"),
_manifest_path(root, "k8s", "prole", "garage-configmap.yaml"),
_manifest_path(root, "k8s", "prole", "garage-statefulset.yaml"),
_manifest_path(root, "k8s", "prole", "garage-service.yaml"),
]
def _repair_released_garage_pvs(*, env: dict | None = None, log: _LogFn | None = None) -> None:
listed = _kubectl(
["get", "pv", "-l", "synology.storage/role=garage", "-o", "json"],
env=env,
timeout=45,
)
if listed.returncode != 0 or not listed.stdout.strip():
return
try:
payload = json.loads(listed.stdout)
except Exception:
return
for item in payload.get("items", []):
meta = item.get("metadata") or {}
spec = item.get("spec") or {}
status = item.get("status") or {}
name = str(meta.get("name") or "").strip()
phase = str(status.get("phase") or "").strip()
if not name or phase != "Released":
continue
if not spec.get("claimRef"):
continue
_log(log, f"[GARAGE] Clearing stale claimRef on PV {name} (phase=Released)")
_kubectl(
[
"patch",
"pv",
name,
"--type=json",
"-p",
'[{"op":"remove","path":"/spec/claimRef"}]',
],
env=env,
timeout=60,
)
def _ensure_garage_secret(*, namespace: str, env: dict | None = None, log: _LogFn | None = None) -> None:
secret_name = str((env or {}).get("GARAGE_SECRET_NAME") or "garage-secrets").strip() or "garage-secrets"
force = _to_bool((env or {}).get("PROLE_GARAGE_FORCE_SECRET"), default=False)
if not force and _exists("secret", secret_name, namespace, env=env):
return
payload = (
"apiVersion: v1\n"
"kind: Secret\n"
"metadata:\n"
f" name: {secret_name}\n"
"type: Opaque\n"
"stringData:\n"
f" rpc_secret: {json.dumps(secrets.token_hex(32))}\n"
f" admin_token: {json.dumps(secrets.token_urlsafe(32))}\n"
f" metrics_token: {json.dumps(secrets.token_urlsafe(32))}\n"
)
_kubectl(
["-n", namespace, "apply", "-f", "-"],
env=env,
input_text=payload,
timeout=90,
check=True,
)
_log(log, f"[GARAGE] Ensured secret/{secret_name} in namespace {namespace}")
def _module(mode: str | None, env: dict | None) -> ModuleType:
m = _detect_mode(mode, env)
if m == "k3d":
from . import k3d_garage_store
return k3d_garage_store
if m == "k8s":
from . import k8s_garage_store
return k8s_garage_store
from . import k3s_garage_store
return k3s_garage_store
def initialize(
@ -115,7 +35,9 @@ def initialize(
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
_module(mode, env).initialize(
namespace=namespace, env=env, project_root=project_root, log=log, mode=mode
)
def start(
@ -126,7 +48,9 @@ def start(
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
_module(mode, env).start(
namespace=namespace, env=env, project_root=project_root, log=log, mode=mode
)
def update(
@ -137,49 +61,9 @@ def update(
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
_detect_mode(mode, env) # mode kept for API parity
target_ns = _garage_namespace(namespace, env)
_ensure_namespace(target_ns, env)
_repair_released_garage_pvs(env=env, log=log)
_ensure_garage_secret(namespace=target_ns, env=env, log=log)
_prune_named_workload_other_namespaces(
kind="statefulset",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
_module(mode, env).update(
namespace=namespace, env=env, project_root=project_root, log=log, mode=mode
)
_prune_named_workload_other_namespaces(
kind="service",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="configmap",
name="garage-config",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
for manifest in _manifest_files(project_root):
if not manifest.exists():
continue
_log(log, f"[GARAGE] Applying {manifest}")
name = manifest.name
if name in {"storageclass-synology-iscsi.yaml", "iscsi-pvs.yaml"}:
_kubectl(["apply", "-f", str(manifest)], env=env, timeout=240, check=True)
else:
_kubectl(["-n", target_ns, "apply", "-f", str(manifest)], env=env, timeout=240, check=True)
if _exists("statefulset", "garage", target_ns, env=env):
_wait_rollout("statefulset", "garage", target_ns, env=env)
def stop(
@ -188,15 +72,7 @@ def stop(
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Scaling statefulset/garage to 0 in namespace {target_ns}")
_kubectl(
["-n", target_ns, "scale", "statefulset/garage", "--replicas=0"],
env=env,
timeout=90,
check=True,
)
_module(None, env).stop(namespace=namespace, env=env, log=log)
def restart(
@ -205,16 +81,7 @@ def restart(
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Restarting statefulset/garage in namespace {target_ns}")
_kubectl(
["-n", target_ns, "rollout", "restart", "statefulset/garage"],
env=env,
timeout=120,
check=True,
)
_wait_rollout("statefulset", "garage", target_ns, env=env)
_module(None, env).restart(namespace=namespace, env=env, log=log)
def status(
@ -222,13 +89,4 @@ def status(
namespace: str | None = None,
env: dict | None = None,
) -> bool:
target_ns = _garage_namespace(namespace, env)
sts = _kubectl(
["-n", target_ns, "get", "statefulset", "garage", "-o", "jsonpath={.status.readyReplicas}"],
env=env,
timeout=20,
)
svc = _kubectl(["-n", target_ns, "get", "svc", "garage"], env=env, timeout=20)
return _to_bool(
sts.returncode == 0 and (sts.stdout or "0").strip() not in {"", "0"} and svc.returncode == 0
)
return _module(None, env).status(namespace=namespace, env=env)

View File

@ -0,0 +1,150 @@
"""Garage object-store ops — k3d mode (local dev cluster).
Uses local-storage manifests. No Synology iSCSI storage classes or static PVs.
"""
from __future__ import annotations
from pathlib import Path
from ._garage_common import _ensure_garage_secret, _garage_namespace
from ._services_common import (
_LogFn,
_ensure_namespace,
_exists,
_kubectl,
_log,
_manifest_path,
_prune_named_workload_other_namespaces,
_wait_rollout,
)
def _manifest_files(project_root: str | Path) -> list[Path]:
root = Path(project_root)
return [
_manifest_path(root, "k8s", "prole", "garage-configmap.yaml"),
_manifest_path(root, "k8s", "prole", "garage-statefulset.yaml"),
_manifest_path(root, "k8s", "prole", "garage-service.yaml"),
]
def initialize(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def start(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def update(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
_ensure_namespace(target_ns, env)
_ensure_garage_secret(namespace=target_ns, env=env, log=log)
_prune_named_workload_other_namespaces(
kind="statefulset",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="service",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="configmap",
name="garage-config",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
for manifest in _manifest_files(project_root):
if not manifest.exists():
continue
_log(log, f"[GARAGE] Applying {manifest}")
_kubectl(["-n", target_ns, "apply", "-f", str(manifest)], env=env, timeout=240, check=True)
if _exists("statefulset", "garage", target_ns, env=env):
_wait_rollout("statefulset", "garage", target_ns, env=env)
def stop(
*,
namespace: str | None = None,
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Scaling statefulset/garage to 0 in namespace {target_ns}")
_kubectl(
["-n", target_ns, "scale", "statefulset/garage", "--replicas=0"],
env=env,
timeout=90,
check=True,
)
def restart(
*,
namespace: str | None = None,
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Restarting statefulset/garage in namespace {target_ns}")
_kubectl(
["-n", target_ns, "rollout", "restart", "statefulset/garage"],
env=env,
timeout=120,
check=True,
)
_wait_rollout("statefulset", "garage", target_ns, env=env)
def status(
*,
namespace: str | None = None,
env: dict | None = None,
) -> bool:
from ._services_common import _to_bool
target_ns = _garage_namespace(namespace, env)
sts = _kubectl(
["-n", target_ns, "get", "statefulset", "garage", "-o", "jsonpath={.status.readyReplicas}"],
env=env,
timeout=20,
)
svc = _kubectl(["-n", target_ns, "get", "svc", "garage"], env=env, timeout=20)
return _to_bool(
sts.returncode == 0 and (sts.stdout or "0").strip() not in {"", "0"} and svc.returncode == 0
)

View File

@ -0,0 +1,119 @@
"""Registry ops — k3d mode (local dev cluster).
Uses k3d's built-in registry or falls back to a plain Docker registry container.
"""
from __future__ import annotations
from pathlib import Path
from ._services_common import (
_LogFn,
_docker,
_k3d,
_log,
_to_bool,
)
def _ensure_k3d_registry(*, env: dict | None = None, log: _LogFn | None = None) -> None:
name = str((env or {}).get("K3D_REGISTRY_NAME") or "prole-registry")
port = str((env or {}).get("REGISTRY_PORT") or "5000")
listed = _k3d(["registry", "list"], env=env, timeout=60)
if listed.returncode == 0 and name in listed.stdout:
_log(log, f"[REGISTRY] k3d registry {name} already exists")
return
_log(log, f"[REGISTRY] Creating k3d registry {name} on port {port}")
created = _k3d(["registry", "create", name, "--port", f"{port}:{port}"], env=env, timeout=180)
if created.returncode == 0:
return
# fallback docker registry
_log(log, "[REGISTRY] k3d registry creation failed; trying docker registry fallback")
running = _docker(["ps", "--format", "{{.Names}}"], env=env, timeout=30)
if running.returncode == 0 and name in running.stdout.splitlines():
return
_docker(["rm", "-f", name], env=env, timeout=30)
_docker(
[
"run",
"-d",
"--restart=always",
"-p",
f"{port}:5000",
"--name",
name,
"registry:2",
],
env=env,
timeout=120,
check=True,
)
def initialize(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def start(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def update(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
_ensure_k3d_registry(env=env, log=log)
def stop(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
name = str((env or {}).get("K3D_REGISTRY_NAME") or "prole-registry")
_log(log, f"[REGISTRY] Removing k3d registry {name}")
_k3d(["registry", "delete", name], env=env, timeout=120)
_docker(["rm", "-f", name], env=env, timeout=30)
def restart(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
update(namespace=namespace, env=env, mode=mode, log=log)
def status(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
) -> bool:
name = str((env or {}).get("K3D_REGISTRY_NAME") or "prole-registry")
reg = _k3d(["registry", "list"], env=env, timeout=30)
return _to_bool(reg.returncode == 0 and name in reg.stdout)

View File

@ -0,0 +1,198 @@
"""Garage object-store ops — k3s mode (bare-metal / Synology iSCSI).
Uses Synology iSCSI storage classes and static PVs. Repairs Released PVs before apply.
"""
from __future__ import annotations
import json
from pathlib import Path
from ._garage_common import _ensure_garage_secret, _garage_namespace
from ._services_common import (
_LogFn,
_ensure_namespace,
_exists,
_kubectl,
_log,
_manifest_path,
_prune_named_workload_other_namespaces,
_wait_rollout,
)
def _repair_released_garage_pvs(*, env: dict | None = None, log: _LogFn | None = None) -> None:
listed = _kubectl(
["get", "pv", "-l", "synology.storage/role=garage", "-o", "json"],
env=env,
timeout=45,
)
if listed.returncode != 0 or not listed.stdout.strip():
return
try:
payload = json.loads(listed.stdout)
except Exception:
return
for item in payload.get("items", []):
meta = item.get("metadata") or {}
spec = item.get("spec") or {}
status = item.get("status") or {}
name = str(meta.get("name") or "").strip()
phase = str(status.get("phase") or "").strip()
if not name or phase != "Released":
continue
if not spec.get("claimRef"):
continue
_log(log, f"[GARAGE] Clearing stale claimRef on PV {name} (phase=Released)")
_kubectl(
[
"patch",
"pv",
name,
"--type=json",
"-p",
'[{"op":"remove","path":"/spec/claimRef"}]',
],
env=env,
timeout=60,
)
def _manifest_files(project_root: str | Path) -> list[Path]:
root = Path(project_root)
return [
_manifest_path(root, "k8s", "prole", "storageclass-synology-iscsi.yaml"),
_manifest_path(root, "k8s", "prole", "iscsi-pvs.yaml"),
_manifest_path(root, "k8s", "prole", "garage-configmap.yaml"),
_manifest_path(root, "k8s", "prole", "garage-statefulset.yaml"),
_manifest_path(root, "k8s", "prole", "garage-service.yaml"),
]
_CLUSTER_SCOPED = {"storageclass-synology-iscsi.yaml", "iscsi-pvs.yaml"}
def initialize(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def start(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def update(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
_ensure_namespace(target_ns, env)
_repair_released_garage_pvs(env=env, log=log)
_ensure_garage_secret(namespace=target_ns, env=env, log=log)
_prune_named_workload_other_namespaces(
kind="statefulset",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="service",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="configmap",
name="garage-config",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
for manifest in _manifest_files(project_root):
if not manifest.exists():
continue
_log(log, f"[GARAGE] Applying {manifest}")
if manifest.name in _CLUSTER_SCOPED:
_kubectl(["apply", "-f", str(manifest)], env=env, timeout=240, check=True)
else:
_kubectl(["-n", target_ns, "apply", "-f", str(manifest)], env=env, timeout=240, check=True)
if _exists("statefulset", "garage", target_ns, env=env):
_wait_rollout("statefulset", "garage", target_ns, env=env)
def stop(
*,
namespace: str | None = None,
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Scaling statefulset/garage to 0 in namespace {target_ns}")
_kubectl(
["-n", target_ns, "scale", "statefulset/garage", "--replicas=0"],
env=env,
timeout=90,
check=True,
)
def restart(
*,
namespace: str | None = None,
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Restarting statefulset/garage in namespace {target_ns}")
_kubectl(
["-n", target_ns, "rollout", "restart", "statefulset/garage"],
env=env,
timeout=120,
check=True,
)
_wait_rollout("statefulset", "garage", target_ns, env=env)
def status(
*,
namespace: str | None = None,
env: dict | None = None,
) -> bool:
from ._services_common import _to_bool
target_ns = _garage_namespace(namespace, env)
sts = _kubectl(
["-n", target_ns, "get", "statefulset", "garage", "-o", "jsonpath={.status.readyReplicas}"],
env=env,
timeout=20,
)
svc = _kubectl(["-n", target_ns, "get", "svc", "garage"], env=env, timeout=20)
return _to_bool(
sts.returncode == 0 and (sts.stdout or "0").strip() not in {"", "0"} and svc.returncode == 0
)

View File

@ -0,0 +1,202 @@
"""Registry ops — k3s mode (bare-metal / Synology cluster).
Deploys an in-cluster Docker registry as a Kubernetes Deployment + Service.
"""
from __future__ import annotations
import json
from pathlib import Path
from ._services_common import (
_LogFn,
_ensure_namespace,
_exists,
_kubectl,
_log,
_manifest_path,
_prune_named_workload_other_namespaces,
_reconcile_deployment_replicasets,
_registry_namespace,
_to_bool,
_wait_rollout,
)
def _manifest(project_root: str | Path) -> Path:
return _manifest_path(project_root, "k8s", "registry", "deployment.yaml")
def _deployment_is_available(
*,
deployment: str,
namespace: str,
env: dict | None = None,
) -> bool:
res = _kubectl(
["-n", namespace, "get", "deployment", deployment, "-o", "json"],
env=env,
timeout=20,
)
if res.returncode != 0 or not res.stdout.strip():
return False
try:
payload = json.loads(res.stdout)
except Exception:
return False
spec = payload.get("spec") or {}
status = payload.get("status") or {}
desired = int(spec.get("replicas") or 1)
available = int(status.get("availableReplicas") or 0)
return desired > 0 and available >= desired
def initialize(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def start(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def update(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
target_ns = _registry_namespace(namespace, env)
_ensure_namespace(target_ns, env)
_prune_named_workload_other_namespaces(
kind="deployment",
name="registry",
target_namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="service",
name="registry",
target_namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
force_apply = _to_bool((env or {}).get("PROLE_REGISTRY_FORCE_APPLY"), default=False)
if not force_apply and _exists("deployment", "registry", target_ns, env=env) and _exists(
"service", "registry", target_ns, env=env
):
_reconcile_deployment_replicasets(
deployment="registry",
namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
if _deployment_is_available(deployment="registry", namespace=target_ns, env=env):
_log(
log,
f"[REGISTRY] Healthy deployment already present in namespace {target_ns}; skipping re-apply",
)
return
_log(
log,
f"[REGISTRY] Existing deployment in namespace {target_ns} is not ready; applying manifest for recovery",
)
if force_apply:
_log(
log,
f"[REGISTRY] Force apply enabled; reconciling manifest in namespace {target_ns}",
)
manifest = _manifest(project_root)
if not manifest.exists():
raise RuntimeError(f"Registry manifest not found: {manifest}")
_log(log, f"[REGISTRY] Applying {manifest}")
_kubectl(
["-n", target_ns, "apply", "-f", str(manifest)],
env=env,
timeout=180,
check=True,
)
_reconcile_deployment_replicasets(
deployment="registry",
namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
_wait_rollout("deployment", "registry", target_ns, env=env)
def stop(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _registry_namespace(namespace, env)
if _exists("deployment", "registry", target_ns, env=env):
_kubectl(
["-n", target_ns, "scale", "deployment/registry", "--replicas=0"],
env=env,
timeout=90,
check=True,
)
def restart(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _registry_namespace(namespace, env)
if not _exists("deployment", "registry", target_ns, env=env):
update(namespace=namespace, env=env, mode=mode, log=log)
return
_kubectl(
["-n", target_ns, "rollout", "restart", "deployment/registry"],
env=env,
timeout=120,
check=True,
)
_wait_rollout("deployment", "registry", target_ns, env=env)
def status(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
) -> bool:
target_ns = _registry_namespace(namespace, env)
dep = _kubectl(
["-n", target_ns, "get", "deployment", "registry", "-o", "jsonpath={.status.readyReplicas}"],
env=env,
timeout=20,
)
svc = _kubectl(["-n", target_ns, "get", "svc", "registry"], env=env, timeout=20)
return _to_bool(
dep.returncode == 0 and (dep.stdout or "0").strip() not in {"", "0"} and svc.returncode == 0
)

View File

@ -0,0 +1,158 @@
"""Garage object-store ops — k8s mode (GKE / GCP pd-standard).
Uses GCP pd-standard storage class and GKE-compatible StatefulSet manifests.
No Synology iSCSI classes, no static PVs, no PV repair.
"""
from __future__ import annotations
from pathlib import Path
from ._garage_common import _ensure_garage_secret, _garage_namespace
from ._services_common import (
_LogFn,
_ensure_namespace,
_exists,
_kubectl,
_log,
_manifest_path,
_prune_named_workload_other_namespaces,
_wait_rollout,
)
def _manifest_files(project_root: str | Path) -> list[Path]:
root = Path(project_root)
return [
_manifest_path(root, "k8s", "prole", "storageclass-gcp-hdd.yaml"),
_manifest_path(root, "k8s", "prole", "garage-configmap.yaml"),
_manifest_path(root, "k8s", "prole", "garage-statefulset-gcp.yaml"),
_manifest_path(root, "k8s", "prole", "garage-service.yaml"),
]
_CLUSTER_SCOPED = {"storageclass-gcp-hdd.yaml"}
def initialize(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def start(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def update(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
_ensure_namespace(target_ns, env)
_ensure_garage_secret(namespace=target_ns, env=env, log=log)
_prune_named_workload_other_namespaces(
kind="statefulset",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="service",
name="garage",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
_prune_named_workload_other_namespaces(
kind="configmap",
name="garage-config",
target_namespace=target_ns,
label_selector="app=garage",
env=env,
log=log,
)
for manifest in _manifest_files(project_root):
if not manifest.exists():
continue
_log(log, f"[GARAGE] Applying {manifest}")
if manifest.name in _CLUSTER_SCOPED:
_kubectl(["apply", "-f", str(manifest)], env=env, timeout=240, check=True)
else:
_kubectl(["-n", target_ns, "apply", "-f", str(manifest)], env=env, timeout=240, check=True)
if _exists("statefulset", "garage", target_ns, env=env):
_wait_rollout("statefulset", "garage", target_ns, env=env)
def stop(
*,
namespace: str | None = None,
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Scaling statefulset/garage to 0 in namespace {target_ns}")
_kubectl(
["-n", target_ns, "scale", "statefulset/garage", "--replicas=0"],
env=env,
timeout=90,
check=True,
)
def restart(
*,
namespace: str | None = None,
env: dict | None = None,
log: _LogFn | None = None,
) -> None:
target_ns = _garage_namespace(namespace, env)
if _exists("statefulset", "garage", target_ns, env=env):
_log(log, f"[GARAGE] Restarting statefulset/garage in namespace {target_ns}")
_kubectl(
["-n", target_ns, "rollout", "restart", "statefulset/garage"],
env=env,
timeout=120,
check=True,
)
_wait_rollout("statefulset", "garage", target_ns, env=env)
def status(
*,
namespace: str | None = None,
env: dict | None = None,
) -> bool:
from ._services_common import _to_bool
target_ns = _garage_namespace(namespace, env)
sts = _kubectl(
["-n", target_ns, "get", "statefulset", "garage", "-o", "jsonpath={.status.readyReplicas}"],
env=env,
timeout=20,
)
svc = _kubectl(["-n", target_ns, "get", "svc", "garage"], env=env, timeout=20)
return _to_bool(
sts.returncode == 0 and (sts.stdout or "0").strip() not in {"", "0"} and svc.returncode == 0
)

View File

@ -0,0 +1,73 @@
"""Registry ops — k8s mode (GKE / GCP Artifact Registry).
In GKE mode there is no in-cluster registry. Images are pushed to and pulled from
GCP Artifact Registry, which is externally managed. All ops here are no-ops.
"""
from __future__ import annotations
from pathlib import Path
from ._services_common import _LogFn, _log
def initialize(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def start(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
def update(
*,
namespace: str | None = None,
env: dict | None = None,
project_root: str | Path = ".",
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
_log(log, "[REGISTRY] GKE/k8s mode: using GCP Artifact Registry — skipping in-cluster registry")
def stop(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
_log(log, "[REGISTRY] GKE/k8s mode: no in-cluster registry to stop")
def restart(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
_log(log, "[REGISTRY] GKE/k8s mode: no in-cluster registry to restart")
def status(
*,
namespace: str | None = None,
env: dict | None = None,
mode: str | None = None,
) -> bool:
# GCP Artifact Registry is externally managed; always report healthy
return True

View File

@ -1,89 +1,30 @@
"""Registry ops — thin dispatcher.
Routes to the mode-specific implementation:
k3d k3d_registry (k3d built-in or Docker fallback)
k3s k3s_registry (in-cluster Deployment + Service)
k8s k8s_registry (GCP Artifact Registry no in-cluster registry)
All public function signatures are unchanged.
"""
from __future__ import annotations
from pathlib import Path
import json
from types import ModuleType
from ._services_common import (
_LogFn,
_detect_mode,
_docker,
_ensure_namespace,
_exists,
_k3d,
_kubectl,
_log,
_manifest_path,
_prune_named_workload_other_namespaces,
_reconcile_deployment_replicasets,
_registry_namespace,
_to_bool,
_wait_rollout,
)
from ._services_common import _LogFn, _detect_mode
def _ensure_k3d_registry(*, env: dict | None = None, log: _LogFn | None = None) -> None:
name = str((env or {}).get("K3D_REGISTRY_NAME") or "prole-registry")
port = str((env or {}).get("REGISTRY_PORT") or "5000")
listed = _k3d(["registry", "list"], env=env, timeout=60)
if listed.returncode == 0 and name in listed.stdout:
_log(log, f"[REGISTRY] k3d registry {name} already exists")
return
_log(log, f"[REGISTRY] Creating k3d registry {name} on port {port}")
created = _k3d(["registry", "create", name, "--port", f"{port}:{port}"], env=env, timeout=180)
if created.returncode == 0:
return
# fallback docker registry
_log(log, "[REGISTRY] k3d registry creation failed; trying docker registry fallback")
running = _docker(["ps", "--format", "{{.Names}}"], env=env, timeout=30)
if running.returncode == 0 and name in running.stdout.splitlines():
return
_docker(["rm", "-f", name], env=env, timeout=30)
_docker(
[
"run",
"-d",
"--restart=always",
"-p",
f"{port}:5000",
"--name",
name,
"registry:2",
],
env=env,
timeout=120,
check=True,
)
def _manifest(project_root: str | Path) -> Path:
return _manifest_path(project_root, "k8s", "registry", "deployment.yaml")
def _deployment_is_available(
*,
deployment: str,
namespace: str,
env: dict | None = None,
) -> bool:
res = _kubectl(
["-n", namespace, "get", "deployment", deployment, "-o", "json"],
env=env,
timeout=20,
)
if res.returncode != 0 or not res.stdout.strip():
return False
try:
payload = json.loads(res.stdout)
except Exception:
return False
spec = payload.get("spec") or {}
status = payload.get("status") or {}
desired = int(spec.get("replicas") or 1)
available = int(status.get("availableReplicas") or 0)
return desired > 0 and available >= desired
def _module(mode: str | None, env: dict | None) -> ModuleType:
m = _detect_mode(mode, env)
if m == "k3d":
from . import k3d_registry
return k3d_registry
if m == "k8s":
from . import k8s_registry
return k8s_registry
from . import k3s_registry
return k3s_registry
def initialize(
@ -94,7 +35,9 @@ def initialize(
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
_module(mode, env).initialize(
namespace=namespace, env=env, project_root=project_root, log=log, mode=mode
)
def start(
@ -105,7 +48,9 @@ def start(
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
update(namespace=namespace, env=env, project_root=project_root, log=log, mode=mode)
_module(mode, env).start(
namespace=namespace, env=env, project_root=project_root, log=log, mode=mode
)
def update(
@ -116,76 +61,9 @@ def update(
log: _LogFn | None = None,
mode: str | None = None,
) -> None:
effective_mode = _detect_mode(mode, env)
if effective_mode == "k3d":
_ensure_k3d_registry(env=env, log=log)
return
target_ns = _registry_namespace(namespace, env)
_ensure_namespace(target_ns, env)
_prune_named_workload_other_namespaces(
kind="deployment",
name="registry",
target_namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
_module(mode, env).update(
namespace=namespace, env=env, project_root=project_root, log=log, mode=mode
)
_prune_named_workload_other_namespaces(
kind="service",
name="registry",
target_namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
force_apply = _to_bool((env or {}).get("PROLE_REGISTRY_FORCE_APPLY"), default=False)
if not force_apply and _exists("deployment", "registry", target_ns, env=env) and _exists(
"service", "registry", target_ns, env=env
):
_reconcile_deployment_replicasets(
deployment="registry",
namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
if _deployment_is_available(deployment="registry", namespace=target_ns, env=env):
_log(
log,
f"[REGISTRY] Healthy deployment already present in namespace {target_ns}; skipping re-apply",
)
return
_log(
log,
f"[REGISTRY] Existing deployment in namespace {target_ns} is not ready; applying manifest for recovery",
)
if force_apply:
_log(
log,
f"[REGISTRY] Force apply enabled; reconciling manifest in namespace {target_ns}",
)
manifest = _manifest(project_root)
if not manifest.exists():
raise RuntimeError(f"Registry manifest not found: {manifest}")
_log(log, f"[REGISTRY] Applying {manifest}")
_kubectl(
["-n", target_ns, "apply", "-f", str(manifest)],
env=env,
timeout=180,
check=True,
)
_reconcile_deployment_replicasets(
deployment="registry",
namespace=target_ns,
label_selector="app=registry",
env=env,
log=log,
)
_wait_rollout("deployment", "registry", target_ns, env=env)
def stop(
@ -195,22 +73,7 @@ def stop(
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
effective_mode = _detect_mode(mode, env)
if effective_mode == "k3d":
name = str((env or {}).get("K3D_REGISTRY_NAME") or "prole-registry")
_log(log, f"[REGISTRY] Removing k3d registry {name}")
_k3d(["registry", "delete", name], env=env, timeout=120)
_docker(["rm", "-f", name], env=env, timeout=30)
return
target_ns = _registry_namespace(namespace, env)
if _exists("deployment", "registry", target_ns, env=env):
_kubectl(
["-n", target_ns, "scale", "deployment/registry", "--replicas=0"],
env=env,
timeout=90,
check=True,
)
_module(mode, env).stop(namespace=namespace, env=env, mode=mode, log=log)
def restart(
@ -220,22 +83,7 @@ def restart(
mode: str | None = None,
log: _LogFn | None = None,
) -> None:
effective_mode = _detect_mode(mode, env)
if effective_mode == "k3d":
update(namespace=namespace, env=env, mode=effective_mode, log=log)
return
target_ns = _registry_namespace(namespace, env)
if not _exists("deployment", "registry", target_ns, env=env):
update(namespace=namespace, env=env, mode=effective_mode, log=log)
return
_kubectl(
["-n", target_ns, "rollout", "restart", "deployment/registry"],
env=env,
timeout=120,
check=True,
)
_wait_rollout("deployment", "registry", target_ns, env=env)
_module(mode, env).restart(namespace=namespace, env=env, mode=mode, log=log)
def status(
@ -244,19 +92,4 @@ def status(
env: dict | None = None,
mode: str | None = None,
) -> bool:
effective_mode = _detect_mode(mode, env)
if effective_mode == "k3d":
name = str((env or {}).get("K3D_REGISTRY_NAME") or "prole-registry")
reg = _k3d(["registry", "list"], env=env, timeout=30)
return _to_bool(reg.returncode == 0 and name in reg.stdout)
target_ns = _registry_namespace(namespace, env)
dep = _kubectl(
["-n", target_ns, "get", "deployment", "registry", "-o", "jsonpath={.status.readyReplicas}"],
env=env,
timeout=20,
)
svc = _kubectl(["-n", target_ns, "get", "svc", "registry"], env=env, timeout=20)
return _to_bool(
dep.returncode == 0 and (dep.stdout or "0").strip() not in {"", "0"} and svc.returncode == 0
)
return _module(mode, env).status(namespace=namespace, env=env, mode=mode)

View File

@ -1,12 +1,46 @@
"""Ncurses UI adapter skeleton for the installer runner."""
"""Ncurses UI adapter for the installer runner.
Provides two concrete classes:
NcursesUI minimal skeleton (original interface, unchanged)
CursesInstallerUI full two-panel TUI with number-selection navigation
Layout
------
[ k3d ] [ k3s ] [ k8s ] mode strip (line 0)
[1] Welcome Screen title / content / log scroll
[2] Dependencies
... Tab / Shift-Tab cycle focusable fields
[18] Post Install / scroll log
q / Esc return focus to left panel
[q] quit [m] cycle mode [] navigate [Enter] select [Tab] fields
Number shortcuts: type a digit (or two digits within 1 s) to jump to that
nav item; press Enter to confirm.
Press 'm' at any time to cycle PROLE_MODE k3d k3s k8s k3d.
"""
from __future__ import annotations
import curses
import os
import time
from collections import deque
from typing import Deque
from ..runner import InstallerRunner
from ..state import InstallerState
from ..milestone import Milestone
# ---------------------------------------------------------------------------
# Minimal skeleton (original public interface — unchanged)
# ---------------------------------------------------------------------------
class NcursesUI:
"""Minimal ncurses adapter that listens to runner events."""
@ -44,3 +78,332 @@ class NcursesUI:
def start(self, start_id: str | None = None) -> bool:
"""Run the installer without a GUI main loop."""
return self.runner.run(start_id=start_id)
# ---------------------------------------------------------------------------
# Full two-panel TUI
# ---------------------------------------------------------------------------
_NAV_ITEMS = [
("Welcome", "welcome"),
("Dependencies", "deps_summary"),
("Network", "network_scan"),
("System Environment", "env_setup"),
("Cluster Environment", "init_cluster"),
("Cluster Nodes", "cluster_nodes"),
("Common Services", "common_services"),
("Database Options", "database_options"),
("Docker Build", "init_db_build"),
("Database Creation", "init_password"),
("Initialization Scripts", "init_scripts"),
("Knoe Authority", "kerberos_config"),
("Knoe Users", "knoe_users"),
("ArgoCD", "argocd_config"),
("GitOps", "gitops_config"),
("Supabase", "supabase_config"),
("Deployment", "init_cnpg_deploy"),
("Post Install", "create_installer"),
]
_MODES = ["k3d", "k3s", "k8s"]
_MODE_PAIRS = list(zip(_MODES, _MODES)) # (label, value)
def _left_panel_width() -> int:
longest = max(len(label) for label, _ in _NAV_ITEMS)
return max(24, longest + 8)
def _current_mode() -> str:
return os.environ.get("PROLE_MODE", "k3s")
def _set_mode(mode: str) -> None:
os.environ["PROLE_MODE"] = mode
def _cycle_mode() -> str:
current = _current_mode()
idx = _MODES.index(current) if current in _MODES else 0
new_mode = _MODES[(idx + 1) % len(_MODES)]
_set_mode(new_mode)
return new_mode
class CursesInstallerUI(NcursesUI):
"""Full two-panel ncurses TUI with number-selection navigation."""
# colour pair indices
_CP_NORMAL = 1
_CP_HIGHLIGHT = 2
_CP_HEADER = 3
_CP_MODE_ACTIVE = 4
_CP_MODE_INACTIVE = 5
_CP_STATUS = 6
_CP_LOG = 7
def __init__(self, runner: InstallerRunner):
super().__init__(runner)
self._log_lines: Deque[str] = deque(maxlen=500)
self._scroll_offset: int = 0
self._selected_index: int = 0 # highlighted in left panel
self._active_index: int = 0 # confirmed (right panel loaded)
self._right_focus: bool = False # True when Tab/arrow are for right panel
self._digit_buf: str = ""
self._digit_time: float = 0.0
self._running: bool = False
# ------------------------------------------------------------------
# Runner event handlers
# ------------------------------------------------------------------
def on_enter_milestone(self, milestone: Milestone, state: InstallerState) -> None:
self._log_lines.clear()
self._scroll_offset = 0
self._log(f"── {milestone.title} ──")
def on_progress(
self,
milestone: Milestone,
state: InstallerState,
message: str,
percent: float | None,
) -> None:
prefix = f"[{percent:5.1f}%] " if percent is not None else " "
self._log(f"{prefix}{message}")
def on_validation_error(
self, milestone: Milestone | None, state: InstallerState, errors: list[str]
) -> None:
for err in errors:
self._log(f"[ERROR] {err}")
def on_complete(self, milestone: Milestone | None, state: InstallerState) -> None:
self._log("── Complete ──")
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _log(self, msg: str) -> None:
for line in msg.splitlines() or [""]:
self._log_lines.append(line)
# ------------------------------------------------------------------
# curses entry point
# ------------------------------------------------------------------
def start(self, start_id: str | None = None) -> bool: # type: ignore[override]
return curses.wrapper(self._main, start_id)
def _main(self, stdscr: "curses.window", start_id: str | None) -> bool: # type: ignore[name-defined]
self._init_colors()
curses.curs_set(0)
stdscr.nodelay(True)
stdscr.keypad(True)
self._running = True
result: bool = False
while self._running:
rows, cols = stdscr.getmaxyx()
lw = min(_left_panel_width(), cols // 3)
rw = max(1, cols - lw - 1)
stdscr.erase()
self._draw_mode_strip(stdscr, cols)
self._draw_left(stdscr, lw, rows)
self._draw_divider(stdscr, lw, rows)
self._draw_right(stdscr, lw + 1, rw, rows)
self._draw_status_bar(stdscr, rows, cols)
stdscr.refresh()
key = stdscr.getch()
if key == -1:
time.sleep(0.05)
continue
result = self._handle_key(key, start_id)
if not self._running:
break
return result
# ------------------------------------------------------------------
# Colour initialisation
# ------------------------------------------------------------------
def _init_colors(self) -> None:
curses.start_color()
curses.use_default_colors()
curses.init_pair(self._CP_NORMAL, curses.COLOR_WHITE, -1)
curses.init_pair(self._CP_HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(self._CP_HEADER, curses.COLOR_CYAN, -1)
curses.init_pair(self._CP_MODE_ACTIVE, curses.COLOR_BLACK, curses.COLOR_GREEN)
curses.init_pair(self._CP_MODE_INACTIVE, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(self._CP_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(self._CP_LOG, curses.COLOR_GREEN, -1)
# ------------------------------------------------------------------
# Drawing
# ------------------------------------------------------------------
def _draw_mode_strip(self, stdscr: "curses.window", cols: int) -> None:
active = _current_mode()
x = 1
try:
stdscr.addstr(0, 0, " " * (cols - 1), curses.color_pair(self._CP_NORMAL))
except curses.error:
pass
for mode in _MODES:
label = f" {mode} "
if active == mode:
attr = curses.color_pair(self._CP_MODE_ACTIVE) | curses.A_BOLD
else:
attr = curses.color_pair(self._CP_MODE_INACTIVE)
try:
stdscr.addstr(0, x, label, attr)
except curses.error:
pass
x += len(label) + 1
def _draw_left(self, stdscr: "curses.window", lw: int, rows: int) -> None:
# header
header = " INSTALLER"[:lw]
try:
stdscr.addstr(1, 0, header.ljust(lw), curses.color_pair(self._CP_HEADER) | curses.A_BOLD)
except curses.error:
pass
nav_rows = rows - 3 # row 0 = mode strip, row 1 = header, last = status
for i, (label, _) in enumerate(_NAV_ITEMS):
row = 2 + i
if row >= rows - 1:
break
num = i + 1
text = f" [{num:>2}] {label}"[:lw].ljust(lw)
if i == self._selected_index:
attr = curses.color_pair(self._CP_HIGHLIGHT) | curses.A_BOLD
elif i == self._active_index:
attr = curses.color_pair(self._CP_NORMAL) | curses.A_UNDERLINE
else:
attr = curses.color_pair(self._CP_NORMAL)
try:
stdscr.addstr(row, 0, text, attr)
except curses.error:
pass
def _draw_divider(self, stdscr: "curses.window", lw: int, rows: int) -> None:
for row in range(1, rows - 1):
try:
stdscr.addch(row, lw, curses.ACS_VLINE)
except curses.error:
pass
def _draw_right(self, stdscr: "curses.window", x0: int, rw: int, rows: int) -> None:
label, page_id = _NAV_ITEMS[self._active_index]
title = f" {label} ({page_id})"[:rw]
try:
stdscr.addstr(1, x0, title.ljust(rw), curses.color_pair(self._CP_HEADER) | curses.A_BOLD)
except curses.error:
pass
log_rows = rows - 3
visible = list(self._log_lines)
total = len(visible)
start = max(0, total - log_rows - self._scroll_offset)
end = max(0, total - self._scroll_offset)
for i, line in enumerate(visible[start:end]):
row = 2 + i
if row >= rows - 1:
break
try:
stdscr.addstr(row, x0, line[:rw].ljust(rw), curses.color_pair(self._CP_LOG))
except curses.error:
pass
def _draw_status_bar(self, stdscr: "curses.window", rows: int, cols: int) -> None:
hint = "[q] quit [m] cycle mode [↑↓] navigate [Enter] select [Tab] right panel"
try:
stdscr.addstr(rows - 1, 0, hint[:cols - 1].ljust(cols - 1),
curses.color_pair(self._CP_STATUS))
except curses.error:
pass
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def _handle_key(self, key: int, start_id: str | None) -> bool:
# Quit
if key in (ord("q"), ord("Q"), 27): # 27 = Esc
self._running = False
return False
# Cycle mode
if key == ord("m"):
_cycle_mode()
return False
# Digit accumulation for number jump
if ord("0") <= key <= ord("9"):
now = time.monotonic()
if self._digit_buf and (now - self._digit_time) > 1.0:
self._digit_buf = ""
self._digit_buf += chr(key)
self._digit_time = now
# If we have 2 digits, resolve immediately
if len(self._digit_buf) == 2:
self._resolve_digit_buf()
return False
# Enter confirms digit buf or selected item
if key in (curses.KEY_ENTER, 10, 13):
if self._digit_buf:
self._resolve_digit_buf()
else:
self._active_index = self._selected_index
self._right_focus = True
self._scroll_offset = 0
return False
# Arrow navigation
if key == curses.KEY_UP:
if self._right_focus:
self._scroll_offset = min(self._scroll_offset + 1, max(0, len(self._log_lines) - 1))
else:
self._selected_index = max(0, self._selected_index - 1)
return False
if key == curses.KEY_DOWN:
if self._right_focus:
self._scroll_offset = max(0, self._scroll_offset - 1)
else:
self._selected_index = min(len(_NAV_ITEMS) - 1, self._selected_index + 1)
return False
# Tab — switch panel focus
if key == ord("\t"):
self._right_focus = True
self._active_index = self._selected_index
return False
# Shift-Tab (key code 353 in most terminals) or backtick — back to left
if key in (curses.KEY_BTAB, 353):
self._right_focus = False
return False
return False
def _resolve_digit_buf(self) -> None:
try:
n = int(self._digit_buf)
if 1 <= n <= len(_NAV_ITEMS):
self._selected_index = n - 1
self._active_index = n - 1
self._right_focus = True
self._scroll_offset = 0
except ValueError:
pass
finally:
self._digit_buf = ""

View File

@ -332,8 +332,8 @@ class KnoeInstaller(
("Docker Build", "init_db_build"),
("Database Creation", "init_password"),
("Initialization Scripts", "init_scripts"),
("Kerberos Authentication", "kerberos_config"),
("Knoe User Authority", "knoe_users"),
("Knoe Authority", "kerberos_config"),
("Knoe Users", "knoe_users"),
("ArgoCD", "argocd_config"),
("GitOps", "gitops_config"),
("Supabase", "supabase_config"),
@ -341,6 +341,8 @@ class KnoeInstaller(
("Post Install", "create_installer"),
]
self.nav_widgets = {}
self._mode_tab_widgets: dict = {}
self.deployment_mode = tk.StringVar(value=__import__("os").environ.get("PROLE_MODE", "k3s"))
self._create_sidebar_nav()
# Validation attributes
@ -537,7 +539,7 @@ class KnoeInstaller(
] = saved_cluster_name
except Exception:
pass
if not saved_service_ns:
if not saved_service_ns or saved_service_ns == "default":
# Seed knoe-system for all managed environments so the
# UI never shows the bare "default" namespace.
_env_hint = prole_conf._env_from_mode_or_env_hint(

View File

@ -806,6 +806,23 @@ class ClusterScreenMixin:
if not self.prod_apply_status.get().strip():
self.prod_apply_status.set("idle/ready: Waiting for plan")
# Artifact Registry traffic light — persisted across screens
ar_indicator = self.bg_canvas.create_oval(
x_label + 20, y, x_label + 34, y + 14,
fill="#ff9f0a", outline="#8e8e93", width=1,
)
self._canvas_items.append(ar_indicator)
self._artifact_registry_indicator = ar_indicator
ar_lbl = ui.canvas_text(
self, x_label + 42, y + 7,
"Artifact Registry: checking…",
fill="black", font=("SF Pro Text", 10), anchor="w",
)
self._canvas_items.append(ar_lbl)
self._artifact_registry_label = ar_lbl
y += 28 # noqa: F841 (y consumed; keep for future rows)
self.safe_after(self._check_artifact_registry_async, delay=1000)
ui.canvas_text(
self,
x_label,
@ -2316,6 +2333,12 @@ class ClusterScreenMixin:
]
_combo["values"] = _updated
_combo.set(f"{location}")
# Re-evaluate Artifact Registry traffic light now that the
# region is available from the selected cluster's context.
try:
self._check_artifact_registry_async()
except Exception:
pass
self.safe_after(_apply)
@ -3288,6 +3311,11 @@ class ClusterScreenMixin:
pass
self.root.after(0, update_ui)
# Refresh Artifact Registry light whenever cluster status is checked (prod only).
try:
self._check_artifact_registry_async()
except Exception:
pass
# Clear message after a short delay if no notice is active
if not notice_active:
self._set_cluster_env_message("", "#34c759", clear_after_ms=2000)
@ -3322,6 +3350,94 @@ class ClusterScreenMixin:
threading.Thread(target=worker, daemon=True).start()
def _check_artifact_registry_async(self):
"""Check GCP Artifact Registry availability and persist the result.
Updates the ``_artifact_registry_indicator`` oval on the Cluster Environment
screen and stores ``ARTIFACT_REGISTRY_AVAILABLE`` in ``prole_cfg_data["Global"]``
so the Database Build screen can read it without re-checking.
"""
def worker():
ok = False
msg = "Artifact Registry: unavailable"
try:
from knoe.core.env import _deployment_mode_from_env # local import to avoid cycles
env_key = self._cluster_env_key()
mode = _deployment_mode_from_env(env_key)
if mode != "k8s":
# Only relevant for GKE/prod; skip silently for other envs.
return
gcp = self.prole_cfg_data.get("GCP", {}) or {}
project_id = (gcp.get("project_id") or gcp.get("PROJECT_ID") or "").strip().strip('"')
kubecontext = (
(self.prole_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT") or ""
).strip()
region = ""
if kubecontext.startswith("gke_"):
parts = kubecontext.split("_", 3)
if len(parts) >= 3:
region = parts[2]
ar_repo = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("SERVICE_NAMESPACE", "knoe-system")
.strip() or "knoe-system"
)
if project_id and region:
result = subprocess.run(
[
"gcloud", "artifacts", "repositories", "describe", ar_repo,
"--project", project_id,
"--location", region,
"--format", "value(name)",
],
capture_output=True,
text=True,
timeout=15,
)
ok = result.returncode == 0
registry_url = f"{region}-docker.pkg.dev/{project_id}/{ar_repo}"
msg = (
f"Artifact Registry: {registry_url}"
if ok
else f"Artifact Registry: {registry_url} (unavailable)"
)
elif project_id:
msg = "Artifact Registry: region not set"
else:
msg = "Artifact Registry: GCP project not configured"
except Exception as exc:
msg = f"Artifact Registry: error ({exc})"
ok = False
# Persist so the Database Build screen can read without re-checking.
try:
self.prole_cfg_data.setdefault("Global", {})["ARTIFACT_REGISTRY_AVAILABLE"] = (
"true" if ok else "false"
)
except Exception:
pass
fill = "#34c759" if ok else "#ff3b30"
def update_ui():
try:
if hasattr(self, "_artifact_registry_indicator"):
self.bg_canvas.itemconfig(
self._artifact_registry_indicator, fill=fill
)
if hasattr(self, "_artifact_registry_label"):
self.bg_canvas.itemconfig(
self._artifact_registry_label, text=msg
)
except Exception:
pass
self.root.after(0, update_ui)
threading.Thread(target=worker, daemon=True).start()
def ensure_cluster_ready(self):
self._action_flags["init_cluster.start_cluster"] = True

View File

@ -9,6 +9,11 @@ from pathlib import Path
from tkinter import messagebox
from knoe import screen as ui
from knoe.core.cnpg_placement import (
load_cnpg_placement_plan,
plan_cnpg_placement,
save_cnpg_placement_plan,
)
from knoe.core.topology import (
ClusterTopology,
DEFAULT_MONITORING_MIN_FREE_BYTES,
@ -44,6 +49,8 @@ class ClusterNodesScreenMixin:
"storage_probe_skip": False,
"storage_probe_quick": True,
"storage_probe_force_refresh": False,
"cnpg_placement_plan": None,
"cnpg_rebalance_requested": False,
}
self._cluster_nodes_topology_state = state
return state
@ -68,6 +75,28 @@ class ClusterNodesScreenMixin:
def _cluster_nodes_topology_root(self) -> Path:
return self._resolve_env_dir("PROLE_DATA", "data") / "topology"
def _cluster_nodes_placement_plan_path(self) -> Path:
conf_root = self._resolve_env_dir("PROLE_CONF", "conf")
return conf_root / "cnpg-placement" / "ecosystem-0-knoe-db.json"
def _cluster_nodes_compute_placement(
self, eligible_nodes: list[str], rebalance: bool = False
) -> dict:
plan_path = self._cluster_nodes_placement_plan_path()
prior = load_cnpg_placement_plan(plan_path)
plan = plan_cnpg_placement(
cluster_name="knoe-db",
desired_instances=3,
candidate_nodes=eligible_nodes,
prior_plan=prior,
rebalance=rebalance,
)
try:
save_cnpg_placement_plan(plan_path, plan)
except Exception:
pass
return plan
def _cluster_nodes_start_discovery(self, force: bool = False) -> None:
state = self._cluster_nodes_state()
if state.get("busy"):
@ -152,8 +181,16 @@ class ClusterNodesScreenMixin:
glob = self.prole_cfg_data.setdefault("Global", {})
if nodes:
glob["CNPG_ELIGIBLE_NODES"] = ",".join(sorted(node.name for node in nodes))
glob["CNPG_STAGE1_NODE"] = sorted(node.name for node in nodes)[0]
eligible_names = sorted(node.name for node in nodes)
glob["CNPG_ELIGIBLE_NODES"] = ",".join(eligible_names)
glob["CNPG_STAGE1_NODE"] = eligible_names[0]
state = self._cluster_nodes_state()
rebalance = state.pop("cnpg_rebalance_requested", False)
plan = self._cluster_nodes_compute_placement(eligible_names, rebalance=rebalance)
state["cnpg_placement_plan"] = plan
glob["CNPG_PLACEMENT_PLAN_ID"] = str(plan.get("plan_id") or "")
glob["CNPG_PLACEMENT_PLAN_HASH"] = str(plan.get("plan_hash") or "")
if synology_roots:
glob["SYNOLOGY_ROOTS"] = ",".join(synology_roots)
@ -465,6 +502,174 @@ class ClusterNodesScreenMixin:
f"{storage_text}"
)
def _cluster_nodes_render_cnpg_placement(
self, topology: ClusterTopology | None, x: int, y: int
) -> int:
DESIRED = 3
CARD_WIDTH = 905
eligible = [n for n in (topology.nodes if topology else []) if n.capabilities.cnpg_eligible is True]
plan: dict | None = self._cluster_nodes_state().get("cnpg_placement_plan")
outer = tk.Frame(
self.bg_canvas,
bg="#f6f8fb",
highlightthickness=1,
highlightbackground="#d1d1d6",
)
self._overlay_widgets.append(outer)
win = self.bg_canvas.create_window(x, y, anchor="nw", width=CARD_WIDTH, window=outer)
self._canvas_items.append(win)
# Header row
hdr_frame = tk.Frame(outer, bg="#f0f4ff")
hdr_frame.pack(fill="x", padx=0, pady=0)
self._overlay_widgets.append(hdr_frame)
tk.Label(
hdr_frame,
text="CNPG Node Placement",
bg="#f0f4ff",
fg="#1d1d1f",
font=("SF Pro Text", 11, "bold"),
padx=10,
pady=6,
anchor="w",
).pack(side="left")
self._overlay_widgets.append(hdr_frame.winfo_children()[-1])
meta_txt = "3 instances · round-robin · premium-rwo · required node separation"
tk.Label(
hdr_frame,
text=meta_txt,
bg="#f0f4ff",
fg="#6e6e73",
font=("SF Pro Text", 10),
padx=6,
pady=6,
anchor="e",
).pack(side="right")
self._overlay_widgets.append(hdr_frame.winfo_children()[-1])
body = tk.Frame(outer, bg="#f6f8fb")
body.pack(fill="x", padx=10, pady=(4, 0))
self._overlay_widgets.append(body)
if len(eligible) < DESIRED:
warn_txt = (
f"Warning: only {len(eligible)} CNPG-eligible node(s) found; "
f"{DESIRED} required for separate-node placement."
)
tk.Label(
body,
text=warn_txt,
bg="#fff3cd",
fg="#856404",
font=("SF Pro Text", 10),
padx=8,
pady=6,
anchor="w",
justify="left",
).pack(fill="x", pady=(2, 6))
self._overlay_widgets.append(body.winfo_children()[-1])
elif plan:
assignments: dict = plan.get("assignments") or {}
col_headers = ["Ordinal", "Node", "Data storage", "WAL storage"]
table = tk.Frame(body, bg="#f6f8fb")
table.pack(fill="x", pady=(2, 4))
self._overlay_widgets.append(table)
for c, hdr in enumerate(col_headers):
tk.Label(
table,
text=hdr,
bg="#e8eaf6",
fg="#1d1d1f",
font=("SF Pro Text", 10, "bold"),
padx=6,
pady=4,
anchor="w",
width=22 if c == 1 else 14,
).grid(row=0, column=c, sticky="nsew", padx=(0, 1))
self._overlay_widgets.append(table.grid_slaves(row=0, column=c)[0])
for ordinal in range(DESIRED):
node_name = assignments.get(str(ordinal)) or assignments.get(ordinal) or ""
row_vals = [str(ordinal), node_name, "premium-rwo 100 Gi", "premium-rwo 20 Gi"]
row_bg = "white" if ordinal % 2 == 0 else "#f9fbff"
for c, val in enumerate(row_vals):
tk.Label(
table,
text=val,
bg=row_bg,
fg="#1d1d1f",
font=("SF Pro Text", 10),
padx=6,
pady=3,
anchor="w",
width=22 if c == 1 else 14,
).grid(row=ordinal + 1, column=c, sticky="nsew", padx=(0, 1))
self._overlay_widgets.append(table.grid_slaves(row=ordinal + 1, column=c)[0])
meta = plan.get("metadata") or {}
reason = str(meta.get("reason") or "")
badge = "reused" if meta.get("reused") else reason.replace("_", " ")
plan_id = str(plan.get("plan_id") or "")
plan_line = f"{plan_id} [{badge}]" if plan_id else ""
if plan_line:
tk.Label(
body,
text=f"Plan: {plan_line}",
bg="#f6f8fb",
fg="#6e6e73",
font=("SF Pro Text", 9),
padx=4,
pady=2,
anchor="w",
).pack(anchor="w")
self._overlay_widgets.append(body.winfo_children()[-1])
note = "rw and ro roles float — CNPG assigns dynamically, not pinned by ordinal"
tk.Label(
body,
text=note,
bg="#f6f8fb",
fg="#6e6e73",
font=("SF Pro Text", 9, "italic"),
padx=4,
pady=2,
anchor="w",
).pack(anchor="w")
self._overlay_widgets.append(body.winfo_children()[-1])
btn_frame = tk.Frame(outer, bg="#f6f8fb")
btn_frame.pack(anchor="w", padx=10, pady=(4, 8))
self._overlay_widgets.append(btn_frame)
def _rebalance():
self._cluster_nodes_state()["cnpg_rebalance_requested"] = True
self._cluster_nodes_start_discovery(force=True)
rebalance_btn = tk.Button(
btn_frame,
text="Rebalance",
command=_rebalance,
bg="#f5f5dc",
fg="black",
activebackground="#e5e5d5",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=10,
pady=4,
)
rebalance_btn.pack(side="left")
self._overlay_widgets.append(rebalance_btn)
outer.update_idletasks()
card_h = outer.winfo_reqheight()
return y + card_h + 8
def _cluster_nodes_save_selected(self) -> None:
state = self._cluster_nodes_state()
selected = sorted(state.get("selected") or [])
@ -562,6 +767,7 @@ class ClusterNodesScreenMixin:
topology = state.get("topology") if isinstance(state.get("topology"), ClusterTopology) else None
y = self._cluster_nodes_render_summary_cards(topology, 48, 388)
y = self._cluster_nodes_render_table(topology, 48, y)
y = self._cluster_nodes_render_cnpg_placement(topology, 48, y + 12)
refresh_btn = tk.Button(
self.bg_canvas,

View File

@ -1139,11 +1139,20 @@ class DatabaseScreenMixin:
except Exception:
env_key = "dev"
# If the user selected Service, treat it as k3s even when Global/DEPLOYMENT_MODE remains k3d.
mode = _deployment_mode_from_env(
((self.prole_cfg_data.get("Global", {}) or {}).get("DEPLOYMENT_MODE") or "").strip()
or env_key
)
# For named environments (prod → k8s, service → k3s) the env_key is the
# authoritative source of truth and must not be overridden by a stale
# DEPLOYMENT_MODE stored in prole_cfg_data (e.g. "k3d" left over from a
# previous dev session while the user has switched to prod).
# For the dev env, DEPLOYMENT_MODE may legitimately be set to "k3s" to
# test service-mode workloads, so we preserve that override there.
_env_based_mode = _deployment_mode_from_env(env_key)
if _env_based_mode in ("k8s", "k3s"):
mode = _env_based_mode
else:
mode = _deployment_mode_from_env(
((self.prole_cfg_data.get("Global", {}) or {}).get("DEPLOYMENT_MODE") or "").strip()
or env_key
)
def _show_init_button(show: bool):
try:
@ -1280,9 +1289,27 @@ class DatabaseScreenMixin:
if len(parts) >= 3:
region = parts[2]
if project_id and region:
registry_url = f"{region}-docker.pkg.dev/{project_id}/prole"
msg = f"Registry: {registry_url}"
color = "#34c759"
ar_repo = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("SERVICE_NAMESPACE", "knoe-system")
.strip() or "knoe-system"
)
registry_url = f"{region}-docker.pkg.dev/{project_id}/{ar_repo}"
# Use the availability flag persisted by the Cluster Environment
# screen (_check_artifact_registry_async). If not yet set, assume
# available so we don't flash red on first open.
ar_available = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("ARTIFACT_REGISTRY_AVAILABLE", "true")
.strip()
.lower()
)
if ar_available == "false":
msg = f"Registry: {registry_url} (unavailable)"
color = "#ff3b30"
else:
msg = f"Registry: {registry_url}"
color = "#34c759"
elif project_id:
registry_url = f"gcr.io/{project_id}"
msg = f"Registry: {registry_url}"

View File

@ -240,8 +240,45 @@ class NavigationMixin:
except Exception:
pass
_MODE_COLORS = {"k3d": "#4A90D9", "k3s": "#27AE60", "k8s": "#E67E22"}
def _set_deployment_mode(self, mode: str) -> None:
"""Switch the active deployment mode and persist it to the environment."""
self.deployment_mode.set(mode)
os.environ["PROLE_MODE"] = mode
self._refresh_mode_tabs()
def _refresh_mode_tabs(self) -> None:
"""Update tab highlight colours to reflect the active deployment mode."""
active = self.deployment_mode.get()
for value, widget in self._mode_tab_widgets.items():
if value == active:
widget.configure(bg=self._MODE_COLORS.get(value, "#888"), fg="white")
else:
widget.configure(bg="#D5D5C5", fg="#555")
def _create_sidebar_nav(self):
"""Create the left-hand navigation menu."""
# ── Deployment mode tab strip ────────────────────────────────────
mode_frame = tk.Frame(self.sidebar, bg="#F5F5DC")
mode_frame.pack(fill="x", padx=12, pady=(14, 4))
for label, value in [("k3d", "k3d"), ("k3s", "k3s"), ("k8s", "k8s")]:
btn = tk.Label(
mode_frame,
text=label,
bg="#D5D5C5",
fg="#555",
font=("SF Pro Text", 9, "bold"),
padx=8,
pady=3,
cursor="hand2",
)
btn.pack(side="left", padx=2)
btn.bind("<Button-1>", lambda e, v=value: self._set_deployment_mode(v))
self._mode_tab_widgets[value] = btn
self._refresh_mode_tabs()
# ── INSTALLER label ──────────────────────────────────────────────
tk.Label(
self.sidebar,
text="INSTALLER",
@ -249,7 +286,7 @@ class NavigationMixin:
fg="#8B8B7A",
font=("SF Pro Text", 10, "bold"),
anchor="w",
).pack(fill="x", padx=20, pady=(20, 10))
).pack(fill="x", padx=20, pady=(10, 10))
for text, page_id in self.nav_items:
lbl = tk.Label(

View File

@ -55,7 +55,7 @@ class SecurityScreenMixin:
anchor="ne",
)
self._render_title("Kerberos Authentication", y=150)
self._render_title("Knoe Authority", y=150)
self._render_paragraph(
"Configure Kerberos authentication for Prole and the PostgreSQL database. Tests run inside the Kubernetes namespace to validate against your realm.",
y=200,
@ -65,7 +65,7 @@ class SecurityScreenMixin:
# Use tk.Checkbutton on canvas
enable_cb = tk.Checkbutton(
self.bg_canvas,
text="Enable Kerberos Authentication",
text="Enable Knoe Authority",
variable=self.kerberos_enabled,
command=self._on_kerberos_toggle,
bg="white",

View File

@ -576,7 +576,7 @@ class ServicesScreenMixin:
self.safe_after(lambda: self._set_init_light("barman_cloud", "green"))
else:
_con("init_cnpg_backup.sh").write(
"Skipping Prole DB backup because previous steps failed.\n"
f"Skipping {cnpg_cluster} backup because previous steps failed.\n"
)
# 5. init_kong.sh start

View File

@ -1 +1 @@
9
13

View File

@ -18,10 +18,25 @@ def test_render_cluster_nodes_page_topology_messages_are_scrollable(monkeypatch)
def configure(self, **_kwargs):
return None
class _DummyFrame(_DummyWidget):
def pack(self, *_args, **_kwargs):
return None
def grid(self, *_args, **_kwargs):
return None
class _DummyFrame(_DummyWidget):
def winfo_children(self):
return [_DummyWidget()]
def winfo_reqheight(self):
return 0
def update_idletasks(self):
return None
def grid_slaves(self, *_args, **_kwargs):
return [_DummyWidget()]
class _DummyScrollbar(_DummyWidget):
instances: list["_DummyScrollbar"] = []
@ -70,6 +85,7 @@ def test_render_cluster_nodes_page_topology_messages_are_scrollable(monkeypatch)
monkeypatch.setattr(cluster_nodes.tk, "Scrollbar", _DummyScrollbar)
monkeypatch.setattr(cluster_nodes.tk, "Text", _DummyText)
monkeypatch.setattr(cluster_nodes.tk, "Button", _DummyWidget)
monkeypatch.setattr(cluster_nodes.tk, "Label", _DummyWidget)
class _DummyCanvas:
def __init__(self):