Harden prod deploy namespace/context routing and vault password handling

- persist and load DB master password via Ansible Vault bootstrap flow

- enforce knoe-system service namespace and explicit app/db kubecontext targeting

- improve OpenBao/CNPG deploy reliability and logging; add retries/readiness diagnostics

- tighten reset/delete cluster behavior and expand installer/deploy pipeline test coverage

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-04-10 00:43:07 -07:00
parent 3393767953
commit c94c62e1bb
32 changed files with 2072 additions and 418 deletions

View File

@ -42,9 +42,32 @@ Setup script: `etc/init_cnpg_gke.sh` (creates buckets, GCP SA, WI binding, appli
--- ---
## install.sh pre-flight checklist ## Service mesh (Cloud Service Mesh / Istio)
Before running `./install.sh` (especially "Initialization Scripts"), confirm these are correct in `conf/prod/prole.cfg`: Both clusters are registered in the **knoe-0** GCP fleet with automatic Cloud Service Mesh management. This is automated in `scripts/reset_clusters.sh` (Phase 7) — no longer requires GCP web console.
```bash
# Check mesh provisioning status (~10 min after cluster creation):
gcloud container fleet mesh describe --project=plenary-truck-485623-p7
# Manual re-registration if needed:
gcloud container fleet memberships register knoe-dev-0 \
--gke-cluster=us-west3/knoe-dev-0 \
--enable-workload-identity \
--project=plenary-truck-485623-p7
gcloud container fleet mesh update \
--management=automatic \
--memberships=knoe-dev-0 \
--project=plenary-truck-485623-p7
```
---
## install.sh / deploy.sh pre-flight checklist
### `conf/prod/prole.cfg` (interactive installer — `./install.sh`)
Before running `./install.sh` (especially "Initialization Scripts"), confirm these are correct:
```ini ```ini
[Inputs] [Inputs]
@ -54,12 +77,35 @@ env_setup.APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0 env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
[Global] [Global]
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0 KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0 APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
CNPG_ELIGIBLE_NODES = <comma-separated node names from knoe-cnpg-0> CNPG_ELIGIBLE_NODES = <comma-separated node names from knoe-cnpg-0>
``` ```
Missing `init_cluster.app_cluster_kubecontext``_cluster_kubecontext("app")` returns `""` → installer falls back to `Global.KUBECONTEXT` (DB cluster) for **both** app and db environments → **Garage deploys to knoe-cnpg-0** (wrong). ### `conf/service/prod.cfg` (unattended deploy — `./deploy.sh`)
Same cluster context entries are required here too:
```ini
[Inputs]
init_cluster.app_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
init_cluster.db_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
env_setup.APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
[Global]
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
SERVICE_NAMESPACE = knoe-system
REGISTRY_NAMESPACE = knoe-system
```
**Why these matter:** `Milestone._get_script_env()` (in `knoe/milestone.py`) reads these to set `KUBECONTEXT=app_ctx` for common services and `DB_CLUSTER_KUBECONTEXT=db_ctx` for CNPG ops. Without them, all kubectl calls use the ambient context, which may be the DB cluster.
Missing `init_cluster.app_cluster_kubecontext``_cluster_kubecontext("app")` returns `""` → installer falls back to `Global.KUBECONTEXT` for **both** app and db environments → **Garage deploys to knoe-cnpg-0** (wrong).
### Get current CNPG node names ### Get current CNPG node names

View File

@ -1,15 +1,15 @@
{ {
"assignments": { "assignments": {
"0": "gke-knoe-dev-0-default-pool-503666dd-d39h", "0": "gke-knoe-dev-0-default-pool-02b40136-m9h9",
"1": "gke-knoe-dev-0-default-pool-98ee334c-qpf9", "1": "gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"2": "gke-knoe-dev-0-default-pool-b26cfdbc-r144" "2": "gke-knoe-dev-0-default-pool-d65ac672-9ncf"
}, },
"cluster_name": "knoe-db", "cluster_name": "knoe-db",
"desired_instances": 3, "desired_instances": 3,
"eligible_nodes": [ "eligible_nodes": [
"gke-knoe-dev-0-default-pool-503666dd-d39h", "gke-knoe-dev-0-default-pool-02b40136-m9h9",
"gke-knoe-dev-0-default-pool-98ee334c-qpf9", "gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"gke-knoe-dev-0-default-pool-b26cfdbc-r144" "gke-knoe-dev-0-default-pool-d65ac672-9ncf"
], ],
"metadata": { "metadata": {
"prior_plan_present": true, "prior_plan_present": true,
@ -17,7 +17,7 @@
"regenerated": true, "regenerated": true,
"reused": false "reused": false
}, },
"plan_hash": "c7f53cb912958603", "plan_hash": "a44825aeb5ed4e13",
"plan_id": "cnpg-placement-c7f53cb912958603", "plan_id": "cnpg-placement-a44825aeb5ed4e13",
"schema_version": "v1" "schema_version": "v1"
} }

View File

@ -1,15 +1,15 @@
{ {
"assignments": { "assignments": {
"0": "gke-knoe-dev-0-default-pool-503666dd-d39h", "0": "gke-knoe-dev-0-default-pool-02b40136-m9h9",
"1": "gke-knoe-dev-0-default-pool-98ee334c-qpf9", "1": "gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"2": "gke-knoe-dev-0-default-pool-b26cfdbc-r144" "2": "gke-knoe-dev-0-default-pool-d65ac672-9ncf"
}, },
"cluster_name": "knoe-db", "cluster_name": "knoe-db",
"desired_instances": 3, "desired_instances": 3,
"eligible_nodes": [ "eligible_nodes": [
"gke-knoe-dev-0-default-pool-503666dd-d39h", "gke-knoe-dev-0-default-pool-02b40136-m9h9",
"gke-knoe-dev-0-default-pool-98ee334c-qpf9", "gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"gke-knoe-dev-0-default-pool-b26cfdbc-r144" "gke-knoe-dev-0-default-pool-d65ac672-9ncf"
], ],
"metadata": { "metadata": {
"prior_plan_present": true, "prior_plan_present": true,
@ -17,7 +17,7 @@
"regenerated": false, "regenerated": false,
"reused": true "reused": true
}, },
"plan_hash": "c7f53cb912958603", "plan_hash": "a44825aeb5ed4e13",
"plan_id": "cnpg-placement-c7f53cb912958603", "plan_id": "cnpg-placement-a44825aeb5ed4e13",
"schema_version": "v1" "schema_version": "v1"
} }

View File

@ -1,5 +1,5 @@
; Prole Master Configuration File ; Prole Master Configuration File
; Generated by install.py on 2026-04-08 23:04:37 ; Generated by install.py on 2026-04-09 16:20:34
; This file is used as input for Ansible deployment and k8s cluster creation. ; This file is used as input for Ansible deployment and k8s cluster creation.
[User] [User]
@ -56,7 +56,7 @@ init_cluster.cluster_env = dev
init_cluster.deployment_target = prole-dev-cluster init_cluster.deployment_target = prole-dev-cluster
init_cluster.gitops_enabled = false init_cluster.gitops_enabled = false
init_cluster.k3s_server_url = https://myrddin.prole.org:6443 init_cluster.k3s_server_url = https://myrddin.prole.org:6443
init_cluster.k3s_token = ${PROLE_SECRET:v1:VehUafohCaBAyMN6:5gV4RGzYRcxlD4_bQg4Z30m9dq6SGuVGFBFF7osArk8RVc1vgNdIW7qtFewIwkskNe9xbSiC2-gvjwKKG5Nv1JJlzhaAR8NNH-_g4PaoGychXb18YL-8oWDFByGMNgokUObDEc3uJ1CNXCkcLtawaruRNwQ_Myz48TT1TQ==} init_cluster.k3s_token = ${PROLE_SECRET:v1:Am7ac6FTkH1zgfZ8:sWyQ3RTstjMoggXbGEiSTmL1cN5-ECCjyHQG_vbRl66n3pHrm5AC5k3dZlRCudXjXul1Mx5YPl6QmBE5xCHl4Kncxai7G555M6vn5mOikpAkZQyyPcIql2_2ITq6asFhv03xyFc0tNvzbjFVdivqa7rqNSYdSkJrImVHWg==}
init_cluster.kerberos_enabled = true init_cluster.kerberos_enabled = true
init_cluster.mode = k3d init_cluster.mode = k3d
init_cluster.start_cluster = true init_cluster.start_cluster = true
@ -92,7 +92,7 @@ CLUSTER_NAME = knoe-db
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/prole/conf/cnpg-placement/knoe-system-knoe-db.json CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/prole/conf/cnpg-placement/knoe-system-knoe-db.json
CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f
DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013 DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013-18-014
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DEPLOYMENT_MODE = k3d DEPLOYMENT_MODE = k3d
DEPLOYMENT_TARGET = prole-dev-cluster DEPLOYMENT_TARGET = prole-dev-cluster
@ -100,7 +100,7 @@ DOCKER_PRELOAD = false
KNOE_DB_USER = root KNOE_DB_USER = root
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:aOx6XJ6T1bwAWJqO:U_Q9bFmBqns_qvVdmIiwlHkz7Y_vRxnlbFij7pHoTfJZP8iPblmtfZjdi-Z1xOAuxnkYy6-0j1SMBLsIJWcJ0G1g5laIfqBB9d1iv9VLKeNmnUrGV-xgiv8-aPn2TM3UtN8hFrfaRKfeiTG01iyKLG6IoNngF2xA8rb7KQ==} PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:0bTiOIXG8-_kE0cV:Xu7WFcxvmIcsEUumAANi5wspfeuTgJmI5vwXCvmnc4Sygz3S2DBcnMJkQozlCmP4XBHMJhFZ8LsE-nDYXCWX5Ndy9_8WmPGFPBx79Df1AEBSK9qgIEZnPbn3prMu5x7iD2hutWELYDn9gog6LbH4qn_nWF5ZL0jbjyJHzA==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080 PROLE_OPENTOFU_URL = http://127.0.0.1:8080
SERVICE_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system
SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE = /synology/d005
@ -162,7 +162,7 @@ SUPABASE_ENABLED = False
[Initialize Cluster] [Initialize Cluster]
ENVIRONMENT = dev ENVIRONMENT = dev
K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:Puz4fzkm528-XoYM:YlPecZrTA2orKSD6NeuquMD3J39L4RGXqOzGzgNzHqmrhLPPlpLaczU3CwoM3omEuF6rjd0NVlf7Ief5HSKPm49S-X70x-vH_9QaiY8bPx3xtfypGC--p-05NQIw_rDWz5mdonzzj41U-zxA9N3r5mVILiBAkI1trkPRLg==} K3S_TOKEN = ${PROLE_SECRET:v1:ozzcomisjsQYIkSH:Ytp91WR_iP4tJyTAmdH_SRhcKycgzea0zLAgTBNxDsQaBPM-pR_VK3u9wc5QkFzszdAHZGBhVN2HKyqnz-cqDR0WAus88DFbF4zWlgvl6gKEAynaXbdMwAa6vYLUGi8ZE0u1pRiO4KJyiulhBIpfoMReM1Wu6Mj1-20hXw==}
[Dev Cluster (k3d)] [Dev Cluster (k3d)]
CLUSTER_ENV = dev CLUSTER_ENV = dev
@ -173,7 +173,7 @@ MODE = k3d
CLUSTER_ENV = prole-service-cluster CLUSTER_ENV = prole-service-cluster
DISPLAY_NAME = prole-service-cluster DISPLAY_NAME = prole-service-cluster
K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:7OALwMwbkRA9fIQG:RKg4R1_K6niZRAf9rCngO_Yvss4jMKsvQ1iVyZoNJa9yMgAOlKBZBKRY74qsnpRgfcjyxAbj9V42crkaEUaExR4Rr64cvAk5C3aUrC8kmBNLnnbSzydGxsF8Vgh4SCIg0xo9RyJF5h9M1r-F2Ua2P6aYZTjRYcW-Ro0xiA==} K3S_TOKEN = ${PROLE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
MODE = k3s MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080 PIPELINE_URL = http://127.0.0.1:8080

View File

@ -1,5 +1,5 @@
; Prole Master Configuration File ; Prole Master Configuration File
; Generated by install.py on 2026-04-08 23:05:25 ; Generated by install.py on 2026-04-09 17:30:49
; This file is used as input for Ansible deployment and k8s cluster creation. ; This file is used as input for Ansible deployment and k8s cluster creation.
[User] [User]
@ -56,7 +56,7 @@ init_cluster.cluster_env = prod
init_cluster.deployment_target = prole-prod-cluster init_cluster.deployment_target = prole-prod-cluster
init_cluster.gitops_enabled = false init_cluster.gitops_enabled = false
init_cluster.k3s_server_url = https://myrddin.prole.org:6443 init_cluster.k3s_server_url = https://myrddin.prole.org:6443
init_cluster.k3s_token = ${PROLE_SECRET:v1:EnJkApzkJeOQYGnQ:wDfFl2LAFzu6KmMpucPyIiKVcffzx7J2dPg8HmdN2o-4LSEhZMBjQupAJZuBL0Uy6AiEB1aO4UKjEAy-ahUKU2GbkJBodTdZrjFkUc-KHdn-MINCQ9V4S3NspZLEVNizKYtEuXr9aUpmKdMv-NllrFx-9K8YAfclOPikEw==} init_cluster.k3s_token = ${PROLE_SECRET:v1:vxIxsLYZSClD_vzj:aSQOAaWbKOIT26PQPCqqIwenTzN4YKcaCa8OihlkDA9cdhbHrPeHtaeT3Pu140a67xKPV45ExSEiJ7jnWSW3lOH0KSO56qgzKxMXzrynKFfQVyJp5OzzQeCl-jrz15AdknLhPeh3eTmOEfLuhNY2Yy9fsBQ-l0gYikw37Q==}
init_cluster.kerberos_enabled = true init_cluster.kerberos_enabled = true
init_cluster.mode = k8s init_cluster.mode = k8s
init_cluster.start_cluster = true init_cluster.start_cluster = true
@ -67,8 +67,8 @@ init_db_build.run_build = true
init_password.cluster_name = ${CLUSTER_NAME} init_password.cluster_name = ${CLUSTER_NAME}
init_password.db_host_port = 5432 init_password.db_host_port = 5432
init_password.db_namespace = ${DATABASE_NAMESPACE} init_password.db_namespace = ${DATABASE_NAMESPACE}
init_password.db_password = init_password.db_password = ${PROLE_SECRET:v1:2CBUs--mwViqouh9:Q0s7wpwoIfKi22i66X1h3o9eAZetKkVDfO6o2dbU1jBFv8ti}
init_password.db_password_confirm = init_password.db_password_confirm = ${PROLE_SECRET:v1:2CBUs--mwViqouh9:Q0s7wpwoIfKi22i66X1h3o9eAZetKkVDfO6o2dbU1jBFv8ti}
init_password.db_username = root init_password.db_username = root
init_password.generate_ssh_key = true init_password.generate_ssh_key = true
init_scripts.run_scripts = true init_scripts.run_scripts = true
@ -92,23 +92,23 @@ ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
ARTIFACT_REGISTRY_AVAILABLE = true ARTIFACT_REGISTRY_AVAILABLE = true
CLUSTER_ENV = prod CLUSTER_ENV = prod
CLUSTER_NAME = knoe-db CLUSTER_NAME = knoe-db
CNPG_ELIGIBLE_NODES = gke-knoe-dev-0-default-pool-503666dd-d39h,gke-knoe-dev-0-default-pool-98ee334c-qpf9,gke-knoe-dev-0-default-pool-b26cfdbc-r144 CNPG_ELIGIBLE_NODES = gke-knoe-cnpg-0-default-pool-56407930-wlg9,gke-knoe-cnpg-0-default-pool-b142a290-bmr3,gke-knoe-cnpg-0-default-pool-d8e3a5ef-lmpl
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/prole/conf/cnpg-placement/knoe-system-knoe-db.json CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/prole/conf/cnpg-placement/knoe-system-knoe-db.json
CNPG_PLACEMENT_PLAN_HASH = c7f53cb912958603 CNPG_PLACEMENT_PLAN_HASH = a44825aeb5ed4e13
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-c7f53cb912958603 CNPG_PLACEMENT_PLAN_ID = cnpg-placement-a44825aeb5ed4e13
CNPG_STAGE1_NODE = gke-knoe-dev-0-default-pool-503666dd-d39h CNPG_STAGE1_NODE = gke-knoe-cnpg-0-default-pool-56407930-wlg9
DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013 DATABASE_NAMESPACE = knoe-db-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0 DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DB_PASSWORD = ${PROLE_SECRET:v1:vnEWu4ZkBlVtoi-J:Dq_Gluj0Yv_3HdbN_9V53QMf9mPRXotsX04HWsMeY92GBj3O}
DEPLOYMENT_MODE = k8s DEPLOYMENT_MODE = k8s
DEPLOYMENT_TARGET = prole-prod-cluster DEPLOYMENT_TARGET = prole-prod-cluster
DOCKER_PRELOAD = false DOCKER_PRELOAD = false
KNOE_DB_USER = root KNOE_DB_USER = root
KUBECONFIG = /Users/chrisfu/.kube/config
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0 KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:on0oK-bUBCGGiibf:RQSubonBNj8V60-zU10D2FYToViJUNt-WyC23-bb6yQrf72WcvkfFMWsiEQA5OxRWvKM4TaaezLF4WFW484p3S9g0Y3oB_9qv7CSc15ZaOOHTUk2DpNOKahV5fItY8bPjmUKWTgKrShSWl6j2C5SFV7fYwmq6TTOuygFPQ==} PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:wEwOvfE5WeOdbVjW:yjYu4AJqgYo_EnwvYx4fgeSA1coQb8uv8FxE4_NNSIkQadVroCjaEpOyHXcStehLxGl7Bk-elU43yKzYQT_85BkLXp4-9k-LVh_kAfJp2cXlw7q8PczBr3-_MJXeWv1Cw7mCiMuWEfVpKjpCzZ5-URsBsIH3iYz20_6qwg==}
SERVICE_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system
SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE = /synology/d005
SUPABASE_PV_BASE_DIR = /synology/d005 SUPABASE_PV_BASE_DIR = /synology/d005
@ -170,19 +170,19 @@ DB_BOOT_DISK_SIZE_GB = 50
DB_BOOT_DISK_TYPE = pd-standard DB_BOOT_DISK_TYPE = pd-standard
ENVIRONMENT = prod ENVIRONMENT = prod
K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:y0saV3fVIh0T5E6G:ek4jKHO09N2Z5uRO3mwsSgimKpVodGN6zE6d6TtYHMqoFlcJ_AZRUf5dMCqAZ8eE9gydBu_k-ey44CQOAmK8m9KryFU9HDL1ioL_AWGfVJIcG16vgVsaiaVO3h7Eppv__qdCzSqaEgwJqNHC8fTYQtfAXBouk3arsM8_Ew==} K3S_TOKEN = ${PROLE_SECRET:v1:4gEuXvZk46ZZEE84:n0hMI_eOPmwOWax62B7G1n5M-aEA39gMkEoTl4m8d_zvn-fBhoSsyjebENabZc8cDbGroo1Cunge3tLH9oZ_KHIz_tLQBGvGgG9pLg9X_raTNoH-ezMgbllWWfUH3BHznawYDxun3hDDgqTuOdhBPctVg3nrW8kSa7gJqQ==}
[Dev Cluster (k3d)] [Dev Cluster (k3d)]
CLUSTER_ENV = dev CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0 KUBECTL_CONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
MODE = k3d MODE = k3d
[Service Cluster (k3s)] [Service Cluster (k3s)]
CLUSTER_ENV = prole-service-cluster CLUSTER_ENV = prole-service-cluster
DISPLAY_NAME = prole-service-cluster DISPLAY_NAME = prole-service-cluster
K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:ts8tzrsAcvLHInVF:xQVZWcdVVVI7tAYqG_048NKLPw25ENxCqtcdiyPb4tGSuyjeqSn9kXoMMR-8TvcBGPpug4dXa3IQZEwIHksQvsWkD-MtbPG5uaehhz7wsLJJ1ILkhd3PGjeyCkFn9zFzLEyPBbJoTRY2I91V_Qck7Vlkkn8zOb3Y9NXuFg==} K3S_TOKEN = ${PROLE_SECRET:v1:6-xBvCegq8BIAUbO:li8GVpawfjJUs4YhZE1ho5o2S3p2BAE6IH1cMX0SftuGkHOHWrwv_rYmufzVzCVt7RV0PtJevGSGOS_hoaPKA5EPtSE520HcQX0f2UJfAMfpvFzsKziVa0UqIkKYGics2c3JXsydP6eVerWagtJ9ciaESbCoy0YnvVgLKg==}
MODE = k3s MODE = k3s
[GCP] [GCP]
@ -190,6 +190,10 @@ BILLING_ACCOUNT = 01193C-25783B-3211AD
BILLING_PROJECT = plenary-truck-485623-p7 BILLING_PROJECT = plenary-truck-485623-p7
ORG_ID = 584001916389 ORG_ID = 584001916389
PROJECT_ID = plenary-truck-485623-p7 PROJECT_ID = plenary-truck-485623-p7
billing_account = 01193C-25783B-3211AD
billing_project = plenary-truck-485623-p7
org_id = 584001916389
project_id = plenary-truck-485623-p7
[Prod Cluster (k8s)] [Prod Cluster (k8s)]
ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
@ -205,7 +209,7 @@ Configure routing, TLS, and frontdoor domain mappings
Prepare migration artifacts from source environment Prepare migration artifacts from source environment
Generate OpenTofu plan and apply production pipeline Generate OpenTofu plan and apply production pipeline
MODE = k8s MODE = k8s
OPENTOFU_VARS = {"admin_user":"knoe_admin","app_database":"knoey","app_user":"knoey_app","backup_bucket":"knoe-0-backups","backup_provider":"gcs","backup_retention_days":14,"bootstrap_admin_email":"admin@knoey.com","cluster_name":"knoe-cnpg-0","cnpg_cluster_name":"knoe-db","db_instances":3,"db_pgdata_size_gi":100,"db_pgdata_storage_class":"premium-rwo","db_storage_class":"premium-rwo","db_storage_size_gi":100,"db_wal_size_gi":25,"db_wal_storage_class":"premium-rwo","ecosystem_id":0,"environment":"production","frontdoor_host":"knoey.com","gcp_project_id":"plenary-truck-485623-p7","gcp_region":"\u2713 us-west3","google_hosted_domain":"","meta_database":"knoe_meta","migration_continuous_until_cutover":false,"migration_mode":"snapshot-restore","migration_source_database":"knoey","migration_source_environment":"prole.org","migration_source_host":"knoe-local-db.prole.org","migration_source_password_ref":"secretref://local-source-db-password","migration_source_port":5432,"migration_source_user":"replication_user","namespace":"ecosystem-0","oidc_client_id_ref":"secretref://google-oidc-client-id","oidc_client_secret_ref":"secretref://google-oidc-client-secret","oidc_issuer":"https://accounts.google.com","platform_domain":"knoe.dev","postgres_version":"16","project_id":"plenary-truck-485623-p7","region":"\u2713 us-west3","tls_mode":"managed","wal_bucket":"knoe-0-wal","workload_identity_sa":"cnpg-backup"} OPENTOFU_VARS = {"admin_user":"knoe_admin","app_database":"knoey","app_user":"knoey_app","backup_bucket":"knoe-0-backups","backup_provider":"gcs","backup_retention_days":14,"bootstrap_admin_email":"admin@knoey.com","cluster_name":"knoe-prod","cnpg_cluster_name":"knoe-db","db_instances":3,"db_pgdata_size_gi":100,"db_pgdata_storage_class":"premium-rwo","db_storage_class":"premium-rwo","db_storage_size_gi":100,"db_wal_size_gi":25,"db_wal_storage_class":"premium-rwo","ecosystem_id":0,"environment":"production","frontdoor_host":"knoey.com","gcp_project_id":"plenary-truck-485623-p7","gcp_region":"us-west3","google_hosted_domain":"","meta_database":"knoe_meta","migration_continuous_until_cutover":false,"migration_mode":"snapshot-restore","migration_source_database":"knoey","migration_source_environment":"prole.org","migration_source_host":"knoe-local-db.prole.org","migration_source_password_ref":"secretref://local-source-db-password","migration_source_port":5432,"migration_source_user":"replication_user","namespace":"ecosystem-0","oidc_client_id_ref":"secretref://google-oidc-client-id","oidc_client_secret_ref":"secretref://google-oidc-client-secret","oidc_issuer":"https://accounts.google.com","platform_domain":"knoe.dev","postgres_version":"16","project_id":"plenary-truck-485623-p7","region":"us-west3","tls_mode":"managed","wal_bucket":"knoe-0-wal","workload_identity_sa":"cnpg-backup"}
PRODUCTION_CONFIG_YAML = kind: KnoeProductionConfig PRODUCTION_CONFIG_YAML = kind: KnoeProductionConfig
metadata: metadata:
ecosystemId: 0 ecosystemId: 0
@ -215,8 +219,8 @@ spec:
cloud: cloud:
provider: gcp provider: gcp
projectId: plenary-truck-485623-p7 projectId: plenary-truck-485623-p7
region: "\u2713 us-west3" region: us-west3
clusterName: knoe-cnpg-0 clusterName: knoe-prod
vpcMode: managed vpcMode: managed
vpcName: null vpcName: null
subnetName: null subnetName: null

