mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
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:
parent
9e8ebaf36a
commit
3312c39b1f
23
conf/cnpg-placement/ecosystem-0-knoe-db.json
Normal file
23
conf/cnpg-placement/ecosystem-0-knoe-db.json
Normal 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"
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
157
docs/intellij-refactor-prompt.md
Normal file
157
docs/intellij-refactor-prompt.md
Normal 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
|
||||
@ -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)"
|
||||
|
||||
53
knoe/core/ops/_garage_common.py
Normal file
53
knoe/core/ops/_garage_common.py
Normal 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}")
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
150
knoe/core/ops/k3d_garage_store.py
Normal file
150
knoe/core/ops/k3d_garage_store.py
Normal 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
|
||||
)
|
||||
119
knoe/core/ops/k3d_registry.py
Normal file
119
knoe/core/ops/k3d_registry.py
Normal 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)
|
||||
198
knoe/core/ops/k3s_garage_store.py
Normal file
198
knoe/core/ops/k3s_garage_store.py
Normal 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
|
||||
)
|
||||
202
knoe/core/ops/k3s_registry.py
Normal file
202
knoe/core/ops/k3s_registry.py
Normal 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
|
||||
)
|
||||
158
knoe/core/ops/k8s_garage_store.py
Normal file
158
knoe/core/ops/k8s_garage_store.py
Normal 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
|
||||
)
|
||||
73
knoe/core/ops/k8s_registry.py
Normal file
73
knoe/core/ops/k8s_registry.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1 +1 @@
|
||||
9
|
||||
13
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user