From 93f7259722d52b96d834df043ea51f416e33798d Mon Sep 17 00:00:00 2001 From: chrisfu Date: Tue, 31 Mar 2026 20:40:41 -0700 Subject: [PATCH] chore: checkpoint k3s deployment and knoe updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update Kong/Supabase ingress and service manifests for k3s - expand init scripts and runtime config/topology handling - refresh KNØE UI screens and Supabase deployment/render logic - include related env helper test adjustments Co-authored-by: Junie --- conf/port-mapping.cfg | 7 + conf/service/prole.cfg | 102 ++++-- .../opentofu/k3s/manifests/prole/ingress.yaml | 10 + .../k3s/manifests/prole/kong-configmap.yaml | 53 ++++ .../k3s/manifests/prole/kong-deployment.yaml | 4 + .../k3s/manifests/prole/kong-service.yaml | 3 + .../k3s/manifests/prole/supabase-ingress.yaml | 10 + etc/init_gitea.sh | 290 +++++++++++++++++- etc/init_kong.sh | 48 +++ knoe/config.py | 39 +++ knoe/core/env.py | 1 + knoe/core/prod_config.py | 3 + knoe/core/topology.py | 46 ++- knoe/ui/screens/__init__.py | 65 ++++ knoe/ui/screens/argocd.py | 16 +- knoe/ui/screens/base.py | 116 +++++++ knoe/ui/screens/cfg.py | 5 + knoe/ui/screens/cluster.py | 49 ++- knoe/ui/screens/gitops.py | 20 +- knoe/ui/screens/navigation.py | 8 + knoe/ui/screens/supabase.py | 58 ++-- modes/k3s/knoe-db/.version | 2 +- scan/network_description.txt | 62 ++-- supabase/deploy.sh | 25 ++ .../templates/storage/deployment.yaml | 23 +- supabase/helm/render_supabase.py | 60 +++- tests/installer/test_env_helpers.py | 15 + 27 files changed, 1018 insertions(+), 122 deletions(-) mode change 100644 => 100755 etc/init_gitea.sh diff --git a/conf/port-mapping.cfg b/conf/port-mapping.cfg index 6c7bafa..66b815b 100644 --- a/conf/port-mapping.cfg +++ b/conf/port-mapping.cfg @@ -2,6 +2,12 @@ # Format: key: local=... remote=... ns=... svc=... address=... argocd: local=8081 remote=80 ns=argocd svc=argocd-server address=0.0.0.0 +supabase-studio: local=18080 remote=3000 ns=supabase svc=studio address=0.0.0.0 +supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=127.0.0.1 +supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0 +supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0 +gitea-http: local=13000 remote=3000 ns=gitea svc=gitea-http address=0.0.0.0 +gitea-ssh: local=22 remote=22 ns=gitea svc=gitea-ssh address=0.0.0.0 garage: local=3900 remote=3900 ns=knoe-system svc=garage address=0.0.0.0 openbao: local=8200 remote=8200 ns=knoe-system svc=openbao address=0.0.0.0 opentofu: local=8080 remote=8080 ns=knoe-system svc=opentofu address=0.0.0.0 @@ -9,3 +15,4 @@ dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboar postgres: local=5432 remote=5432 ns=knoe-db svc=knoe-db-rw address=0.0.0.0 prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1 grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0 +supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0 diff --git a/conf/service/prole.cfg b/conf/service/prole.cfg index d623d16..25807ce 100644 --- a/conf/service/prole.cfg +++ b/conf/service/prole.cfg @@ -1,5 +1,5 @@ ; Prole Master Configuration File -; Generated by install.py on 2026-03-31 00:16:16 +; Generated by install.py on 2026-03-31 18:38:57 ; This file is used as input for Ansible deployment and k8s cluster creation. [User] @@ -8,6 +8,7 @@ [Inputs] ; Screen-scoped inputs used for unattended replays (-S) +argocd.node_selector = gandalf.prole.org build.deploy_env = Dev build.run_build = false database_options.distribution = percona @@ -47,33 +48,41 @@ 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 +gitops.node_selector = gandalf.prole.org init_cluster.argocd_enabled = false init_cluster.at_rest_encryption_enabled = true init_cluster.cluster_env = service init_cluster.deployment_target = prole-service-cluster -init_cluster.gitops_enabled = false +init_cluster.gitops_enabled = true init_cluster.k3s_server_url = https://myrddin.prole.org:6443 -init_cluster.k3s_token = ${PROLE_SECRET:v1:AL731QFqjjFGqfFT:Xt95lYCIk-6iCHerFSJQpKwWGJMdr04v-4DawlGbM3OBLJ7qbHOSZ_oJm1WtHkvqpoM7BSJNwlEqHMV7R3gl7IFqVUtqJ_AoaRcLSPKE2Z6JGWkIatR6Vzw3J1cm1iZiM2M9wikI5Wqp3t4cdWTOTf7PUaQHkYbBG1PV3w==} +init_cluster.k3s_token = ${PROLE_SECRET:v1:m0esKgX4z-UxYI3V:Rtau88iOMq7zuzT4A9y5PnaWy80PrSqFmoo9vqqXAsOORqFIM1CdcDyFdkHxmIPKTsSEnj91LXmRr3mie83lq4Y-qRj1PMmDD5f3yAtvFA33i709FunDECChkJLVoZJrDHGnRuSaA5ZHwIvqNXRY36S4LxCKluHk6scy8A==} init_cluster.kerberos_enabled = true init_cluster.mode = k3s init_cluster.start_cluster = true -init_cluster.supabase_enabled = false +init_cluster.supabase_analytics_enabled = true +init_cluster.supabase_auth_enabled = true +init_cluster.supabase_enabled = true +init_cluster.supabase_meta_enabled = true +init_cluster.supabase_realtime_enabled = true +init_cluster.supabase_studio_enabled = false +init_cluster.supabase_studio_url = db.prole.org init_cnpg_deploy.force_rollout = false init_cnpg_deploy.run_deploy = true init_db_build.run_build = true init_password.cluster_name = ${CLUSTER_NAME} init_password.db_host_port = 5432 init_password.db_namespace = ${DATABASE_NAMESPACE} -init_password.db_password = ${PROLE_SECRET:v1:OcWBTOiijqf-bfTT:6r-ioWdDRdkPNxmbEAQP2TYEPaycWasP} -init_password.db_password_confirm = ${PROLE_SECRET:v1:OcWBTOiijqf-bfTT:6r-ioWdDRdkPNxmbEAQP2TYEPaycWasP} +init_password.db_password = ${PROLE_SECRET:v1:iC68SlzGpoPl9nma:iQEsd-2BzbTXdxTnSldMjHmrFrij_hWJ} +init_password.db_password_confirm = ${PROLE_SECRET:v1:iC68SlzGpoPl9nma:iQEsd-2BzbTXdxTnSldMjHmrFrij_hWJ} init_password.db_username = root 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.3 -kerberos_config.password = ${PROLE_SECRET:v1:h9mzEWvGpnpU2FRz:5W8czWfjnRxzA8tJPk3eFRtrVLD5DxREkfqmQsnKLsbuAaec} +kerberos_config.password = ${PROLE_SECRET:v1:U-EPMl7qv4heEB1k:BEcXGbI_LT4lCXwBuvFEGgPbLFa9MpVzECObdH0pbLtClDHf} kerberos_config.realm = PROLE.ORG -kerberos_config.test_connection = false +kerberos_config.test_connection = true kerberos_config.user = administrator network_scan.run = true ollama_config.model = @@ -85,6 +94,7 @@ supabase_config.pv_node = gandalf.prole.org [Global] ; Variables used by name in more than one place or assumed global scope ARGOCD_NAMESPACE = argocd +ARGOCD_NODE_SELECTOR = gandalf.prole.org CLUSTER_ENV = service CLUSTER_NAME = knoe-db CNPG_ELIGIBLE_NODES = gandalf.prole.org,merlin.prole.org,myrddin.prole.org,pi.prole.org @@ -94,15 +104,22 @@ CNPG_PLACEMENT_PLAN_ID = cnpg-placement-7036c5f65b6ef0db CNPG_STAGE1_NODE = gandalf.prole.org DATABASE_NAMESPACE = knoe-db DB_HOST_PORT = 5432 -DB_PASSWORD = ${PROLE_SECRET:v1:M7Rt2Xp273X8ixb5:Rjys8A6SqmBv19DvPAvgihwJ0eJ8Xvda} +DB_PASSWORD = ${PROLE_SECRET:v1:t7NgRHfXTXH-mHcx:CRbKP9b6ccuxrTMMeJ742Q48_vegoUY4} DEPLOYMENT_MODE = k3s DEPLOYMENT_TARGET = prole-service-cluster +DOCKER_IMPORT_DIR = DOCKER_PRELOAD = false +GITEA_NODE_SELECTOR = gandalf.prole.org +K3S_SERVER = https://myrddin.prole.org:6443 +K3S_TOKEN = ${PROLE_SECRET:v1:gnQjvO_dhM7qkU_g:kFb7E6IQ40RM64JfsrJWBPzkJsMaLXNpNPxfho6t8w5KAeWCAyrw2bMsQtJ8n7cPc_SLWh_zX53Sa1pvEOclmd79e-c_Qff0gZleba1PzzfkjsYRfVlpzQ34gdAHrNHQG2mKnpkcyrNFZ0j6J-w4c3m_m6bTN1cV1KQvMQ==} KNOE_DB_USER = root KUBECONTEXT = knoe.dev.prole.org +OPENTOFU_URL = http://myrddin.prole.org:8080 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 +PROLE_HOME = /Users/chrisfu/dev/prole PROLE_K3S_SERVER = https://myrddin.prole.org:6443 -PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:DkOrjdIC-ji9iusP:7uc4RUXpALlyQcGfZfX6LBP7BEwOqBtbbZpbIgdbfJvMQ_1klRdLtDQrcG54p6ayHvaMQ6W7FSPHPLeKbcjDZ8eJMhNMaH9Q0kIyfmiZdbX3nN6QGCxJWjIn-4GJ4z6_gF7hjHrqAAqxReNoWkAokyoVgqKTiOoWToD0GA==} +PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b +REGISTRY_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE_DIR = /synology/d005 @@ -113,7 +130,7 @@ SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d003,/synology/d004,/sy ; No configuration values captured yet for this section. [Dependencies] -; No configuration values captured yet for this section. +STATUS = All installed [Network] AD_DC_HOST = myrddin.prole.org @@ -128,7 +145,21 @@ KDC_AUTO_DETECTED = 10.0.0.3 KERBEROS_AUTO_ENABLED = True [Port Forwards] -; No configuration values captured yet for this section. +PORT_FORWARD_K3S_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_K3S_MAPPING_10 = id=supabase-studio;namespace=supabase;target=svc/studio;address=0.0.0.0;hostPort=18080;servicePort=3000;protocol=TCP;description=Supabase Studio +PORT_FORWARD_K3S_MAPPING_11 = id=supabase-auth;namespace=supabase;target=svc/auth;address=127.0.0.1;hostPort=9999;servicePort=9999;protocol=TCP;description=Supabase Auth (GoTrue) +PORT_FORWARD_K3S_MAPPING_12 = id=supabase-rest;namespace=supabase;target=svc/rest;address=0.0.0.0;hostPort=3001;servicePort=3000;protocol=TCP;description=Supabase REST (PostgREST) +PORT_FORWARD_K3S_MAPPING_13 = id=supabase-realtime;namespace=supabase;target=svc/realtime;address=0.0.0.0;hostPort=4000;servicePort=4000;protocol=TCP;description=Supabase Realtime +PORT_FORWARD_K3S_MAPPING_14 = id=gitea-http;namespace=gitea;target=svc/gitea-http;address=0.0.0.0;hostPort=13000;servicePort=3000;protocol=TCP;description=Gitea Web +PORT_FORWARD_K3S_MAPPING_15 = id=gitea-ssh;namespace=gitea;target=svc/gitea-ssh;address=0.0.0.0;hostPort=22;servicePort=22;protocol=TCP;description=Gitea SSH +PORT_FORWARD_K3S_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_K3S_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_K3S_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_K3S_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_K3S_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_K3S_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_K3S_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/kps-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana UI +PORT_FORWARD_K3S_MAPPING_9 = id=supabase-kong;namespace=supabase;target=svc/kong;address=0.0.0.0;hostPort=8000;servicePort=8000;protocol=TCP;description=Supabase API (Kong) [System Environment] PROLE_CONF = /Users/chrisfu/dev/prole/conf @@ -138,10 +169,22 @@ PROLE_LOGS = /Users/chrisfu/dev/prole/logs PROLE_SERVICE = /Users/chrisfu/dev/prole/etc [Monitoring] -GRAFANA_ADMIN_PASSWORD = ${PROLE_SECRET:v1:st2uaIMwx55ftSPt:HjzyDuKpzGJyIDVzIKWD6YDzyoqZ4iJ9} +GRAFANA_ADMIN_PASSWORD = ${PROLE_SECRET:v1:L2BhAyHRKVei4cGM:-J83-HR_CpnPFpiB6NbOXKdmmagVW2qL} [Kerberos Authentication] -; No configuration values captured yet for this section. +AD_PORT_FORWARD = 1 +AD_PROXY_HOST_NETWORK = 1 +AD_PROXY_IMAGE = alpine/socat +AD_PROXY_SERVICE = prole-kerberos-ad-dc +AD_TCP_PORTS = 88 389 445 464 636 +AD_UDP_PORTS = 88 464 +ENABLED = True +KDC = 10.0.0.3 +PASSWORD = ${PROLE_SECRET:v1:jJ4tvCF8ef8xxQ-o:82chD7iVrlUNaDDA8K5DgYMWO2DN9FUSE7Rrm8IVhwuapK2P} +REALM = PROLE.ORG +SERVER = 10.0.0.3 +STATUS = Initialized +USER = administrator [Ollama] OLLAMA_HOST = http://fairyland.prole.org:11434 @@ -149,40 +192,47 @@ OLLAMA_SERVER_HOST = fairyland.prole.org OLLAMA_SERVER_PORT = 11434 [Optional Features] -AT_REST_ENCRYPTION_ENABLED = True -GITOPS_ENABLED = False -KERBEROS_ENABLED = True -SUPABASE_ENABLED = False +ARGOCD_ENABLED = False +AT_REST_ENCRYPTION_ENABLED = true +GITOPS_ENABLED = true +GITOPS_PROVIDER = gitea +KERBEROS_ENABLED = true +SUPABASE_ENABLED = true [GitOps] -; No configuration values captured yet for this section. +STATUS = Deployed [Database Creation] -; No configuration values captured yet for this section. +DB_USER = root [Initialize Cluster] ENVIRONMENT = service K3S_SERVER_URL = https://myrddin.prole.org:6443 -K3S_TOKEN = ${PROLE_SECRET:v1:sQeWAX7UamXXV4oR:O7sUJk54DE9gUtZAjmN4uiusuVl0T3SDTn-S4Ojgg844EOQV7dMhE1wncgn8kLXXz_klqkQszDzbTqQm2HdaQuFyCqnHGCtHyUAyFvXSAdhhQ3PSkggylLtbPQPPrEP6WFuFywdKf1QgLl2BOcipREupBj5XDfiNfEAigw==} +K3S_TOKEN = ${PROLE_SECRET:v1:HAOwKPWbbBvJ7JUN:fuseXaNaQzGdzklTyEelMLldsma1MX7Ciq2P-nzrlsDXpJy9DXv4i4siVOa4aPIYvKP66fAh8IGfk_gwX0JwGQorSgGrgRpaEKjBtZ-0sh48AJPFkP7VkpQw4QZr5xzoDgAA96Q3r70TgDFHlo81OV8_riptpqYp5BO5nA==} [Dev Cluster (k3d)] -CLUSTER_ENV = dev +CLUSTER_ENV = k3d-knoe-dev-cluster DISPLAY_NAME = knoe-dev-cluster -KUBECTL_CONTEXT = knoe.dev.prole.org +KUBECTL_CONTEXT = service MODE = k3d [Service Cluster (k3s)] CLUSTER_ENV = prole-service-cluster DISPLAY_NAME = prole-service-cluster K3S_SERVER_URL = https://myrddin.prole.org:6443 -K3S_TOKEN = ${PROLE_SECRET:v1:sast5Yrr_9mB5BzC:J8Et0XHuUbZbFzAovq-lgqdu_3rR4k3FHOJ7LmzCls-qcZV9D9_NNdmtw5gOTRA8kyZeP5oAFGAXKPGWHgxMJxGO_Lh2oQ1m_68_ExKCK7oeXxlZC3bFCd_fvQ-lP0thBYA9R-GkV_67tJfluOo632xhv6i3Pl1sgBZt5w==} +K3S_TOKEN = ${PROLE_SECRET:v1:-5rud_XHzHRKoZIU:Dxv2nKbY883uDrJlmjRIqUKTtF42KPwXmzONy1s0-cIsJ2TLpTkaSWjfYx0tXNnz65lPnwIR16kXVtICYHhOxhfSIaz-lQiNGwZ_hm1tGMjp9BvpANt_l5Ie6swbOHVw8QfpGLtiXuIv7ur2ocSX4xAYSdCKtYWWV58U0Q==} MODE = k3s +PIPELINE_URL = http://myrddin.prole.org:8080 + +[GCP] +; No configuration values captured yet for this section. [Prod Cluster (k8s)] -ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging +ARTIFACTS_DIR = CLUSTER_ENV = prole-prod-cluster DISPLAY_NAME = prole-prod-cluster MODE = k8s +PIPELINE_URL = http://myrddin.prole.org:8080 [Docker Build] ; No configuration values captured yet for this section. @@ -195,4 +245,4 @@ MODE = k3s TARGET = prole-service-cluster [Install] -; No configuration values captured yet for this section. +STATUS = Finished diff --git a/deploy/opentofu/k3s/manifests/prole/ingress.yaml b/deploy/opentofu/k3s/manifests/prole/ingress.yaml index 1f83cdf..e68572f 100644 --- a/deploy/opentofu/k3s/manifests/prole/ingress.yaml +++ b/deploy/opentofu/k3s/manifests/prole/ingress.yaml @@ -66,3 +66,13 @@ spec: name: kong port: number: 8000 + - host: supabase.prole.org + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kong + port: + number: 8000 diff --git a/deploy/opentofu/k3s/manifests/prole/kong-configmap.yaml b/deploy/opentofu/k3s/manifests/prole/kong-configmap.yaml index 6a4362d..767e78a 100644 --- a/deploy/opentofu/k3s/manifests/prole/kong-configmap.yaml +++ b/deploy/opentofu/k3s/manifests/prole/kong-configmap.yaml @@ -10,10 +10,63 @@ data: _transform: true services: + - name: prole-service + url: http://prole-svc.knoe-db.svc.cluster.local:8080 + routes: + - name: prole-k3s-kubeconfig + hosts: + - svc.prole.org + paths: + - /k3s/kube_config.sh + strip_path: false + - name: db-manager url: http://knoe-db-manager:80 routes: - name: backup-route + hosts: + - svc.prole.org paths: - /backup strip_path: false + + - name: grafana + url: http://kps-grafana.monitoring.svc.cluster.local:80 + routes: + - name: grafana-root + hosts: + - svc.prole.org + paths: + - / + strip_path: false + + - name: knoe-auth + url: http://knoe-auth.knoe-system.svc.cluster.local:8080 + routes: + - name: knoe-auth-root + hosts: + - api.prole.org + paths: + - / + strip_path: false + + - name: gitea-http + url: http://gitea-http.gitea.svc.cluster.local:3000 + routes: + - name: gitea-root + hosts: + - git.prole.org + paths: + - / + strip_path: false + + - name: gitea-ssh + host: gitea-ssh.gitea.svc.cluster.local + port: 22 + protocol: tcp + routes: + - name: gitea-ssh-tcp + protocols: + - tcp + destinations: + - port: 3022 diff --git a/deploy/opentofu/k3s/manifests/prole/kong-deployment.yaml b/deploy/opentofu/k3s/manifests/prole/kong-deployment.yaml index fd84721..4c1662f 100644 --- a/deploy/opentofu/k3s/manifests/prole/kong-deployment.yaml +++ b/deploy/opentofu/k3s/manifests/prole/kong-deployment.yaml @@ -25,6 +25,8 @@ spec: name: proxy - containerPort: 8001 name: admin + - containerPort: 3022 + name: ssh-proxy env: - name: KONG_DATABASE value: "off" @@ -32,6 +34,8 @@ spec: value: "0.0.0.0:8000" - name: KONG_ADMIN_LISTEN value: "0.0.0.0:8001" + - name: KONG_STREAM_LISTEN + value: "0.0.0.0:3022 reuseport backlog=16384" - name: KONG_DECLARATIVE_CONFIG value: "/etc/kong/declarative/kong.yml" volumeMounts: diff --git a/deploy/opentofu/k3s/manifests/prole/kong-service.yaml b/deploy/opentofu/k3s/manifests/prole/kong-service.yaml index 3e8c166..7b93088 100644 --- a/deploy/opentofu/k3s/manifests/prole/kong-service.yaml +++ b/deploy/opentofu/k3s/manifests/prole/kong-service.yaml @@ -16,4 +16,7 @@ spec: - name: admin port: 8001 targetPort: admin + - name: ssh-proxy + port: 3022 + targetPort: ssh-proxy type: ClusterIP diff --git a/deploy/opentofu/k3s/manifests/prole/supabase-ingress.yaml b/deploy/opentofu/k3s/manifests/prole/supabase-ingress.yaml index 22f1bef..4c06906 100644 --- a/deploy/opentofu/k3s/manifests/prole/supabase-ingress.yaml +++ b/deploy/opentofu/k3s/manifests/prole/supabase-ingress.yaml @@ -17,3 +17,13 @@ spec: name: kong port: number: 8000 + - host: supabase.prole.org + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kong + port: + number: 8000 diff --git a/etc/init_gitea.sh b/etc/init_gitea.sh old mode 100644 new mode 100755 index 6177c5f..67eeff4 --- a/etc/init_gitea.sh +++ b/etc/init_gitea.sh @@ -81,6 +81,118 @@ RELEASE_NAME="gitea" IMAGE_REPO_DEFAULT="gitea/gitea" IMAGE_TAG="${GITEA_IMAGE_TAG:-1.22.3}" IMAGE_REPO="$IMAGE_REPO_DEFAULT" +NODE_SELECTOR="${GITEA_NODE_SELECTOR:-${NODE_SELECTOR:-}}" +GITEA_PV_NODE="${GITEA_PV_NODE:-${GITEA_NODE_SELECTOR:-gandalf.prole.org}}" +GITEA_PV_BASE_DIR="${GITEA_PV_BASE_DIR:-/synology/d005}" +GITEA_STORAGE_CLASS="${GITEA_STORAGE_CLASS:-gitea-local-d005}" +GITEA_DOMAIN="${GITEA_DOMAIN:-git.prole.org}" +GITEA_SSH_DOMAIN="${GITEA_SSH_DOMAIN:-$GITEA_DOMAIN}" +KNOE_DB_NAMESPACE="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db}}" +KNOE_DB_CLUSTER="${KNOE_DB_CLUSTER:-${CLUSTER_NAME:-knoe-db}}" +KNOE_DB_SERVICE="${KNOE_DB_SERVICE:-${KNOE_DB_CLUSTER}-rw}" +KNOE_DB_PORT="${KNOE_DB_PORT:-${DB_HOST_PORT:-5432}}" +KNOE_DB_ADMIN_USER="${KNOE_DB_ADMIN_USER:-${KNOE_DB_USER:-postgres}}" +GITEA_DB_NAME="${GITEA_DB_NAME:-gitea}" +GITEA_DB_USER="${GITEA_DB_USER:-gitea}" +GITEA_DB_PASSWORD="${GITEA_DB_PASSWORD:-${DB_PASSWORD:-}}" + +is_secret_placeholder() { + case "${1:-}" in + '${PROLE_SECRET:'*|'${OPENBAO:'*) return 0 ;; + esac + return 1 +} + +resolve_gitea_db_password() { + if [[ -n "${GITEA_DB_PASSWORD:-}" ]] && ! is_secret_placeholder "${GITEA_DB_PASSWORD}"; then + return 0 + fi + + if kubectl -n "$KNOE_DB_NAMESPACE" get secret knoe-db-superuser >/dev/null 2>&1; then + local resolved + resolved=$(kubectl -n "$KNOE_DB_NAMESPACE" get secret knoe-db-superuser \ + -o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true) + if [[ -n "$resolved" ]]; then + GITEA_DB_PASSWORD="$resolved" + fi + fi + + if [[ -z "${GITEA_DB_PASSWORD:-}" ]] || is_secret_placeholder "${GITEA_DB_PASSWORD}"; then + warn "Could not resolve a concrete Gitea DB password; database bootstrap may be skipped." + fi +} + +resolve_knoe_db_primary_pod() { + local primary + primary=$(kubectl -n "$KNOE_DB_NAMESPACE" get pods \ + -l "cnpg.io/cluster=${KNOE_DB_CLUSTER},cnpg.io/instanceRole=primary" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + if [[ -z "$primary" ]]; then + primary=$(kubectl -n "$KNOE_DB_NAMESPACE" get pods \ + -l "cnpg.io/cluster=${KNOE_DB_CLUSTER}" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + fi + printf '%s' "$primary" +} + +sql_escape_literal() { + printf '%s' "${1:-}" | sed "s/'/''/g" +} + +setup_knoe_db_for_gitea() { + local primary + primary="$(resolve_knoe_db_primary_pod)" + if [[ -z "$primary" ]]; then + warn "No knoe-db pod found in namespace '${KNOE_DB_NAMESPACE}'; skipping Gitea DB setup." + return 0 + fi + + resolve_gitea_db_password + if [[ -z "${GITEA_DB_PASSWORD:-}" ]] || is_secret_placeholder "${GITEA_DB_PASSWORD}"; then + warn "Skipping Gitea DB setup due to unresolved GITEA_DB_PASSWORD." + return 0 + fi + + local admin_user="" + local candidate + for candidate in "$KNOE_DB_ADMIN_USER" postgres root; do + [[ -z "$candidate" ]] && continue + if kubectl -n "$KNOE_DB_NAMESPACE" exec "$primary" -c postgres -- \ + psql -U "$candidate" -d postgres -tAc "SELECT 1" >/dev/null 2>&1; then + admin_user="$candidate" + break + fi + done + + if [[ -z "$admin_user" ]]; then + warn "Unable to connect to knoe-db as admin user; skipping Gitea DB setup." + return 0 + fi + + local escaped_password + escaped_password="$(sql_escape_literal "$GITEA_DB_PASSWORD")" + + kubectl -n "$KNOE_DB_NAMESPACE" exec "$primary" -c postgres -- \ + psql -U "$admin_user" -d postgres -c " + DO \$\$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='${GITEA_DB_USER}') THEN + CREATE ROLE ${GITEA_DB_USER} LOGIN PASSWORD '${escaped_password}'; + END IF; + END \$\$; + " >/dev/null 2>&1 || warn "Could not create role '${GITEA_DB_USER}'." + + local db_exists + db_exists=$(kubectl -n "$KNOE_DB_NAMESPACE" exec "$primary" -c postgres -- \ + psql -U "$admin_user" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='${GITEA_DB_NAME}';" 2>/dev/null || true) + + if [[ "$db_exists" != "1" ]]; then + kubectl -n "$KNOE_DB_NAMESPACE" exec "$primary" -c postgres -- \ + psql -U "$admin_user" -d postgres -c "CREATE DATABASE ${GITEA_DB_NAME} OWNER ${GITEA_DB_USER};" \ + >/dev/null 2>&1 || warn "Could not create database '${GITEA_DB_NAME}'." + fi + + log "Gitea database '${GITEA_DB_NAME}' prepared in namespace '${KNOE_DB_NAMESPACE}'." +} # Optional reset if [[ "$FORCE" -eq 1 ]]; then @@ -119,20 +231,177 @@ deploy_with_helm() { # Minimal config: NodePort or ClusterIP + Ingress depending on environment. # Avoid heavy persistence defaults; users can override via values later. + local -a helm_args=( + --set "image.repository=$IMAGE_REPO" + --set "image.tag=$IMAGE_TAG" + --set "image.rootless=false" + --set "image.fullOverride=${IMAGE_REPO}:${IMAGE_TAG}" + --set "service.http.type=ClusterIP" + --set "service.ssh.type=ClusterIP" + --set "service.ssh.port=22" + --set "gitea.admin.username=${GITEA_ADMIN_USER:-gitea_admin}" + --set "gitea.admin.password=${GITEA_ADMIN_PASSWORD:-gitea_admin}" + --set "gitea.admin.email=${GITEA_ADMIN_EMAIL:-gitea_admin@example.local}" + --set "gitea.config.server.DOMAIN=${GITEA_DOMAIN}" + --set "gitea.config.server.SSH_DOMAIN=${GITEA_SSH_DOMAIN}" + --set "gitea.config.server.ROOT_URL=http://${GITEA_DOMAIN}/" + --set "gitea.config.server.HTTP_PORT=3000" + --set "gitea.config.server.SSH_PORT=22" + --set "gitea.config.database.DB_TYPE=postgres" + --set "gitea.config.database.HOST=${KNOE_DB_SERVICE}.${KNOE_DB_NAMESPACE}.svc.cluster.local:${KNOE_DB_PORT}" + --set "gitea.config.database.NAME=${GITEA_DB_NAME}" + --set "gitea.config.database.USER=${GITEA_DB_USER}" + --set-string "gitea.config.database.PASSWD=${GITEA_DB_PASSWORD}" + --set "gitea.config.database.SSL_MODE=disable" + --set "gitea.config.session.PROVIDER=db" + --set "postgresql.enabled=false" + --set "postgresql-ha.enabled=false" + --set "valkey-cluster.enabled=false" + --set "persistence.enabled=true" + ) + + if [[ "$MODE" == "k3s" ]]; then + helm_args+=( + --set "gitea.config.cache.ADAPTER=memory" + --set "global.storageClass=$GITEA_STORAGE_CLASS" + --set "persistence.storageClass=$GITEA_STORAGE_CLASS" + --set "nodeSelector.kubernetes\\.io/hostname=$GITEA_PV_NODE" + --set "deployment.strategy=Recreate" + ) + fi + helm upgrade --install "$RELEASE_NAME" "$CHART_NAME" \ -n "$NAMESPACE" \ - --set image.repository="$IMAGE_REPO" \ - --set image.tag="$IMAGE_TAG" \ - --set service.http.type=ClusterIP \ - --set service.ssh.type=ClusterIP \ - --set gitea.admin.username="${GITEA_ADMIN_USER:-gitea_admin}" \ - --set gitea.admin.password="${GITEA_ADMIN_PASSWORD:-gitea_admin}" \ - --set gitea.admin.email="${GITEA_ADMIN_EMAIL:-gitea_admin@example.local}" \ + "${helm_args[@]}" \ --wait --timeout 10m } +ensure_k3s_storage_layout() { + [[ "$MODE" == "k3s" ]] || return 0 + + log "Ensuring Gitea k3s storage on node '${GITEA_PV_NODE}' at '${GITEA_PV_BASE_DIR}' (storageClass=${GITEA_STORAGE_CLASS})" + + cat </dev/null 2>&1 || true + + cat </dev/null + kubectl -n "$NAMESPACE" delete pod "$prep_pod" --ignore-not-found >/dev/null 2>&1 || true +} + +reset_stuck_k3s_release() { + [[ "$MODE" == "k3s" ]] || return 0 + local pending + pending=$(kubectl -n "$NAMESPACE" get pvc -o jsonpath='{range .items[*]}{.spec.storageClassName}:{.status.phase}{"\n"}{end}' 2>/dev/null | grep '^:Pending$' || true) + if [[ -z "$pending" ]]; then + return 0 + fi + + warn "Detected pending PVCs without storageClass in namespace ${NAMESPACE}; resetting stuck Gitea release" + if command -v helm >/dev/null 2>&1; then + helm uninstall "$RELEASE_NAME" -n "$NAMESPACE" >/dev/null 2>&1 || true + fi + kubectl -n "$NAMESPACE" delete pvc gitea-shared-storage data-gitea-postgresql-ha-postgresql-0 data-gitea-postgresql-ha-postgresql-1 data-gitea-postgresql-ha-postgresql-2 valkey-data-gitea-valkey-cluster-0 valkey-data-gitea-valkey-cluster-1 valkey-data-gitea-valkey-cluster-2 >/dev/null 2>&1 || true +} + +release_exists() { + command -v helm >/dev/null 2>&1 && helm status "$RELEASE_NAME" -n "$NAMESPACE" >/dev/null 2>&1 +} + +gitea_workload_exists() { + kubectl -n "$NAMESPACE" get deploy "$RELEASE_NAME" >/dev/null 2>&1 +} + apply_manifest_fallback() { - warn "Helm unavailable; applying fallback manifest" + if release_exists || gitea_workload_exists; then + warn "Helm resources detected for release '$RELEASE_NAME'; skipping fallback manifest to avoid immutable selector/port conflicts" + return 0 + fi + + warn "Applying fallback manifest" + local node_selector_block="" + if [[ -n "$NODE_SELECTOR" ]]; then + node_selector_block=$(cat < ${KONG_NAME}:${KONG_PROXY_PORT} (namespace=${NAMESPACE}) ..." ( tmp="$(mktemp)" diff --git a/knoe/config.py b/knoe/config.py index 9e4a491..de13655 100644 --- a/knoe/config.py +++ b/knoe/config.py @@ -709,6 +709,20 @@ def _expand_cfg_value(val: str, variables: dict) -> str: return result +def _preserve_cfg_expr_for_persistence( + raw_value: str | None, + expanded_value: str | None = None, +) -> str: + """Prefer raw tokenized value (e.g. `${HOME}`) when persisting config.""" + + raw = "" if raw_value is None else str(raw_value).strip() + if "$" in raw: + return raw + if expanded_value is None: + return raw + return str(expanded_value).strip() + + def _parse_bool(val: any, default: bool | None = False) -> bool | None: if val is None: return default @@ -1202,3 +1216,28 @@ def load_dependencies(refresh: bool = True) -> list[dict]: except Exception: dep["installed"] = False return deps + + +def _parse_gcp_cfg(path) -> dict: + """Parse flat tfvars-style file written by etc/config.py --provider gcp. + Handles: key = "value" and key = value + Skips lines starting with # or ; + Returns empty dict if file missing or unreadable. + """ + result = {} + try: + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if "=" not in line: + continue + k, _, v = line.partition("=") + key = k.strip() + val = v.strip().strip('"').strip("'") + if key: + result[key] = val + except Exception: + pass + return result + diff --git a/knoe/core/env.py b/knoe/core/env.py index 2e65d54..b9d07b9 100644 --- a/knoe/core/env.py +++ b/knoe/core/env.py @@ -2326,6 +2326,7 @@ def _render_prole_cfg( "Initialize Cluster", "Dev Cluster (k3d)", "Service Cluster (k3s)", + "GCP", "Prod Cluster (k8s)", "Docker Build", "Initialization Scripts", diff --git a/knoe/core/prod_config.py b/knoe/core/prod_config.py index 5844075..39609be 100644 --- a/knoe/core/prod_config.py +++ b/knoe/core/prod_config.py @@ -333,6 +333,9 @@ def canonical_yaml(config: KnoeProductionConfig) -> str: def opentofu_vars(config: KnoeProductionConfig) -> dict[str, Any]: return { + # GCP identity (populated from conf/prod/gcp.cfg via installer) + "gcp_project_id": config.cloud.projectId, + "gcp_region": config.cloud.region, "ecosystem_id": config.metadata.ecosystemId, "environment": config.metadata.environment, "project_id": config.cloud.projectId, diff --git a/knoe/core/topology.py b/knoe/core/topology.py index 546029f..2f6a216 100644 --- a/knoe/core/topology.py +++ b/knoe/core/topology.py @@ -588,6 +588,36 @@ def _extract_inventory_from_log(log_text: str) -> dict | None: return data if isinstance(data, dict) else None +def cleanup_topology_collectors( + *, + kubectl_base_cmd: Sequence[str], + namespace: str = TOPOLOGY_NAMESPACE, + env: dict[str, str] | None = None, + collector_name: str | None = None, +) -> None: + selector = ( + f"prole.io/topology-collector={collector_name}" + if collector_name + else "app.kubernetes.io/name=prole-topology-collector" + ) + for kind in ("daemonset", "pod"): + _run_kubectl( + kubectl_base_cmd, + [ + "-n", + namespace, + "delete", + kind, + "-l", + selector, + "--ignore-not-found=true", + "--wait=false", + ], + timeout=15, + env=env, + ) + + def _merge_node( k8s_node: dict, inventory: dict | None, @@ -1078,19 +1108,11 @@ def discover_cluster_topology( _progress(f"Timed out waiting for node {node_name}") if collector_applied: - _run_kubectl( - kubectl_base_cmd, - [ - "-n", - cfg.namespace, - "delete", - "daemonset", - collector_name, - "--ignore-not-found=true", - "--wait=false", - ], - timeout=15, + cleanup_topology_collectors( + kubectl_base_cmd=kubectl_base_cmd, + namespace=cfg.namespace, env=env, + collector_name=collector_name, ) merged_nodes: list[NodeTopology] = [] diff --git a/knoe/ui/screens/__init__.py b/knoe/ui/screens/__init__.py index 824b458..1e5dc7e 100644 --- a/knoe/ui/screens/__init__.py +++ b/knoe/ui/screens/__init__.py @@ -22,6 +22,8 @@ from knoe import screen as ui from knoe.config import ( _expand_cfg_value, _collect_cfg_vars, + _parse_gcp_cfg, + _preserve_cfg_expr_for_persistence, ) from knoe.core.controller import KnoeController from knoe.core.env import ( @@ -412,8 +414,10 @@ class KnoeInstaller( self.service_namespace = tk.StringVar(value="default") self.gitops_enabled = tk.BooleanVar(value=False) self.gitops_namespace = tk.StringVar(value="gitea") + self.gitops_node_selector = tk.StringVar(value="") self.argocd_enabled = tk.BooleanVar(value=False) self.argocd_namespace = tk.StringVar(value="argocd") + self.argocd_node_selector = tk.StringVar(value="") # Load existing docker_import_dir from prole.cfg try: @@ -458,6 +462,12 @@ class KnoeInstaller( saved_argocd_ns = _expand_cfg_value( cfg["Global"].get("ARGOCD_NAMESPACE", ""), cfg_vars ).strip() + saved_gitops_node = _expand_cfg_value( + cfg["Global"].get("GITEA_NODE_SELECTOR", ""), cfg_vars + ).strip() + saved_argocd_node = _expand_cfg_value( + cfg["Global"].get("ARGOCD_NODE_SELECTOR", ""), cfg_vars + ).strip() saved_gitops_ns = ( _expand_cfg_value( cfg.get("GitOps", "GITOPS_NAMESPACE", fallback=""), @@ -547,6 +557,22 @@ class KnoeInstaller( self.prole_cfg_data.setdefault("GitOps", {})[ "NAMESPACE" ] = saved_gitops_ns + if saved_gitops_node: + try: + self.gitops_node_selector.set(saved_gitops_node) + except Exception: + pass + self.prole_cfg_data.setdefault("Global", {})[ + "GITEA_NODE_SELECTOR" + ] = saved_gitops_node + if saved_argocd_node: + try: + self.argocd_node_selector.set(saved_argocd_node) + except Exception: + pass + self.prole_cfg_data.setdefault("Global", {})[ + "ARGOCD_NODE_SELECTOR" + ] = saved_argocd_node if saved_argocd_enabled: val = saved_argocd_enabled.lower() in ("true", "1", "yes") try: @@ -575,6 +601,33 @@ class KnoeInstaller( mon[k] = v if mon: self.prole_cfg_data["Monitoring"] = mon + + if cfg.has_section("Prod Cluster (k8s)"): + prod_sec = {} + for k, v in cfg.items("Prod Cluster (k8s)"): + try: + prod_sec[k.upper()] = _expand_cfg_value(v, cfg_vars) + except Exception: + prod_sec[k.upper()] = v + if prod_sec: + self.prole_cfg_data["Prod Cluster (k8s)"].update(prod_sec) + + gcp_cfg_path = conf_dir / "prod" / "gcp.cfg" + gcp_values = _parse_gcp_cfg(gcp_cfg_path) + if gcp_values: + self.prole_cfg_data.setdefault("GCP", {}).update( + {k.upper(): v for k, v in gcp_values.items()} + ) + + # Preserve raw shell/config expressions for persistence so paths + # like `${HOME}/...` remain portable across hosts. + for section in cfg.sections(): + section_data = self.prole_cfg_data.setdefault(section, {}) + for key, raw_value in cfg.items(section): + section_data[key] = _preserve_cfg_expr_for_persistence( + raw_value, + section_data.get(key, raw_value), + ) except Exception: pass @@ -638,6 +691,18 @@ class KnoeInstaller( self.prod_artifacts_path = tk.StringVar( value=str(PROJECT_ROOT / "data" / "staging") ) + saved_artifacts_dir = ( + self.prole_cfg_data.get("Prod Cluster (k8s)", {}) + .get("ARTIFACTS_DIR", "") + .strip() + ) + if saved_artifacts_dir: + try: + self.prod_artifacts_path.set(saved_artifacts_dir) + except Exception: + pass + # Stash GCP values so _ensure_prod_config_state can apply them lazily. + self._gcp_cfg_values = dict(self.prole_cfg_data.get("GCP", {})) # GitOps provider choice (gitea / argocd / none) self.gitops_provider = tk.StringVar(value="gitea") # Google Workspace diff --git a/knoe/ui/screens/argocd.py b/knoe/ui/screens/argocd.py index 9064af8..5340c34 100644 --- a/knoe/ui/screens/argocd.py +++ b/knoe/ui/screens/argocd.py @@ -92,7 +92,16 @@ class ArgoCDScreenMixin: self._canvas_items.append(ns_window) self._overlay_widgets.append(ns_entry) - y = 360 + node_selector_var = getattr(self, "argocd_node_selector", None) + if hasattr(self, "_render_k3s_node_selector") and node_selector_var is not None: + y = self._render_k3s_node_selector( + y=360, + target_var=node_selector_var, + title="Node selector:", + ) + else: + y = 360 + initial_status = "Ready" if self.argocd_enabled.get() else "Disabled" self._argocd_status_var = tk.StringVar(value=initial_status) status_item = ui.canvas_text( @@ -196,6 +205,11 @@ class ArgoCDScreenMixin: else: mode = "k3d" args = ["--mode", mode, "--namespace", namespace, "update"] + node_selector = (self.argocd_node_selector.get() or "").strip() + if mode == "k3s" and node_selector: + env["ARGOCD_NODE_SELECTOR"] = node_selector + env["NODE_SELECTOR"] = node_selector + self._argocd_console.write(f"Node selector: {node_selector}\n") cfg_path = None try: diff --git a/knoe/ui/screens/base.py b/knoe/ui/screens/base.py index 1e6e8a3..a47f09c 100644 --- a/knoe/ui/screens/base.py +++ b/knoe/ui/screens/base.py @@ -39,6 +39,122 @@ class ScreenBaseMixin: self._overlay_widgets.append(console) return console + def _is_k3s_mode_active(self): + """Return True when current mode resolves to k3s/service cluster.""" + mode = (os.environ.get("PROLE_MODE") or "").strip().lower() + if mode == "k3s": + return True + try: + return (self.cluster_env.get() or "").strip().lower() == "service" + except Exception: + return False + + def _fetch_k3s_nodes(self): + """Return a list of node names from kubectl get nodes in k3s mode.""" + cmd = ["kubectl", "get", "nodes", "-o", "name"] + try: + proc = subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + timeout=20, + ) + names = [] + for line in (proc.stdout or "").splitlines(): + line = (line or "").strip() + if not line: + continue + if line.startswith("node/"): + line = line.split("/", 1)[1] + names.append(line) + if not names: + raise ValueError("no nodes returned") + return names + except Exception: + fallback = [] + for candidate in ("gandalf.prole.org", "myrddin.prole.org"): + if candidate not in fallback: + fallback.append(candidate) + return fallback + + def _is_tainted_node(self, node_name): + """Return True if the node has taints and should be visually disabled.""" + cmd = [ + "kubectl", + "get", + "node", + node_name, + "-o", + "jsonpath={.spec.taints[*].key}", + ] + try: + proc = subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + timeout=15, + ) + return bool((proc.stdout or "").strip()) + except Exception: + return node_name == "myrddin.prole.org" + + def _render_k3s_node_selector( + self, + *, + y, + target_var, + title="Node selector:", + x_label=48, + x_options=190, + option_spacing=175, + ): + """Render a reusable k3s node selector and return the next y offset.""" + if not self._is_k3s_mode_active(): + return y + + item = ui.canvas_text( + self, + x_label, + y, + title, + fill="black", + font=("SF Pro Text", 12), + ) + self._canvas_items.append(item) + + selected = (target_var.get() or "").strip() + nodes = self._fetch_k3s_nodes() + x = x_options + y_radio = y - 2 + for node in nodes: + disabled = self._is_tainted_node(node) + rb = tk.Radiobutton( + self.bg_canvas, + text=node, + variable=target_var, + value=node, + bg="white", + fg="#8c8c8c" if disabled else "black", + activebackground="white", + selectcolor="white", + state="disabled" if disabled else "normal", + font=("SF Pro Text", 10), + ) + window = self.bg_canvas.create_window(x, y_radio, window=rb, anchor="nw") + self._canvas_items.append(window) + self._overlay_widgets.append(rb) + x += option_spacing + + if (not selected or selected not in nodes or self._is_tainted_node(selected)) and nodes: + for node in nodes: + if not self._is_tainted_node(node): + target_var.set(node) + break + + return y + 36 + def safe_after(self, func, delay=0): """Run a function in the main thread if the root window still exists.""" if not self.root or not self.root.winfo_exists(): diff --git a/knoe/ui/screens/cfg.py b/knoe/ui/screens/cfg.py index ac3da57..0a2e4cd 100644 --- a/knoe/ui/screens/cfg.py +++ b/knoe/ui/screens/cfg.py @@ -112,6 +112,8 @@ class ConfigMixin: "SUPABASE_PV_NODE": (self.supabase_pv_node.get() or "").strip(), "SUPABASE_PV_BASE": (self.supabase_pv_base_dir.get() or "").strip(), "SUPABASE_PV_BASE_DIR": (self.supabase_pv_base_dir.get() or "").strip(), + "GITEA_NODE_SELECTOR": (self.gitops_node_selector.get() or "").strip(), + "ARGOCD_NODE_SELECTOR": (self.argocd_node_selector.get() or "").strip(), "PROLE_OPENTOFU_URL": _default_opentofu_pipeline_url(), } if prole_home_override: @@ -216,6 +218,7 @@ class ConfigMixin: "Initialize Cluster", "Docker Build", "Initialization Scripts", + "GCP", "Deployment", "Install", ] @@ -533,9 +536,11 @@ class ConfigMixin: # GitOps _set("gitops.namespace", _get_var(self.gitops_namespace, "gitea")) + _set("gitops.node_selector", _get_var(self.gitops_node_selector, "")) # ArgoCD _set("argocd.namespace", _get_var(self.argocd_namespace, "argocd")) + _set("argocd.node_selector", _get_var(self.argocd_node_selector, "")) # Disk selection (installer packaging) _set("disk_selection.disk_type", _get_var(self.selected_disk_type, "local")) diff --git a/knoe/ui/screens/cluster.py b/knoe/ui/screens/cluster.py index 0271caa..c57e615 100644 --- a/knoe/ui/screens/cluster.py +++ b/knoe/ui/screens/cluster.py @@ -18,6 +18,7 @@ from knoe.config import ( _encrypt_cfg_secret, _expand_path_expr, _merge_kubeconfig, + _parse_gcp_cfg, _write_k3s_kubeconfig, ) from knoe.core.actions import _reset_k3s_namespace @@ -404,6 +405,35 @@ class ClusterScreenMixin: ) _add_row(cloud_tab, cloud_row, "DNS Zone", "cloud.dnsZone") + def _on_load_gcp_cfg(): + gcp_cfg_path = self._resolve_prole_conf_dir() / "prod" / "gcp.cfg" + loaded = _parse_gcp_cfg(gcp_cfg_path) + if not loaded: + import tkinter.messagebox as _mb + _mb.showwarning( + "GCP Setup", + f"No values found at:\n{gcp_cfg_path}\n\n" + "Run: python3 etc/config.py --mode k8s --provider gcp", + ) + return + upper = {k.upper(): v for k, v in loaded.items()} + if upper.get("PROJECT_ID"): + self.prod_form_vars["cloud.projectId"].set(upper["PROJECT_ID"]) + if upper.get("REGION"): + self.prod_form_vars["cloud.region"].set(upper["REGION"]) + self.prole_cfg_data.setdefault("GCP", {}).update(upper) + self._gcp_cfg_values = dict(self.prole_cfg_data["GCP"]) + + load_gcp_btn = tk.Button( + cloud_tab, + text="Load from gcp.cfg", + command=_on_load_gcp_cfg, + font=("SF Pro Text", 10), + ) + load_gcp_btn.grid( + row=cloud_row + 1, column=0, columnspan=2, sticky="w", padx=8, pady=(6, 2) + ) + db_row = 0 db_row = _add_row( db_tab, db_row, "Namespace", "kubernetes.namespace", readonly=True @@ -1815,7 +1845,24 @@ class ClusterScreenMixin: self._prod_preview_text_widget = None self._prod_logs_text_widget = None + gcp = getattr(self, "_gcp_cfg_values", {}) or {} + if gcp.get("PROJECT_ID"): + self.prod_form_vars["cloud.projectId"].set(gcp["PROJECT_ID"]) + if gcp.get("REGION") and self.prod_form_vars["cloud.region"].get() in ("", "us-central1"): + self.prod_form_vars["cloud.region"].set(gcp["REGION"]) + try: + saved_yaml = ( + (getattr(self, "prole_cfg_data", None) or {}) + .get("Prod Cluster (k8s)", {}) + .get("PRODUCTION_CONFIG_YAML", "") + .strip() + ) + if saved_yaml: + import yaml as _yaml + saved_doc = _yaml.safe_load(saved_yaml) or {} + restored = build_config(saved_doc) + self.prod_config_api.put_prod_config(to_api_payload(restored)) payload = self.prod_config_api.get_prod_config() self._prod_apply_payload_to_vars(payload) except Exception: @@ -2199,7 +2246,7 @@ class ClusterScreenMixin: if env_key == "prod": self.prole_cfg_data["Prod Cluster (k8s)"][ - "STAGING_DIRECTORY" + "ARTIFACTS_DIR" ] = self.prod_artifacts_path.get() try: prod_payload = self._prod_payload_from_vars() diff --git a/knoe/ui/screens/gitops.py b/knoe/ui/screens/gitops.py index 220c751..17bffc5 100644 --- a/knoe/ui/screens/gitops.py +++ b/knoe/ui/screens/gitops.py @@ -13,7 +13,7 @@ from knoe.core.env import PROJECT_ROOT, _normalize_cluster_env, _parse_bool class GitOpsScreenMixin: - """GitOps / Gitea deployment screen.""" + """GitOps deployment screen.""" # ------------------------------------------------------------------ # GitOps provider choice screen @@ -120,7 +120,7 @@ class GitOpsScreenMixin: anchor="ne", ) - self._render_title("GitOps / Gitea", y=150) + self._render_title("GitOps", y=150) description = ( "Deploys self-hosted GitOps (Gitea) via etc/init_gitea.sh.\n" @@ -171,7 +171,16 @@ class GitOpsScreenMixin: self._canvas_items.append(ns_window) self._overlay_widgets.append(ns_entry) - y = 360 + node_selector_var = getattr(self, "gitops_node_selector", None) + if hasattr(self, "_render_k3s_node_selector") and node_selector_var is not None: + y = self._render_k3s_node_selector( + y=360, + target_var=node_selector_var, + title="Node selector:", + ) + else: + y = 360 + initial_status = "Ready" if self.gitops_enabled.get() else "Disabled" self._gitops_status_var = tk.StringVar(value=initial_status) status_item = ui.canvas_text( @@ -275,6 +284,11 @@ class GitOpsScreenMixin: else: mode = "k3d" args = ["--mode", mode, "--namespace", namespace] + node_selector = (self.gitops_node_selector.get() or "").strip() + if mode == "k3s" and node_selector: + env["GITEA_NODE_SELECTOR"] = node_selector + env["NODE_SELECTOR"] = node_selector + self._gitops_console.write(f"Node selector: {node_selector}\n") cfg_path = None try: diff --git a/knoe/ui/screens/navigation.py b/knoe/ui/screens/navigation.py index 96d80af..26a9a38 100644 --- a/knoe/ui/screens/navigation.py +++ b/knoe/ui/screens/navigation.py @@ -6,6 +6,7 @@ from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox, filedialog from knoe import config as inst_config +from knoe.core.topology import cleanup_topology_collectors from knoe.core.env import get_resource_path, _deployment_mode_from_env @@ -582,6 +583,13 @@ class NavigationMixin: if hasattr(self, "_validate_and_save_cluster_nodes_policy"): if not self._validate_and_save_cluster_nodes_policy(policy): return + try: + env_key = self._cluster_env_key() + cleanup_topology_collectors( + kubectl_base_cmd=self._kubectl_base_cmd(env_key), + ) + except Exception: + pass self.show_page("common_services") return diff --git a/knoe/ui/screens/supabase.py b/knoe/ui/screens/supabase.py index 3ce8e35..8785fc8 100644 --- a/knoe/ui/screens/supabase.py +++ b/knoe/ui/screens/supabase.py @@ -62,31 +62,39 @@ class SupabaseScreenMixin: self._canvas_items.append(enable_window) self._overlay_widgets.append(enable_cb) - y = 280 - node_label = ui.canvas_text( - self, - 48, - y, - "Node selector:", - fill="black", - font=("SF Pro Text", 12), - ) - self._canvas_items.append(node_label) - node_entry = tk.Entry( - self.bg_canvas, - textvariable=self.supabase_pv_node, - font=("SF Pro Text", 11), - width=38, - relief="solid", - bd=1, - ) - node_entry_window = self.bg_canvas.create_window( - 190, y - 2, window=node_entry, anchor="nw" - ) - self._canvas_items.append(node_entry_window) - self._overlay_widgets.append(node_entry) - - y += 36 + node_selector_var = getattr(self, "supabase_pv_node", None) + if hasattr(self, "_render_k3s_node_selector") and node_selector_var is not None: + y = self._render_k3s_node_selector( + y=280, + target_var=node_selector_var, + title="Node selector:", + ) + else: + y = 280 + if y == 280: + node_label = ui.canvas_text( + self, + 48, + y, + "Node selector:", + fill="black", + font=("SF Pro Text", 12), + ) + self._canvas_items.append(node_label) + node_entry = tk.Entry( + self.bg_canvas, + textvariable=self.supabase_pv_node, + font=("SF Pro Text", 11), + width=38, + relief="solid", + bd=1, + ) + node_entry_window = self.bg_canvas.create_window( + 190, y - 2, window=node_entry, anchor="nw" + ) + self._canvas_items.append(node_entry_window) + self._overlay_widgets.append(node_entry) + y += 36 storage_label = ui.canvas_text( self, 48, diff --git a/modes/k3s/knoe-db/.version b/modes/k3s/knoe-db/.version index 147ea53..b000479 100644 --- a/modes/k3s/knoe-db/.version +++ b/modes/k3s/knoe-db/.version @@ -1 +1 @@ -158 \ No newline at end of file +159 \ No newline at end of file diff --git a/scan/network_description.txt b/scan/network_description.txt index 4b2b53b..e5f1f69 100644 --- a/scan/network_description.txt +++ b/scan/network_description.txt @@ -1,53 +1,59 @@ Network Discovery Summary: Primary Router: 10.0.0.1 (eero_5d:50:f2) -DNS Servers: 10.0.0.4, 10.0.0.5, 100.100.100.100 +DNS Servers: 100.100.100.100, 10.0.0.4, 10.0.0.5 Detected Devices: - 10.0.0.35 [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.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.35\ [7e:19:9a:fe:9f:ed] (7e:19:9a:fe:9f:ed): Ports [], Services: [] -- 10.0.0.3 [2c:cf:67:8c:2b:47] (RaspberryPi_8c:2b:47): Ports [22, 80, 2049, 445, 5900, 88, 389, 636], Services: ['SSH', 'NFS', 'VNC', 'Web Server', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related'] +- 10.0.0.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [443], Services: ['Web Server'] +- 10.0.0.170 [b0:ee:7b:ca:73:99] (Roku_ca:73:99): Ports [], Services: [] - 10.0.0.1 [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [], Services: [] -- 10.0.0.4 [b8:27:eb:b3:6f:2b] (RaspberryPiF_b3:6f:2b): Ports [22, 53, 443], Services: ['DNS', 'SSH', 'Web Server'] -- 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.3 [2c:cf:67:8c:2b:47] (RaspberryPi_8c:2b:47): Ports [22, 53, 80, 443, 2049, 5900, 88, 389, 636], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server', 'Active Directory Related'] +- 10.0.0.5 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 2049], Services: ['SSH', 'NFS'] +- 10.0.0.4 [b8:27:eb:b3:6f:2b] (RaspberryPiF_b3:6f:2b): Ports [22, 53, 2049], Services: ['DNS', 'SSH', 'NFS'] +- 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.32 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: [] -- 10.0.0.26 [c8:db:26:08:52:f8] (Logitech_08:52:f8): Ports [], Services: [] - 10.0.0.6 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 443], Services: ['SSH', 'Web Server'] - 10.0.0.2 [dc:a4:ca:ea:1a:2f] (Apple_ea:1a:2f): Ports [], Services: [] +- 10.0.0.26 [c8:db:26:08:52:f8] (Logitech_08:52:f8): Ports [], Services: [] - 10.0.0.7 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH'] -- 10.0.0.22 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22], Services: ['SSH'] -- 10.0.0.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [443], Services: ['Web Server'] -- 10.0.0.48 [0c:fe:45:53:f4:3c] (SonyInteract_53:f4:3c): Ports [], Services: [] +- 10.0.0.22 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 443], Services: ['SSH', '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.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.37 [54:07:7d:22:c0:b2] (Netgear_22:c0:b2): Ports [80], 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.46 [d4:f7:d5:40:ab:17] (SonyInteract_40:ab:17): Ports [], Services: [] -- 10.0.0.52 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [], Services: [] -- 10.0.0.41 [b8:27:eb:57:10:d9] (RaspberryPiF_57:10:d9): Ports [], Services: [] +- 10.0.0.48 [0c:fe:45:53:f4:3c] (SonyInteract_53:f4:3c): 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.41 [b8:27:eb:57:10:d9] (RaspberryPiF_57:10:d9): Ports [22], Services: ['SSH'] +- 10.0.0.52 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH'] +- 10.0.0.205 [a8:20:66:28:12:e7] (Apple_28:12:e7): Ports [22, 53, 445, 5900, 88], Services: ['DNS', 'SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related'] +- 10.0.0.35\ [7e:19:9a:fe:9f:ed] (7e:19:9a:fe:9f:ed): Ports [], Services: [] +- 10.0.0.188 [00:16:6c:c5:1f:54] (SamsungElect_c5:1f:54): Ports [80, 443], Services: ['Web Server'] +- 10.0.0.208 [a0:ad:9f:30:84:5f] (ASUSTekCOMPU_30:84:5f): Ports [], Services: [] +- 10.0.0.113 [60:81:10:92:02:4e] (Apple_92:02:4e): Ports [], Services: [] +- 10.0.0.58 [7e:46:74:2a:b0:b2] (7e:46:74:2a:b0:b2): 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.106 [48:a6:b8:a7:50:60] (Sonos_a7:50:60): Ports [], Services: [] +- 10.0.0.95 [b8:27:eb:dd:6f:14] (RaspberryPiF_dd:6f:14): 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.100 [24:fc:e5:51:cf:74] (SamsungElect_51:cf:74): Ports [], Services: [] -- 10.0.0.95 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [2049], Services: ['NFS'] -- 10.0.0.106 [48:a6:b8:a7:50:60] (Sonos_a7:50:60): 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.117 [40:f5:20:1e:5e:91] (Espressif_1e:5e:91): Ports [80], Services: ['Web Server'] - 10.0.0.107 [b8:06:0d:b7:7c:56] (TuyaSmart_b7:7c:56): Ports [], Services: [] -- 10.0.0.111 [ca:0b:6c:5d:0a:e5] (ca:0b:6c:5d:0a:e5): Ports [], Services: [] -- 10.0.0.113 [60:81:10:92:02:4e] (Apple_92:02:4e): Ports [], Services: [] - 10.0.0.123 [3c:ef:8c:96:e3:3c] (ZhejiangDahu_96:e3:3c): Ports [80], Services: ['Web Server'] +- 10.0.0.1\ [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [], Services: [] - 10.0.0.124 [9c:8e:cd:02:73:c4] (AmcrestTechn_02:73:c4): 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.117 [40:f5:20:1e:5e:91] (Espressif_1e:5e:91): Ports [80], Services: ['Web Server'] -- 10.0.0.125 [a0:60:32:04:73:ba] (AmcrestTechn_04:73:ba): Ports [], Services: [] - 10.0.0.130 [f8:bb:bf:a3:84:eb] (eero_a3:84:eb): 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.205 [a8:20:66:28:12:e7] (Apple_28:12:e7): Ports [22, 53, 445, 5900, 88], Services: ['DNS', 'SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related'] +- 10.0.0.125 [a0:60:32:04:73:ba] (AmcrestTechn_04:73:ba): Ports [80], Services: ['Web Server'] +- 10.0.0.143 [60:5f:8d:7a:aa:32] (eero_7a:aa:32): Ports [], Services: [] - 10.0.0.128 [a8:20:66:28:12:e7] (Apple_28:12:e7): Ports [], Services: [] - 10.0.0.145 [7c:a6:b0:04:db:1c] (7c:a6:b0:04:db:1c): Ports [], Services: [] -- 10.0.0.170 [b0:ee:7b:ca:73:99] (Roku_ca:73:99): Ports [], Services: [] +- 10.0.0.160 [96:c3:fd:58:02:6c] (96:c3:fd:58:02:6c): Ports [], Services: [] +- 10.0.0.180 [4c:e1:73:42:1a:be] (HuizhouDehon_2:1a:be): Ports [445], Services: ['SMB/CIFS (Possible Windows/AD)'] - 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.196 [60:5f:8d:88:08:f2] (eero_88:08:f2): Ports [53], Services: ['DNS'] -- 10.0.0.188 [00:16:6c:c5:1f:54] (SamsungElect_c5:1f:54): Ports [80, 443], Services: ['Web Server'] -- 10.0.0.204 [50:eb:f6:56:f3:26] (ASUSTekCOMPU_56:f3:26): Ports [22, 3389, 11434], Services: ['SSH', 'RDP (Windows)', 'Ollama'] -- 10.0.0.206 [00:a0:de:a2:0b:ef] (Yamaha_a2:0b:ef): Ports [80], Services: ['Web Server'] -- 10.0.0.1\ [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [], Services: [] +- 10.0.0.199 [7c:a6:b0:01:a7:33] (7c:a6:b0:01:a7:33): Ports [], Services: [] Ollama Instances found at: 10.0.0.35, 10.0.0.204 Ansible Inventory Summary: diff --git a/supabase/deploy.sh b/supabase/deploy.sh index 9d2d5c3..ae59927 100755 --- a/supabase/deploy.sh +++ b/supabase/deploy.sh @@ -1154,6 +1154,10 @@ setup_knoe_db_for_supabase() { CREATE SCHEMA IF NOT EXISTS knoe AUTHORIZATION knoe; CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_auth_admin; CREATE SCHEMA IF NOT EXISTS storage AUTHORIZATION supabase_storage_admin; + -- Idempotent: if storage schema pre-existed with a different owner (e.g. postgres), + -- CREATE SCHEMA IF NOT EXISTS is a no-op and ownership is NOT transferred. + -- Explicitly grant so supabase_storage_admin can create its migration tables there. + GRANT USAGE, CREATE ON SCHEMA storage TO supabase_storage_admin; CREATE SCHEMA IF NOT EXISTS graphql_public; CREATE SCHEMA IF NOT EXISTS _realtime; ALTER SCHEMA _realtime OWNER TO postgres; @@ -1298,6 +1302,27 @@ helm_render_values() { export DB_PASSWORD="$_live_pg_pw" fi + # Resolve Garage S3 credentials from the live k8s secret so the renderer + # always has concrete values regardless of prole.cfg state. + if [[ -z "${GARAGE_S3_KEY_ID:-}" ]]; then + local _live_garage_key_id + _live_garage_key_id=$(kubectl get secret knoe-db-barman-s3 \ + -n "${DATABASE_NAMESPACE:-knoe-db}" \ + -o jsonpath='{.data.ACCESS_KEY_ID}' 2>/dev/null | base64 --decode 2>/dev/null || true) + if [[ -n "$_live_garage_key_id" ]]; then + export GARAGE_S3_KEY_ID="$_live_garage_key_id" + fi + fi + if [[ -z "${GARAGE_S3_ACCESS_KEY:-}" ]]; then + local _live_garage_secret + _live_garage_secret=$(kubectl get secret knoe-db-barman-s3 \ + -n "${DATABASE_NAMESPACE:-knoe-db}" \ + -o jsonpath='{.data.SECRET_ACCESS_KEY}' 2>/dev/null | base64 --decode 2>/dev/null || true) + if [[ -n "$_live_garage_secret" ]]; then + export GARAGE_S3_ACCESS_KEY="$_live_garage_secret" + fi + fi + # Always regenerate — never reuse stale cached values from a previous deploy attempt rm -f "$PROJECT_ROOT/supabase/helm/generated/values.generated.json" diff --git a/supabase/helm/prole-supabase/templates/storage/deployment.yaml b/supabase/helm/prole-supabase/templates/storage/deployment.yaml index e0578e2..4d44c9b 100644 --- a/supabase/helm/prole-supabase/templates/storage/deployment.yaml +++ b/supabase/helm/prole-supabase/templates/storage/deployment.yaml @@ -91,6 +91,7 @@ spec: imagePullPolicy: {{ .Values.image.storage.pullPolicy }} env: {{- $storageEnv := (.Values.environment.storage | default dict) }} + {{- $storageBackend := (default (ternary "s3" "file" .Values.deployment.minio.enabled) (get $storageEnv "STORAGE_BACKEND")) }} {{- range $key, $value := $storageEnv }} {{- if and (ne $key "DB_HOST") (ne $key "DB_PORT") (ne $key "DB_SSL") }} - name: {{ $key }} @@ -138,7 +139,7 @@ spec: key: database {{- end }} - name: DATABASE_URL - value: $(DB_DRIVER)://$(DB_USER):$(DB_PASSWORD_ENC)@{{ include "supabase.database.host" . }}:{{ include "supabase.database.port" . }}/{{ include "supabase.database.name" . }}?search_path=auth&sslmode={{ include "supabase.database.ssl" . }} + value: $(DB_DRIVER)://$(DB_USER):$(DB_PASSWORD_ENC)@{{ include "supabase.database.host" . }}:{{ include "supabase.database.port" . }}/{{ include "supabase.database.name" . }}?search_path=storage,public&sslmode={{ include "supabase.database.ssl" . }} - name: PGRST_JWT_SECRET valueFrom: secretKeyRef: @@ -196,7 +197,7 @@ spec: {{- if not (hasKey $storageEnv "STORAGE_BACKEND") }} - name: STORAGE_BACKEND - value: {{ if .Values.deployment.minio.enabled }}s3{{ else }}file{{ end }} + value: {{ $storageBackend }} {{- end }} {{- if .Values.deployment.minio.enabled }} @@ -212,25 +213,27 @@ spec: - name: GLOBAL_S3_FORCE_PATH_STYLE value: "true" {{- end }} + {{- end }} + {{- if eq $storageBackend "s3" }} - name: AWS_ACCESS_KEY_ID valueFrom: secretKeyRef: - {{- if .Values.secret.minio.secretRef }} - name: {{ .Values.secret.minio.secretRef }} - key: {{ .Values.secret.minio.secretRefKey.user | default "user" }} + {{- if .Values.secret.s3.secretRef }} + name: {{ .Values.secret.s3.secretRef }} + key: {{ .Values.secret.s3.secretRefKey.keyId | default "keyId" }} {{- else }} - name: {{ include "supabase.secret.minio" . }} - key: user + name: {{ include "supabase.secret.s3" . }} + key: keyId {{- end }} - name: AWS_SECRET_ACCESS_KEY valueFrom: secretKeyRef: {{- if .Values.secret.s3.secretRef }} name: {{ .Values.secret.s3.secretRef }} - key: {{ .Values.secret.s3.secretRefKey.password | default "password" }} + key: {{ .Values.secret.s3.secretRefKey.accessKey | default "accessKey" }} {{- else }} - name: {{ include "supabase.secret.minio" . }} - key: password + name: {{ include "supabase.secret.s3" . }} + key: accessKey {{- end }} {{- end }} {{- with .Values.deployment.storage.livenessProbe }} diff --git a/supabase/helm/render_supabase.py b/supabase/helm/render_supabase.py index fb26794..f430bc6 100755 --- a/supabase/helm/render_supabase.py +++ b/supabase/helm/render_supabase.py @@ -340,17 +340,62 @@ def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) -> if _is_placeholder(supabase_storage_class): supabase_storage_class = "merlin-local-iscsi-d002" + storage_backend = _first( + os.environ.get("SUPABASE_STORAGE_BACKEND", ""), + os.environ.get("STORAGE_BACKEND", ""), + _cfg_get(cfg, "Supabase", "STORAGE_BACKEND"), + _cfg_get(cfg, "Global", "STORAGE_BACKEND"), + default="s3", + ).strip().lower() + if _is_placeholder(storage_backend): + storage_backend = "" + + global_s3_endpoint = _first( + os.environ.get("SUPABASE_GLOBAL_S3_ENDPOINT", ""), + os.environ.get("GLOBAL_S3_ENDPOINT", ""), + _cfg_get(cfg, "Supabase", "GLOBAL_S3_ENDPOINT"), + _cfg_get(cfg, "Global", "GLOBAL_S3_ENDPOINT"), + default="http://garage.knoe-system.svc.cluster.local:3900", + ).strip() + if _is_placeholder(global_s3_endpoint): + global_s3_endpoint = "" + + garage_s3_key_id = _first( + os.environ.get("GARAGE_S3_KEY_ID", ""), + _cfg_get(cfg, "Supabase", "GARAGE_S3_KEY_ID"), + _cfg_get(cfg, "Global", "GARAGE_S3_KEY_ID"), + default="", + ).strip() + garage_s3_access_key = _first( + os.environ.get("GARAGE_S3_ACCESS_KEY", ""), + _cfg_get(cfg, "Supabase", "GARAGE_S3_ACCESS_KEY"), + _cfg_get(cfg, "Global", "GARAGE_S3_ACCESS_KEY"), + default="", + ).strip() + if _is_placeholder(garage_s3_key_id): + garage_s3_key_id = "" + if _is_placeholder(garage_s3_access_key): + garage_s3_access_key = "" + + use_garage_s3 = storage_backend == "s3" and "garage" in global_s3_endpoint.lower() + if use_garage_s3 and (not garage_s3_key_id or not garage_s3_access_key): + raise SystemExit( + "Garage S3 credentials are required for Supabase storage. " + "Set GARAGE_S3_KEY_ID and GARAGE_S3_ACCESS_KEY in [Supabase] or [Global], " + "or via environment variables." + ) + overlay: dict[str, Any] = { "nameOverride": "supabase", "fullnameOverride": "supabase", "deployment": { "db": {"enabled": False}, - "functions": {"enabled": True}, - "vector": {"enabled": True}, - "kong": {"enabled": True}, - "storage": {"enabled": True}, - "minio": {"enabled": True}, - "imgproxy": {"enabled": True}, + "functions": {"enabled": True, "fullnameOverride": "supabase-functions"}, + "vector": {"enabled": True, "fullnameOverride": "supabase-vector"}, + "kong": {"enabled": True, "fullnameOverride": "supabase-kong"}, + "storage": {"enabled": True, "fullnameOverride": "supabase-storage"}, + "minio": {"enabled": not use_garage_s3, "fullnameOverride": "supabase-minio"}, + "imgproxy": {"enabled": True, "fullnameOverride": "supabase-imgproxy"}, # Explicit fullnameOverride per component strips the chart name # from pod names (avoids 'supabase-prole-supabase-'). "analytics": {"enabled": analytics_enabled, "fullnameOverride": "supabase-analytics"}, @@ -385,6 +430,7 @@ def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) -> "secret": { "db": {"password": db_password, "database": db_name}, "jwt": {"secret": jwt_secret, "anonKey": anon_key, "serviceKey": service_key}, + "s3": {"keyId": garage_s3_key_id, "accessKey": garage_s3_access_key}, }, "ingress": { "enabled": True, @@ -468,7 +514,7 @@ def render(args: argparse.Namespace) -> None: cmd = [ "helm", "template", - "prole-supabase", + "supabase", str(REPO_ROOT / "supabase" / "helm" / "prole-supabase"), "-n", meta["supabase_namespace"], diff --git a/tests/installer/test_env_helpers.py b/tests/installer/test_env_helpers.py index 4b9de92..c2aa6a9 100644 --- a/tests/installer/test_env_helpers.py +++ b/tests/installer/test_env_helpers.py @@ -16,6 +16,7 @@ from knoe.config import ( _parse_bool as cfg_parse_bool, _expand_path as cfg_expand_path, _expand_cfg_value as cfg_expand_cfg_value, + _preserve_cfg_expr_for_persistence, _collect_cfg_vars as cfg_collect_cfg_vars, _filter_cfg_values_for_persistence, _normalize_cfg_value_for_persistence, @@ -324,6 +325,20 @@ class TestExpandHelpers: assert cfg_expand_cfg_value("", {}) == "" assert cfg_expand_cfg_value(None, {}) is None + def test_preserve_cfg_expr_for_persistence_keeps_home_token(self): + value = _preserve_cfg_expr_for_persistence( + "${HOME}/dev/prole/data/staging", + "/Users/chrisfu/dev/prole/data/staging", + ) + assert value == "${HOME}/dev/prole/data/staging" + + def test_preserve_cfg_expr_for_persistence_uses_expanded_without_tokens(self): + value = _preserve_cfg_expr_for_persistence( + "/Users/chrisfu/dev/prole/data/staging", + "/Users/chrisfu/dev/prole/data/staging", + ) + assert value == "/Users/chrisfu/dev/prole/data/staging" + # ===== _collect_cfg_vars =====