View File

@ -1,8 +1,211 @@
# Prole GCP deployment entrypoint ; Prole Master Configuration File
# Used by deploy.sh for unattended production pipeline runs ; Generated by install.py on 2026-04-09 23:19:13
; This file is used as input for Ansible deployment and k8s cluster creation.
[Global] [User]
CLUSTER_ENV = prod ; User-editable values; derived values below reference these by default.
; No user values captured yet for this section.
[Inputs] [Inputs]
# Values will be populated/updated by deploy_pipeline.py ; Screen-scoped inputs used for unattended replays (-S)
build.deploy_env = Dev
build.run_build = false
dependencies.ansible.install = true
dependencies.auto_install_missing = true
dependencies.brew.install = true
dependencies.docker.install = true
dependencies.k3d.install = true
dependencies.kubectl.install = true
dependencies.kubectx.install = true
dependencies.opentofu.install = true
dependencies.python.install = true
dependencies.verify_all = false
disk_selection.disk_type = local
disk_selection.local_path = /Users/chrisfu
disk_selection.removable_mount =
env_setup.APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
env_setup.APP_CLUSTER_MODE = standard
env_setup.APP_CLUSTER_NAME = knoe-dev-0
env_setup.CLUSTER_NAME = ${CLUSTER_NAME}
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
env_setup.DB_CLUSTER_MODE = standard
env_setup.DB_CLUSTER_NAME = knoe-cnpg-0
env_setup.PROLE_CONF = /Users/chrisfu/dev/prole/conf
env_setup.PROLE_DATA = /Users/chrisfu/dev/prole/data
env_setup.PROLE_HOME = /Users/chrisfu/dev/prole
env_setup.PROLE_LOGS = /Users/chrisfu/dev/prole/logs
env_setup.PROLE_SERVICE = /Users/chrisfu/dev/prole/etc
init_cluster.app_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
init_cluster.app_cluster_machine_type = e2-small
init_cluster.app_cluster_mode = standard
init_cluster.app_cluster_name = knoe-dev-0
init_cluster.app_cluster_node_count = 3
init_cluster.app_cluster_region = us-west3
init_cluster.at_rest_encryption_enabled = true
init_cluster.cluster_env = prod
init_cluster.db_boot_disk_size_gb = 50
init_cluster.db_boot_disk_type = pd-standard
init_cluster.db_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
init_cluster.db_cluster_machine_type = e2-standard-2
init_cluster.db_cluster_mode = standard
init_cluster.db_cluster_name = knoe-cnpg-0
init_cluster.db_cluster_node_count = 3
init_cluster.db_cluster_region = us-west3
init_cluster.db_cluster_zones =
init_cluster.gitops_enabled = false
init_cluster.k3s_server_url =
init_cluster.k3s_token =
init_cluster.kerberos_enabled = false
init_cluster.project_id = plenary-truck-485623-p7
init_cluster.start_cluster = true
init_cluster.supabase_analytics_enabled = true
init_cluster.supabase_auth_enabled = true
init_cluster.supabase_enabled = false
init_cluster.supabase_meta_enabled = true
init_cluster.supabase_realtime_enabled = true
init_cluster.supabase_studio_enabled = false
init_cluster.supabase_studio_url = db.knoe.org
init_cnpg_deploy.force_rollout = false
init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true
init_password.app_cluster_name = knoe-dev-0
init_password.cluster_name = ${CLUSTER_NAME}
init_password.db_cluster_name = knoe-cnpg-0
init_password.db_host_port = 5432
init_password.db_namespace = ${DATABASE_NAMESPACE}
init_password.db_password = ${PROLE_SECRET:v1:aqtfPnE_i0CuDU42:KvAQK1cUXKkFPta7NGyaWJVX1Ufw6xH432ZLzg==}
init_password.db_password_confirm = ${PROLE_SECRET:v1:aqtfPnE_i0CuDU42:KvAQK1cUXKkFPta7NGyaWJVX1Ufw6xH432ZLzg==}
init_password.db_username = chrisfu
init_password.generate_ssh_key = true
init_scripts.run_scripts = true
kerberos_config.enabled = True
kerberos_config.init_authority = false
kerberos_config.kdc = 10.0.0.196
kerberos_config.password =
kerberos_config.realm =
kerberos_config.test_connection = false
kerberos_config.user = administrator
network_scan.run = true
ollama_config.model =
ollama_config.server_host =
ollama_config.server_port = 11434
[Global]
; Variables used by name in more than one place or assumed global scope
APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
ARGOCD_NAMESPACE = argocd
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
CLUSTER_ENV = prod
CLUSTER_NAME = knoe-db
CLUSTER_SIZE_PROFILE = small
CNPG_ELIGIBLE_NODES = gke-knoe-cnpg-0-default-pool-56407930-wlg9,gke-knoe-cnpg-0-default-pool-b142a290-bmr3,gke-knoe-cnpg-0-default-pool-d8e3a5ef-lmpl
CNPG_INSTANCES = 3
CNPG_SIZE_PROFILE = small
CNPG_STAGE1_NODE = gke-knoe-cnpg-0-default-pool-56407930-wlg9
DATABASE_NAMESPACE = knoe-db-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
DB_HOST_PORT = 5432
DB_PASSWORD = ${PROLE_SECRET:v1:BQs_xFgVxqbFwmtj:bXF155fo4KH1ZSwz9NvhgqTwQ7_oRkHewFsm5w==}
DEPLOYMENT_MODE = k8s
DEPLOYMENT_TARGET = prole-prod-cluster
DOCKER_IMPORT_DIR =
DOCKER_PRELOAD = false
K3S_SERVER =
K3S_TOKEN =
KNOE_DB_USER = chrisfu
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
OPENTOFU_URL = http://127.0.0.1:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_HOME = /Users/chrisfu/dev/prole
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system
[Welcome]
; No configuration values captured yet for this section.
[Dependencies]
STATUS = All installed
[Network]
KDC_AUTO_DETECTED = 10.0.0.196
KERBEROS_AUTO_ENABLED = True
[Port Forwards]
PORT_FORWARD_K3D_MAPPING_1 = id=argocd;namespace=argocd;target=svc/argocd-server;address=0.0.0.0;hostPort=8081;servicePort=80;protocol=TCP;description=ArgoCD
PORT_FORWARD_K3D_MAPPING_2 = id=garage;namespace=knoe-system;target=svc/garage;address=0.0.0.0;hostPort=3900;servicePort=3900;protocol=TCP;description=Garage S3
PORT_FORWARD_K3D_MAPPING_3 = id=openbao;namespace=knoe-system;target=svc/openbao;address=0.0.0.0;hostPort=8200;servicePort=8200;protocol=TCP;description=OpenBao
PORT_FORWARD_K3D_MAPPING_4 = id=opentofu;namespace=knoe-system;target=svc/opentofu;address=0.0.0.0;hostPort=8080;servicePort=8080;protocol=TCP;description=OpenTofu
PORT_FORWARD_K3D_MAPPING_5 = id=dashboard;namespace=kubernetes-dashboard;target=svc/kubernetes-dashboard-kong-proxy;address=127.0.0.1;hostPort=8443;servicePort=443;protocol=TCP;description=Kubernetes Dashboard
PORT_FORWARD_K3D_MAPPING_6 = id=postgres;namespace=${DATABASE_NAMESPACE};target=svc/knoe-db-rw;address=0.0.0.0;hostPort=5432;servicePort=5432;protocol=TCP;description=PostgreSQL (primary)
PORT_FORWARD_K3D_MAPPING_7 = id=prometheus;namespace=monitoring;target=svc/kps-kube-prometheus-stack-prometheus;address=127.0.0.1;hostPort=9090;servicePort=9090;protocol=TCP;description=Prometheus UI
PORT_FORWARD_K3D_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/kps-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana UI
[System Environment]
PROLE_CONF = /Users/chrisfu/dev/prole/conf
PROLE_DATA = /Users/chrisfu/dev/prole/data
PROLE_HOME = /Users/chrisfu/dev/prole
PROLE_LOGS = /Users/chrisfu/dev/prole/logs
PROLE_SERVICE = /Users/chrisfu/dev/prole/etc
[Monitoring]
; No configuration values captured yet for this section.
[Kerberos Authentication]
; No configuration values captured yet for this section.
[Ollama]
; No configuration values captured yet for this section.
[Optional Features]
AT_REST_ENCRYPTION_ENABLED = true
GITOPS_ENABLED = false
KERBEROS_ENABLED = false
SUPABASE_ENABLED = false
[GitOps]
[Database Creation]
APP_CLUSTER_NAME = knoe-dev-0
DB_CLUSTER_NAME = knoe-cnpg-0
DB_USER = chrisfu
[Initialize Cluster]
; No configuration values captured yet for this section.
[Dev Cluster (k3d)]
CLUSTER_ENV = k3d-knoe-dev-cluster
DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = prod
MODE = k3d
[Service Cluster (k3s)]
CLUSTER_ENV = prole-service-cluster
DISPLAY_NAME = prole-service-cluster
K3S_SERVER_URL =
K3S_TOKEN =
MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080
[GCP]
; No configuration values captured yet for this section.
[Prod Cluster (k8s)]
ARTIFACTS_DIR =
CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster
MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080
[Docker Build]
; No configuration values captured yet for this section.
[Initialization Scripts]
; No configuration values captured yet for this section.
[Deployment]
MODE = k8s
TARGET = prole-prod-cluster
[Install]
STATUS = Failed

View File

@ -26,6 +26,9 @@ spec:
operator: "Equal" operator: "Equal"
value: "true" value: "true"
effect: "NoSchedule" effect: "NoSchedule"
# nodeSelector removed: knoe-cnpg-0 is a dedicated DB cluster — all nodes are
# available to CNPG. A workload label selector here causes scheduling failures
# when CNPG v1.28 translates it into requiredDuringScheduling nodeAffinity.
postgresql: postgresql:
parameters: parameters:
@ -108,11 +111,11 @@ spec:
resources: resources:
requests: requests:
cpu: "250m" # halved for e2-small quota-increase window; restore to 500m after increase cpu: "100m"
memory: "256Mi" # halved for e2-small quota-increase window; restore to 512Mi after increase memory: "128Mi"
limits: limits:
cpu: "1" cpu: "500m"
memory: "1Gi" memory: "512Mi"
enableSuperuserAccess: true enableSuperuserAccess: true

View File

@ -25,7 +25,8 @@ usage() {
cat <<EOF cat <<EOF
Usage: init_common_services.sh [-n|--namespace NS] [-k|--kerberos] <update|start|status|verify> Usage: init_common_services.sh [-n|--namespace NS] [-k|--kerberos] <update|start|status|verify>
Deploys common infrastructure services (Registry, OpenTofu, Garage, OpenBao, Kong, Cert-Manager) Deploys common infrastructure services (Registry, Garage, OpenBao, Kong, Cert-Manager;
OpenTofu on non-k8s modes)
into the given Kubernetes namespace. Use -k to include the Kerberos/KDC service. into the given Kubernetes namespace. Use -k to include the Kerberos/KDC service.
EOF EOF
} }
@ -540,17 +541,19 @@ collect_other_namespaces() {
migrate_common_services() { migrate_common_services() {
local old_ns local old_ns
for old_ns in $(collect_other_namespaces "$NS" "$OPENTOFU_NAME" deployment service); do if [ "${PROLE_MODE:-}" != "k8s" ]; then
echo "Found OpenTofu in namespace '$old_ns'; removing before deploy to '$NS' ..." for old_ns in $(collect_other_namespaces "$NS" "$OPENTOFU_NAME" deployment service); do
if [ -x "$SCRIPT_DIR/init_opentofu.sh" ]; then echo "Found OpenTofu in namespace '$old_ns'; removing before deploy to '$NS' ..."
"$SCRIPT_DIR/init_opentofu.sh" -n "$old_ns" stop || true if [ -x "$SCRIPT_DIR/init_opentofu.sh" ]; then
else "$SCRIPT_DIR/init_opentofu.sh" -n "$old_ns" stop || true
kubectl delete -n "$old_ns" deploy "$OPENTOFU_NAME" --ignore-not-found >/dev/null 2>&1 || true else
kubectl delete -n "$old_ns" svc "$OPENTOFU_NAME" --ignore-not-found >/dev/null 2>&1 || true kubectl delete -n "$old_ns" deploy "$OPENTOFU_NAME" --ignore-not-found >/dev/null 2>&1 || true
fi kubectl delete -n "$old_ns" svc "$OPENTOFU_NAME" --ignore-not-found >/dev/null 2>&1 || true
kubectl delete -n "$old_ns" configmap "$OPENTOFU_CONFIGMAP" --ignore-not-found >/dev/null 2>&1 || true fi
kubectl delete -n "$old_ns" secret "$OPENTOFU_SECRET" --ignore-not-found >/dev/null 2>&1 || true kubectl delete -n "$old_ns" configmap "$OPENTOFU_CONFIGMAP" --ignore-not-found >/dev/null 2>&1 || true
done kubectl delete -n "$old_ns" secret "$OPENTOFU_SECRET" --ignore-not-found >/dev/null 2>&1 || true
done
fi
for old_ns in $(collect_other_namespaces "$REGISTRY_NS" "$REGISTRY_NAME" deployment service); do for old_ns in $(collect_other_namespaces "$REGISTRY_NS" "$REGISTRY_NAME" deployment service); do
echo "Found Registry ($REGISTRY_NAME) in namespace '$old_ns'; removing before deploy to '$REGISTRY_NS' ..." echo "Found Registry ($REGISTRY_NAME) in namespace '$old_ns'; removing before deploy to '$REGISTRY_NS' ..."
@ -666,7 +669,7 @@ esac
# 1. Registry no dependencies; other services pull images from it # 1. Registry no dependencies; other services pull images from it
# 2. OpenBao secrets vault; needed by downstream services # 2. OpenBao secrets vault; needed by downstream services
# 3. Garage object storage # 3. Garage object storage
# 4. OpenTofu IaC engine; depends on registry + secrets # 4. OpenTofu IaC engine; depends on registry + secrets (non-k8s)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [ "${PROLE_MODE:-}" = "k8s" ]; then if [ "${PROLE_MODE:-}" = "k8s" ]; then
@ -706,7 +709,9 @@ else
echo "WARN: init_garage_store.sh not found; garage deploy skipped." echo "WARN: init_garage_store.sh not found; garage deploy skipped."
fi fi
if [ -x "$SCRIPT_DIR/init_opentofu.sh" ]; then if [ "${PROLE_MODE:-}" = "k8s" ]; then
echo "[INFO] k8s mode: skipping OpenTofu deploy."
elif [ -x "$SCRIPT_DIR/init_opentofu.sh" ]; then
ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-300s}" "$SCRIPT_DIR/init_opentofu.sh" -n "$NS" "$ACTION" || rc=$? ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-300s}" "$SCRIPT_DIR/init_opentofu.sh" -n "$NS" "$ACTION" || rc=$?
else else
echo "WARN: init_opentofu.sh not found; skipping OpenTofu." echo "WARN: init_opentofu.sh not found; skipping OpenTofu."

View File

@ -4,7 +4,7 @@ set -euo pipefail
# init_service_layer.sh # init_service_layer.sh
# Purpose: # Purpose:
# - Deploy the Prole service layer (OpenTofu, Garage, OpenBao, Kong; Kerberos optional) # - Deploy the Prole service layer (Garage, OpenBao, Kong; OpenTofu in non-k8s modes)
# - Keep service-layer resources grouped in SERVICE_NAMESPACE # - Keep service-layer resources grouped in SERVICE_NAMESPACE
# - Migrate service layer to a new namespace # - Migrate service layer to a new namespace
@ -169,6 +169,11 @@ deploy_service_layer() {
local action="$1" local action="$1"
local ns="$2" local ns="$2"
local rc=0 local rc=0
local manage_opentofu=1
if [[ "${PROLE_MODE:-}" == "k8s" ]]; then
manage_opentofu=0
fi
ensure_namespace "$ns" ensure_namespace "$ns"
label_namespace "$ns" label_namespace "$ns"
@ -182,13 +187,15 @@ deploy_service_layer() {
*) argocd_action="update" ;; *) argocd_action="update" ;;
esac esac
case "$action" in if [[ "$manage_opentofu" == "1" ]]; then
start|initialize|update|reload) opentofu_action="update" ;; case "$action" in
restart) opentofu_action="restart" ;; start|initialize|update|reload) opentofu_action="update" ;;
stop) opentofu_action="stop" ;; restart) opentofu_action="restart" ;;
status) opentofu_action="status" ;; stop) opentofu_action="stop" ;;
*) opentofu_action="update" ;; status) opentofu_action="status" ;;
esac *) opentofu_action="update" ;;
esac
fi
case "$action" in case "$action" in
start|initialize|update|reload) openbao_action="update" ;; start|initialize|update|reload) openbao_action="update" ;;
@ -247,7 +254,7 @@ deploy_service_layer() {
# 2. OpenBao secrets vault; needed by downstream services # 2. OpenBao secrets vault; needed by downstream services
# 3. Garage object storage # 3. Garage object storage
# 4. Kong API gateway # 4. Kong API gateway
# 5. OpenTofu IaC engine; depends on registry + secrets (last) # 5. OpenTofu IaC engine; depends on registry + secrets (non-k8s, last)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
if [[ -x "$SCRIPT_DIR/init_registry.sh" ]]; then if [[ -x "$SCRIPT_DIR/init_registry.sh" ]]; then
@ -275,8 +282,12 @@ deploy_service_layer() {
KONG_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \ KONG_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_kong.sh" -n "$ns" "$kong_action" || rc=$? "$SCRIPT_DIR/init_kong.sh" -n "$ns" "$kong_action" || rc=$?
OPENTOFU_NAMESPACE="$ns" OPENTOFU_SECRET_NAMESPACE="${NAMESPACE:-$ns}" OPENTOFU_OPENBAO_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \ if [[ "$manage_opentofu" == "1" ]]; then
"$SCRIPT_DIR/init_opentofu.sh" -n "$ns" "$opentofu_action" || rc=$? OPENTOFU_NAMESPACE="$ns" OPENTOFU_SECRET_NAMESPACE="${NAMESPACE:-$ns}" OPENTOFU_OPENBAO_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_opentofu.sh" -n "$ns" "$opentofu_action" || rc=$?
else
log "[INFO] k8s mode: skipping OpenTofu deploy."
fi
if [[ "$ENABLE_KERBEROS" == "1" ]]; then if [[ "$ENABLE_KERBEROS" == "1" ]]; then
# KDC is embedded in `knoe-auth` by default. Only deploy standalone KDC when requested. # KDC is embedded in `knoe-auth` by default. Only deploy standalone KDC when requested.

View File

@ -533,8 +533,13 @@ if [[ -n "$_prole_cfg_file" ]]; then
_cfg_ctx=$(_prole_cfg_extract_key_in_files "KUBECONTEXT" "${_prole_cfg_files[@]}") _cfg_ctx=$(_prole_cfg_extract_key_in_files "KUBECONTEXT" "${_prole_cfg_files[@]}")
fi fi
if [[ -n "$_cfg_ctx" ]]; then if [[ -n "$_cfg_ctx" ]]; then
export KUBE_CONTEXT_NAME="$_cfg_ctx" # Respect explicitly provided runtime context (e.g., installer per-role env)
export KUBECONTEXT="$_cfg_ctx" # and only fall back to prole.cfg when no runtime context is set.
if [[ -z "${KUBE_CONTEXT_NAME:-}" && -z "${KUBECTL_CONTEXT:-}" && -z "${KUBECONTEXT:-}" ]]; then
export KUBE_CONTEXT_NAME="$_cfg_ctx"
export KUBECTL_CONTEXT="$_cfg_ctx"
export KUBECONTEXT="$_cfg_ctx"
fi
fi fi
unset _cfg_ns _cfg_sns _cfg_dm _cfg_sh _cfg_sbh _cfg_ctx _prole_cfg_files _prole_cfg_f unset _cfg_ns _cfg_sns _cfg_dm _cfg_sh _cfg_sbh _cfg_ctx _prole_cfg_files _prole_cfg_f
fi fi

View File

@ -112,6 +112,10 @@ REGISTRY_CHECK=0
if [[ "$MODE" == "k3s" || "$MODE" == "k3d" ]]; then if [[ "$MODE" == "k3s" || "$MODE" == "k3d" ]]; then
REGISTRY_CHECK=1 REGISTRY_CHECK=1
fi fi
OPENTOFU_CHECK=1
if [[ "$MODE" == "k8s" ]]; then
OPENTOFU_CHECK=0
fi
timestamp=$(date "+%Y-%m-%d %H:%M:%S") timestamp=$(date "+%Y-%m-%d %H:%M:%S")
ctx=$(kubectl config current-context 2>/dev/null || true) ctx=$(kubectl config current-context 2>/dev/null || true)
@ -143,7 +147,13 @@ if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
elif [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" == "k3d" ]]; then elif [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" == "k3d" ]]; then
run_cmd k3d registry list run_cmd k3d registry list
fi fi
run_cmd kubectl -n "$NS" get svc opentofu garage openbao if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
run_cmd kubectl -n "$NS" get svc opentofu garage openbao
else
run_cmd kubectl -n "$NS" get svc garage openbao
echo "[INFO] k8s mode: skipping OpenTofu service check."
echo ""
fi
if [[ "$ENABLE_KERBEROS" == "1" ]]; then if [[ "$ENABLE_KERBEROS" == "1" ]]; then
run_cmd kubectl -n "$NS" get svc auth run_cmd kubectl -n "$NS" get svc auth
fi fi
@ -154,7 +164,12 @@ echo "== Workloads =="
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
run_cmd kubectl -n "$REGISTRY_NS" get deploy registry run_cmd kubectl -n "$REGISTRY_NS" get deploy registry
fi fi
run_cmd kubectl -n "$NS" get deploy opentofu if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
run_cmd kubectl -n "$NS" get deploy opentofu
else
echo "[INFO] k8s mode: skipping OpenTofu workload check."
echo ""
fi
if kubectl -n "$NS" get statefulset openbao >/dev/null 2>&1; then if kubectl -n "$NS" get statefulset openbao >/dev/null 2>&1; then
run_cmd kubectl -n "$NS" get statefulset openbao run_cmd kubectl -n "$NS" get statefulset openbao
else else
@ -171,7 +186,11 @@ echo "== Pods =="
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
run_cmd kubectl -n "$REGISTRY_NS" get pods | grep -Ei "registry" || true run_cmd kubectl -n "$REGISTRY_NS" get pods | grep -Ei "registry" || true
fi fi
run_cmd kubectl -n "$NS" get pods | grep -Ei "opentofu|garage|openbao" || true if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
run_cmd kubectl -n "$NS" get pods | grep -Ei "opentofu|garage|openbao" || true
else
run_cmd kubectl -n "$NS" get pods | grep -Ei "garage|openbao" || true
fi
if [[ "$ENABLE_KERBEROS" == "1" ]]; then if [[ "$ENABLE_KERBEROS" == "1" ]]; then
run_cmd kubectl -n "$NS" get pods | grep -Ei "auth" || true run_cmd kubectl -n "$NS" get pods | grep -Ei "auth" || true
fi fi
@ -296,7 +315,9 @@ if [[ "$REGISTRY_CHECK" -eq 1 ]]; then
fi fi
fi fi
check_resource svc opentofu "$NS" if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
check_resource svc opentofu "$NS"
fi
check_resource svc garage "$NS" check_resource svc garage "$NS"
check_resource svc openbao "$NS" check_resource svc openbao "$NS"
if [[ "$ENABLE_KERBEROS" == "1" ]]; then if [[ "$ENABLE_KERBEROS" == "1" ]]; then
@ -306,7 +327,9 @@ check_resource svc "$KONG_NAME" "$KONG_NS"
check_resource svc cert-manager "$CERTMGR_NS" check_resource svc cert-manager "$CERTMGR_NS"
check_resource svc cert-manager-webhook "$CERTMGR_NS" check_resource svc cert-manager-webhook "$CERTMGR_NS"
check_resource deploy opentofu "$NS" if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
check_resource deploy opentofu "$NS"
fi
if kubectl -n "$NS" get statefulset openbao >/dev/null 2>&1; then if kubectl -n "$NS" get statefulset openbao >/dev/null 2>&1; then
check_resource statefulset openbao "$NS" check_resource statefulset openbao "$NS"
else else
@ -321,9 +344,12 @@ check_resource deploy cert-manager "$CERTMGR_NS"
check_resource deploy cert-manager-cainjector "$CERTMGR_NS" check_resource deploy cert-manager-cainjector "$CERTMGR_NS"
check_resource deploy cert-manager-webhook "$CERTMGR_NS" check_resource deploy cert-manager-webhook "$CERTMGR_NS"
pod_filter="opentofu|garage|openbao|kong|cert-manager" pod_filter="garage|openbao|kong|cert-manager"
if [[ "$ENABLE_KERBEROS" == "1" ]]; then if [[ "$ENABLE_KERBEROS" == "1" ]]; then
pod_filter="opentofu|garage|openbao|auth|kong|cert-manager" pod_filter="garage|openbao|auth|kong|cert-manager"
fi
if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
pod_filter="opentofu|${pod_filter}"
fi fi
analyze_pods "$NS" "$pod_filter" analyze_pods "$NS" "$pod_filter"
analyze_pods "$KONG_NS" "kong" analyze_pods "$KONG_NS" "kong"
@ -391,7 +417,11 @@ _workloads_for_comp() {
echo "deployment openbao $_ns" echo "deployment openbao $_ns"
fi fi
;; ;;
opentofu) echo "deployment opentofu $_ns" ;; opentofu)
if [[ "$OPENTOFU_CHECK" -eq 1 ]]; then
echo "deployment opentofu $_ns"
fi
;;
garage) echo "statefulset garage $_ns" ;; garage) echo "statefulset garage $_ns" ;;
auth) echo "deployment auth $_ns" ;; auth) echo "deployment auth $_ns" ;;
kong) echo "deployment $KONG_NAME $KONG_NS" ;; kong) echo "deployment $KONG_NAME $KONG_NS" ;;
@ -427,6 +457,7 @@ _blocked_comps() {
for _bc in registry openbao garage opentofu auth kong certmgr; do for _bc in registry openbao garage opentofu auth kong certmgr; do
[[ "$_bc" == "auth" && "$ENABLE_KERBEROS" != "1" ]] && continue [[ "$_bc" == "auth" && "$ENABLE_KERBEROS" != "1" ]] && continue
[[ "$_bc" == "registry" && ( "$REGISTRY_CHECK" -ne 1 || "$MODE" == "k3d" ) ]] && continue [[ "$_bc" == "registry" && ( "$REGISTRY_CHECK" -ne 1 || "$MODE" == "k3d" ) ]] && continue
[[ "$_bc" == "opentofu" && "$OPENTOFU_CHECK" -ne 1 ]] && continue
[[ ${BLOCKED_COUNT["$_bc"]:-0} -gt 0 ]] && echo "$_bc" [[ ${BLOCKED_COUNT["$_bc"]:-0} -gt 0 ]] && echo "$_bc"
done done
} }

View File

@ -0,0 +1,7 @@
vault_prole_db_master_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
34636665663663616661623235656234636236363764383766353934306466363238326165616632
3937613765353935636661333032306333626234306564370a373630393535643531623764383661
63616233643631666539346534666332343463633435653736336666643066383062303566326235
3138626139363635390a376335666665366261623761383764393833396533396662643439666339
34333464623839666532313463363062366533393033626130613132346639633030

View File

@ -26,6 +26,7 @@ from knoe.config import (
_write_k3s_kubeconfig, _write_k3s_kubeconfig,
_encrypt_cfg_secret, _encrypt_cfg_secret,
_merge_kubeconfig, _merge_kubeconfig,
_try_read_ansible_vault_value,
) )
from knoe.core.build_context import copy_build_context_dir from knoe.core.build_context import copy_build_context_dir
from knoe.core.cnpg_placement import ( from knoe.core.cnpg_placement import (
@ -122,7 +123,7 @@ DEFAULT_APP_CLUSTER_NODE_COUNT = 3
DEFAULT_DB_CLUSTER_NAME = "knoe-cnpg-0" DEFAULT_DB_CLUSTER_NAME = "knoe-cnpg-0"
DEFAULT_DB_CLUSTER_MODE = "standard" DEFAULT_DB_CLUSTER_MODE = "standard"
DEFAULT_DB_CLUSTER_NODE_COUNT = 3 DEFAULT_DB_CLUSTER_NODE_COUNT = 3
DEFAULT_DB_CLUSTER_MACHINE_TYPE = "e2-small" DEFAULT_DB_CLUSTER_MACHINE_TYPE = "e2-standard-2"
DEFAULT_DB_BOOT_DISK_TYPE = "pd-standard" DEFAULT_DB_BOOT_DISK_TYPE = "pd-standard"
DEFAULT_DB_BOOT_DISK_SIZE_GB = 50 DEFAULT_DB_BOOT_DISK_SIZE_GB = 50
@ -290,7 +291,14 @@ class KnoeInstaller:
).strip() ).strip()
if ns.startswith("${") and ns.endswith("}"): if ns.startswith("${") and ns.endswith("}"):
ns = "" ns = ""
return ns or "default" if ns:
return ns
try:
if self._deployment_mode() == "k8s":
return "knoe-db-0"
except Exception:
pass
return "default"
def _service_namespace(self) -> str: def _service_namespace(self) -> str:
ns = ( ns = (
@ -313,7 +321,7 @@ class KnoeInstaller:
except Exception: except Exception:
pass pass
return "default" return "knoe-system"
# ------------------------------------------ k3s connection helpers # ------------------------------------------ k3s connection helpers
def _read_k3s_cfg_values( def _read_k3s_cfg_values(
@ -1708,6 +1716,37 @@ class KnoeInstaller:
if reg_internal: if reg_internal:
env["LOCAL_REGISTRY_INTERNAL"] = reg_internal env["LOCAL_REGISTRY_INTERNAL"] = reg_internal
# Artifact Registry is required by CNPG manifests in k8s mode.
artifact_registry = str(env.get("ARTIFACT_REGISTRY") or "").strip().strip('"')
if not artifact_registry:
artifact_registry = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("ARTIFACT_REGISTRY", "")
.strip()
.strip('"')
)
if not artifact_registry:
gcp_cfg = self.prole_cfg_data.get("GCP", {}) or {}
project_id = (
(self._get_input("init_cluster.project_id", "") or "").strip()
or str(gcp_cfg.get("project_id") or "").strip().strip('"')
or str(gcp_cfg.get("PROJECT_ID") or "").strip().strip('"')
or (os.environ.get("GCP_PROJECT") or "").strip()
)
region = (
(self._get_input("init_cluster.db_cluster_region", "") or "").strip()
or (self._get_input("init_cluster.app_cluster_region", "") or "").strip()
or str(gcp_cfg.get("region") or "").strip().strip('"')
or str(gcp_cfg.get("REGION") or "").strip().strip('"')
or str(gcp_cfg.get("location") or "").strip().strip('"')
or (os.environ.get("GCP_REGION") or "").strip()
)
repo = (service_ns or "knoe-system").strip() or "knoe-system"
if project_id and region:
artifact_registry = f"{region}-docker.pkg.dev/{project_id}/{repo}"
if artifact_registry:
env["ARTIFACT_REGISTRY"] = artifact_registry.rstrip("/")
try: try:
placement_plan, placement_path = self._resolve_cnpg_placement_plan(namespace) placement_plan, placement_path = self._resolve_cnpg_placement_plan(namespace)
eligible_nodes = placement_plan.get("eligible_nodes") or [] eligible_nodes = placement_plan.get("eligible_nodes") or []
@ -1805,12 +1844,70 @@ class KnoeInstaller:
if role == "app" if role == "app"
else "env_setup.DB_CLUSTER_KUBECONTEXT" else "env_setup.DB_CLUSTER_KUBECONTEXT"
) )
context = (self._get_input(key, "") or self._get_input(fallback_key, "")).strip() context = _safe_str(self._get_input(key, "") or self._get_input(fallback_key, ""))
if context: if context:
return context return context
glob = self.prole_cfg_data.get("Global", {}) or {}
global_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT"
context = _safe_str(glob.get(global_key, ""))
if context:
return context
selected_context = _safe_str(
self._get_input("init_cluster.selected_kubectx", "") or glob.get("KUBECONTEXT", "")
)
if selected_context and not selected_context.startswith("gke_"):
return selected_context
if role == "app": if role == "app":
return (self._get_input("init_cluster.selected_kubectx", "") or "").strip() cluster_name = self._app_cluster_name()
return "" region = _safe_str(
self._get_input("init_cluster.app_cluster_region", "")
or self._get_input("env_setup.APP_CLUSTER_REGION", "")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("region")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("REGION")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("location")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("LOCATION")
)
else:
cluster_name = _safe_str(
self._get_input("init_password.db_cluster_name", "")
or self._get_input("env_setup.DB_CLUSTER_NAME", "")
or glob.get("DB_CLUSTER_NAME", "")
or glob.get("CNPG_CLUSTER_NAME", "")
or DEFAULT_DB_CLUSTER_NAME
) or DEFAULT_DB_CLUSTER_NAME
region = _safe_str(
self._get_input("init_cluster.db_cluster_region", "")
or self._get_input("env_setup.DB_CLUSTER_REGION", "")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("region")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("REGION")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("location")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("LOCATION")
)
gcp_cfg = self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}
project_id = _safe_str(
self._get_input("init_cluster.project_id", "")
or gcp_cfg.get("project_id")
or gcp_cfg.get("PROJECT_ID")
)
if not region:
region = _safe_str(
gcp_cfg.get("region")
or gcp_cfg.get("REGION")
or gcp_cfg.get("location")
or gcp_cfg.get("LOCATION")
)
if project_id and region:
return f"gke_{project_id}_{region}_{cluster_name}"
parts = selected_context.split("_", 3)
if len(parts) == 4:
return f"{parts[0]}_{parts[1]}_{parts[2]}_{cluster_name}"
return selected_context
def _cnpg_desired_instances(self) -> int: def _cnpg_desired_instances(self) -> int:
glob = self.prole_cfg_data.get("Global", {}) or {} glob = self.prole_cfg_data.get("Global", {}) or {}
@ -5571,7 +5668,10 @@ class KnoeConsoleInstaller(KnoeInstaller):
service_ns = self._service_namespace() service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns) env = self._script_env_for_namespace(service_ns)
mode = env.get("PROLE_MODE") or self._deployment_mode() mode = env.get("PROLE_MODE") or self._deployment_mode()
manage_opentofu = str(mode).lower() != "k8s"
registry_ns = self._registry_namespace() registry_ns = self._registry_namespace()
if not manage_opentofu:
self.log("[INFO] k8s mode: skipping OpenTofu checks/deploy in common services.")
try: try:
# Avoid deploying into a namespace that is currently being deleted. # Avoid deploying into a namespace that is currently being deleted.
@ -5595,36 +5695,38 @@ class KnoeConsoleInstaller(KnoeInstaller):
except Exception: except Exception:
return False return False
is_healthy = all( status_checks = [
[ _status_safe(
_status_safe( lambda: registry_ops.status(
lambda: registry_ops.status( namespace=registry_ns,
namespace=registry_ns, env=env,
env=env, mode=mode,
mode=mode, )
) ),
), _status_safe(
_status_safe( lambda: openbao_ops.status(
lambda: openbao_ops.status( namespace=service_ns,
namespace=service_ns, env=env,
env=env, mode=mode,
mode=mode, )
) ),
), _status_safe(
_status_safe( lambda: garage_store_ops.status(
lambda: garage_store_ops.status( namespace=service_ns,
namespace=service_ns, env=env,
env=env, )
) ),
), ]
if manage_opentofu:
status_checks.append(
_status_safe( _status_safe(
lambda: opentofu_ops.status( lambda: opentofu_ops.status(
namespace=service_ns, namespace=service_ns,
env=env, env=env,
) )
), )
] )
) is_healthy = all(status_checks)
if is_healthy: if is_healthy:
self.log( self.log(
f"[OK] Common services in {service_ns} are healthy. Skipping update." f"[OK] Common services in {service_ns} are healthy. Skipping update."
@ -5656,47 +5758,50 @@ class KnoeConsoleInstaller(KnoeInstaller):
project_root=self.project_root, project_root=self.project_root,
log=self.log, log=self.log,
) )
opentofu_ops.update( if manage_opentofu:
namespace=service_ns, opentofu_ops.update(
env=env, namespace=service_ns,
mode=mode, env=env,
project_root=self.project_root, mode=mode,
log=self.log, project_root=self.project_root,
) log=self.log,
)
except Exception as e: except Exception as e:
self.err(f"[ERROR] Common services deploy failed: {e}") self.err(f"[ERROR] Common services deploy failed: {e}")
else: else:
# Re-validate after fix. # Re-validate after fix.
repaired = all( repair_checks = [
[ _status_safe(
_status_safe( lambda: registry_ops.status(
lambda: registry_ops.status( namespace=registry_ns,
namespace=registry_ns, env=env,
env=env, mode=mode,
mode=mode, )
) ),
), _status_safe(
_status_safe( lambda: openbao_ops.status(
lambda: openbao_ops.status( namespace=service_ns,
namespace=service_ns, env=env,
env=env, mode=mode,
mode=mode, )
) ),
), _status_safe(
_status_safe( lambda: garage_store_ops.status(
lambda: garage_store_ops.status( namespace=service_ns,
namespace=service_ns, env=env,
env=env, )
) ),
), ]
if manage_opentofu:
repair_checks.append(
_status_safe( _status_safe(
lambda: opentofu_ops.status( lambda: opentofu_ops.status(
namespace=service_ns, namespace=service_ns,
env=env, env=env,
) )
), )
] )
) repaired = all(repair_checks)
if not repaired: if not repaired:
self.err( self.err(
f"[ERROR] Common services still unhealthy after repair attempt." f"[ERROR] Common services still unhealthy after repair attempt."
@ -5862,11 +5967,13 @@ class KnoeConsoleInstaller(KnoeInstaller):
return return
self.log("==> Initialization scripts") self.log("==> Initialization scripts")
ns = ( mode = self._deployment_mode()
self._get_input("init_password.db_namespace", "") or "" db_ns = self._secret_namespace()
).strip() or "default" if mode == "k8s" and db_ns == "default":
db_env = self._script_env_for_namespace(ns, cluster_role="db") db_ns = "knoe-db-0"
app_env = self._script_env_for_namespace(ns, cluster_role="app") service_ns = self._service_namespace()
db_env = self._script_env_for_namespace(db_ns, cluster_role="db")
app_env = self._script_env_for_namespace(service_ns, cluster_role="app")
opt_allowed, _opt_count, opt_reason = self._optional_workloads_policy(app_env) opt_allowed, _opt_count, opt_reason = self._optional_workloads_policy(app_env)
@ -5884,20 +5991,22 @@ class KnoeConsoleInstaller(KnoeInstaller):
if not opt_allowed: if not opt_allowed:
self.log(f"[SKIP] Monitoring disabled by policy: {opt_reason}") self.log(f"[SKIP] Monitoring disabled by policy: {opt_reason}")
self.prole_cfg_data.setdefault("Monitoring", {})["STATUS"] = "Skipped" self.prole_cfg_data.setdefault("Monitoring", {})["STATUS"] = "Skipped"
mode = self._deployment_mode()
if mode != "k3d": if mode != "k3d":
post_cnpg_steps.append(("init_nginx_ingress.sh", ["initialize"], False, "app")) post_cnpg_steps.append(("init_nginx_ingress.sh", ["initialize"], False, "app"))
overall_success = True overall_success = True
# Common services (Python owners) — replaces init_common_services.sh # Common services (Python owners) — replaces init_common_services.sh
self.log(f"[app-cluster {self._app_cluster_name()}] common services (python owners)") self.log(
f"[app-cluster {self._app_cluster_name()} context={app_env.get('KUBECONTEXT','')}] "
f"common services namespace={service_ns}"
)
try: try:
registry_ns = str(app_env.get("REGISTRY_NAMESPACE") or ns).strip() or ns registry_ns = str(app_env.get("REGISTRY_NAMESPACE") or service_ns).strip() or service_ns
registry_ops.update(namespace=registry_ns, env=app_env, mode=mode, log=self.log) registry_ops.update(namespace=registry_ns, env=app_env, mode=mode, log=self.log)
openbao_ops.update(namespace=ns, env=app_env, mode=mode, log=self.log) openbao_ops.update(namespace=service_ns, env=app_env, mode=mode, log=self.log)
garage_store_ops.update(namespace=ns, env=app_env, mode=mode, log=self.log) garage_store_ops.update(namespace=service_ns, env=app_env, mode=mode, log=self.log)
opentofu_ops.update(namespace=ns, env=app_env, mode=mode, log=self.log) opentofu_ops.update(namespace=service_ns, env=app_env, mode=mode, log=self.log)
except Exception as e: except Exception as e:
self.err(f"[ERROR] common services (python owners) failed: {e}") self.err(f"[ERROR] common services (python owners) failed: {e}")
overall_success = False overall_success = False
@ -5919,16 +6028,19 @@ class KnoeConsoleInstaller(KnoeInstaller):
# CNPG initialization (Python) — replaces init_cloudnative_pg.sh initialize # CNPG initialization (Python) — replaces init_cloudnative_pg.sh initialize
if overall_success: if overall_success:
self.log(f"[db-cluster {self._cnpg_cluster_name()}] cnpg_initialize (Python)") self.log(
f"[db-cluster {self._cnpg_cluster_name()} context={db_env.get('KUBECONTEXT','')}] "
f"cnpg_initialize namespace={db_ns}"
)
try: try:
self.ensure_db_k8s_secrets(ns, password, log_fn=self.log) self.ensure_db_k8s_secrets(db_ns, password, log_fn=self.log)
except Exception as e: except Exception as e:
self.err(f"[ERROR] Failed to ensure DB secrets before CNPG init: {e}") self.err(f"[ERROR] Failed to ensure DB secrets before CNPG init: {e}")
overall_success = False overall_success = False
if overall_success: if overall_success:
if self._cluster_storage_milestone_enabled(): if self._cluster_storage_milestone_enabled():
try: try:
self._ensure_cnpg_storage_provisioned(ns, db_env) self._ensure_cnpg_storage_provisioned(db_ns, db_env)
except Exception as e: except Exception as e:
self.err(f"[WARN] Failed to provision CNPG storage before init: {e}") self.err(f"[WARN] Failed to provision CNPG storage before init: {e}")
else: else:
@ -5938,7 +6050,7 @@ class KnoeConsoleInstaller(KnoeInstaller):
) )
try: try:
cnpg_initialize( cnpg_initialize(
namespace=ns, namespace=db_ns,
cluster_name=str(db_env.get("CLUSTER_NAME") or db_env.get("CNPG_CLUSTER_NAME") or "knoe-db").strip(), cluster_name=str(db_env.get("CLUSTER_NAME") or db_env.get("CNPG_CLUSTER_NAME") or "knoe-db").strip(),
env=db_env, env=db_env,
project_root=self.project_root, project_root=self.project_root,
@ -5983,17 +6095,20 @@ class KnoeConsoleInstaller(KnoeInstaller):
self._scripts_success = overall_success self._scripts_success = overall_success
# Verify critical secrets # Verify critical secrets
self.log(f"==> Verifying critical secrets in namespace {ns}") self.log(f"==> Verifying critical secrets in namespace {db_ns}")
critical_secrets = ["knoe-db-user", "knoe-db-superuser", "cnpg-admin-key"] critical_secrets = ["knoe-db-user", "knoe-db-superuser", "cnpg-admin-key"]
missing_secrets = [] missing_secrets = []
for secret in critical_secrets: for secret in critical_secrets:
rc_s = self._run_cmd(["kubectl", "get", "secret", secret, "-n", ns]) rc_s = self._run_cmd(
["kubectl", "get", "secret", secret, "-n", db_ns],
env=db_env,
)
if rc_s != 0: if rc_s != 0:
missing_secrets.append(secret) missing_secrets.append(secret)
if missing_secrets: if missing_secrets:
self.err( self.err(
f"[CRITICAL] Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}" f"[CRITICAL] Missing secrets in namespace '{db_ns}': {', '.join(missing_secrets)}"
) )
self.err("Database initialization will fail without these secrets.") self.err("Database initialization will fail without these secrets.")
self._scripts_success = False self._scripts_success = False
@ -6012,11 +6127,9 @@ class KnoeConsoleInstaller(KnoeInstaller):
return return
db_cluster = self._cnpg_cluster_name() db_cluster = self._cnpg_cluster_name()
self.log(f"==> [db-cluster {db_cluster}] Deploy CloudNativePG") self.log(f"==> [db-cluster {db_cluster}] Deploy CloudNativePG")
ns = (self._get_input("init_password.db_namespace", "") or "").strip() ns = self._secret_namespace()
if not ns: if self._deployment_mode() == "k8s" and ns == "default":
ns = (self._get_input("env_setup.DATABASE_NAMESPACE", "") or "").strip() ns = "knoe-db-0"
if not ns:
ns = "default"
env = self._script_env_for_namespace(ns, cluster_role="db") env = self._script_env_for_namespace(ns, cluster_role="db")
cluster_name = str( cluster_name = str(
env.get("CLUSTER_NAME") env.get("CLUSTER_NAME")
@ -6537,6 +6650,133 @@ class KnoeConsoleInstaller(KnoeInstaller):
) )
out.flush() out.flush()
def _db_master_vault_file(self) -> Path:
raw = (os.environ.get("PROLE_DB_MASTER_VAULT_FILE") or "").strip()
if raw:
expanded = _expand_cfg_value(raw, _collect_cfg_vars())
return Path(expanded).expanduser()
return (
self.project_root
/ "infrastructure"
/ "inventory"
/ "group_vars"
/ "all"
/ "vault_db_master.yml"
)
def _db_master_vault_key(self) -> str:
key = (os.environ.get("PROLE_DB_MASTER_VAULT_KEY") or "").strip()
return key or "vault_prole_db_master_password"
def _resolve_ansible_vault_password_file(self) -> str:
vault_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip()
if vault_file and Path(vault_file).expanduser().is_file():
return str(Path(vault_file).expanduser())
for base in (self.project_root, Path.cwd()):
candidate = base / ".vault_pass"
if candidate.is_file():
os.environ["ANSIBLE_VAULT_PASSWORD_FILE"] = str(candidate)
return str(candidate)
return ""
def _load_db_password_from_ansible_vault(self, *, log_found: bool = True) -> str:
vault_file = self._db_master_vault_file()
vault_key = self._db_master_vault_key()
password = _try_read_ansible_vault_value(vault_file, vault_key).strip()
if password and log_found:
self.log(
f"[CONFIG] Loaded DB master password from Ansible Vault ({vault_file})."
)
return password
def _persist_db_password_to_ansible_vault(self, password: str) -> None:
if not password:
raise RuntimeError("Cannot persist an empty database master password.")
if shutil.which("ansible-vault") is None:
raise RuntimeError(
"ansible-vault command is required to store the database master password securely."
)
vault_file = self._db_master_vault_file()
vault_key = self._db_master_vault_key()
vault_file.parent.mkdir(parents=True, exist_ok=True)
vault_password_file = self._resolve_ansible_vault_password_file()
vault_password = (os.environ.get("ANSIBLE_VAULT_PASSWORD") or "").strip()
if not vault_password_file and not vault_password:
raise RuntimeError(
"Ansible Vault password is not configured. Set ANSIBLE_VAULT_PASSWORD_FILE (or create .vault_pass) "
"or set ANSIBLE_VAULT_PASSWORD before bootstrap."
)
tmp_password_file = None
if not vault_password_file and vault_password:
tmp_pw = tempfile.NamedTemporaryFile(mode="w", delete=False)
tmp_pw.write(vault_password)
tmp_pw.flush()
tmp_pw.close()
tmp_password_file = tmp_pw.name
vault_password_file = tmp_password_file
cmd = [
"ansible-vault",
"encrypt_string",
"--name",
vault_key,
password,
]
if vault_password_file:
cmd += ["--vault-password-file", vault_password_file]
try:
res = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=20,
env=os.environ.copy(),
stdin=subprocess.DEVNULL,
)
if res.returncode != 0:
raise RuntimeError(
"Failed to write database master password to Ansible Vault: "
f"{(res.stderr or res.stdout or '').strip()}"
)
rendered_block = (res.stdout or "").rstrip() + "\n"
if not rendered_block.strip():
raise RuntimeError(
"Failed to write database master password to Ansible Vault: empty encrypted output."
)
existing = ""
if vault_file.exists():
try:
existing = vault_file.read_text()
except Exception:
existing = ""
key_pattern = rf"(?ms)^\s*{re.escape(vault_key)}\s*:.*?(?=^\S|\Z)"
if existing and re.search(key_pattern, existing):
updated = re.sub(key_pattern, rendered_block.rstrip(), existing, count=1)
output_text = updated.rstrip() + "\n"
elif existing.strip():
output_text = existing.rstrip() + "\n\n" + rendered_block
else:
output_text = rendered_block
vault_file.write_text(output_text)
os.chmod(vault_file, 0o600)
self.log(
f"[CONFIG] Persisted DB master password into Ansible Vault ({vault_file}:{vault_key})."
)
finally:
if tmp_password_file:
try:
os.unlink(tmp_password_file)
except Exception:
pass
def run(self) -> int: def run(self) -> int:
_configure_unbuffered_io() _configure_unbuffered_io()
self.log(f"[CONFIG] Using {self.cfg_path}") self.log(f"[CONFIG] Using {self.cfg_path}")
@ -6547,23 +6787,65 @@ class KnoeConsoleInstaller(KnoeInstaller):
# If the value is an OpenBao reference or an encrypted `${PROLE_SECRET:...}` # If the value is an OpenBao reference or an encrypted `${PROLE_SECRET:...}`
# placeholder, allow downstream secret-management steps to resolve it. # placeholder, allow downstream secret-management steps to resolve it.
# Only prompt/generate when the value is truly missing. # Only prompt/generate when the value is truly missing.
db_pw = self._get_input("init_password.db_password", "") db_pw = (self._get_input("init_password.db_password", "") or "").strip()
env_db_pw = (
os.environ.get("KNOE_DB_PASSWORD")
or os.environ.get("DB_PASSWORD")
or ""
).strip()
# If runtime env provides a password, reconcile with vault for
# consistency and bootstrap vault when missing.
if db_pw and env_db_pw and db_pw == env_db_pw:
vault_pw = self._load_db_password_from_ansible_vault(log_found=False)
if vault_pw and vault_pw != db_pw:
self.log(
"[WARN] Runtime DB password differs from Ansible Vault value; "
"using vault password for consistency."
)
self.inputs["init_password.db_password"] = vault_pw
self.inputs["init_password.db_password_confirm"] = vault_pw
db_pw = vault_pw
elif not vault_pw:
self._persist_db_password_to_ansible_vault(db_pw)
self.log(
"[CONFIG] Bootstrapped Ansible Vault from KNOE_DB_PASSWORD/DB_PASSWORD."
)
if not db_pw:
vault_pw = self._load_db_password_from_ansible_vault()
if vault_pw:
self.inputs["init_password.db_password"] = vault_pw
self.inputs["init_password.db_password_confirm"] = vault_pw
db_pw = vault_pw
needs_prompt = not bool(db_pw) needs_prompt = not bool(db_pw)
if needs_prompt: if needs_prompt:
# In silent installs, never block on an interactive prompt in environments # In silent installs, never block on an interactive prompt in environments
# that cannot provide input (CI/pytest/non-interactive runners). # that cannot provide input (CI/pytest/non-interactive runners).
stdin_is_tty = getattr(sys.__stdin__, "isatty", lambda: False)() stdin_is_tty = getattr(sys.__stdin__, "isatty", lambda: False)()
stdout_is_tty = getattr(sys.__stdout__, "isatty", lambda: False)()
running_under_pytest = "PYTEST_CURRENT_TEST" in os.environ running_under_pytest = "PYTEST_CURRENT_TEST" in os.environ
running_in_ci = bool(os.environ.get("CI")) running_in_ci = bool(os.environ.get("CI"))
allow_prompt = stdin_is_tty and not running_under_pytest and not running_in_ci allow_prompt = (
stdin_is_tty
and stdout_is_tty
and not running_under_pytest
and not running_in_ci
)
if allow_prompt: if allow_prompt:
new_pw = self._prompt_for_master_password() new_pw = self._prompt_for_master_password()
else: self._persist_db_password_to_ansible_vault(new_pw)
self.err( self.log(
"[WARN] Silent install requires a database master password but prompting is not available; generating one automatically." "[CONFIG] Master password captured interactively and saved for subsequent runs."
)
else:
raise RuntimeError(
"Database master password is missing and prompting is unavailable. "
"Run once in an interactive terminal to bootstrap Ansible Vault, "
"or provide KNOE_DB_PASSWORD/DB_PASSWORD."
) )
new_pw = secrets.token_urlsafe(24)
self.inputs["init_password.db_password"] = new_pw self.inputs["init_password.db_password"] = new_pw
self.inputs["init_password.db_password_confirm"] = new_pw self.inputs["init_password.db_password_confirm"] = new_pw
# Save immediately to prole.cfg so subsequent steps/scripts see it. # Save immediately to prole.cfg so subsequent steps/scripts see it.

View File

@ -623,22 +623,25 @@ class ClusterLifecycleMilestone(Milestone):
# completed automatically — mirroring the UI shortcut behaviour. # completed automatically — mirroring the UI shortcut behaviour.
if env_key in ("service", "k3s"): if env_key in ("service", "k3s"):
service_ns = env.get("SERVICE_NAMESPACE", env.get("NAMESPACE", "default")) service_ns = env.get("SERVICE_NAMESPACE", env.get("NAMESPACE", "default"))
status_ok = all( mode = str(env.get("PROLE_MODE", "")).strip().lower()
[ status_checks = [
registry_ops.status( registry_ops.status(
namespace=env.get("REGISTRY_NAMESPACE", service_ns), namespace=env.get("REGISTRY_NAMESPACE", service_ns),
env=env, env=env,
mode=env.get("PROLE_MODE"), mode=env.get("PROLE_MODE"),
), ),
openbao_ops.status( openbao_ops.status(
namespace=service_ns, namespace=service_ns,
env=env, env=env,
mode=env.get("PROLE_MODE"), mode=env.get("PROLE_MODE"),
), ),
garage_store_ops.status(namespace=service_ns, env=env), garage_store_ops.status(namespace=service_ns, env=env),
opentofu_ops.status(namespace=service_ns, env=env), ]
] if mode != "k8s":
) status_checks.append(opentofu_ops.status(namespace=service_ns, env=env))
else:
self.logger.info("k8s mode: skipping OpenTofu health check.")
status_ok = all(status_checks)
if status_ok: if status_ok:
self.logger.info( self.logger.info(
"All common services healthy running service layer migration inline." "All common services healthy running service layer migration inline."
@ -728,6 +731,7 @@ class InitializationScriptsMilestone(Milestone):
) -> None: ) -> None:
env = self._get_script_env(state) env = self._get_script_env(state)
mode = env.get("PROLE_MODE", "") mode = env.get("PROLE_MODE", "")
manage_opentofu = str(mode).strip().lower() != "k8s"
mode_args = ["--mode", mode] if mode else [] mode_args = ["--mode", mode] if mode else []
raw_min = (state.config_data.get("Global", {}) or {}).get(POLICY_CFG_KEY, "") raw_min = (state.config_data.get("Global", {}) or {}).get(POLICY_CFG_KEY, "")
@ -771,13 +775,16 @@ class InitializationScriptsMilestone(Milestone):
mode=mode, mode=mode,
log=self.logger.info, log=self.logger.info,
) )
opentofu_ops.update( if manage_opentofu:
namespace=service_ns, opentofu_ops.update(
env=env, namespace=service_ns,
project_root=project_root, env=env,
mode=mode, project_root=project_root,
log=self.logger.info, mode=mode,
) log=self.logger.info,
)
else:
self.logger.info("k8s mode: skipping OpenTofu deploy.")
except Exception as e: except Exception as e:
msg = f"Common services (python owners) failed: {e}" msg = f"Common services (python owners) failed: {e}"
self.logger.error(msg) self.logger.error(msg)
@ -788,6 +795,15 @@ class InitializationScriptsMilestone(Milestone):
progress("Initializing CloudNative-PG...", 1 / 6) progress("Initializing CloudNative-PG...", 1 / 6)
ns = env.get("DATABASE_NAMESPACE") or env.get("NAMESPACE", "default") ns = env.get("DATABASE_NAMESPACE") or env.get("NAMESPACE", "default")
cluster_name = env.get("CLUSTER_NAME") or env.get("CNPG_CLUSTER_NAME") or "knoe-db" cluster_name = env.get("CLUSTER_NAME") or env.get("CNPG_CLUSTER_NAME") or "knoe-db"
# Build a DB-cluster-scoped env for CNPG operations.
# In k8s mode the default KUBECONTEXT is the app cluster; CNPG lives on the
# dedicated DB cluster, so override KUBECONTEXT to DB_CLUSTER_KUBECONTEXT.
cnpg_env = dict(env)
_db_ctx = env.get("DB_CLUSTER_KUBECONTEXT", "").strip()
if _db_ctx:
cnpg_env["KUBECONTEXT"] = _db_ctx
# Ensure DB secrets exist before CNPG init (mirrors _step_init_scripts). # Ensure DB secrets exist before CNPG init (mirrors _step_init_scripts).
# env["DB_PASSWORD"] is already resolved by _script_env_for_namespace. # env["DB_PASSWORD"] is already resolved by _script_env_for_namespace.
db_pw = (env.get("DB_PASSWORD") or state.inputs.get("init_password.db_password") or "").strip() db_pw = (env.get("DB_PASSWORD") or state.inputs.get("init_password.db_password") or "").strip()
@ -799,7 +815,7 @@ class InitializationScriptsMilestone(Milestone):
cnpg_initialize( cnpg_initialize(
namespace=ns, namespace=ns,
cluster_name=cluster_name, cluster_name=cluster_name,
env=env, env=cnpg_env,
project_root=project_root, project_root=project_root,
log=self.logger.info, log=self.logger.info,
mode=mode, mode=mode,
@ -819,13 +835,17 @@ class InitializationScriptsMilestone(Milestone):
if script == "init_cnpg_backup.sh": if script == "init_cnpg_backup.sh":
args: list[str] = mode_args + ["start"] args: list[str] = mode_args + ["start"]
# CNPG backup runs on the DB cluster, not the app cluster
script_env = cnpg_env
elif script == "init_kong.sh": elif script == "init_kong.sh":
args = mode_args + ["start"] args = mode_args + ["start"]
script_env = env
else: else:
args = mode_args + ["initialize"] args = mode_args + ["initialize"]
script_env = env
rc = state.controller.run_script( rc = state.controller.run_script(
script, args=args, env=env, on_line=_stream_line script, args=args, env=script_env, on_line=_stream_line
) )
if rc != 0: if rc != 0:
msg = f"Script {script} failed (code {rc})" msg = f"Script {script} failed (code {rc})"
@ -848,13 +868,19 @@ class InitializationScriptsMilestone(Milestone):
self.logger.error(msg) self.logger.error(msg)
raise Exception(msg) raise Exception(msg)
# Verify critical secrets # Verify critical secrets (in the DB cluster namespace where CNPG lives)
ns = env.get("NAMESPACE", "default") ns = env.get("DATABASE_NAMESPACE") or env.get("NAMESPACE", "default")
self.logger.info(f"Verifying critical secrets in namespace {ns}...") self.logger.info(f"Verifying critical secrets in namespace {ns}...")
critical_secrets = ["knoe-db-user", "knoe-db-superuser", "cnpg-admin-key"] critical_secrets = ["knoe-db-user", "knoe-db-superuser", "cnpg-admin-key"]
missing_secrets = [] missing_secrets = []
for secret in critical_secrets: for secret in critical_secrets:
if self._run_cmd(f"kubectl get secret {secret} -n {ns}") != 0: if (
self._run_cmd(
["kubectl", "get", "secret", secret, "-n", ns],
env=cnpg_env,
)
!= 0
):
missing_secrets.append(secret) missing_secrets.append(secret)
if missing_secrets: if missing_secrets:

View File

@ -62,26 +62,42 @@ def _log(log: _LogFn | None, msg: str) -> None:
_LOG.info(msg) _LOG.info(msg)
def _kubectl_cmd(args: list[str], env: dict | None = None) -> list[str]:
"""Build a kubectl command that honors env['KUBECONTEXT'] as --context."""
ctx = str((env or {}).get("KUBECONTEXT") or "").strip()
cmd = ["kubectl"]
if ctx:
cmd.extend(["--context", ctx])
cmd.extend(args)
return cmd
def _kubectl_run(
args: list[str],
env: dict | None = None,
timeout: int = 30,
check: bool = False,
input_text: str | None = None,
) -> subprocess.CompletedProcess:
"""Run kubectl with optional stdin payload and context-aware command building."""
return subprocess.run(
_kubectl_cmd(args, env=env),
env=env,
capture_output=True,
text=True,
timeout=timeout,
check=check,
input=input_text,
)
def _kubectl( def _kubectl(
args: list[str], args: list[str],
env: dict | None = None, env: dict | None = None,
timeout: int = 30, timeout: int = 30,
check: bool = False, check: bool = False,
) -> subprocess.CompletedProcess: ) -> subprocess.CompletedProcess:
# KUBECONTEXT is not a real kubectl env var — extract it and pass as --context flag. return _kubectl_run(args, env=env, timeout=timeout, check=check)
ctx = str((env or {}).get("KUBECONTEXT") or "").strip()
cmd = ["kubectl"]
if ctx:
cmd.extend(["--context", ctx])
cmd.extend(args)
return subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=timeout,
check=check,
)
def _kubectl_ok(args: list[str], env: dict | None = None, timeout: int = 15) -> bool: def _kubectl_ok(args: list[str], env: dict | None = None, timeout: int = 15) -> bool:
@ -98,10 +114,7 @@ def _release_stale_pv_claims(
Only touches PVs whose status.phase is 'Released' for the given storageClass. Only touches PVs whose status.phase is 'Released' for the given storageClass.
Idempotent Available/Bound PVs are left untouched. Idempotent Available/Bound PVs are left untouched.
""" """
r = subprocess.run( r = _kubectl(["get", "pv", "-o", "json"], env=env, timeout=20)
["kubectl", "get", "pv", "-o", "json"],
text=True, capture_output=True, env=env, timeout=20,
)
if r.returncode != 0: if r.returncode != 0:
_log(log, f"WARN: could not list PVs: {r.stderr.strip()}") _log(log, f"WARN: could not list PVs: {r.stderr.strip()}")
return return
@ -119,9 +132,10 @@ def _release_stale_pv_claims(
return return
_log(log, f"Releasing claimRef on {len(released)} Released '{storage_class}' PV(s): {', '.join(released)}") _log(log, f"Releasing claimRef on {len(released)} Released '{storage_class}' PV(s): {', '.join(released)}")
for pv_name in released: for pv_name in released:
pr = subprocess.run( pr = _kubectl(
["kubectl", "patch", "pv", pv_name, "-p", '{"spec":{"claimRef":null}}'], ["patch", "pv", pv_name, "-p", '{"spec":{"claimRef":null}}'],
text=True, capture_output=True, env=env, timeout=15, env=env,
timeout=15,
) )
if pr.returncode != 0: if pr.returncode != 0:
_log(log, f"WARN: could not release claimRef on PV '{pv_name}': {pr.stderr.strip()}") _log(log, f"WARN: could not release claimRef on PV '{pv_name}': {pr.stderr.strip()}")
@ -196,13 +210,7 @@ def _ensure_namespace(namespace: str, env: dict | None = None) -> None:
"metadata:\n" "metadata:\n"
f" name: {namespace}\n" f" name: {namespace}\n"
) )
res = subprocess.run( res = _kubectl_run(["apply", "-f", "-"], env=env, input_text=manifest)
["kubectl", "apply", "-f", "-"],
input=manifest,
text=True,
capture_output=True,
env=env,
)
if res.returncode != 0: if res.returncode != 0:
raise RuntimeError(f"Failed to ensure namespace '{namespace}':\n{res.stderr}") raise RuntimeError(f"Failed to ensure namespace '{namespace}':\n{res.stderr}")
@ -416,6 +424,113 @@ def _wait_pod_ready(
_log(log, f"WARN: pod {pod} did not become Ready within {timeout}s") _log(log, f"WARN: pod {pod} did not become Ready within {timeout}s")
def _cnpg_pod_readiness_lines(namespace: str, cluster_name: str, env: dict | None) -> list[str]:
r = _kubectl(
[
"-n", namespace, "get", "pods",
"-l", f"cnpg.io/cluster={cluster_name}",
"-o",
"jsonpath={range .items[*]}{.metadata.name}={.status.conditions[?(@.type==\"Ready\")].status}\\n{end}",
],
env=env,
timeout=15,
)
if r.returncode != 0:
return []
# kubectl jsonpath emits literal \n (backslash-n) not real newlines
raw = (r.stdout or "").replace("\\n", "\n")
return [line for line in raw.splitlines() if "=" in line]
def _cnpg_timeout_diagnostics(namespace: str, cluster_name: str, env: dict | None) -> str:
parts: list[str] = []
pod_status = _kubectl(
[
"-n", namespace, "get", "pods",
"-l", f"cnpg.io/cluster={cluster_name}",
"-o",
"jsonpath={range .items[*]}{.metadata.name}|phase={.status.phase}|node={.spec.nodeName}|ready={.status.conditions[?(@.type==\"Ready\")].status}|reason={.status.containerStatuses[0].state.waiting.reason}\\n{end}",
],
env=env,
timeout=20,
)
if pod_status.returncode == 0:
pod_lines = [line for line in (pod_status.stdout or "").replace("\\n", "\n").splitlines() if line.strip()]
if pod_lines:
parts.append("Pods: " + "; ".join(pod_lines))
failed_sched = _kubectl(
[
"-n", namespace, "get", "events",
"--field-selector", "reason=FailedScheduling,involvedObject.kind=Pod",
"-o", "jsonpath={range .items[*]}{.involvedObject.name}|{.message}\\n{end}",
],
env=env,
timeout=20,
)
if failed_sched.returncode == 0:
event_lines = [line for line in (failed_sched.stdout or "").replace("\\n", "\n").splitlines() if line.strip()]
if event_lines:
parts.append("FailedScheduling: " + " | ".join(event_lines[-3:]))
if not parts:
return "No additional scheduling diagnostics available."
return "\n".join(parts)
def _relax_cnpg_workload_node_selector_if_unmatched(
namespace: str,
cluster_name: str,
env: dict | None,
log: _LogFn | None,
) -> bool:
cfg = env or {}
relax_enabled = str(cfg.get("CNPG_AUTO_RELAX_WORKLOAD_SELECTOR", "true") or "true").strip().lower()
if relax_enabled in {"0", "false", "no", "off"}:
return False
selector_key = str(cfg.get("CNPG_WORKLOAD_SELECTOR_KEY", "workload") or "workload").strip() or "workload"
selector_value_res = _kubectl(
[
"-n", namespace, "get", "cluster", cluster_name,
"-o", f"jsonpath={{.spec.affinity.nodeSelector.{selector_key}}}",
],
env=env,
timeout=15,
)
selector_value = (selector_value_res.stdout or "").strip()
if selector_value_res.returncode != 0 or not selector_value:
return False
selector_expr = f"{selector_key}={selector_value}"
matching_nodes = _kubectl(["get", "nodes", "-l", selector_expr, "--no-headers"], env=env, timeout=15)
if matching_nodes.returncode != 0:
return False
matched = len([line for line in (matching_nodes.stdout or "").splitlines() if line.strip()])
if matched > 0:
return False
_log(
log,
"WARN: CNPG pods appear unschedulable because no nodes match "
f"'{selector_expr}'. Removing spec.affinity.nodeSelector as tiny-cluster fallback.",
)
patch = json.dumps({"spec": {"affinity": {"nodeSelector": None}}})
patched = _kubectl(
["-n", namespace, "patch", "cluster", cluster_name, "--type", "merge", "-p", patch],
env=env,
timeout=30,
)
if patched.returncode != 0:
reason = (patched.stderr or patched.stdout or "").strip()
_log(log, f"WARN: failed to relax CNPG nodeSelector: {reason}")
return False
_log(log, "CNPG cluster nodeSelector relaxed successfully; retrying pod readiness wait.")
return True
def _wait_cnpg_pods( def _wait_cnpg_pods(
namespace: str, namespace: str,
cluster_name: str, cluster_name: str,
@ -440,29 +555,29 @@ def _wait_cnpg_pods(
_log(log, f"Waiting for {target} CNPG pod(s) for cluster '{cluster_name}' in '{namespace}' (timeout={timeout}s)...") _log(log, f"Waiting for {target} CNPG pod(s) for cluster '{cluster_name}' in '{namespace}' (timeout={timeout}s)...")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
selector_relaxed = False
last_feedback = 0.0 last_feedback = 0.0
while True: while True:
now = time.monotonic() now = time.monotonic()
if now >= deadline: if now >= deadline:
if not selector_relaxed and _relax_cnpg_workload_node_selector_if_unmatched(namespace, cluster_name, env, log):
selector_relaxed = True
grace = int((env or {}).get("CNPG_SELECTOR_RELAX_GRACE_TIMEOUT", "300") or "300")
timeout = max(30, grace)
deadline = now + timeout
last_feedback = 0.0
_log(log, f"Waiting up to {timeout}s for CNPG pods after nodeSelector relaxation...")
continue
diagnostics = _cnpg_timeout_diagnostics(namespace, cluster_name, env)
raise RuntimeError( raise RuntimeError(
f"Timed out ({timeout}s) waiting for {target} CNPG pods to be Ready " f"Timed out ({timeout}s) waiting for {target} CNPG pods to be Ready "
f"in namespace '{namespace}'" f"in namespace '{namespace}'.\n"
f"{diagnostics}"
) )
r2 = _kubectl( lines = _cnpg_pod_readiness_lines(namespace, cluster_name, env)
[ if lines:
"-n", namespace, "get", "pods",
"-l", f"cnpg.io/cluster={cluster_name}",
"-o",
"jsonpath={range .items[*]}{.metadata.name}={.status.conditions[?(@.type==\"Ready\")].status}\\n{end}",
],
env=env,
timeout=15,
)
if r2.returncode == 0:
# kubectl jsonpath emits literal \n (backslash-n) not real newlines
raw = (r2.stdout or "").replace("\\n", "\n")
lines = [l for l in raw.splitlines() if "=" in l]
ready = sum(1 for l in lines if l.split("=", 1)[1].strip() == "True") ready = sum(1 for l in lines if l.split("=", 1)[1].strip() == "True")
if ready >= target: if ready >= target:
_log(log, f"All {ready}/{target} CNPG pods are Ready.") _log(log, f"All {ready}/{target} CNPG pods are Ready.")
@ -873,10 +988,7 @@ def install_barman_plugin(
_last_error: str = "" _last_error: str = ""
_apply_ok = False _apply_ok = False
for _attempt in range(1, 7): # up to 6 attempts; first triggers cert-manager recovery for _attempt in range(1, 7): # up to 6 attempts; first triggers cert-manager recovery
r = subprocess.run( r = _kubectl(["apply", "-f", url], env=env, timeout=120)
["kubectl", "apply", "-f", url],
env=env, capture_output=True, text=True, timeout=120,
)
if r.returncode == 0: if r.returncode == 0:
if r.stdout.strip(): if r.stdout.strip():
_log(log, r.stdout.strip()) _log(log, r.stdout.strip())
@ -926,7 +1038,7 @@ def install_barman_plugin(
def _ensure_cert_manager_for_barman( def _ensure_cert_manager_for_barman(
env: dict | None = None, env: dict | None = None,
log: _LogFn | None = None, log: _LogFn | None = None,
) -> None: ) -> bool:
def _exists(kind: str, name: str) -> bool: def _exists(kind: str, name: str) -> bool:
return _kubectl(["-n", "cert-manager", "get", kind, name], env=env, timeout=20).returncode == 0 return _kubectl(["-n", "cert-manager", "get", kind, name], env=env, timeout=20).returncode == 0
@ -980,13 +1092,7 @@ def _ensure_cert_manager_for_barman(
"https://github.com/cert-manager/cert-manager/releases/download/" "https://github.com/cert-manager/cert-manager/releases/download/"
f"{certmgr_version}/cert-manager.yaml" f"{certmgr_version}/cert-manager.yaml"
) )
r = subprocess.run( r = _kubectl(["apply", "-f", certmgr_url], env=env, timeout=180)
["kubectl", "apply", "-f", certmgr_url],
env=env,
capture_output=True,
text=True,
timeout=180,
)
if r.returncode != 0: if r.returncode != 0:
raise RuntimeError( raise RuntimeError(
f"Failed to bootstrap cert-manager from {certmgr_url}: " f"Failed to bootstrap cert-manager from {certmgr_url}: "
@ -1028,12 +1134,10 @@ def _try_inject_ca_bundle_manually(
Returns True if the patch succeeded and the caBundle is now non-empty. Returns True if the patch succeeded and the caBundle is now non-empty.
""" """
for secret_name in ("cert-manager-webhook-ca", "cert-manager-cainjector-leader-election"): for secret_name in ("cert-manager-webhook-ca", "cert-manager-cainjector-leader-election"):
r = subprocess.run( r = _kubectl(
[ ["-n", "cert-manager", "get", "secret", secret_name, "-o", "jsonpath={.data.tls\\.crt}"],
"kubectl", "-n", "cert-manager", "get", "secret", secret_name, env=env,
"-o", "jsonpath={.data.tls\\.crt}", timeout=15,
],
env=env, capture_output=True, text=True, timeout=15,
) )
ca_bundle = (r.stdout or "").strip() ca_bundle = (r.stdout or "").strip()
if r.returncode == 0 and ca_bundle: if r.returncode == 0 and ca_bundle:
@ -1046,12 +1150,16 @@ def _try_inject_ca_bundle_manually(
patch_json = json.dumps( patch_json = json.dumps(
[{"op": "replace", "path": "/webhooks/0/clientConfig/caBundle", "value": ca_bundle}] [{"op": "replace", "path": "/webhooks/0/clientConfig/caBundle", "value": ca_bundle}]
) )
r2 = subprocess.run( r2 = _kubectl(
[ [
"kubectl", "patch", "validatingwebhookconfiguration", "cert-manager-webhook", "patch",
"--type=json", f"-p={patch_json}", "validatingwebhookconfiguration",
"cert-manager-webhook",
"--type=json",
f"-p={patch_json}",
], ],
env=env, capture_output=True, text=True, timeout=20, env=env,
timeout=20,
) )
if r2.returncode != 0: if r2.returncode != 0:
_log(log, f"WARN: Failed to patch caBundle: {(r2.stderr or r2.stdout or '').strip()}") _log(log, f"WARN: Failed to patch caBundle: {(r2.stderr or r2.stdout or '').strip()}")
@ -1074,13 +1182,16 @@ def _wait_for_webhook_ca_bundle(
deadline = time.time() + timeout deadline = time.time() + timeout
interval = 5 interval = 5
while time.time() < deadline: while time.time() < deadline:
r = subprocess.run( r = _kubectl(
[ [
"kubectl", "get", "validatingwebhookconfiguration", "get",
"validatingwebhookconfiguration",
"cert-manager-webhook", "cert-manager-webhook",
"-o", "jsonpath={.webhooks[0].clientConfig.caBundle}", "-o",
"jsonpath={.webhooks[0].clientConfig.caBundle}",
], ],
env=env, capture_output=True, text=True, timeout=15, env=env,
timeout=15,
) )
if r.returncode == 0 and r.stdout.strip(): if r.returncode == 0 and r.stdout.strip():
return True return True
@ -1109,12 +1220,18 @@ def _wait_for_barman_tls_secrets(
all_ready = True all_ready = True
for secret in ("barman-cloud-client-tls", "barman-cloud-server-tls"): for secret in ("barman-cloud-client-tls", "barman-cloud-server-tls"):
for field in ("tls.crt", "tls.key"): for field in ("tls.crt", "tls.key"):
r = subprocess.run( r = _kubectl(
[ [
"kubectl", "-n", "cnpg-system", "get", "secret", secret, "-n",
"-o", f"jsonpath={{.data.{field.replace('.', '\\.')}}}", "cnpg-system",
"get",
"secret",
secret,
"-o",
f"jsonpath={{.data.{field.replace('.', '\\.')}}}",
], ],
env=env, capture_output=True, text=True, timeout=15, env=env,
timeout=15,
) )
if r.returncode != 0 or not (r.stdout or "").strip(): if r.returncode != 0 or not (r.stdout or "").strip():
all_ready = False all_ready = False
@ -1151,12 +1268,10 @@ def _bootstrap_barman_tls_secrets(
def _secret_has_tls(name: str) -> bool: def _secret_has_tls(name: str) -> bool:
for field in ("tls.crt", "tls.key"): for field in ("tls.crt", "tls.key"):
r = subprocess.run( r = _kubectl(
[ ["-n", namespace, "get", "secret", name, "-o", f"jsonpath={{.data.{field.replace('.', '\\.')}}}"],
"kubectl", "-n", namespace, "get", "secret", name, env=env,
"-o", f"jsonpath={{.data.{field.replace('.', '\\.')}}}", timeout=15,
],
env=env, capture_output=True, text=True, timeout=15,
) )
if r.returncode != 0 or not (r.stdout or "").strip(): if r.returncode != 0 or not (r.stdout or "").strip():
return False return False
@ -1230,9 +1345,10 @@ def _bootstrap_barman_tls_secrets(
f" tls.crt: {base64.b64encode(cert_pem.encode()).decode()}\n" f" tls.crt: {base64.b64encode(cert_pem.encode()).decode()}\n"
f" tls.key: {base64.b64encode(key_pem.encode()).decode()}\n" f" tls.key: {base64.b64encode(key_pem.encode()).decode()}\n"
) )
res = subprocess.run( res = _kubectl_run(
["kubectl", "apply", "-n", namespace, "-f", "-"], ["apply", "-n", namespace, "-f", "-"],
input=manifest, text=True, capture_output=True, env=env, env=env,
input_text=manifest,
) )
if res.returncode != 0: if res.returncode != 0:
raise RuntimeError(f"Failed to apply Barman TLS secret '{name}': {res.stderr}") raise RuntimeError(f"Failed to apply Barman TLS secret '{name}': {res.stderr}")
@ -1298,21 +1414,28 @@ def _bootstrap_db_user_secrets(
if _kubectl_ok(["-n", namespace, "get", "secret", secret_name], env=env): if _kubectl_ok(["-n", namespace, "get", "secret", secret_name], env=env):
return return
_log(log, f"Creating missing secret '{secret_name}' in '{namespace}'...") _log(log, f"Creating missing secret '{secret_name}' in '{namespace}'...")
manifest_res = subprocess.run( manifest_res = _kubectl(
[ [
"kubectl", "create", "secret", "generic", secret_name, "create",
"-n", namespace, "secret",
"generic",
secret_name,
"-n",
namespace,
f"--from-literal=username={username}", f"--from-literal=username={username}",
f"--from-literal=password={password}", f"--from-literal=password={password}",
"--dry-run=client", "-o", "yaml", "--dry-run=client",
"-o",
"yaml",
], ],
text=True, capture_output=True, env=env, env=env,
) )
if manifest_res.returncode != 0: if manifest_res.returncode != 0:
raise RuntimeError(f"Failed to render secret '{secret_name}':\n{manifest_res.stderr}") raise RuntimeError(f"Failed to render secret '{secret_name}':\n{manifest_res.stderr}")
apply_res = subprocess.run( apply_res = _kubectl_run(
["kubectl", "apply", "-n", namespace, "-f", "-"], ["apply", "-n", namespace, "-f", "-"],
input=manifest_res.stdout, text=True, capture_output=True, env=env, env=env,
input_text=manifest_res.stdout,
) )
if apply_res.returncode != 0: if apply_res.returncode != 0:
raise RuntimeError(f"Failed to apply secret '{secret_name}':\n{apply_res.stderr}") raise RuntimeError(f"Failed to apply secret '{secret_name}':\n{apply_res.stderr}")
@ -1363,21 +1486,28 @@ def _bootstrap_cnpg_admin_key(
serialization.PublicFormat.SubjectPublicKeyInfo, serialization.PublicFormat.SubjectPublicKeyInfo,
).decode() ).decode()
manifest_res = subprocess.run( manifest_res = _kubectl(
[ [
"kubectl", "create", "secret", "generic", secret_name, "create",
"-n", namespace, "secret",
"generic",
secret_name,
"-n",
namespace,
f"--from-literal=admin.key={priv_pem}", f"--from-literal=admin.key={priv_pem}",
f"--from-literal=admin.pub={pub_pem}", f"--from-literal=admin.pub={pub_pem}",
"--dry-run=client", "-o", "yaml", "--dry-run=client",
"-o",
"yaml",
], ],
text=True, capture_output=True, env=env, env=env,
) )
if manifest_res.returncode != 0: if manifest_res.returncode != 0:
raise RuntimeError(f"Failed to render secret '{secret_name}':\n{manifest_res.stderr}") raise RuntimeError(f"Failed to render secret '{secret_name}':\n{manifest_res.stderr}")
apply_res = subprocess.run( apply_res = _kubectl_run(
["kubectl", "apply", "-n", namespace, "-f", "-"], ["apply", "-n", namespace, "-f", "-"],
input=manifest_res.stdout, text=True, capture_output=True, env=env, env=env,
input_text=manifest_res.stdout,
) )
if apply_res.returncode != 0: if apply_res.returncode != 0:
raise RuntimeError(f"Failed to apply secret '{secret_name}':\n{apply_res.stderr}") raise RuntimeError(f"Failed to apply secret '{secret_name}':\n{apply_res.stderr}")
@ -1485,12 +1615,10 @@ def bootstrap_cnpg_tls_secrets(
).decode() ).decode()
def _apply_secret(manifest: str) -> None: def _apply_secret(manifest: str) -> None:
res = subprocess.run( res = _kubectl_run(
["kubectl", "apply", "-n", namespace, "-f", "-"], ["apply", "-n", namespace, "-f", "-"],
input=manifest,
text=True,
capture_output=True,
env=env, env=env,
input_text=manifest,
) )
if res.returncode != 0: if res.returncode != 0:
raise RuntimeError(f"kubectl apply failed:\n{res.stderr}") raise RuntimeError(f"kubectl apply failed:\n{res.stderr}")
@ -1552,6 +1680,7 @@ def initialize(
""" """
_log(log, f"Initializing CNPG cluster '{cluster_name}' in namespace '{namespace}'...") _log(log, f"Initializing CNPG cluster '{cluster_name}' in namespace '{namespace}'...")
_log(log, "[CNPG] Ensuring operator is installed and healthy...")
ensure_operator( ensure_operator(
env=env, env=env,
log=log, log=log,
@ -1559,16 +1688,21 @@ def initialize(
allow_reapply_if_unhealthy=True, allow_reapply_if_unhealthy=True,
aggressive_cleanup=True, aggressive_cleanup=True,
) )
_log(log, "[CNPG] Pinning controller placement...")
pin_controller(env=env, log=log, mode=mode) pin_controller(env=env, log=log, mode=mode)
_log(log, "[CNPG] Installing Barman Cloud plugin...")
install_barman_plugin(env=env, log=log) install_barman_plugin(env=env, log=log)
# Bootstrap TLS secrets (create-if-missing; no rotation of existing certs) # Bootstrap TLS secrets (create-if-missing; no rotation of existing certs)
_log(log, "[CNPG] Bootstrapping TLS secrets...")
bootstrap_cnpg_tls_secrets(namespace=namespace, cluster_name=cluster_name, env=env, log=log) bootstrap_cnpg_tls_secrets(namespace=namespace, cluster_name=cluster_name, env=env, log=log)
# Bootstrap DB user secrets from env if missing (create-if-missing only) # Bootstrap DB user secrets from env if missing (create-if-missing only)
_log(log, "[CNPG] Ensuring DB user secrets are present...")
_bootstrap_db_user_secrets(namespace=namespace, env=env, log=log) _bootstrap_db_user_secrets(namespace=namespace, env=env, log=log)
# Bootstrap admin keypair secret (create-if-missing; no rotation) # Bootstrap admin keypair secret (create-if-missing; no rotation)
_log(log, "[CNPG] Ensuring admin keypair secret is present...")
_bootstrap_cnpg_admin_key(namespace=namespace, env=env, log=log) _bootstrap_cnpg_admin_key(namespace=namespace, env=env, log=log)
# Preflight: required secrets must exist before applying the cluster # Preflight: required secrets must exist before applying the cluster
@ -1592,10 +1726,12 @@ def initialize(
env=env, env=env,
log=log, log=log,
) )
_log(log, "[CNPG] Applying cluster manifest...")
manifest = _resolve_cnpg_manifest(project_root, env) manifest = _resolve_cnpg_manifest(project_root, env)
_apply_manifest(namespace, manifest, env, log) _apply_manifest(namespace, manifest, env, log)
wait_timeout = int((env or {}).get("CNPG_WAIT_TIMEOUT", "900") or "900") wait_timeout = int((env or {}).get("CNPG_WAIT_TIMEOUT", "900") or "900")
_log(log, f"[CNPG] Waiting for cluster pods to become Ready (timeout={wait_timeout}s)...")
_wait_cnpg_pods(namespace, cluster_name, env, timeout=wait_timeout, log=log) _wait_cnpg_pods(namespace, cluster_name, env, timeout=wait_timeout, log=log)
_log(log, f"Initialization complete for CNPG cluster '{cluster_name}'.") _log(log, f"Initialization complete for CNPG cluster '{cluster_name}'.")

View File

@ -19,6 +19,7 @@ class GkeClusterSpec:
machine_type: str = "e2-small" machine_type: str = "e2-small"
node_count: int = 3 node_count: int = 3
node_pool_name: str = "default-pool" node_pool_name: str = "default-pool"
node_labels: str = "workload=db"
boot_disk_type: str = "pd-standard" # pd-standard keeps boot disks off pd-ssd quota boot_disk_type: str = "pd-standard" # pd-standard keeps boot disks off pd-ssd quota
boot_disk_size_gb: int = 50 boot_disk_size_gb: int = 50
@ -29,6 +30,22 @@ def _run(cmd: list[str], log: _LogFn | None = None) -> subprocess.CompletedProce
return subprocess.run(cmd, capture_output=True, text=True) return subprocess.run(cmd, capture_output=True, text=True)
def _nodes_per_location(node_count: int, location: str) -> int:
"""Return GKE --num-nodes value, treating regional locations as per-zone counts.
For regional clusters, gcloud interprets --num-nodes as per-zone, so convert
requested total nodes to a per-zone value (ceil(total/3)) for the common
3-zone regional layout.
"""
requested = max(1, int(node_count))
loc = (location or "").strip().lower()
suffix = loc.rsplit("-", 1)[-1] if "-" in loc else ""
is_zone = len(suffix) == 1 and suffix.isalpha()
if is_zone:
return requested
return max(1, (requested + 2) // 3)
def build_kubectl_env_for_cluster( def build_kubectl_env_for_cluster(
base_env: dict | None, base_env: dict | None,
kubecontext: str, kubecontext: str,
@ -37,6 +54,8 @@ def build_kubectl_env_for_cluster(
) -> dict: ) -> dict:
env = dict(base_env or os.environ) env = dict(base_env or os.environ)
if kubecontext: if kubecontext:
env["KUBECONTEXT"] = kubecontext
env["KUBE_CONTEXT_NAME"] = kubecontext
env["KUBECTL_CONTEXT"] = kubecontext env["KUBECTL_CONTEXT"] = kubecontext
env["CLUSTER_NAME"] = cluster_name env["CLUSTER_NAME"] = cluster_name
env["KNOE_CLUSTER_ROLE"] = cluster_role env["KNOE_CLUSTER_ROLE"] = cluster_role
@ -159,6 +178,11 @@ def ensure_db_cluster(
spec: GkeClusterSpec, spec: GkeClusterSpec,
log: _LogFn | None = None, log: _LogFn | None = None,
) -> None: ) -> None:
if spec.mode.lower() != "standard":
raise RuntimeError(
f"Database cluster '{spec.name}' must run in Standard mode, got '{spec.mode}'."
)
describe_cmd = [ describe_cmd = [
"gcloud", "gcloud",
"container", "container",
@ -173,38 +197,112 @@ def ensure_db_cluster(
"--quiet", "--quiet",
] ]
describe = _run(describe_cmd, log=log) describe = _run(describe_cmd, log=log)
if describe.returncode == 0 and (describe.stdout or "").strip() == spec.name: cluster_exists = describe.returncode == 0 and (describe.stdout or "").strip() == spec.name
if not cluster_exists:
create_cmd = [
"gcloud",
"container",
"clusters",
"create",
spec.name,
"--project",
project_id,
"--region",
spec.location,
"--num-nodes",
str(_nodes_per_location(spec.node_count, spec.location)),
"--machine-type",
spec.machine_type,
"--disk-type",
spec.boot_disk_type,
"--disk-size",
str(spec.boot_disk_size_gb),
"--node-labels",
spec.node_labels,
"--enable-ip-alias",
"--workload-pool",
f"{project_id}.svc.id.goog",
"--quiet",
]
created = _run(create_cmd, log=log)
if created.returncode != 0:
stderr = (created.stderr or "").strip()
raise RuntimeError(f"Failed to create DB cluster '{spec.name}': {stderr}")
return return
if spec.mode.lower() != "standard": pool_describe_cmd = [
raise RuntimeError(
f"Database cluster '{spec.name}' must run in Standard mode, got '{spec.mode}'."
)
create_cmd = [
"gcloud", "gcloud",
"container", "container",
"clusters", "node-pools",
"create", "describe",
spec.node_pool_name,
"--cluster",
spec.name,
"--project",
project_id,
"--region",
spec.location,
"--format=value(name)",
"--quiet",
]
pool_describe = _run(pool_describe_cmd, log=log)
pool_exists = (
pool_describe.returncode == 0
and (pool_describe.stdout or "").strip() == spec.node_pool_name
)
if pool_exists:
update_labels_cmd = [
"gcloud",
"container",
"node-pools",
"update",
spec.node_pool_name,
"--cluster",
spec.name,
"--project",
project_id,
"--region",
spec.location,
"--node-labels",
spec.node_labels,
"--quiet",
]
updated = _run(update_labels_cmd, log=log)
if updated.returncode != 0:
stderr = (updated.stderr or "").strip()
raise RuntimeError(
f"Failed to label DB node pool '{spec.node_pool_name}' for cluster '{spec.name}': {stderr}"
)
return
create_pool_cmd = [
"gcloud",
"container",
"node-pools",
"create",
spec.node_pool_name,
"--cluster",
spec.name, spec.name,
"--project", "--project",
project_id, project_id,
"--region", "--region",
spec.location, spec.location,
"--num-nodes",
str(max(3, int(spec.node_count))),
"--machine-type", "--machine-type",
spec.machine_type, spec.machine_type,
"--disk-type", "--disk-type",
spec.boot_disk_type, spec.boot_disk_type,
"--disk-size", "--disk-size",
str(spec.boot_disk_size_gb), str(spec.boot_disk_size_gb),
"--enable-ip-alias", "--num-nodes",
"--workload-pool", str(_nodes_per_location(spec.node_count, spec.location)),
f"{project_id}.svc.id.goog", "--node-labels",
spec.node_labels,
"--quiet", "--quiet",
] ]
created = _run(create_cmd, log=log) pool_created = _run(create_pool_cmd, log=log)
if created.returncode != 0: if pool_created.returncode != 0:
stderr = (created.stderr or "").strip() stderr = (pool_created.stderr or "").strip()
raise RuntimeError(f"Failed to create DB cluster '{spec.name}': {stderr}") raise RuntimeError(
f"Failed to create DB node pool '{spec.node_pool_name}' in cluster '{spec.name}': {stderr}"
)

View File

@ -51,13 +51,30 @@ def _apply(
for manifest in _manifest_files(mode, project_root, include_kerberos_configmap): for manifest in _manifest_files(mode, project_root, include_kerberos_configmap):
if not manifest.exists(): if not manifest.exists():
continue continue
if manifest.name == "kerberos-configmap.yaml":
_ensure_namespace("knoe-system", env)
_log(log, f"[OPENBAO] Applying {manifest}") _log(log, f"[OPENBAO] Applying {manifest}")
_kubectl( try:
["-n", namespace, "apply", "-f", str(manifest)], _kubectl(
env=env, ["-n", namespace, "apply", "-f", str(manifest)],
timeout=300, env=env,
check=True, timeout=300,
) check=True,
)
except RuntimeError as exc:
msg = str(exc)
if "does not match the namespace" not in msg:
raise
_log(
log,
"[OPENBAO] Namespace mismatch in manifest metadata; retrying apply without forced namespace.",
)
_kubectl(
["apply", "-f", str(manifest)],
env=env,
timeout=300,
check=True,
)
if mode == "k3s" or _exists("statefulset", "openbao", namespace, env=env): if mode == "k3s" or _exists("statefulset", "openbao", namespace, env=env):
_wait_rollout("statefulset", "openbao", namespace, env=env) _wait_rollout("statefulset", "openbao", namespace, env=env)

View File

@ -48,6 +48,7 @@ class Milestone(ABC):
self, self,
cmd: str | list[str], cmd: str | list[str],
cwd: str | None = None, cwd: str | None = None,
env: dict | None = None,
on_stdout: callable | None = None, on_stdout: callable | None = None,
) -> int: ) -> int:
if isinstance(cmd, str): if isinstance(cmd, str):
@ -57,7 +58,10 @@ class Milestone(ABC):
import subprocess import subprocess
try: try:
env = inst_config._augment_env_for_brew(os.environ.copy()) proc_env = os.environ.copy()
if env:
proc_env.update(env)
proc_env = inst_config._augment_env_for_brew(proc_env)
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@ -65,7 +69,7 @@ class Milestone(ABC):
text=True, text=True,
bufsize=1, bufsize=1,
cwd=cwd, cwd=cwd,
env=env, env=proc_env,
) )
for line in iter(proc.stdout.readline, ""): for line in iter(proc.stdout.readline, ""):
if on_stdout: if on_stdout:
@ -172,6 +176,33 @@ class Milestone(ABC):
default_kube = str(Path.home() / ".kube" / "config") default_kube = str(Path.home() / ".kube" / "config")
if Path(default_kube).exists(): if Path(default_kube).exists():
env["KUBECONFIG"] = default_kube env["KUBECONFIG"] = default_kube
elif mode == "k8s":
# GKE / standard k8s — use ~/.kube/config and set explicit cluster contexts.
# APP cluster = common services (garage, openbao, registry, monitoring, kong).
# DB cluster = CNPG/PostgreSQL (knoe-cnpg-0).
default_kube = str(Path.home() / ".kube" / "config")
if not (env.get("KUBECONFIG") or "").strip():
if Path(default_kube).exists():
env["KUBECONFIG"] = default_kube
app_ctx = (
state.inputs.get("init_cluster.app_cluster_kubecontext", "")
or state.inputs.get("env_setup.APP_CLUSTER_KUBECONTEXT", "")
or (state.config_data.get("Global", {}) or {}).get("APP_CLUSTER_KUBECONTEXT", "")
).strip()
db_ctx = (
state.inputs.get("init_cluster.db_cluster_kubecontext", "")
or state.inputs.get("env_setup.DB_CLUSTER_KUBECONTEXT", "")
or (state.config_data.get("Global", {}) or {}).get("DB_CLUSTER_KUBECONTEXT", "")
).strip()
if app_ctx:
env["APP_CLUSTER_KUBECONTEXT"] = app_ctx
# Default KUBECONTEXT = app cluster; callers needing the DB cluster
# must override with DB_CLUSTER_KUBECONTEXT before invoking kubectl.
env["KUBECONTEXT"] = app_ctx
if db_ctx:
env["DB_CLUSTER_KUBECONTEXT"] = db_ctx
db_pw = state.inputs.get("init_password.db_password", "").strip() db_pw = state.inputs.get("init_password.db_password", "").strip()
if db_pw: if db_pw:

View File

@ -1 +1 @@
13 14

View File

@ -1 +1 @@
48 55

View File

@ -66,11 +66,29 @@ def _prepare_installer(controller: KnoeController, cfg_path: Path) -> KnoeConsol
installer.inputs.setdefault("init_cluster.db_boot_disk_type", "pd-standard") installer.inputs.setdefault("init_cluster.db_boot_disk_type", "pd-standard")
installer.inputs.setdefault("init_cluster.db_boot_disk_size_gb", "50") installer.inputs.setdefault("init_cluster.db_boot_disk_size_gb", "50")
app_cluster_name = installer.inputs.get("init_cluster.app_cluster_name", "knoe-dev-0")
db_cluster_name = installer.inputs.get("init_cluster.db_cluster_name", "knoe-cnpg-0")
app_cluster_region = region
db_cluster_region = region
# Production CNPG namespace is fixed by shipped manifests.
installer.inputs["init_password.db_namespace"] = "knoe-db-0"
installer.inputs["env_setup.DATABASE_NAMESPACE"] = "knoe-db-0"
if project_id: if project_id:
installer.inputs["init_cluster.project_id"] = project_id installer.inputs["init_cluster.project_id"] = project_id
installer.inputs["init_cluster.app_cluster_region"] = region if project_id and app_cluster_region:
installer.inputs["init_cluster.db_cluster_region"] = region installer.inputs["init_cluster.app_cluster_kubecontext"] = (
f"gke_{project_id}_{app_cluster_region}_{app_cluster_name}"
)
if project_id and db_cluster_region:
installer.inputs["init_cluster.db_cluster_kubecontext"] = (
f"gke_{project_id}_{db_cluster_region}_{db_cluster_name}"
)
installer.inputs["init_cluster.app_cluster_region"] = app_cluster_region
installer.inputs["init_cluster.db_cluster_region"] = db_cluster_region
# Ensure install phases run # Ensure install phases run
installer.inputs["init_cluster.start_cluster"] = "true" installer.inputs["init_cluster.start_cluster"] = "true"
@ -82,6 +100,34 @@ def _prepare_installer(controller: KnoeController, cfg_path: Path) -> KnoeConsol
glob["CLUSTER_SIZE_PROFILE"] = "small" glob["CLUSTER_SIZE_PROFILE"] = "small"
glob["CNPG_SIZE_PROFILE"] = "small" glob["CNPG_SIZE_PROFILE"] = "small"
glob["CNPG_INSTANCES"] = "3" glob["CNPG_INSTANCES"] = "3"
glob["DATABASE_NAMESPACE"] = "knoe-db-0"
db_creation = installer.prole_cfg_data.setdefault("Database Creation", {})
db_creation["DATABASE_NAMESPACE"] = "knoe-db-0"
gcp_section = installer.prole_cfg_data.setdefault("GCP", {})
if project_id:
gcp_section["project_id"] = project_id
if region:
gcp_section["region"] = region
service_ns = (
installer.inputs.get("init_cluster.service_namespace")
or glob.get("SERVICE_NAMESPACE")
or "knoe-system"
)
artifact_registry = (
installer.inputs.get("init_cluster.artifact_registry")
or glob.get("ARTIFACT_REGISTRY")
or os.environ.get("ARTIFACT_REGISTRY")
or ""
)
if not artifact_registry and project_id and region:
artifact_registry = f"{region}-docker.pkg.dev/{project_id}/{service_ns}"
if artifact_registry:
artifact_registry = artifact_registry.rstrip("/")
os.environ["ARTIFACT_REGISTRY"] = artifact_registry
glob["ARTIFACT_REGISTRY"] = artifact_registry
return installer return installer

View File

@ -1,55 +1,53 @@
Network Discovery Summary: Network Discovery Summary:
Primary Router: 10.0.0.1 (eero_5d:50:f2) Primary Router: 10.0.0.1 (eero_5d:50:f2)
DNS Servers: 100.100.100.100, 10.0.0.5, 10.0.0.4 DNS Servers: 100.100.100.100, 10.0.0.4, 10.0.0.5
Detected Devices: Detected Devices:
- 10.0.0.203 [00:11:32:3b:2f:08] (Synology_3b:2f:08): Ports [22, 80, 443, 2049, 445], Services: ['SSH', 'NFS', 'Web Server', 'SMB/CIFS (Possible Windows/AD)'] - 10.0.0.203 [00:11:32:3b:2f:08] (Synology_3b:2f:08): Ports [22, 80, 443, 2049, 445], Services: ['SSH', 'NFS', 'Web Server', 'SMB/CIFS (Possible Windows/AD)']
- 10.0.0.31 [2e:c1:c1:e9:87:b4] (2e:c1:c1:e9:87:b4): Ports [22, 445, 5900, 11434, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Ollama', 'Active Directory Related'] - 10.0.0.39 [7e:19:9a:fe:9f:ed] (7e:19:9a:fe:9f:ed): Ports [22, 445, 5900, 11434, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Ollama', 'Active Directory Related']
- 10.0.0.2 [dc:a4:ca:ea:1a:2f] (Apple_ea:1a:2f): Ports [], Services: []
- 10.0.0.1 [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [53], Services: ['DNS']
- 10.0.0.7 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH'] - 10.0.0.7 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH']
- 10.0.0.4 [b8:27:eb:b3:6f:2b] (RaspberryPiF_b3:6f:2b): Ports [22, 53, 443, 2049], Services: ['DNS', 'SSH', 'NFS', 'Web Server']
- 10.0.0.6 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 80, 443], Services: ['SSH', 'Web Server']
- 10.0.0.26 [c8:db:26:08:52:f8] (Logitech_08:52:f8): Ports [], Services: [] - 10.0.0.26 [c8:db:26:08:52:f8] (Logitech_08:52:f8): Ports [], Services: []
- 10.0.0.32 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: [] - 10.0.0.32 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: []
- 10.0.0.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [80, 443], Services: ['Web Server']
- 10.0.0.31\ [2e:c1:c1:e9:87:b4] (2e:c1:c1:e9:87:b4): Ports [], Services: []
- 10.0.0.6 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 80, 443], Services: ['SSH', 'Web Server']
- 10.0.0.3 [2c:cf:67:8c:2b:47] (RaspberryPi_8c:2b:47): Ports [22, 53, 80, 443, 2049, 445, 5900, 88, 389, 636], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related'] - 10.0.0.3 [2c:cf:67:8c:2b:47] (RaspberryPi_8c:2b:47): Ports [22, 53, 80, 443, 2049, 445, 5900, 88, 389, 636], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related']
- 10.0.0.4 [b8:27:eb:b3:6f:2b] (RaspberryPiF_b3:6f:2b): Ports [22, 53, 443, 2049], Services: ['DNS', 'SSH', 'NFS', 'Web Server'] - 10.0.0.1 [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [53], Services: ['DNS']
- 10.0.0.22 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 80, 443], Services: ['SSH', 'Web Server'] - 10.0.0.2 [dc:a4:ca:ea:1a:2f] (Apple_ea:1a:2f): Ports [], Services: []
- 10.0.0.5 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 2049, 5900], Services: ['SSH', 'NFS', 'VNC'] - 10.0.0.5 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 2049, 5900], Services: ['SSH', 'NFS', 'VNC']
- 10.0.0.205 [a8:20:66:28:12:e7] (Apple_28:12:e7): Ports [22, 445, 5900, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related'] - 10.0.0.205 [a8:20:66:28:12:e7] (Apple_28:12:e7): Ports [22, 445, 5900, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related']
- 10.0.0.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [80, 443], Services: ['Web Server']
- 10.0.0.37 [54:07:7d:22:c0:b2] (Netgear_22:c0:b2): Ports [80], Services: ['Web Server'] - 10.0.0.37 [54:07:7d:22:c0:b2] (Netgear_22:c0:b2): Ports [80], Services: ['Web Server']
- 10.0.0.38 [24:fc:e5:51:cf:74] (SamsungElect_51:cf:74): Ports [], Services: [] - 10.0.0.44 [24:fc:e5:51:cf:74] (SamsungElect_51:cf:74): Ports [], Services: []
- 10.0.0.45 [f8:b4:6a:30:a4:1f] (HewlettPacka_30:a4:1f): Ports [80, 443], Services: ['Web Server'] - 10.0.0.45 [f8:b4:6a:30:a4:1f] (HewlettPacka_30:a4:1f): Ports [80, 443], Services: ['Web Server']
- 10.0.0.48 [0c:fe:45:53:f4:3c] (SonyInteract_53:f4:3c): Ports [], Services: [] - 10.0.0.48 [0c:fe:45:53:f4:3c] (SonyInteract_53:f4:3c): Ports [], Services: []
- 10.0.0.52 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH']
- 10.0.0.36 [7e:46:74:2a:b0:b2] (7e:46:74:2a:b0:b2): Ports [], Services: [] - 10.0.0.36 [7e:46:74:2a:b0:b2] (7e:46:74:2a:b0:b2): Ports [], Services: []
- 10.0.0.52 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH']
- 10.0.0.46 [d4:f7:d5:40:ab:17] (SonyInteract_40:ab:17): Ports [], Services: [] - 10.0.0.46 [d4:f7:d5:40:ab:17] (SonyInteract_40:ab:17): Ports [], Services: []
- 10.0.0.107 [b8:06:0d:b7:7c:56] (TuyaSmart_b7:7c:56): Ports [], Services: []
- 10.0.0.73 [4c:a9:19:b3:12:f8] (TuyaSmart_b3:12:f8): Ports [], Services: []
- 10.0.0.111 [ca:0b:6c:5d:0a:e5] (ca:0b:6c:5d:0a:e5): Ports [], Services: [] - 10.0.0.111 [ca:0b:6c:5d:0a:e5] (ca:0b:6c:5d:0a:e5): Ports [], Services: []
- 10.0.0.128 [20:c9:d0:94:30:dd] (Apple_94:30:dd): Ports [22, 445, 5900, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related'] - 10.0.0.128 [20:c9:d0:94:30:dd] (Apple_94:30:dd): Ports [22, 445, 5900, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related']
- 10.0.0.95 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 2049, 5900], Services: ['SSH', 'NFS', 'VNC'] - 10.0.0.73 [4c:a9:19:b3:12:f8] (TuyaSmart_b3:12:f8): Ports [], Services: []
- 10.0.0.99 [20:28:bc:f1:dc:ba] (Visionscape_f1:dc:ba): Ports [], Services: [] - 10.0.0.94 [f0:20:ff:bf:9a:19] (Intel_bf:9a:19): Ports [], Services: []
Note: Potential host for NVIDIA cards (PC Hardware vendor)
- 10.0.0.95 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22], Services: ['SSH']
- 10.0.0.106 [48:a6:b8:a7:50:60] (Sonos_a7:50:60): Ports [], Services: [] - 10.0.0.106 [48:a6:b8:a7:50:60] (Sonos_a7:50:60): Ports [], Services: []
- 10.0.0.204 [50:eb:f6:56:f3:26] (ASUSTekCOMPU_56:f3:26): Ports [22, 3389, 445, 5900, 11434], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'RDP (Windows)', 'Ollama']
- 10.0.0.123 [3c:ef:8c:96:e3:3c] (ZhejiangDahu_96:e3:3c): Ports [80], Services: ['Web Server'] - 10.0.0.123 [3c:ef:8c:96:e3:3c] (ZhejiangDahu_96:e3:3c): Ports [80], Services: ['Web Server']
- 10.0.0.117 [40:f5:20:1e:5e:91] (Espressif_1e:5e:91): Ports [80], Services: ['Web Server']
- 10.0.0.124 [9c:8e:cd:02:73:c4] (AmcrestTechn_02:73:c4): Ports [80], Services: ['Web Server'] - 10.0.0.124 [9c:8e:cd:02:73:c4] (AmcrestTechn_02:73:c4): Ports [80], Services: ['Web Server']
- 10.0.0.113 [60:81:10:92:02:4e] (Apple_92:02:4e): Ports [], Services: [] - 10.0.0.113 [60:81:10:92:02:4e] (Apple_92:02:4e): Ports [], Services: []
- 10.0.0.107 [b8:06:0d:b7:7c:56] (TuyaSmart_b7:7c:56): Ports [], Services: []
- 10.0.0.125 [a0:60:32:04:73:ba] (AmcrestTechn_04:73:ba): Ports [80], Services: ['Web Server'] - 10.0.0.125 [a0:60:32:04:73:ba] (AmcrestTechn_04:73:ba): Ports [80], Services: ['Web Server']
- 10.0.0.127 [58:55:ca:4a:60:6e] (Apple_4a:60:6e): Ports [22], Services: ['SSH'] - 10.0.0.127 [58:55:ca:4a:60:6e] (Apple_4a:60:6e): Ports [22], Services: ['SSH']
- 10.0.0.130 [f8:bb:bf:a3:84:eb] (eero_a3:84:eb): Ports [53], Services: ['DNS'] - 10.0.0.130 [f8:bb:bf:a3:84:eb] (eero_a3:84:eb): Ports [], Services: []
- 10.0.0.145 [7c:a6:b0:04:db:1c] (7c:a6:b0:04:db:1c): Ports [], Services: []
- 10.0.0.143 [60:5f:8d:7a:aa:32] (eero_7a:aa:32): Ports [53], Services: ['DNS'] - 10.0.0.143 [60:5f:8d:7a:aa:32] (eero_7a:aa:32): Ports [53], Services: ['DNS']
- 10.0.0.155 [62:17:97:b3:06:a1] (62:17:97:b3:06:a1): Ports [], Services: [] - 10.0.0.155 [62:17:97:b3:06:a1] (62:17:97:b3:06:a1): Ports [], Services: []
- 10.0.0.206 [00:a0:de:a2:0b:ef] (Yamaha_a2:0b:ef): Ports [80], Services: ['Web Server']
- 10.0.0.170 [b0:ee:7b:ca:73:99] (Roku_ca:73:99): Ports [], Services: [] - 10.0.0.170 [b0:ee:7b:ca:73:99] (Roku_ca:73:99): Ports [], Services: []
- 10.0.0.204 [50:eb:f6:56:f3:26] (ASUSTekCOMPU_56:f3:26): Ports [22, 3389, 445, 5900, 11434], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'RDP (Windows)', 'Ollama']
- 10.0.0.175 [28:80:88:e4:10:1a] (Netgear_e4:10:1a): Ports [80], Services: ['Web Server'] - 10.0.0.175 [28:80:88:e4:10:1a] (Netgear_e4:10:1a): Ports [80], Services: ['Web Server']
- 10.0.0.189 [00:17:88:a3:2f:cc] (PhilipsLight_a3:2f:cc): Ports [80, 443], Services: ['Web Server'] - 10.0.0.189 [00:17:88:a3:2f:cc] (PhilipsLight_a3:2f:cc): Ports [80, 443], Services: ['Web Server']
- 10.0.0.188 [00:16:6c:c5:1f:54] (SamsungElect_c5:1f:54): Ports [80, 443], Services: ['Web Server'] - 10.0.0.188 [00:16:6c:c5:1f:54] (SamsungElect_c5:1f:54): Ports [80, 443], Services: ['Web Server']
- 10.0.0.196 [60:5f:8d:88:08:f2] (eero_88:08:f2): Ports [53], Services: ['DNS'] - 10.0.0.196 [60:5f:8d:88:08:f2] (eero_88:08:f2): Ports [53], Services: ['DNS']
- 10.0.0.199 [7c:a6:b0:01:a7:33] (7c:a6:b0:01:a7:33): Ports [], Services: [] - 10.0.0.206 [00:a0:de:a2:0b:ef] (Yamaha_a2:0b:ef): Ports [80], Services: ['Web Server']
- 10.0.0.208 [a0:ad:9f:30:84:5f] (ASUSTekCOMPU_30:84:5f): Ports [], Services: [] - 10.0.0.22 [Unknown] (Unknown): Ports [22], Services: ['SSH']
Ollama Instances found at: 10.0.0.31, 10.0.0.204 Ollama Instances found at: 10.0.0.39, 10.0.0.204
Ansible Inventory Summary: Ansible Inventory Summary:
Inventory directory: /Users/chrisfu/dev/prole/infrastructure Inventory directory: /Users/chrisfu/dev/prole/infrastructure

View File

@ -7,7 +7,7 @@
# #
# Solution: Delete both, recreate with pd-standard boot disks: # Solution: Delete both, recreate with pd-standard boot disks:
# knoe-dev-0 → Standard (app workloads: GitLab, platform), e2-small × 3, VPA, pd-standard boot # knoe-dev-0 → Standard (app workloads: GitLab, platform), e2-small × 3, VPA, pd-standard boot
# knoe-cnpg-0 → Standard (CNPG only, 3× e2-small), pd-standard boot # knoe-cnpg-0 → Standard (CNPG only, 3× e2-standard-2), pd-standard boot
# #
# SSD quota budget after reset: # SSD quota budget after reset:
# Boot disks: pd-standard — does NOT count against SSD_TOTAL_GB # Boot disks: pd-standard — does NOT count against SSD_TOTAL_GB
@ -23,7 +23,7 @@
# GCP_REGION (default: us-west3) # GCP_REGION (default: us-west3)
# APP_CLUSTER (default: knoe-dev-0) # APP_CLUSTER (default: knoe-dev-0)
# DB_CLUSTER (default: knoe-cnpg-0) # DB_CLUSTER (default: knoe-cnpg-0)
# DB_MACHINE_TYPE (default: e2-small) # DB_MACHINE_TYPE (default: e2-standard-2)
# DB_DISK_TYPE (default: pd-standard) # DB_DISK_TYPE (default: pd-standard)
# DB_DISK_SIZE_GB (default: 50) # DB_DISK_SIZE_GB (default: 50)
# DB_NODES_PER_ZONE (default: 1 → 3 nodes across 3 zones) # DB_NODES_PER_ZONE (default: 1 → 3 nodes across 3 zones)
@ -44,7 +44,7 @@ GCP_PROJECT="${GCP_PROJECT:-plenary-truck-485623-p7}"
GCP_REGION="${GCP_REGION:-us-west3}" GCP_REGION="${GCP_REGION:-us-west3}"
APP_CLUSTER="${APP_CLUSTER:-knoe-dev-0}" APP_CLUSTER="${APP_CLUSTER:-knoe-dev-0}"
DB_CLUSTER="${DB_CLUSTER:-knoe-cnpg-0}" DB_CLUSTER="${DB_CLUSTER:-knoe-cnpg-0}"
DB_MACHINE_TYPE="${DB_MACHINE_TYPE:-e2-small}" DB_MACHINE_TYPE="${DB_MACHINE_TYPE:-e2-standard-2}"
DB_DISK_TYPE="${DB_DISK_TYPE:-pd-standard}" DB_DISK_TYPE="${DB_DISK_TYPE:-pd-standard}"
DB_DISK_SIZE_GB="${DB_DISK_SIZE_GB:-50}" DB_DISK_SIZE_GB="${DB_DISK_SIZE_GB:-50}"
DB_NODES_PER_ZONE="${DB_NODES_PER_ZONE:-1}" # regional cluster = 3 zones = 3 nodes total DB_NODES_PER_ZONE="${DB_NODES_PER_ZONE:-1}" # regional cluster = 3 zones = 3 nodes total
@ -84,6 +84,73 @@ cluster_exists() {
--quiet 2>/dev/null | grep -q "${name}" --quiet 2>/dev/null | grep -q "${name}"
} }
wait_for_cluster_absent() {
local name="$1"
local timeout_s="${2:-900}"
local poll_s="${3:-15}"
local elapsed=0
while cluster_exists "${name}"; do
if [[ ${elapsed} -ge ${timeout_s} ]]; then
return 1
fi
log " ${name} still exists; waiting ${poll_s}s (${elapsed}s/${timeout_s}s) ..."
sleep "${poll_s}"
elapsed=$(( elapsed + poll_s ))
done
return 0
}
delete_cluster_until_absent() {
local name="$1"
local max_attempts="${2:-6}"
local retry_sleep_s="${3:-20}"
local attempt=1
local output=""
local status=0
if ! cluster_exists "${name}"; then
log " ${name} not found — skipping."
return 0
fi
while [[ ${attempt} -le ${max_attempts} ]]; do
log " Deleting ${name} (attempt ${attempt}/${max_attempts}) ..."
set +e
output=$(gcloud container clusters delete "${name}" \
--project="${GCP_PROJECT}" \
--region="${GCP_REGION}" \
--quiet 2>&1)
status=$?
set -e
if [[ ${status} -ne 0 ]]; then
if grep -qi "incompatible operation" <<<"${output}"; then
log " ${name} has an incompatible operation in progress; retrying after ${retry_sleep_s}s."
elif grep -qi "not found" <<<"${output}"; then
log " ${name} already absent."
return 0
else
printf "%s\n" "${output}" >&2
die "cluster delete failed for ${name}"
fi
fi
if wait_for_cluster_absent "${name}" 900 15; then
log " ${name} deletion confirmed."
return 0
fi
log " ${name} still present after delete attempt ${attempt}; retrying."
sleep "${retry_sleep_s}"
attempt=$(( attempt + 1 ))
done
die "timed out deleting ${name} after ${max_attempts} attempts"
}
_ssd_quota_yaml() { _ssd_quota_yaml() {
# Emit the 3-line YAML block for SSD_TOTAL_GB quota entry, e.g.: # Emit the 3-line YAML block for SSD_TOTAL_GB quota entry, e.g.:
# - limit: 300.0 # - limit: 300.0
@ -125,7 +192,7 @@ if [[ "${CONFIRM}" != "true" ]]; then
exit 1 exit 1
fi fi
log "==> Cluster reset: ${APP_CLUSTER} (Autopilot) + ${DB_CLUSTER} (Standard)" log "==> Cluster reset: ${APP_CLUSTER} (Standard) + ${DB_CLUSTER} (Standard)"
log " Project : ${GCP_PROJECT}" log " Project : ${GCP_PROJECT}"
log " Region : ${GCP_REGION}" log " Region : ${GCP_REGION}"
log " DRY_RUN : ${DRY_RUN}" log " DRY_RUN : ${DRY_RUN}"
@ -157,23 +224,17 @@ for cluster in "${DB_CLUSTER}" "${APP_CLUSTER}"; do
if [[ "${DRY_RUN}" == "true" ]]; then if [[ "${DRY_RUN}" == "true" ]]; then
echo "[DRY-RUN] gcloud container clusters delete ${cluster} --project=${GCP_PROJECT} --region=${GCP_REGION} --quiet" echo "[DRY-RUN] gcloud container clusters delete ${cluster} --project=${GCP_PROJECT} --region=${GCP_REGION} --quiet"
else else
if cluster_exists "${cluster}"; then delete_cluster_until_absent "${cluster}"
log " Deleting ${cluster} (this takes ~5-10 min) ..."
gcloud container clusters delete "${cluster}" \
--project="${GCP_PROJECT}" \
--region="${GCP_REGION}" \
--quiet &
log " ${cluster} deletion running in background (PID $!)."
else
log " ${cluster} not found — skipping."
fi
fi fi
done done
# Wait for all background deletions to finish # Confirm all target clusters are absent before proceeding
if [[ "${DRY_RUN}" != "true" ]]; then if [[ "${DRY_RUN}" != "true" ]]; then
log " Waiting for cluster deletions to complete ..." log " Waiting for cluster deletions to complete ..."
wait for cluster in "${DB_CLUSTER}" "${APP_CLUSTER}"; do
wait_for_cluster_absent "${cluster}" 900 15 \
|| die "cluster ${cluster} still exists after deletion phase"
done
log " All deletions complete." log " All deletions complete."
fi fi
@ -265,6 +326,7 @@ run_cmd gcloud container clusters create "${DB_CLUSTER}" \
--region="${GCP_REGION}" \ --region="${GCP_REGION}" \
--cluster-version=latest \ --cluster-version=latest \
--machine-type="${DB_MACHINE_TYPE}" \ --machine-type="${DB_MACHINE_TYPE}" \
--node-labels="workload=db" \
--disk-type="${DB_DISK_TYPE}" \ --disk-type="${DB_DISK_TYPE}" \
--disk-size="${DB_DISK_SIZE_GB}" \ --disk-size="${DB_DISK_SIZE_GB}" \
--num-nodes="${DB_NODES_PER_ZONE}" \ --num-nodes="${DB_NODES_PER_ZONE}" \
@ -333,10 +395,48 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Phase 7: Show final SSD quota # Phase 7: Register clusters in knoe-0 fleet + enable service mesh
# ---------------------------------------------------------------------------
# Cloud Service Mesh (CSM) is enabled per-cluster via fleet membership.
# This automates what was previously done via the GCP web console.
log "[phase 7] Registering clusters in fleet and enabling service mesh ..."
_register_and_mesh() {
local cluster_name="$1"
log " Registering ${cluster_name} in fleet ..."
if ! run_cmd gcloud container fleet memberships register "${cluster_name}" \
--gke-cluster="${GCP_REGION}/${cluster_name}" \
--enable-workload-identity \
--project="${GCP_PROJECT}" \
--quiet 2>&1; then
log " WARNING: Fleet registration for ${cluster_name} failed (may already be registered — continuing)."
fi
log " Enabling automatic service mesh management for ${cluster_name} ..."
if ! run_cmd gcloud container fleet mesh update \
--management=automatic \
--memberships="${cluster_name}" \
--project="${GCP_PROJECT}" \
--quiet 2>&1; then
log " WARNING: Service mesh update for ${cluster_name} failed — enable manually via GCP console."
fi
}
if [[ "${DRY_RUN}" != "true" ]]; then
_register_and_mesh "${DB_CLUSTER}"
_register_and_mesh "${APP_CLUSTER}"
log " Fleet + mesh registration submitted. Mesh provisioning is async (~10 min)."
log " Check status: gcloud container fleet mesh describe --project=${GCP_PROJECT}"
else
log " [DRY-RUN] Would register ${DB_CLUSTER} and ${APP_CLUSTER} in fleet + enable service mesh."
fi
# ---------------------------------------------------------------------------
# Phase 8: Show final SSD quota
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "[phase 7] Final SSD quota ..." log "[phase 8] Final SSD quota ..."
if [[ "${DRY_RUN}" != "true" ]]; then if [[ "${DRY_RUN}" != "true" ]]; then
usage=$(ssd_usage_gb) usage=$(ssd_usage_gb)
limit=$(ssd_limit_gb) limit=$(ssd_limit_gb)
@ -351,7 +451,7 @@ echo ""
log "==> Reset complete." log "==> Reset complete."
log "" log ""
log "Contexts:" log "Contexts:"
log " App (Autopilot): ${app_ctx}" log " App (Standard) : ${app_ctx}"
log " DB (Standard) : ${db_ctx}" log " DB (Standard) : ${db_ctx}"
log "" log ""
log "Next steps:" log "Next steps:"
@ -365,3 +465,6 @@ log ""
log " 3. Check your quota increase request:" log " 3. Check your quota increase request:"
log " gcloud compute regions describe ${GCP_REGION} --project=${GCP_PROJECT} \\" log " gcloud compute regions describe ${GCP_REGION} --project=${GCP_PROJECT} \\"
log " --format='table(quotas.metric,quotas.usage,quotas.limit)' | grep SSD" log " --format='table(quotas.metric,quotas.usage,quotas.limit)' | grep SSD"
log ""
log " 4. Check service mesh provisioning status (~10 min after cluster creation):"
log " gcloud container fleet mesh describe --project=${GCP_PROJECT}"

View File

@ -0,0 +1,167 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME_REPO=$(cd "$SCRIPT_DIR/../.." && pwd)
SCRIPT_UNDER_TEST="$PROLE_HOME_REPO/etc/status_common_services.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
export TMP_DIR
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat <<'K_EOF' >"$BIN_DIR/kubectl"
#!/usr/bin/env bash
set -euo pipefail
_log_file="${TMP_DIR}/mock_calls.log"
args="$*"
echo "$args" >>"${_log_file}"
if [[ "$args" == *"opentofu"* ]]; then
echo "$args" >>"${TMP_DIR}/opentofu_calls.log"
echo "Error from server (NotFound): opentofu removed" >&2
exit 1
fi
if [[ "$args" == "config current-context" ]]; then
echo "gke_plenary-truck-485623-p7_us-west3_knoe-dev-0"
exit 0
fi
if [[ "$args" == "config view --minify -o jsonpath={.clusters[0].cluster.server}" ]]; then
echo "https://34.106.133.94"
exit 0
fi
if [[ "$args" == "-n test-ns get svc garage openbao" ]]; then
cat <<'EOF'
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
garage ClusterIP 34.118.228.37 <none> 3900/TCP,3901/TCP,3903/TCP 30m
openbao ClusterIP 34.118.231.12 <none> 8200/TCP 31m
EOF
exit 0
fi
if [[ "$args" == "-n test-ns get svc knoe-svc-kong" ]]; then
echo "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE"
echo "knoe-svc-kong ClusterIP 34.118.226.103 <none> 8000/TCP 26m"
exit 0
fi
if [[ "$args" == "-n cert-manager get svc cert-manager cert-manager-webhook" ]]; then
echo "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE"
echo "cert-manager ClusterIP 34.118.236.229 <none> 9402/TCP 30m"
echo "cert-manager-webhook ClusterIP 34.118.232.148 <none> 443/TCP 30m"
exit 0
fi
if [[ "$args" == "-n test-ns get statefulset openbao" ]]; then
echo "NAME READY AGE"
echo "openbao 1/1 31m"
exit 0
fi
if [[ "$args" == "-n test-ns get statefulset garage" ]]; then
echo "NAME READY AGE"
echo "garage 1/1 30m"
exit 0
fi
if [[ "$args" == "-n test-ns get deploy knoe-svc-kong" ]]; then
echo "NAME READY UP-TO-DATE AVAILABLE AGE"
echo "knoe-svc-kong 1/1 1 1 27m"
exit 0
fi
if [[ "$args" == "-n cert-manager get deploy cert-manager cert-manager-cainjector cert-manager-webhook" ]]; then
echo "NAME READY UP-TO-DATE AVAILABLE AGE"
echo "cert-manager 1/1 1 1 31m"
echo "cert-manager-cainjector 1/1 1 1 31m"
echo "cert-manager-webhook 1/1 1 1 30m"
exit 0
fi
# check_resource queries
if [[ "$args" == "-n test-ns get svc garage" || "$args" == "-n test-ns get svc openbao" || "$args" == "-n test-ns get svc knoe-svc-kong" ]]; then
exit 0
fi
if [[ "$args" == "-n cert-manager get svc cert-manager" || "$args" == "-n cert-manager get svc cert-manager-webhook" ]]; then
exit 0
fi
if [[ "$args" == "-n test-ns get statefulset openbao" || "$args" == "-n test-ns get statefulset garage" ]]; then
exit 0
fi
if [[ "$args" == "-n test-ns get deploy knoe-svc-kong" ]]; then
exit 0
fi
if [[ "$args" == "-n cert-manager get deploy cert-manager" || "$args" == "-n cert-manager get deploy cert-manager-cainjector" || "$args" == "-n cert-manager get deploy cert-manager-webhook" ]]; then
exit 0
fi
if [[ "$args" == "-n test-ns get pods --no-headers" ]]; then
cat <<'EOF'
garage-0 1/1 Running 0 30m
openbao-0 1/1 Running 0 32m
knoe-svc-kong-f685786f-rgtwl 1/1 Running 0 27m
EOF
exit 0
fi
if [[ "$args" == "-n cert-manager get pods --no-headers" ]]; then
cat <<'EOF'
cert-manager-7b67f8d7d8-gsf2t 1/1 Running 0 31m
cert-manager-cainjector-76f94d9655-blqd9 1/1 Running 0 31m
cert-manager-webhook-7b86d8b889-l88kr 1/1 Running 0 31m
EOF
exit 0
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
CFG_DIR="$TMP_DIR/conf"
mkdir -p "$CFG_DIR"
cat <<C_EOF >"$CFG_DIR/prole.cfg"
[User]
NAMESPACE = test-ns
SERVICE_NAMESPACE = test-ns
KONG_NAMESPACE = test-ns
CERT_MANAGER_NAMESPACE = cert-manager
[Global]
DEPLOYMENT_MODE = k8s
C_EOF
export PATH="$BIN_DIR:$PATH"
set +e
KONG_NAME=knoe-svc-kong bash "$SCRIPT_UNDER_TEST" -c "$CFG_DIR/prole.cfg" --mode k8s -n test-ns >"$TMP_DIR/stdout" 2>"$TMP_DIR/stderr"
RC=$?
set -e
if [[ $RC -ne 0 ]]; then
echo "FAILURE: expected status_common_services.sh to succeed in k8s mode without OpenTofu" >&2
sed -n '1,240p' "$TMP_DIR/stdout" >&2 || true
sed -n '1,240p' "$TMP_DIR/stderr" >&2 || true
exit 1
fi
if [[ -s "$TMP_DIR/opentofu_calls.log" ]]; then
echo "FAILURE: expected no OpenTofu kubectl calls in k8s mode" >&2
sed -n '1,240p' "$TMP_DIR/opentofu_calls.log" >&2 || true
sed -n '1,240p' "$TMP_DIR/mock_calls.log" >&2 || true
exit 1
fi
if ! grep -q "skipping OpenTofu service check" "$TMP_DIR/stdout"; then
echo "FAILURE: expected informational OpenTofu skip message" >&2
sed -n '1,240p' "$TMP_DIR/stdout" >&2 || true
exit 1
fi
echo "SUCCESS"

View File

@ -523,7 +523,9 @@ class TestCfgPathVariables:
def test_load_inputs_from_cfg_keeps_legacy_system_environment_shell_vars(self, tmp_path): def test_load_inputs_from_cfg_keeps_legacy_system_environment_shell_vars(self, tmp_path):
import configparser import configparser
cfg_path = tmp_path / "prole.cfg" cfg_dir = tmp_path / "dev"
cfg_dir.mkdir(parents=True, exist_ok=True)
cfg_path = cfg_dir / "prole.cfg"
cfg = configparser.ConfigParser(interpolation=None) cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str cfg.optionxform = str
cfg.add_section("System Environment") cfg.add_section("System Environment")
@ -612,6 +614,20 @@ class TestScriptEnvForNamespace:
assert env.get("CNPG_CLUSTER_NAME") == "knoe-db" assert env.get("CNPG_CLUSTER_NAME") == "knoe-db"
assert env.get("CNPG_INSTANCES") == "3" assert env.get("CNPG_INSTANCES") == "3"
def test_resolves_db_kubecontext_from_selected_gke_context(self):
inst = _TestableInstaller(inputs={"init_cluster.cluster_env": "prole-prod-cluster"})
inst.prole_cfg_data.setdefault("Global", {})["KUBECONTEXT"] = (
"gke_plenary-truck-485623-p7_us-west3_knoe-dev-0"
)
inst.prole_cfg_data.setdefault("Global", {})["DB_CLUSTER_NAME"] = "knoe-cnpg-0"
env = inst._script_env_for_namespace("knoe-db-0", cluster_role="db")
assert (
env.get("KUBECONTEXT")
== "gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ensure_db_k8s_secrets # ensure_db_k8s_secrets
@ -810,6 +826,56 @@ class TestStepInitScripts:
assert inst.prole_cfg_data["Initialization Scripts"]["STATUS"] == "Completed" assert inst.prole_cfg_data["Initialization Scripts"]["STATUS"] == "Completed"
assert inst._scripts_success is True assert inst._scripts_success is True
def test_verifies_critical_secrets_with_db_env(self, monkeypatch):
import knoe.core.actions as actions_mod
inst = _TestableSilentInstaller(
inputs={
"init_cluster.cluster_env": "service",
"init_password.db_namespace": "default",
"init_password.db_password": "pw",
"kerberos_config.enabled": "false",
"init_scripts.run_scripts": "true",
}
)
inst.controller.run_script.return_value = 0
monkeypatch.setattr(inst, "_deployment_mode", lambda: "k3s")
db_env = {
"NAMESPACE": "default",
"DATABASE_NAMESPACE": "default",
"CLUSTER_NAME": "knoe-db",
}
app_env = {"NAMESPACE": "knoe-system"}
def _fake_script_env(_ns, cluster_role="db"):
return db_env if cluster_role == "db" else app_env
monkeypatch.setattr(inst, "_script_env_for_namespace", _fake_script_env)
monkeypatch.setattr(inst, "ensure_db_k8s_secrets", lambda *a, **k: None)
monkeypatch.setattr(inst, "_ensure_cnpg_storage_provisioned", lambda *a, **k: None)
monkeypatch.setattr(inst, "_optional_workloads_policy", lambda _env: (False, 0, "skipped"))
monkeypatch.setattr(actions_mod, "cnpg_initialize", lambda **_kw: None)
monkeypatch.setattr(actions_mod.registry_ops, "update", lambda **_kw: None)
monkeypatch.setattr(actions_mod.openbao_ops, "update", lambda **_kw: None)
monkeypatch.setattr(actions_mod.garage_store_ops, "update", lambda **_kw: None)
monkeypatch.setattr(actions_mod.opentofu_ops, "update", lambda **_kw: None)
secret_calls = []
def _fake_run_cmd(cmd, cwd=None, env=None, **_kwargs):
if isinstance(cmd, list) and cmd[:3] == ["kubectl", "get", "secret"]:
secret_calls.append((cmd, env))
return 0
monkeypatch.setattr(inst, "_run_cmd", _fake_run_cmd, raising=False)
inst._step_init_scripts()
assert len(secret_calls) == 3
assert all(call_env is db_env for _, call_env in secret_calls)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _step_cnpg_deploy # _step_cnpg_deploy
@ -817,7 +883,7 @@ class TestStepInitScripts:
class TestStepCnpgDeploy: class TestStepCnpgDeploy:
def test_provisions_storage_before_cnpg_deploy(self, monkeypatch): def test_deploy_invokes_cnpg_with_db_env(self, monkeypatch):
import knoe.core.actions as actions_mod import knoe.core.actions as actions_mod
inst = _TestableSilentInstaller( inst = _TestableSilentInstaller(
@ -830,31 +896,24 @@ class TestStepCnpgDeploy:
inst.prole_cfg_data.setdefault("Deployment", {}) inst.prole_cfg_data.setdefault("Deployment", {})
env = {"CNPG_CLUSTER_NAME": "cluster-a"} env = {"CNPG_CLUSTER_NAME": "cluster-a"}
monkeypatch.setattr(inst, "_script_env_for_namespace", lambda _ns: env) monkeypatch.setattr(
inst,
"_script_env_for_namespace",
lambda _ns, cluster_role="db": env,
)
calls = {"provision": 0, "deploy": 0} calls = {"deploy": 0}
def _fake_provision(ns, env_arg):
calls["provision"] += 1
assert ns == "knoe-db"
assert env_arg is env
env_arg["CNPG_DATA_SELECTOR_JSON"] = '{"synology.storage/role":"data"}'
env_arg["CNPG_WAL_SELECTOR_JSON"] = '{"synology.storage/role":"wal"}'
def _fake_cnpg_deploy(namespace, cluster_name, env=None, **_kwargs): def _fake_cnpg_deploy(namespace, cluster_name, env=None, **_kwargs):
calls["deploy"] += 1 calls["deploy"] += 1
assert namespace == "knoe-db" assert namespace == "knoe-db"
assert cluster_name == "cluster-a" assert cluster_name == "cluster-a"
assert env is not None assert env is env
assert env["CNPG_DATA_SELECTOR_JSON"]
assert env["CNPG_WAL_SELECTOR_JSON"]
monkeypatch.setattr(inst, "_ensure_cnpg_storage_provisioned", _fake_provision)
monkeypatch.setattr(actions_mod, "cnpg_deploy", _fake_cnpg_deploy) monkeypatch.setattr(actions_mod, "cnpg_deploy", _fake_cnpg_deploy)
inst._step_cnpg_deploy() inst._step_cnpg_deploy()
assert calls["provision"] == 1
assert calls["deploy"] == 1 assert calls["deploy"] == 1
assert inst._cnpg_success is True assert inst._cnpg_success is True
assert inst.prole_cfg_data["Deployment"]["STATUS"] == "Deployed" assert inst.prole_cfg_data["Deployment"]["STATUS"] == "Deployed"
@ -872,7 +931,7 @@ class TestStepCnpgDeploy:
monkeypatch.setattr( monkeypatch.setattr(
inst, inst,
"_script_env_for_namespace", "_script_env_for_namespace",
lambda _ns: {"CNPG_CLUSTER_NAME": "cluster-a"}, lambda _ns, cluster_role="db": {"CNPG_CLUSTER_NAME": "cluster-a"},
) )
monkeypatch.setattr( monkeypatch.setattr(
inst, inst,

View File

@ -142,8 +142,8 @@ def test_install_barman_plugin_succeeds_after_cert_manager_recovery(monkeypatch)
_apply_calls = [0] _apply_calls = [0]
_cert_mgr_called = [False] _cert_mgr_called = [False]
def _fake_run(args, **kwargs): def _fake_kubectl(args, **_kwargs):
if args[:2] == ["kubectl", "apply"]: if args[:2] == ["apply", "-f"]:
_apply_calls[0] += 1 _apply_calls[0] += 1
if _apply_calls[0] == 1: if _apply_calls[0] == 1:
return SimpleNamespace( return SimpleNamespace(
@ -152,12 +152,9 @@ def test_install_barman_plugin_succeeds_after_cert_manager_recovery(monkeypatch)
stderr="webhook.cert-manager.io: failed to call webhook: tls: failed to verify certificate: x509: certificate signed by unknown authority", stderr="webhook.cert-manager.io: failed to call webhook: tls: failed to verify certificate: x509: certificate signed by unknown authority",
) )
return SimpleNamespace(returncode=0, stdout="configured", stderr="") return SimpleNamespace(returncode=0, stdout="configured", stderr="")
# CA bundle poll
if "jsonpath={.webhooks[0].clientConfig.caBundle}" in args:
return SimpleNamespace(returncode=0, stdout="dGVzdA==", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run) monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None) monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
def _fake_ensure(**_kw): def _fake_ensure(**_kw):
@ -165,7 +162,6 @@ def test_install_barman_plugin_succeeds_after_cert_manager_recovery(monkeypatch)
return True # CA bundle confirmed ready return True # CA bundle confirmed ready
monkeypatch.setattr(cloudnative_pg, "_ensure_cert_manager_for_barman", _fake_ensure) monkeypatch.setattr(cloudnative_pg, "_ensure_cert_manager_for_barman", _fake_ensure)
monkeypatch.setattr(cloudnative_pg, "_kubectl", lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True) monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"} env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
@ -181,8 +177,8 @@ def test_install_barman_plugin_logs_warn_when_ca_not_confirmed(monkeypatch):
logs: list[str] = [] logs: list[str] = []
_apply_calls = [0] _apply_calls = [0]
def _fake_run(args, **kwargs): def _fake_kubectl(args, **_kwargs):
if args[:2] == ["kubectl", "apply"]: if args[:2] == ["apply", "-f"]:
_apply_calls[0] += 1 _apply_calls[0] += 1
if _apply_calls[0] == 1: if _apply_calls[0] == 1:
return SimpleNamespace( return SimpleNamespace(
@ -191,10 +187,9 @@ def test_install_barman_plugin_logs_warn_when_ca_not_confirmed(monkeypatch):
return SimpleNamespace(returncode=0, stdout="ok", stderr="") return SimpleNamespace(returncode=0, stdout="ok", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run) monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None) monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
monkeypatch.setattr(cloudnative_pg, "_ensure_cert_manager_for_barman", lambda **_kw: False) monkeypatch.setattr(cloudnative_pg, "_ensure_cert_manager_for_barman", lambda **_kw: False)
monkeypatch.setattr(cloudnative_pg, "_kubectl", lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True) monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"} env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
@ -207,19 +202,20 @@ def test_install_barman_plugin_logs_warn_when_ca_not_confirmed(monkeypatch):
def test_install_barman_plugin_raises_after_all_retries_exhausted(monkeypatch): def test_install_barman_plugin_raises_after_all_retries_exhausted(monkeypatch):
"""RuntimeError is raised if all 6 attempts fail with webhook CA errors.""" """RuntimeError is raised if all 6 attempts fail with webhook CA errors."""
monkeypatch.setattr( monkeypatch.setattr(
cloudnative_pg.subprocess, "run", cloudnative_pg,
lambda *_a, **_kw: SimpleNamespace( "_kubectl",
lambda args, **_kw: SimpleNamespace(
returncode=1, returncode=1,
stdout="", stdout="",
stderr="x509: certificate signed by unknown authority", stderr="x509: certificate signed by unknown authority",
), ) if args[:2] == ["apply", "-f"] else SimpleNamespace(returncode=0, stdout="", stderr=""),
) )
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None) monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
monkeypatch.setattr( monkeypatch.setattr(
cloudnative_pg, "_ensure_cert_manager_for_barman", cloudnative_pg, "_ensure_cert_manager_for_barman",
lambda **_kw: False, lambda **_kw: False,
) )
monkeypatch.setattr(cloudnative_pg, "_kubectl", lambda *_a, **_kw: SimpleNamespace(returncode=0, stdout="", stderr="")) monkeypatch.setattr(cloudnative_pg, "_wait_for_barman_tls_secrets", lambda **_kw: True)
env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"} env = {"BARMAN_PLUGIN_MANIFEST_URL": "https://example.com/barman.yaml"}
with pytest.raises(RuntimeError, match="Failed to apply Barman Cloud plugin after cert-manager restart"): with pytest.raises(RuntimeError, match="Failed to apply Barman Cloud plugin after cert-manager restart"):
@ -372,6 +368,34 @@ def test_bootstrap_barman_tls_secrets_raises_on_apply_failure(monkeypatch):
cloudnative_pg._bootstrap_barman_tls_secrets(env=None, log=None) cloudnative_pg._bootstrap_barman_tls_secrets(env=None, log=None)
# ---------------------------------------------------------------------------
# _bootstrap_db_user_secrets
# ---------------------------------------------------------------------------
def test_bootstrap_db_user_secrets_honors_kubecontext(monkeypatch):
commands: list[list[str]] = []
def _fake_run(args, **_kwargs):
commands.append(list(args))
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=0, stdout="apiVersion: v1\nkind: Secret\n", stderr="")
if "apply" in args:
return SimpleNamespace(returncode=0, stdout="created", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg._bootstrap_db_user_secrets(
namespace="knoe-db-0",
env={"KUBECONTEXT": "gke-db-context", "DB_PASSWORD": "pw"},
log=None,
)
assert commands
assert all(cmd[:3] == ["kubectl", "--context", "gke-db-context"] for cmd in commands)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _bootstrap_cnpg_admin_key # _bootstrap_cnpg_admin_key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -407,6 +431,31 @@ def test_bootstrap_cnpg_admin_key_creates_secret_when_missing(monkeypatch):
assert any("Created secret 'cnpg-admin-key'" in m for m in logs) assert any("Created secret 'cnpg-admin-key'" in m for m in logs)
def test_bootstrap_cnpg_admin_key_honors_kubecontext(monkeypatch):
commands: list[list[str]] = []
def _fake_run(args, **_kwargs):
commands.append(list(args))
if "get" in args and "secret" in args:
return SimpleNamespace(returncode=1, stdout="", stderr="not found")
if "--dry-run=client" in args:
return SimpleNamespace(returncode=0, stdout="apiVersion: v1\nkind: Secret\n", stderr="")
if "apply" in args:
return SimpleNamespace(returncode=0, stdout="created", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg._bootstrap_cnpg_admin_key(
namespace="knoe-db-0",
env={"KUBECONTEXT": "gke-db-context"},
log=None,
)
assert commands
assert all(cmd[:3] == ["kubectl", "--context", "gke-db-context"] for cmd in commands)
def test_bootstrap_cnpg_admin_key_raises_on_apply_failure(monkeypatch): def test_bootstrap_cnpg_admin_key_raises_on_apply_failure(monkeypatch):
"""Raises RuntimeError when kubectl apply fails.""" """Raises RuntimeError when kubectl apply fails."""
def _fake_run(args, **kwargs): def _fake_run(args, **kwargs):
@ -633,6 +682,74 @@ def test_remove_legacy_barman_object_store_skips_when_plugin_not_present(monkeyp
assert not any("patch" in args for args in calls) assert not any("patch" in args for args in calls)
def test_wait_cnpg_pods_relaxes_workload_selector_when_no_matching_nodes(monkeypatch):
calls: list[list[str]] = []
readiness_polls = 0
def _fake_kubectl(args, env=None, timeout=30, check=False):
nonlocal readiness_polls
calls.append(list(args))
if "jsonpath={.spec.instances}" in args:
return SimpleNamespace(returncode=0, stdout="3", stderr="")
if "jsonpath={range .items[*]}{.metadata.name}={.status.conditions[?(@.type==\"Ready\")].status}\\n{end}" in args:
readiness_polls += 1
if readiness_polls == 1:
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1=False\\nknoe-db-2=False\\nknoe-db-3=False\\n",
stderr="",
)
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1=True\\nknoe-db-2=True\\nknoe-db-3=True\\n",
stderr="",
)
if "jsonpath={range .items[*]}{.metadata.name}|phase=" in args:
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1|phase=Pending|node=|ready=False|reason=Unschedulable\\n",
stderr="",
)
if "get" in args and "events" in args:
return SimpleNamespace(
returncode=0,
stdout="knoe-db-1|0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector.\\n",
stderr="",
)
if "jsonpath={.spec.affinity.nodeSelector.workload}" in args:
return SimpleNamespace(returncode=0, stdout="db", stderr="")
if args[:2] == ["get", "nodes"]:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "patch" in args and "cluster" in args:
return SimpleNamespace(returncode=0, stdout="patched", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monotonic_values = iter([0.0, 1.1, 1.2, 1.3, 1.4])
monkeypatch.setattr(cloudnative_pg, "_kubectl", _fake_kubectl)
monkeypatch.setattr(cloudnative_pg.time, "monotonic", lambda: next(monotonic_values))
monkeypatch.setattr(cloudnative_pg.time, "sleep", lambda _: None)
cloudnative_pg._wait_cnpg_pods(
namespace="knoe-db-0",
cluster_name="knoe-db",
env={"CNPG_SELECTOR_RELAX_GRACE_TIMEOUT": "60"},
timeout=1,
log=None,
)
patch_calls = [c for c in calls if "patch" in c and "cluster" in c]
assert len(patch_calls) == 1
assert '"nodeSelector": null' in patch_calls[0][-1]
def test_init_cnpg_backup_cleans_legacy_backup_fields_before_plugin_enable(): def test_init_cnpg_backup_cleans_legacy_backup_fields_before_plugin_enable():
script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8") script = (Path(__file__).resolve().parents[2] / "etc" / "init_cnpg_backup.sh").read_text(encoding="utf-8")

View File

@ -8,6 +8,11 @@ def test_gke_cnpg_manifest_uses_autopilot_baseline_profile() -> None:
manifest = (REPO_ROOT / "deploy" / "gcp" / "gke" / "knoe-db.yaml").read_text(encoding="utf-8") manifest = (REPO_ROOT / "deploy" / "gcp" / "gke" / "knoe-db.yaml").read_text(encoding="utf-8")
assert "podAntiAffinityType: preferred" in manifest assert "podAntiAffinityType: preferred" in manifest
assert " nodeSelector:\n workload: db" in manifest
assert "\n nodeSelector:\n workload: db\n" not in manifest
assert 'cpu: "100m"' in manifest
assert 'memory: "128Mi"' in manifest
assert 'cpu: "500m"' in manifest
assert 'memory: "512Mi"' in manifest assert 'memory: "512Mi"' in manifest
assert "metadata:" in manifest and "namespace: knoe-db-0" in manifest assert "metadata:" in manifest and "namespace: knoe-db-0" in manifest
assert "CREATE EXTENSION IF NOT EXISTS postgis_topology;" in manifest assert "CREATE EXTENSION IF NOT EXISTS postgis_topology;" in manifest

View File

@ -71,3 +71,28 @@ def test_bootstrap_cnpg_tls_secrets_keeps_fqdn_cn_when_within_limit(monkeypatch)
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
assert cn == f"{cluster_name}.{namespace}.svc" assert cn == f"{cluster_name}.{namespace}.svc"
assert len(cn) <= 64 assert len(cn) <= 64
def test_bootstrap_cnpg_tls_secrets_honors_kubecontext(monkeypatch):
namespace = "knoe-db-0"
cluster_name = "knoe-db"
commands: list[list[str]] = []
monkeypatch.setattr(cloudnative_pg, "_ensure_namespace", lambda *_args, **_kwargs: None)
monkeypatch.setattr(cloudnative_pg, "_kubectl_ok", lambda *_args, **_kwargs: False)
def _fake_run(args, input=None, **_kwargs):
commands.append(list(args))
assert input is not None
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(cloudnative_pg.subprocess, "run", _fake_run)
cloudnative_pg.bootstrap_cnpg_tls_secrets(
namespace=namespace,
cluster_name=cluster_name,
env={"KUBECONTEXT": "gke-db-context"},
)
assert commands
assert all(cmd[:3] == ["kubectl", "--context", "gke-db-context"] for cmd in commands)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@ -326,7 +327,14 @@ class TestKnoeController:
c = KnoeController(tmp_path) c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c) installer = KnoeConsoleInstaller(c)
with mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), \ with mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": "pw123",
"init_password.db_password_confirm": "pw123",
},
), \
mock.patch.object(installer, "_write_cfg"), \ mock.patch.object(installer, "_write_cfg"), \
mock.patch.object(installer, "_perform_cluster_reset"), \ mock.patch.object(installer, "_perform_cluster_reset"), \
mock.patch.object(installer, "_close_log_file"): mock.patch.object(installer, "_close_log_file"):
@ -377,6 +385,164 @@ class TestKnoeController:
# No early cfg write for generated password; only the normal writes. # No early cfg write for generated password; only the normal writes.
assert write_cfg.call_count == 2 assert write_cfg.call_count == 2
def test_silent_installer_fails_when_password_missing_in_non_interactive_mode(
self, tmp_path
):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object(
installer, "_load_db_password_from_ansible_vault", return_value=""
),
mock.patch.object(installer, "_write_cfg"),
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict(os.environ, {"CI": "1"}, clear=False),
):
rc = installer.run()
assert rc == 2
def test_silent_installer_loads_db_password_from_ansible_vault(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object(
installer,
"_load_db_password_from_ansible_vault",
return_value="vaultpw123",
),
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
):
captured = {}
def fake_run_milestones(milestones, progress_callback=None):
captured["ids"] = [m.id for m in milestones]
c.run_milestones = fake_run_milestones
rc = installer.run()
assert rc == 0
assert installer.inputs.get("init_password.db_password") == "vaultpw123"
assert installer.inputs.get("init_password.db_password_confirm") == "vaultpw123"
assert "deployment" in captured["ids"]
assert write_cfg.call_count == 2
def test_silent_installer_persists_bootstrap_password_to_ansible_vault(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
fake_stdin = mock.Mock()
fake_stdin.isatty.return_value = True
fake_stdout = mock.Mock()
fake_stdout.isatty.return_value = True
env_no_pytest = dict(os.environ)
env_no_pytest.pop("PYTEST_CURRENT_TEST", None)
env_no_pytest.pop("CI", None)
with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object(
installer, "_load_db_password_from_ansible_vault", return_value=""
),
mock.patch.object(
installer,
"_prompt_for_master_password",
return_value="bootstrap_pw_123",
),
mock.patch.object(
installer, "_persist_db_password_to_ansible_vault"
) as persist_pw,
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict("knoe.core.actions.os.environ", env_no_pytest, clear=True),
mock.patch.object(sys, "__stdin__", fake_stdin),
mock.patch.object(sys, "__stdout__", fake_stdout),
):
c.run_milestones = lambda milestones, progress_callback=None: None
rc = installer.run()
assert rc == 0
persist_pw.assert_called_once_with("bootstrap_pw_123")
assert installer.inputs.get("init_password.db_password") == "bootstrap_pw_123"
assert installer.inputs.get("init_password.db_password_confirm") == "bootstrap_pw_123"
assert write_cfg.call_count == 3
def test_silent_installer_bootstraps_vault_from_env_password(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": "env_pw_123",
"init_password.db_password_confirm": "env_pw_123",
},
),
mock.patch.object(
installer, "_load_db_password_from_ansible_vault", return_value=""
),
mock.patch.object(
installer, "_persist_db_password_to_ansible_vault"
) as persist_pw,
mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict(
"knoe.core.actions.os.environ", {"KNOE_DB_PASSWORD": "env_pw_123"}, clear=False
),
):
c.run_milestones = lambda milestones, progress_callback=None: None
rc = installer.run()
assert rc == 0
persist_pw.assert_called_once_with("env_pw_123")
assert write_cfg.call_count == 2
def test_silent_installer_prefers_vault_when_env_password_differs(self, tmp_path):
c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c)
with (
mock.patch.object(
installer,
"_load_inputs_from_cfg",
return_value={
"init_password.db_password": "env_pw_123",
"init_password.db_password_confirm": "env_pw_123",
},
),
mock.patch.object(
installer,
"_load_db_password_from_ansible_vault",
return_value="vault_pw_999",
),
mock.patch.object(
installer, "_persist_db_password_to_ansible_vault"
) as persist_pw,
mock.patch.object(installer, "_write_cfg"),
mock.patch.object(installer, "_perform_cluster_reset"),
mock.patch.object(installer, "_close_log_file"),
mock.patch.dict(
"knoe.core.actions.os.environ", {"KNOE_DB_PASSWORD": "env_pw_123"}, clear=False
),
):
c.run_milestones = lambda milestones, progress_callback=None: None
rc = installer.run()
assert rc == 0
persist_pw.assert_not_called()
assert installer.inputs.get("init_password.db_password") == "vault_pw_999"
assert installer.inputs.get("init_password.db_password_confirm") == "vault_pw_999"
# ===== Milestone concrete subclasses (from core/milestones.py) ===== # ===== Milestone concrete subclasses (from core/milestones.py) =====

View File

@ -55,12 +55,12 @@ def test_get_cluster_credentials_builds_expected_gcloud_command(monkeypatch):
] ]
def test_ensure_db_cluster_creates_standard_cluster_with_minimum_nodes(monkeypatch): def test_ensure_db_cluster_creates_standard_cluster_with_db_node_labels(monkeypatch):
commands = [] commands = []
def fake_run(cmd, log=None): def fake_run(cmd, log=None):
commands.append(cmd) commands.append(cmd)
if "describe" in cmd: if cmd[:4] == ["gcloud", "container", "clusters", "describe"]:
return CompletedProcess(cmd, 1, stdout="", stderr="not found") return CompletedProcess(cmd, 1, stdout="", stderr="not found")
return CompletedProcess(cmd, 0, stdout="created", stderr="") return CompletedProcess(cmd, 0, stdout="created", stderr="")
@ -80,4 +80,41 @@ def test_ensure_db_cluster_creates_standard_cluster_with_minimum_nodes(monkeypat
create_cmd = commands[-1] create_cmd = commands[-1]
assert create_cmd[:5] == ["gcloud", "container", "clusters", "create", "knoe-cnpg-0"] assert create_cmd[:5] == ["gcloud", "container", "clusters", "create", "knoe-cnpg-0"]
assert "--num-nodes" in create_cmd assert "--num-nodes" in create_cmd
assert create_cmd[create_cmd.index("--num-nodes") + 1] == "3" assert create_cmd[create_cmd.index("--num-nodes") + 1] == "1"
assert "--node-labels" in create_cmd
assert create_cmd[create_cmd.index("--node-labels") + 1] == "workload=db"
def test_ensure_db_cluster_existing_cluster_adds_labeled_db_pool(monkeypatch):
commands = []
def fake_run(cmd, log=None):
commands.append(cmd)
if cmd[:4] == ["gcloud", "container", "clusters", "describe"]:
return CompletedProcess(cmd, 0, stdout="knoe-cnpg-0", stderr="")
if cmd[:4] == ["gcloud", "container", "node-pools", "describe"]:
return CompletedProcess(cmd, 1, stdout="", stderr="not found")
return CompletedProcess(cmd, 0, stdout="ok", stderr="")
monkeypatch.setattr(gke_clusters, "_run", fake_run)
spec = gke_clusters.GkeClusterSpec(
name="knoe-cnpg-0",
mode="standard",
location="us-central1-a",
machine_type="e2-standard-2",
node_count=3,
node_pool_name="cnpg-db-pool",
)
gke_clusters.ensure_db_cluster(project_id="proj-1", spec=spec)
create_pool_cmd = commands[-1]
assert create_pool_cmd[:4] == ["gcloud", "container", "node-pools", "create"]
assert create_pool_cmd[4] == "cnpg-db-pool"
assert "--machine-type" in create_pool_cmd
assert create_pool_cmd[create_pool_cmd.index("--machine-type") + 1] == "e2-standard-2"
assert "--num-nodes" in create_pool_cmd
assert create_pool_cmd[create_pool_cmd.index("--num-nodes") + 1] == "3"
assert "--node-labels" in create_pool_cmd
assert create_pool_cmd[create_pool_cmd.index("--node-labels") + 1] == "workload=db"

View File

@ -206,6 +206,7 @@ class TestMilestones(unittest.TestCase):
mock_cnpg_init, mock_cnpg_init,
): ):
self.state.controller.run_script.return_value = 0 self.state.controller.run_script.return_value = 0
self.state.inputs["init_cluster.cluster_env"] = "prod"
# Mock _run_cmd to return 0 (secrets exist) # Mock _run_cmd to return 0 (secrets exist)
mock_run_cmd.return_value = 0 mock_run_cmd.return_value = 0
mock_opt_allowed.return_value = (True, 1, "ok") mock_opt_allowed.return_value = (True, 1, "ok")
@ -217,7 +218,7 @@ class TestMilestones(unittest.TestCase):
mock_registry_update.assert_called() mock_registry_update.assert_called()
mock_openbao_update.assert_called() mock_openbao_update.assert_called()
mock_garage_update.assert_called() mock_garage_update.assert_called()
mock_opentofu_update.assert_called() mock_opentofu_update.assert_not_called()
# CNPG is now Python-owned — assert cnpg_initialize was called # CNPG is now Python-owned — assert cnpg_initialize was called
mock_cnpg_init.assert_called_once() mock_cnpg_init.assert_called_once()
# monitoring is now Python-owned in milestone flow # monitoring is now Python-owned in milestone flow
@ -228,6 +229,16 @@ class TestMilestones(unittest.TestCase):
self.assertNotIn("init_monitoring.sh", called_scripts) self.assertNotIn("init_monitoring.sh", called_scripts)
self.assertNotIn("init_cloudnative_pg.sh", called_scripts) self.assertNotIn("init_cloudnative_pg.sh", called_scripts)
secret_calls = [
c
for c in mock_run_cmd.call_args_list
if isinstance(c.args[0], list) and c.args[0][:3] == ["kubectl", "get", "secret"]
]
self.assertEqual(len(secret_calls), 3)
for c in secret_calls:
self.assertIn("env", c.kwargs)
self.assertEqual(c.kwargs["env"].get("DATABASE_NAMESPACE"), "default")
@patch("knoe.core.milestones.cnpg_deploy") @patch("knoe.core.milestones.cnpg_deploy")
def test_deployment_milestone(self, mock_cnpg_deploy): def test_deployment_milestone(self, mock_cnpg_deploy):
milestone = DeploymentMilestone() milestone = DeploymentMilestone()

View File

@ -101,6 +101,11 @@ def test_tk_init_scripts_runner_invokes_init_certmgr(tmp_path, monkeypatch):
def _deployment_mode(self): def _deployment_mode(self):
return "k3s" return "k3s"
def _cluster_kubecontext(self, cluster_role: str = "app") -> str:
if cluster_role == "db":
return "gke_db_ctx"
return "gke_app_ctx"
def _resolve_prole_logs_dir(self): def _resolve_prole_logs_dir(self):
return tmp_path return tmp_path
@ -247,6 +252,11 @@ def test_tk_init_scripts_common_services_prefers_service_namespace(
def _deployment_mode(self): def _deployment_mode(self):
return "k3s" return "k3s"
def _cluster_kubecontext(self, cluster_role: str = "app") -> str:
if cluster_role == "db":
return "gke_db_ctx"
return "gke_app_ctx"
def _resolve_prole_logs_dir(self): def _resolve_prole_logs_dir(self):
return tmp_path return tmp_path
@ -1002,6 +1012,11 @@ def test_tk_init_scripts_k8s_sets_artifact_registry_env_for_cnpg(tmp_path, monke
def _deployment_mode(self): def _deployment_mode(self):
return "k8s" return "k8s"
def _cluster_kubecontext(self, cluster_role: str = "app") -> str:
if cluster_role == "db":
return "gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0"
return "gke_plenary-truck-485623-p7_us-west3_knoe-dev-0"
def _resolve_prole_logs_dir(self): def _resolve_prole_logs_dir(self):
return tmp_path return tmp_path