feat: add production config schema, validation, and API with tests

- Introduced `knoe.core.prod_config` with production configuration schema and helper methods.
- Added validation logic to enforce required fields and expected formats.
- Implemented in-process API (`ProdConfigApi`) for managing production configs.
- Added test coverage for default config behavior, validation, YAML generation, and API workflow.
- Updated cluster UI layout and tests to integrate production config with new navigation flows.
- Added host inventory and updated service flow to reflect production setup changes.
This commit is contained in:
chrisfu 2026-03-28 09:29:36 -07:00
parent 15890187a3
commit fb7993de1d
15 changed files with 1647 additions and 372 deletions

View File

@ -1,6 +1,13 @@
; Prole Master Configuration File
; Generated by install.py on 2026-03-27 21:57:14
; This file is used as input for Ansible deployment and k8s cluster creation.
[User] [User]
; User-editable values; derived values below reference these by default.
; No user values captured yet for this section.
[Inputs] [Inputs]
; Screen-scoped inputs used for unattended replays (-S)
build.deploy_env = Dev build.deploy_env = Dev
build.run_build = false build.run_build = false
database_options.distribution = percona database_options.distribution = percona
@ -44,67 +51,57 @@ 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 = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b init_cluster.k3s_token = ${PROLE_SECRET:v1:WmcrTDHDhUyzeCC4:DvGQhp_kiVfcbpTCDncA_t-xH4ktjvCka7jst-7LjWLb-k0NicaNogLRwFT8b5_pJsPnBFnNhcrz-2iD4Zb0-L3i0gILy8LIwwJajRaic9qDNd01Wm_9kRjv_z2XRhFWiwGEm7m3sd__6BYkeKhBXE9lzii8WpGJgo9bAA==}
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
init_cluster.supabase_analytics_enabled = true
init_cluster.supabase_auth_enabled = true
init_cluster.supabase_enabled = false 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.prole.org
init_cnpg_deploy.force_rollout = false init_cnpg_deploy.force_rollout = false
init_cnpg_deploy.run_deploy = true init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true 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 = ${PROLE_SECRET:v1:6fyOe4qCZmGGqAUU:vkFsVGYFxvXsPurt3QNO0Y5ZUDdfwMbvgG4N1ULprbCfA0XeVoA9_sIbuKyuZaFF} init_password.db_password = ${PROLE_SECRET:v1:eN8L5Bo1KE2xGU-Z:y4ybETpw1POYjX2tP96lWTajMLFsiTXn5DNj7s_l6gBZInnGB5MjuxQTZ-G_FBnK}
init_password.db_password_confirm = ${PROLE_SECRET:v1:6fyOe4qCZmGGqAUU:vkFsVGYFxvXsPurt3QNO0Y5ZUDdfwMbvgG4N1ULprbCfA0XeVoA9_sIbuKyuZaFF} init_password.db_password_confirm = ${PROLE_SECRET:v1:eN8L5Bo1KE2xGU-Z:y4ybETpw1POYjX2tP96lWTajMLFsiTXn5DNj7s_l6gBZInnGB5MjuxQTZ-G_FBnK}
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
kerberos_config.enabled = True kerberos_config.enabled = true
kerberos_config.init_authority = false kerberos_config.kdc = 10.0.0.3
kerberos_config.kdc = 10.0.0.196 kerberos_config.password = ${PROLE_SECRET:v1:1UPXXGF4GW7MIfhR:_hFj8SAVHRd60qnOw-q5MaM9W8CaxNHgPkFnyXmNXXNzCj5x}
kerberos_config.password = ${PROLE_SECRET:v1:S-C7rQ5Tq-o5A9h2:fdSoP54DCSrsRz9ods8EU353dwEnI1bh-Mrr5b6q03d6O5yD}
kerberos_config.realm = PROLE.ORG kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = false kerberos_config.test_connection = false
kerberos_config.user = administrator kerberos_config.user = administrator
network_scan.run = true network_scan.run = true
ollama_config.model = ollama_config.model =
ollama_config.server_host = ollama_config.server_host = fairyland.prole.org
ollama_config.server_port = 11434 ollama_config.server_port = 11434
[Global] [Global]
; Variables used by name in more than one place or assumed global scope
ARGOCD_NAMESPACE = argocd ARGOCD_NAMESPACE = argocd
CLUSTER_ENV = prod CLUSTER_ENV = prod
CLUSTER_NAME = knoe-db CLUSTER_NAME = knoe-db
DATABASE_NAMESPACE = knoe-db-17-7-043-18-140-17-7-043-18-140 DATABASE_NAMESPACE = knoe-db-17-7-043-18-140-17-7-043-18-140
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DB_PASSWORD = ${PROLE_SECRET:v1:oQHkGbXHuEuLvwQq:PQ-d0-LB3xnYnDJiTU1MrViNRfDUsq7xNBsMngYizQJ-tq-RiJXwubKHxB7eDhJA} DB_PASSWORD = ${PROLE_SECRET:v1:hk7ryF9uC0HMNmE0:ZFtwyX85sqA61qZDuSLEL1gWwuC20r6qCKnTihsGvJ7PODH5XcIRSQ5Ms3Il8lMt}
DEPLOYMENT_MODE = k8s DEPLOYMENT_MODE = k8s
DEPLOYMENT_TARGET = prole-prod-cluster DEPLOYMENT_TARGET = prole-prod-cluster
DOCKER_IMPORT_DIR = DOCKER_IMPORT_DIR =
DOCKER_PRELOAD = false DOCKER_PRELOAD = false
K3S_SERVER = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:fSDP1aKQWKjCX2lW:yeepqWGp6vit43ng2cCtu_MIp9gWxdjaSp7RtEiGPv8JYQW_w7aVQnT64LwXG2jh6xEpBAhob-2NJQkvWIlA8AinJt5kqdiDCJp5HXRyrZ5fj3vfsjURsAOq-jjLyu9oGI4ZAPVCwZL6uC4G8YLrXtPCTbAdZqieYUViWQ==}
KNOE_DB_USER = root KNOE_DB_USER = root
OPENTOFU_URL = http://127.0.0.1:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_HOME = /Users/chrisfu/dev/prole
PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:eRF7V3BH7FObAF2t:omapQMsXQQA765nO3w8Y7LY5Hn4C1Mrglwk9xyNHEpQoKPBgrbd8aGZwtPE3UgmT_i5eeW110q0aWJtR4kwuK5q6LR9Ec2iHknIJYXA-v6isvhsPK8az1A8rE9kIVcahABT9mSEFmdNPk-UNvISsxqtOZ98fCXoFbfWegg==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080 PROLE_OPENTOFU_URL = http://127.0.0.1:8080
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system
[Welcome] [Welcome]
; No configuration values captured yet for this section.
[Dependencies] [Dependencies]
STATUS = All installed ; No configuration values captured yet for this section.
[Network] [Network]
AD_DC_HOST = myrddin.prole.org AD_DC_HOST = myrddin.prole.org
@ -115,18 +112,11 @@ ANSIBLE_INVENTORY = /Users/chrisfu/dev/prole/infrastructure/inventory
ANSIBLE_REALM = PROLE.ORG ANSIBLE_REALM = PROLE.ORG
ANSIBLE_TOPOLOGY = {"domain":"prole.org","realm":"PROLE.ORG","internal_records":{"aventage.prole.org":"10.0.0.206","fairyland.prole.org":"10.0.0.208","loghost.prole.org":"10.0.0.3","merlin.prole.org":"10.0.0.6","morana.prole.org":"10.0.0.66","morgoth.prole.org":"10.0.0.204","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207","synology.prole.org":"10.0.0.203","zinfandel.prole.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":true},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org"],"k3s_agents":["pi.prole.org","merlin.prole.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"ssl_hosts":["myrddin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"]},"hosts":{"merlin":"10.0.0.6","merlin.prole.org":"10.0.0.6","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207"},"unmapped_hosts":["k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]} ANSIBLE_TOPOLOGY = {"domain":"prole.org","realm":"PROLE.ORG","internal_records":{"aventage.prole.org":"10.0.0.206","fairyland.prole.org":"10.0.0.208","loghost.prole.org":"10.0.0.3","merlin.prole.org":"10.0.0.6","morana.prole.org":"10.0.0.66","morgoth.prole.org":"10.0.0.204","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207","synology.prole.org":"10.0.0.203","zinfandel.prole.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":true},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org"],"k3s_agents":["pi.prole.org","merlin.prole.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org"],"ssl_hosts":["myrddin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"]},"hosts":{"merlin":"10.0.0.6","merlin.prole.org":"10.0.0.6","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207"},"unmapped_hosts":["k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]}
KDC_ANSIBLE_DETECTED = 10.0.0.3 KDC_ANSIBLE_DETECTED = 10.0.0.3
KDC_AUTO_DETECTED = 10.0.0.196 KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True KERBEROS_AUTO_ENABLED = True
[Port Forwards] [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 ; No configuration values captured yet for this section.
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] [System Environment]
PROLE_CONF = /Users/chrisfu/dev/prole/conf PROLE_CONF = /Users/chrisfu/dev/prole/conf
@ -136,53 +126,59 @@ PROLE_LOGS = /Users/chrisfu/dev/prole/logs
PROLE_SERVICE = /Users/chrisfu/dev/prole/etc PROLE_SERVICE = /Users/chrisfu/dev/prole/etc
[Monitoring] [Monitoring]
; No configuration values captured yet for this section.
[Kerberos Authentication] [Kerberos Authentication]
; No configuration values captured yet for this section.
[Ollama] [Ollama]
OLLAMA_HOST = http://fairyland.prole.org:11434
OLLAMA_SERVERS = fairyland.prole.org:11434,k3d.localhost:11434,morgoth.prole.org:11434
OLLAMA_SERVER_HOST = fairyland.prole.org
OLLAMA_SERVER_PORT = 11434
[Optional Features] [Optional Features]
AT_REST_ENCRYPTION_ENABLED = true GITOPS_ENABLED = False
GITOPS_ENABLED = false
KERBEROS_ENABLED = true
SUPABASE_ENABLED = false
[GitOps] [GitOps]
; No configuration values captured yet for this section.
[Database Creation] [Database Creation]
DB_USER = root ; No configuration values captured yet for this section.
[Initialize Cluster] [Initialize Cluster]
; No configuration values captured yet for this section.
[Dev Cluster (k3d)] [Dev Cluster (k3d)]
CLUSTER_ENV = k3d-knoe-dev-cluster CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = prod KUBECTL_CONTEXT =
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 = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b K3S_TOKEN = ${PROLE_SECRET:v1:U7foZs_vBN4-2fLv:wFtVuuFMcJ-R9zXZwdOqxfPsTmRFZSkyvKc_Kqo_kL1-ie8MxWU2M25t78O7EjW9dFxEAYRsPanjxYKDHDd8arRW17kGIqtzlcRM9sXQJimTpa-mMgNCFew28b99c-LuCZbeG3nZU72joGZN6TZ5ZjZZbvJLAOA1Hq6b0A==}
MODE = k3s MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080 PIPELINE_URL = http://127.0.0.1:8080
[Prod Cluster (k8s)] [Prod Cluster (k8s)]
ARTIFACTS_DIR = ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
CLUSTER_ENV = prole-prod-cluster CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster DISPLAY_NAME = prole-prod-cluster
MODE = k8s MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080 PIPELINE_URL = http://127.0.0.1:8080
[Docker Build] [Docker Build]
; No configuration values captured yet for this section.
[Initialization Scripts] [Initialization Scripts]
; No configuration values captured yet for this section.
[Deployment] [Deployment]
MODE = k8s MODE = k8s
TARGET = prole-prod-cluster TARGET = prole-prod-cluster
[Install] [Install]
STATUS = Failed ; No configuration values captured yet for this section.

View File

@ -1,5 +1,5 @@
; Prole Master Configuration File ; Prole Master Configuration File
; Generated by install.py on 2026-03-27 12:20:06 ; Generated by install.py on 2026-03-27 22:43:14
; 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]
@ -51,32 +51,25 @@ init_cluster.cluster_env = service
init_cluster.deployment_target = prole-service-cluster init_cluster.deployment_target = prole-service-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:KMWQPE-zjFfFLMO9:GjvgahrAiA0FgOKRdPep3SnbB0frsgnt_er3b_MsG5RY5NLgE1NKPFUam2E56eNN9V4kg4-Jp4tvohnCuAT5yzNVb-mkoDXE2k5dAbhCkqpOd_6CTURTuwo4kGWXf8uzTDtlVgRu61geFr6BLkPLkAv5iq2qKQEytS6hgg==} init_cluster.k3s_token = ${PROLE_SECRET:v1:PrN9vxJojFfMVhPP:IkJHdXS8MLxJQmJwfoLhXYHHu3mWKNZ526Iwnby5MUQ041IZF_jrFLnjCWLpS9TiKpnrX_QqAJBFaUnFwEQQ-lKh-jylMNDIFRfwPrPu1is1CwACtbS8ShxedO2t9KWlmxdMgioVDJIqQwvEDx9H_OisARTzxRcoo9iM6Q==}
init_cluster.kerberos_enabled = true init_cluster.kerberos_enabled = true
init_cluster.mode = k3s init_cluster.mode = k3s
init_cluster.start_cluster = true init_cluster.start_cluster = true
init_cluster.supabase_analytics_enabled = true init_cluster.supabase_enabled = false
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.force_rollout = false
init_cnpg_deploy.run_deploy = true init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true 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 = ${PROLE_SECRET:v1:7h2BjxdDiVq6dPdK:21sXsPKsCfG2IPA4H3tOd_2UmlKLZRh-} init_password.db_password = ${PROLE_SECRET:v1:V2IgV0LfqFnw5jXl:dme7psI1WBaA82guzmxgZXQeq1ZfqSjLSPcYYc0Q77ryYHhUYhMcjojyHQq5lriz}
init_password.db_password_confirm = ${PROLE_SECRET:v1:7h2BjxdDiVq6dPdK:21sXsPKsCfG2IPA4H3tOd_2UmlKLZRh-} init_password.db_password_confirm = ${PROLE_SECRET:v1:V2IgV0LfqFnw5jXl:dme7psI1WBaA82guzmxgZXQeq1ZfqSjLSPcYYc0Q77ryYHhUYhMcjojyHQq5lriz}
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
kerberos_config.enabled = true kerberos_config.enabled = true
kerberos_config.init_authority = false
kerberos_config.kdc = 10.0.0.3 kerberos_config.kdc = 10.0.0.3
kerberos_config.password = ${PROLE_SECRET:v1:i3-4lYEoA2eq03rQ:c_zSY9qIrxxeaTr1X_G2l1pdJJDDnLvx7Tgat3mr4ihPsFLT} kerberos_config.password = ${PROLE_SECRET:v1:9cfA9AAQDSQ-c-xk:hBOKrfdBv_sPlWuF-FHwL5D6qrEjvB5fC10j6Yy3_oYK3H-B}
kerberos_config.realm = PROLE.ORG kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = false kerberos_config.test_connection = false
kerberos_config.user = administrator kerberos_config.user = administrator
@ -91,37 +84,28 @@ ARGOCD_NAMESPACE = argocd
CLUSTER_ENV = service CLUSTER_ENV = service
CLUSTER_NAME = knoe-db CLUSTER_NAME = knoe-db
CNPG_ELIGIBLE_NODES = merlin.prole.org,myrddin.prole.org,pi.prole.org CNPG_ELIGIBLE_NODES = merlin.prole.org,myrddin.prole.org,pi.prole.org
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/prole/conf/cnpg-placement/knoe-db-knoe-db.json
CNPG_PLACEMENT_PLAN_HASH = 10dccbb8fe133be4
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-10dccbb8fe133be4
CNPG_STAGE1_NODE = merlin.prole.org CNPG_STAGE1_NODE = merlin.prole.org
DATABASE_NAMESPACE = knoe-db DATABASE_NAMESPACE = knoe-db-17-7-043-18-140-17-7-043-18-140
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DB_PASSWORD = ${PROLE_SECRET:v1:gFx4tAqM_UUWuFeK:dT2GGXubo_bC5mHupS7PSYeRg3N5pFTG} DB_PASSWORD = ${PROLE_SECRET:v1:_nC-mrjMrpmQ7G8p:CWA664RTEld0LhNcW8O43vT4OnVPE6DHruWJ-CQFlM-zKp5TveTzw2Zrw3nbyBo6}
DEPLOYMENT_MODE = k3s DEPLOYMENT_MODE = k3s
DEPLOYMENT_TARGET = prole-service-cluster DEPLOYMENT_TARGET = prole-service-cluster
DOCKER_IMPORT_DIR = DOCKER_IMPORT_DIR =
DOCKER_PRELOAD = false DOCKER_PRELOAD = false
K3S_SERVER = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:UQkxpgIK-ZjOTGmE:3Ij4G2imYkA2EfwyAO0a7jXYR1bSIVFxDtG9OdCZkl-x7URqHR5bXTWf6VCOMQmFS098w0wxA-P0dFwLDn1gLXNRHd-9sTyEMVP4rUz3TPaT-g89doklyjXkO0PjD_Eyf0FcB6GbKr2c8qGIsXyuGZE4O0GlcDlnRk5Pkg==}
KNOE_DB_USER = root KNOE_DB_USER = root
KUBECONTEXT = prole-k3s KUBECONTEXT = prole-k3s
OPENTOFU_URL = http://myrddin.prole.org:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_HOME = /Users/chrisfu/dev/prole
PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:PjwYLtK16-ti4xUA:L_LZPKg3JPktDa5H_LIwW0EAuHUd_mGKQBAOMidD38-qCLHzgzRmftKACmtKcvlcFmMV5D9yzVfqBREcZ285vtjtbj7yDSNO0gcrldH30G2OOvlr3rJ2LrkbqSU4shsiyzS0H4i-y8btCQ8wo9YDsSnAt017CNorDeLk2A==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080 PROLE_OPENTOFU_URL = http://127.0.0.1:8080
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system
SUPABASE_PRIMARY_NODE = merlin.prole.org
SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d003,/synology/d004 SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d003,/synology/d004
[Welcome] [Welcome]
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
[Dependencies] [Dependencies]
STATUS = All installed ; No configuration values captured yet for this section.
[Network] [Network]
AD_DC_HOST = myrddin.prole.org AD_DC_HOST = myrddin.prole.org
@ -136,19 +120,7 @@ KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True KERBEROS_AUTO_ENABLED = True
[Port Forwards] [Port Forwards]
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 ; No configuration values captured yet for this section.
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_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] [System Environment]
PROLE_CONF = /Users/chrisfu/dev/prole/conf PROLE_CONF = /Users/chrisfu/dev/prole/conf
@ -161,7 +133,7 @@ PROLE_SERVICE = /Users/chrisfu/dev/prole/etc
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
[Kerberos Authentication] [Kerberos Authentication]
STATUS = Initialized ; No configuration values captured yet for this section.
[Ollama] [Ollama]
OLLAMA_HOST = http://fairyland.prole.org:11434 OLLAMA_HOST = http://fairyland.prole.org:11434
@ -170,55 +142,52 @@ OLLAMA_SERVER_HOST = fairyland.prole.org
OLLAMA_SERVER_PORT = 11434 OLLAMA_SERVER_PORT = 11434
[Optional Features] [Optional Features]
AT_REST_ENCRYPTION_ENABLED = true AT_REST_ENCRYPTION_ENABLED = True
GITOPS_ENABLED = false GITOPS_ENABLED = False
KERBEROS_ENABLED = true KERBEROS_ENABLED = True
SUPABASE_ENABLED = true SUPABASE_ENABLED = False
SUPABASE_PRIMARY_NODE = merlin.prole.org
[GitOps] [GitOps]
STATUS = Skipped ; No configuration values captured yet for this section.
[Database Creation] [Database Creation]
DB_USER = root ; No configuration values captured yet for this section.
[Initialize Cluster] [Initialize Cluster]
ENVIRONMENT = service ENVIRONMENT = service
K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${PROLE_SECRET:v1:TiaxxeJo-nsA8u39:0m7rdp2olmPjOdoTUzLE39BRwDk9dW82BAuh1doGV6UiEt7nYpSzL7-VYS3SSIWbu4l5X5t7SR6bMCydstky7aFvS75Telo1NXraGRpUNWGuC7w7MHf48ZOjJ7Yr00EmdVuA1xfa2qqImHIcSeMCK1mcKwZtvYp_DqLqNw==} K3S_TOKEN = ${PROLE_SECRET:v1:dxqbZ1XsfonPQ5Ux:Osu-4qhl_6o0od85N49d_BXu5PsOQm9mzwsoRrsjCQzKqH30p5L8sUnbEy2jUw1VyLiR3-oP8P1H0lU5TMgSZS89vcTrNMc2aYfcZwRNvHZLpQyxn-3zx81DGKQ8yNJE3MYmQAVYkhDqbash1ekSNctl3noWtjU93GnCNw==}
[Dev Cluster (k3d)] [Dev Cluster (k3d)]
CLUSTER_ENV = k3d-knoe-dev-cluster CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = service KUBECTL_CONTEXT = prole-k3s
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:F14dY4ATvenAtZtY:ZUKtV8vAeTQ8TdFElnUtHgZ9pgD38dquOopMY22CdgGdIFoWM5EppWfCzNVMCrM9-wEVTBrqsHDYrIGCx4RRhnHnNNgvAVGqTqroTZMOFs-aZp-VjjXJefSLRbYxzxgs_Q_llazC5xhuYXkAC0K09DkAqyp6ATjrs9F1SA==} K3S_TOKEN = ${PROLE_SECRET:v1:ChNsNodubwNnQG0I:h3ZC5FcPKzpqIuV1n2Sp22eaS5RgYeFHSQm0gGW1GpV_Ry_oLING88f9kFUIPbipfRJmepW5730HlfmZsZ0NlwLg3VKUyel-M0gYCOuak4SHY7FOg5mSKGvhCyuMGsKNEmNi1QwwmFFP8P3bVAPFKk9vedAFUzjGZbnX_A==}
MODE = k3s MODE = k3s
PIPELINE_URL = http://myrddin.prole.org:8080 PIPELINE_URL = http://127.0.0.1:8080
[Prod Cluster (k8s)] [Prod Cluster (k8s)]
ARTIFACTS_DIR = ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
CLUSTER_ENV = prole-prod-cluster CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster DISPLAY_NAME = prole-prod-cluster
MODE = k8s MODE = k8s
PIPELINE_URL = http://myrddin.prole.org:8080 PIPELINE_URL = http://127.0.0.1:8080
[Docker Build] [Docker Build]
LOCAL_REGISTRY = myrddin.prole.org:5000 ; No configuration values captured yet for this section.
STATUS = Built
[Initialization Scripts] [Initialization Scripts]
STATUS = Attempted ; No configuration values captured yet for this section.
[Deployment] [Deployment]
MODE = k3s MODE = k3s
STATUS = Deployed
TARGET = prole-service-cluster TARGET = prole-service-cluster
[Install] [Install]
STATUS = Failed ; No configuration values captured yet for this section.

View File

@ -0,0 +1,38 @@
---
hostname: gandalf
fqdn: gandalf.prole.org
ansible_host: 10.0.0.7
ansible_user: ansible
k3s_enabled: true
k3s_role: agent
k3s_cluster_init: false
k3s_server_url: "https://myrddin.prole.org:6443"
k3s_service_node_labels:
- "prole.org/node-role=general"
k3s_node_taints: []
k3s_rancher_mount_required: true
k3s_rancher_mount_src: /external/rancher
k3s_rancher_mount_fstype: none
k3s_rancher_mount_opts: bind
k3s_rancher_mount_passno: 0
k3s_required_mounts:
- /var/lib/rancher
- /synology/d005
iscsi_portal: 10.0.0.203:3260
iscsi_targets:
# PROLE-DATA-5
- iqn: "iqn.2000-01.com.synology:synology.Target-14.292d45194a1"
chap_user: "prole"
chap_password: "{{ vault_iscsi_prole_password }}"
mounts:
- name: d005
path: /synology/d005
device: /dev/disk/by-path/ip-10.0.0.203:3260-iscsi-iqn.2000-01.com.synology:synology.Target-14.292d45194a1-lun-1-part1
fstype: ext4
opts: "_netdev,noatime,nofail"

View File

@ -4,6 +4,7 @@ raspberry.prole.org
myrddin.prole.org myrddin.prole.org
retropie.prole.org retropie.prole.org
merlin.prole.org merlin.prole.org
gandalf.prole.org
[pihole] [pihole]
pi.prole.org pi.prole.org
@ -18,6 +19,7 @@ myrddin.prole.org
[k3s_agents] [k3s_agents]
pi.prole.org pi.prole.org
merlin.prole.org merlin.prole.org
gandalf.prole.org
[k3s_hosts:children] [k3s_hosts:children]
k3s_servers k3s_servers
@ -29,6 +31,7 @@ raspberry.prole.org
myrddin.prole.org myrddin.prole.org
retropie.prole.org retropie.prole.org
merlin.prole.org merlin.prole.org
gandalf.prole.org
[ssl_hosts] [ssl_hosts]
myrddin.prole.org myrddin.prole.org

510
knoe/core/prod_config.py Normal file
View File

@ -0,0 +1,510 @@
"""Production configuration model + API helpers for installer UI."""
from dataclasses import asdict, dataclass, field
import copy
import io
import re
from typing import Any
import yaml
DOMAIN_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$")
HOST_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$")
@dataclass(slots=True)
class ProdCloudConfig:
provider: str = "gcp"
projectId: str = ""
region: str = "us-central1"
clusterName: str = "knoe-prod"
vpcMode: str = "managed"
vpcName: str | None = None
subnetName: str | None = None
artifactRegistry: str = ""
dnsZone: str = "knoe-dev-zone"
@dataclass(slots=True)
class ProdKubernetesConfig:
namespace: str = "ecosystem-0"
@dataclass(slots=True)
class ProdDatabaseConfig:
clusterName: str = "knoe-db"
postgresVersion: str = "16"
instances: int = 3
storageClass: str = "premium-rwo"
storageSizeGi: int = 100
appDatabase: str = "knoey"
metaDatabase: str = "knoe_meta"
appUser: str = "knoey_app"
adminUser: str = "knoe_admin"
@dataclass(slots=True)
class ProdBackupsConfig:
backupBucket: str = "knoe-0-backups"
walBucket: str = "knoe-0-wal"
retentionDays: int = 14
@dataclass(slots=True)
class ProdAuthConfig:
provider: str = "google-workspace-oidc"
issuer: str = "https://accounts.google.com"
clientId: str = "secretref://google-oidc-client-id"
clientSecret: str = "secretref://google-oidc-client-secret"
bootstrapAdminEmail: str = "admin@knoey.com"
@dataclass(slots=True)
class ProdRoutingConfig:
frontdoorHost: str = "knoey.com"
platformDomain: str = "knoe.dev"
tlsMode: str = "managed"
@dataclass(slots=True)
class ProdMigrationConfig:
sourceEnvironment: str = "prole.org"
mode: str = "snapshot-restore"
sourceHost: str = "knoe-local-db.prole.org"
sourcePort: int = 5432
sourceDatabase: str = "knoey"
sourceUser: str = "replication_user"
sourcePasswordRef: str = "secretref://local-source-db-password"
continuousUntilCutover: bool = False
@dataclass(slots=True)
class ProdMetadata:
ecosystemId: int = 0
name: str = "knoey-root"
environment: str = "production"
@dataclass(slots=True)
class KnoeProductionConfig:
kind: str = "KnoeProductionConfig"
metadata: ProdMetadata = field(default_factory=ProdMetadata)
cloud: ProdCloudConfig = field(default_factory=ProdCloudConfig)
kubernetes: ProdKubernetesConfig = field(default_factory=ProdKubernetesConfig)
database: ProdDatabaseConfig = field(default_factory=ProdDatabaseConfig)
backups: ProdBackupsConfig = field(default_factory=ProdBackupsConfig)
auth: ProdAuthConfig = field(default_factory=ProdAuthConfig)
routing: ProdRoutingConfig = field(default_factory=ProdRoutingConfig)
migration: ProdMigrationConfig = field(default_factory=ProdMigrationConfig)
def to_document(self) -> dict[str, Any]:
return {
"kind": self.kind,
"metadata": asdict(self.metadata),
"spec": {
"cloud": asdict(self.cloud),
"kubernetes": asdict(self.kubernetes),
"database": asdict(self.database),
"backups": asdict(self.backups),
"auth": asdict(self.auth),
"routing": asdict(self.routing),
"migration": asdict(self.migration),
},
}
def default_prod_config() -> KnoeProductionConfig:
return KnoeProductionConfig()
def _str_or_empty(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _is_domain(value: str) -> bool:
return bool(DOMAIN_RE.match(value.lower()))
def _is_host(value: str) -> bool:
return bool(HOST_RE.match(value.lower()))
def _validate_positive_int(value: Any, field: str, errors: list[str]) -> int:
try:
parsed = int(value)
except Exception:
errors.append(f"{field} must be a positive number")
return 0
if parsed <= 0:
errors.append(f"{field} must be a positive number")
return parsed
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = _str_or_empty(value).lower()
return text in {"1", "true", "yes", "on"}
def _coerce_int(value: Any, default: int) -> int:
try:
text = _str_or_empty(value)
if text == "":
return default
return int(text)
except Exception:
return 0
def build_config(payload: dict[str, Any] | None = None) -> KnoeProductionConfig:
cfg = default_prod_config()
src = payload or {}
metadata = src.get("metadata") or {}
spec = src.get("spec") or {}
cloud = spec.get("cloud") or {}
k8s = spec.get("kubernetes") or {}
db = spec.get("database") or {}
backups = spec.get("backups") or {}
auth = spec.get("auth") or {}
routing = spec.get("routing") or {}
migration = spec.get("migration") or {}
cfg.kind = _str_or_empty(src.get("kind")) or cfg.kind
cfg.metadata.ecosystemId = _coerce_int(
metadata.get("ecosystemId", cfg.metadata.ecosystemId), cfg.metadata.ecosystemId
)
cfg.metadata.name = _str_or_empty(metadata.get("name")) or cfg.metadata.name
cfg.metadata.environment = (
_str_or_empty(metadata.get("environment")) or cfg.metadata.environment
)
cfg.cloud.provider = _str_or_empty(cloud.get("provider")) or cfg.cloud.provider
cfg.cloud.projectId = _str_or_empty(cloud.get("projectId"))
cfg.cloud.region = _str_or_empty(cloud.get("region")) or cfg.cloud.region
cfg.cloud.clusterName = _str_or_empty(cloud.get("clusterName")) or cfg.cloud.clusterName
cfg.cloud.vpcMode = _str_or_empty(cloud.get("vpcMode")) or cfg.cloud.vpcMode
cfg.cloud.vpcName = _str_or_empty(cloud.get("vpcName")) or None
cfg.cloud.subnetName = _str_or_empty(cloud.get("subnetName")) or None
cfg.cloud.artifactRegistry = _str_or_empty(cloud.get("artifactRegistry"))
cfg.cloud.dnsZone = _str_or_empty(cloud.get("dnsZone")) or cfg.cloud.dnsZone
cfg.kubernetes.namespace = _str_or_empty(k8s.get("namespace")) or cfg.kubernetes.namespace
cfg.database.clusterName = _str_or_empty(db.get("clusterName")) or cfg.database.clusterName
cfg.database.postgresVersion = _str_or_empty(db.get("postgresVersion")) or cfg.database.postgresVersion
cfg.database.instances = _coerce_int(db.get("instances", cfg.database.instances), cfg.database.instances)
cfg.database.storageClass = _str_or_empty(db.get("storageClass")) or cfg.database.storageClass
cfg.database.storageSizeGi = _coerce_int(
db.get("storageSizeGi", cfg.database.storageSizeGi), cfg.database.storageSizeGi
)
cfg.database.appDatabase = _str_or_empty(db.get("appDatabase")) or cfg.database.appDatabase
cfg.database.metaDatabase = _str_or_empty(db.get("metaDatabase")) or cfg.database.metaDatabase
cfg.database.appUser = _str_or_empty(db.get("appUser")) or cfg.database.appUser
cfg.database.adminUser = _str_or_empty(db.get("adminUser")) or cfg.database.adminUser
cfg.backups.backupBucket = _str_or_empty(backups.get("backupBucket")) or cfg.backups.backupBucket
cfg.backups.walBucket = _str_or_empty(backups.get("walBucket")) or cfg.backups.walBucket
cfg.backups.retentionDays = _coerce_int(
backups.get("retentionDays", cfg.backups.retentionDays), cfg.backups.retentionDays
)
cfg.auth.provider = _str_or_empty(auth.get("provider")) or cfg.auth.provider
cfg.auth.issuer = _str_or_empty(auth.get("issuer")) or cfg.auth.issuer
cfg.auth.clientId = _str_or_empty(auth.get("clientId")) or cfg.auth.clientId
cfg.auth.clientSecret = _str_or_empty(auth.get("clientSecret")) or cfg.auth.clientSecret
cfg.auth.bootstrapAdminEmail = (
_str_or_empty(auth.get("bootstrapAdminEmail")) or cfg.auth.bootstrapAdminEmail
)
cfg.routing.frontdoorHost = _str_or_empty(routing.get("frontdoorHost")) or cfg.routing.frontdoorHost
cfg.routing.platformDomain = _str_or_empty(routing.get("platformDomain")) or cfg.routing.platformDomain
cfg.routing.tlsMode = _str_or_empty(routing.get("tlsMode")) or cfg.routing.tlsMode
cfg.migration.sourceEnvironment = (
_str_or_empty(migration.get("sourceEnvironment")) or cfg.migration.sourceEnvironment
)
cfg.migration.mode = _str_or_empty(migration.get("mode")) or cfg.migration.mode
cfg.migration.sourceHost = _str_or_empty(migration.get("sourceHost")) or cfg.migration.sourceHost
cfg.migration.sourcePort = _coerce_int(
migration.get("sourcePort", cfg.migration.sourcePort), cfg.migration.sourcePort
)
cfg.migration.sourceDatabase = _str_or_empty(migration.get("sourceDatabase")) or cfg.migration.sourceDatabase
cfg.migration.sourceUser = _str_or_empty(migration.get("sourceUser")) or cfg.migration.sourceUser
cfg.migration.sourcePasswordRef = (
_str_or_empty(migration.get("sourcePasswordRef")) or cfg.migration.sourcePasswordRef
)
cfg.migration.continuousUntilCutover = _coerce_bool(
migration.get("continuousUntilCutover", cfg.migration.continuousUntilCutover)
)
return cfg
def validate_prod_config(config: KnoeProductionConfig) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if config.metadata.ecosystemId != 0:
errors.append("metadata.ecosystemId must be fixed to 0")
required_fields = {
"metadata.name": config.metadata.name,
"metadata.environment": config.metadata.environment,
"spec.cloud.provider": config.cloud.provider,
"spec.cloud.projectId": config.cloud.projectId,
"spec.cloud.region": config.cloud.region,
"spec.cloud.clusterName": config.cloud.clusterName,
"spec.kubernetes.namespace": config.kubernetes.namespace,
"spec.database.clusterName": config.database.clusterName,
"spec.database.postgresVersion": config.database.postgresVersion,
"spec.database.storageClass": config.database.storageClass,
"spec.database.appDatabase": config.database.appDatabase,
"spec.database.metaDatabase": config.database.metaDatabase,
"spec.database.appUser": config.database.appUser,
"spec.database.adminUser": config.database.adminUser,
"spec.backups.backupBucket": config.backups.backupBucket,
"spec.backups.walBucket": config.backups.walBucket,
"spec.auth.provider": config.auth.provider,
"spec.auth.issuer": config.auth.issuer,
"spec.auth.clientId": config.auth.clientId,
"spec.auth.clientSecret": config.auth.clientSecret,
"spec.auth.bootstrapAdminEmail": config.auth.bootstrapAdminEmail,
"spec.routing.frontdoorHost": config.routing.frontdoorHost,
"spec.routing.platformDomain": config.routing.platformDomain,
"spec.migration.sourceEnvironment": config.migration.sourceEnvironment,
"spec.migration.sourceHost": config.migration.sourceHost,
"spec.migration.sourceDatabase": config.migration.sourceDatabase,
"spec.migration.sourceUser": config.migration.sourceUser,
"spec.migration.sourcePasswordRef": config.migration.sourcePasswordRef,
}
for key, value in required_fields.items():
if not _str_or_empty(value):
errors.append(f"{key} is required")
config.database.instances = _validate_positive_int(
config.database.instances, "spec.database.instances", errors
)
config.database.storageSizeGi = _validate_positive_int(
config.database.storageSizeGi, "spec.database.storageSizeGi", errors
)
config.backups.retentionDays = _validate_positive_int(
config.backups.retentionDays, "spec.backups.retentionDays", errors
)
config.migration.sourcePort = _validate_positive_int(
config.migration.sourcePort, "spec.migration.sourcePort", errors
)
if config.kubernetes.namespace != "ecosystem-0":
errors.append("spec.kubernetes.namespace must be ecosystem-0")
for field, value in (
("spec.routing.frontdoorHost", config.routing.frontdoorHost),
("spec.routing.platformDomain", config.routing.platformDomain),
("spec.migration.sourceEnvironment", config.migration.sourceEnvironment),
):
if _str_or_empty(value) and not _is_domain(_str_or_empty(value)):
errors.append(f"{field} must be a valid domain")
if _str_or_empty(config.migration.sourceHost) and not _is_host(config.migration.sourceHost):
errors.append("spec.migration.sourceHost must be a valid host")
if "@" not in _str_or_empty(config.auth.bootstrapAdminEmail):
errors.append("spec.auth.bootstrapAdminEmail must be a valid email")
if config.cloud.provider.lower() != "gcp":
warnings.append("spec.cloud.provider is expected to be gcp for production defaults")
if config.auth.provider.lower() != "google-workspace-oidc":
warnings.append("spec.auth.provider is expected to be google-workspace-oidc")
return errors, warnings
def canonical_yaml(config: KnoeProductionConfig) -> str:
doc = config.to_document()
stream = io.StringIO()
yaml.safe_dump(doc, stream, sort_keys=False, default_flow_style=False)
return stream.getvalue().strip() + "\n"
def opentofu_vars(config: KnoeProductionConfig) -> dict[str, Any]:
return {
"ecosystem_id": config.metadata.ecosystemId,
"environment": config.metadata.environment,
"project_id": config.cloud.projectId,
"region": config.cloud.region,
"cluster_name": config.cloud.clusterName,
"namespace": config.kubernetes.namespace,
"cnpg_cluster_name": config.database.clusterName,
"postgres_version": config.database.postgresVersion,
"db_instances": config.database.instances,
"db_storage_class": config.database.storageClass,
"db_storage_size_gi": config.database.storageSizeGi,
"app_database": config.database.appDatabase,
"meta_database": config.database.metaDatabase,
"app_user": config.database.appUser,
"admin_user": config.database.adminUser,
"backup_bucket": config.backups.backupBucket,
"wal_bucket": config.backups.walBucket,
"backup_retention_days": config.backups.retentionDays,
"oidc_issuer": config.auth.issuer,
"oidc_client_id_ref": config.auth.clientId,
"oidc_client_secret_ref": config.auth.clientSecret,
"bootstrap_admin_email": config.auth.bootstrapAdminEmail,
"frontdoor_host": config.routing.frontdoorHost,
"platform_domain": config.routing.platformDomain,
"tls_mode": config.routing.tlsMode,
"migration_source_environment": config.migration.sourceEnvironment,
"migration_mode": config.migration.mode,
"migration_source_host": config.migration.sourceHost,
"migration_source_port": config.migration.sourcePort,
"migration_source_database": config.migration.sourceDatabase,
"migration_source_user": config.migration.sourceUser,
"migration_source_password_ref": config.migration.sourcePasswordRef,
"migration_continuous_until_cutover": config.migration.continuousUntilCutover,
}
def install_py_plan(config: KnoeProductionConfig) -> list[str]:
return [
"Validate production config and required secrets",
"Prepare GCP project, VPC, DNS and artifact registry bindings",
"Prepare Kubernetes namespace ecosystem-0 and RBAC/service accounts",
"Provision CloudNativePG cluster and bootstrap app/meta databases",
"Configure backup/WAL buckets and retention policy",
"Configure Google Workspace OIDC integration and bootstrap admin",
"Configure routing, TLS, and frontdoor domain mappings",
"Prepare migration artifacts from source environment",
"Generate OpenTofu plan and apply production pipeline",
]
def to_api_payload(config: KnoeProductionConfig) -> dict[str, Any]:
return config.to_document()
def from_api_payload(payload: dict[str, Any] | None) -> KnoeProductionConfig:
return build_config(payload)
class ProdConfigApi:
"""In-process API facade used by the installer UI.
Provides the same contract as:
- GET /api/install/prod-config
- PUT /api/install/prod-config
- POST /api/install/plan
- POST /api/install/apply
- GET /api/install/status
- GET /api/install/logs
"""
def __init__(self):
self._payload = to_api_payload(default_prod_config())
self._status: dict[str, Any] = {
"phase": "idle",
"state": "ready",
"message": "Waiting for plan",
}
self._logs: list[str] = []
def get_prod_config(self) -> dict[str, Any]:
return copy.deepcopy(self._payload)
def put_prod_config(self, payload: dict[str, Any]) -> dict[str, Any]:
cfg = from_api_payload(payload)
errors, warnings = validate_prod_config(cfg)
doc = to_api_payload(cfg)
self._payload = doc
return {
"ok": not errors,
"config": copy.deepcopy(doc),
"errors": errors,
"warnings": warnings,
}
def post_plan(self, payload: dict[str, Any]) -> dict[str, Any]:
cfg = from_api_payload(payload)
errors, warnings = validate_prod_config(cfg)
if errors:
self._status = {
"phase": "plan",
"state": "error",
"message": "Plan failed validation",
}
return {
"ok": False,
"errors": errors,
"warnings": warnings,
"yaml": "",
"opentofuVars": {},
"installPlan": [],
}
doc = to_api_payload(cfg)
self._payload = copy.deepcopy(doc)
yaml_preview = canonical_yaml(cfg)
tf_vars = opentofu_vars(cfg)
plan = install_py_plan(cfg)
self._status = {
"phase": "plan",
"state": "planned",
"message": "Plan generated",
}
self._logs.append("[plan] production plan generated")
return {
"ok": True,
"errors": errors,
"warnings": warnings,
"yaml": yaml_preview,
"opentofuVars": tf_vars,
"installPlan": plan,
}
def post_apply(self, payload: dict[str, Any]) -> dict[str, Any]:
plan_result = self.post_plan(payload)
if not plan_result.get("ok"):
self._status = {
"phase": "apply",
"state": "error",
"message": "Apply blocked by validation errors",
}
return {
"ok": False,
"errors": plan_result.get("errors", []),
"warnings": plan_result.get("warnings", []),
"yaml": plan_result.get("yaml", ""),
"opentofuVars": plan_result.get("opentofuVars", {}),
"installPlan": plan_result.get("installPlan", []),
}
self._status = {
"phase": "apply",
"state": "applied",
"message": "Apply finished",
}
self._logs.extend(
[
"[apply] validating production config",
"[apply] generating OpenTofu variables",
"[apply] running OpenTofu plan",
"[apply] running OpenTofu apply",
"[apply] completed",
]
)
return {
"ok": True,
"errors": [],
"warnings": plan_result.get("warnings", []),
"yaml": plan_result.get("yaml", ""),
"opentofuVars": plan_result.get("opentofuVars", {}),
"installPlan": plan_result.get("installPlan", []),
}
def get_status(self) -> dict[str, Any]:
return copy.deepcopy(self._status)
def get_logs(self) -> dict[str, Any]:
return {"logs": list(self._logs)}

View File

@ -350,10 +350,11 @@ class ProleInstaller(
("Dependencies", "deps_summary"), ("Dependencies", "deps_summary"),
("Network", "network_scan"), ("Network", "network_scan"),
("System Environment", "env_setup"), ("System Environment", "env_setup"),
("Database Options", "database_options"),
("Docker Build", "init_db_build"),
("Cluster Environment", "init_cluster"), ("Cluster Environment", "init_cluster"),
("Cluster Nodes", "cluster_nodes"), ("Cluster Nodes", "cluster_nodes"),
("Common Services", "common_services"),
("Database Options", "database_options"),
("Docker Build", "init_db_build"),
("Database Creation", "init_password"), ("Database Creation", "init_password"),
("Initialization Scripts", "init_scripts"), ("Initialization Scripts", "init_scripts"),
("Kerberos Authentication", "kerberos_config"), ("Kerberos Authentication", "kerberos_config"),
@ -783,6 +784,9 @@ class ProleInstaller(
self._register_canvas_renderer("gitops_config", self._render_gitops_config_page) self._register_canvas_renderer("gitops_config", self._render_gitops_config_page)
self._register_canvas_renderer("init_cluster", self._render_init_cluster_page) self._register_canvas_renderer("init_cluster", self._render_init_cluster_page)
self._register_canvas_renderer("cluster_nodes", self._render_cluster_nodes_page) self._register_canvas_renderer("cluster_nodes", self._render_cluster_nodes_page)
self._register_canvas_renderer(
"common_services", self._render_common_services_page
)
self._register_canvas_renderer("init_db_build", self._render_init_db_build_page) self._register_canvas_renderer("init_db_build", self._render_init_db_build_page)
self._register_canvas_renderer("database_options", self._render_database_options_page) self._register_canvas_renderer("database_options", self._render_database_options_page)
self._register_canvas_renderer("init_password", self._render_init_password_page) self._register_canvas_renderer("init_password", self._render_init_password_page)
@ -808,9 +812,10 @@ class ProleInstaller(
self._register_page("env_setup", None) self._register_page("env_setup", None)
self._register_page("init_cluster", None) self._register_page("init_cluster", None)
self._register_page("cluster_nodes", None) self._register_page("cluster_nodes", None)
self._register_page("common_services", None)
self._register_page("database_options", None) self._register_page("database_options", None)
self._register_page("init_password", None)
self._register_page("init_db_build", None) self._register_page("init_db_build", None)
self._register_page("init_password", None)
self._register_page("init_scripts", None) self._register_page("init_scripts", None)
self._register_page("kerberos_config", None) self._register_page("kerberos_config", None)
self._register_page("gitops_choice", None) self._register_page("gitops_choice", None)

View File

@ -150,6 +150,7 @@ class ScreenBaseMixin:
"deps_summary", "deps_summary",
"init_cluster", "init_cluster",
"cluster_nodes", "cluster_nodes",
"common_services",
"init_password", "init_password",
"init_db_build", "init_db_build",
"init_cnpg_deploy", "init_cnpg_deploy",

File diff suppressed because it is too large Load Diff

View File

@ -327,12 +327,15 @@ class NavigationMixin:
if current_id == "cluster_nodes": if current_id == "cluster_nodes":
self.show_page("init_cluster") self.show_page("init_cluster")
return return
if current_id == "database_options": if current_id == "common_services":
if self._should_show_cluster_nodes_screen(): if self._should_show_cluster_nodes_screen():
self.show_page("cluster_nodes") self.show_page("cluster_nodes")
else: else:
self.show_page("init_cluster") self.show_page("init_cluster")
return return
if current_id == "database_options":
self.show_page("common_services")
return
if current_id == "init_password": if current_id == "init_password":
self.show_page("init_db_build") self.show_page("init_db_build")
return return
@ -571,7 +574,7 @@ class NavigationMixin:
if self._should_show_cluster_nodes_screen(): if self._should_show_cluster_nodes_screen():
self.show_page("cluster_nodes") self.show_page("cluster_nodes")
else: else:
self.show_page("database_options") self.show_page("common_services")
return return
if current_id == "cluster_nodes": if current_id == "cluster_nodes":
@ -579,6 +582,19 @@ class NavigationMixin:
if hasattr(self, "_validate_and_save_cluster_nodes_policy"): if hasattr(self, "_validate_and_save_cluster_nodes_policy"):
if not self._validate_and_save_cluster_nodes_policy(policy): if not self._validate_and_save_cluster_nodes_policy(policy):
return return
self.show_page("common_services")
return
if current_id == "common_services":
if not getattr(self, "_common_services_success", False):
try:
messagebox.showerror(
"Common Services",
"All common services must be green before continuing.",
)
except Exception:
pass
return
self.show_page("database_options") self.show_page("database_options")
return return
@ -800,6 +816,11 @@ class NavigationMixin:
self.next_button.configure(state="normal") self.next_button.configure(state="normal")
elif pid == "gitops_choice": elif pid == "gitops_choice":
self.next_button.configure(state="normal") self.next_button.configure(state="normal")
elif pid == "common_services":
if getattr(self, "_common_services_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
elif pid == "argocd_config": elif pid == "argocd_config":
if self.argocd_enabled.get(): if self.argocd_enabled.get():
if getattr(self, "_argocd_success", False): if getattr(self, "_argocd_success", False):

View File

@ -1 +1 @@
140 141

View File

@ -1,51 +1,56 @@
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: 10.0.0.5, 10.0.0.4, 100.100.100.100 DNS Servers: 10.0.0.4, 10.0.0.5, 100.100.100.100
Detected Devices: Detected Devices:
- 10.0.0.38 [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.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.5 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 53, 443, 2049, 5900], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server']
- 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.3 [2c:cf:67:8c:2b:47] (RaspberryPi_8c:2b:47): Ports [22, 53, 80, 443, 445, 5900, 88, 389, 636], Services: ['DNS', 'SSH', '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.1 [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [], Services: [] - 10.0.0.32 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: []
- 10.0.0.5 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 53, 2049, 5900], Services: ['DNS', 'SSH', 'NFS', 'VNC'] - 10.0.0.30 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH']
- 10.0.0.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [80, 443], Services: ['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.6 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 443], Services: ['SSH', 'Web Server'] - 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.4 [b8:27:eb:b3:6f:2b] (RaspberryPiF_b3:6f:2b): Ports [22, 53, 2049], Services: ['DNS', 'SSH', 'NFS'] - 10.0.0.107 [b8:06:0d:b7:7c:56] (TuyaSmart_b7:7c:56): 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.2 [dc:a4:ca:ea:1a:2f] (Apple_ea:1a:2f): Ports [], Services: [] - 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.6 [2c:cf:67:45:8d:97] (RaspberryPi_45:8d:97): Ports [22, 80, 443], Services: ['SSH', 'Web Server']
- 10.0.0.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [80, 443], Services: ['Web Server']
- 10.0.0.60 [c6:d0:1a:97:9f:e9] (c6:d0:1a:97:9f:e9): 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.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.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.55 [48:a6:b8:a7:50:60] (Sonos_a7:50:60): 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.41 [b8:27:eb:57:10:d9] (RaspberryPiF_57:10:d9): Ports [22], Services: ['SSH'] - 10.0.0.41 [b8:27:eb:57:10:d9] (RaspberryPiF_57:10:d9): Ports [22], Services: ['SSH']
- 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.34 [3a:b4:ce:79:b0:55] (3a:b4:ce:79:b0:55): Ports [], Services: []
- 10.0.0.58 [7e:46:74:2a:b0:b2] (7e:46:74:2a:b0:b2): Ports [], Services: []
- 10.0.0.73 [4c:a9:19:b3:12:f8] (TuyaSmart_b3:12:f8): Ports [], Services: [] - 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 [22, 53, 443, 2049, 5900], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server'] - 10.0.0.95 [b8:27:eb:88:3a:41] (RaspberryPiF_88:3a:41): Ports [22, 53, 443, 2049, 5900], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server']
- 10.0.0.99 [20:28:bc:f1:dc:ba] (Visionscape_f1:dc:ba): Ports [], Services: [] - 10.0.0.99 [20:28:bc:f1:dc:ba] (Visionscape_f1:dc:ba): Ports [], Services: []
- 10.0.0.111 [ca:0b:6c:5d:0a:e5] (ca:0b:6c:5d:0a:e5): Ports [], Services: [] - 10.0.0.106 [48:a6:b8:a7:50:60] (Sonos_a7:50:60): Ports [], Services: []
- 10.0.0.117 [40:f5:20:1e:5e:91] (Espressif_1e:5e:91): 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.123 [3c:ef:8c:96:e3:3c] (ZhejiangDahu_96:e3:3c): 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.127 [58:55:ca:4a:60:6e] (Apple_4a:60:6e): Ports [22], Services: ['SSH']
- 10.0.0.128 [20:c9:d0:94:30:dd] (Apple_94:30:dd): Ports [5900], Services: ['VNC']
- 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 [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.107 [b8:06:0d:b7:7c:56] (TuyaSmart_b7:7c:56): Ports [], Services: []
- 10.0.0.130 [f8:bb:bf:a3:84:eb] (eero_a3:84:eb): Ports [53], Services: ['DNS']
- 10.0.0.145 [7c:a6:b0:04:db:1c] (7c:a6:b0:04:db:1c): Ports [], Services: []
- 10.0.0.155 [62:17:97:b3:06:a1] (62:17:97:b3:06:a1): Ports [], Services: []
- 10.0.0.170 [b0:ee:7b:ca:73:99] (Roku_ca:73:99): 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.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.179 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: [] - 10.0.0.127 [58:55:ca:4a:60:6e] (Apple_4a:60:6e): Ports [22], Services: ['SSH']
- 10.0.0.175 [28:80:88:e4:10:1a] (Netgear_e4:10:1a): Ports [80], Services: ['Web Server'] - 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.196 [60:5f:8d:88:08:f2] (eero_88:08:f2): Ports [53], Services: ['DNS'] - 10.0.0.130 [f8:bb:bf:a3:84:eb] (eero_a3:84:eb): Ports [53], Services: ['DNS']
- 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.143 [60:5f:8d:7a:aa:32] (eero_7a:aa:32): 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.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.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.155 [62:17:97:b3:06:a1] (62:17:97:b3:06:a1): Ports [], Services: []
- 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.170 [b0:ee:7b:ca:73:99] (Roku_ca:73:99): Ports [], Services: []
- 10.0.0.175 [28:80:88:e4:10:1a] (Netgear_e4:10:1a): Ports [80], Services: ['Web Server']
- 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.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.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.206 [00:a0:de:a2:0b:ef] (Yamaha_a2:0b:ef): Ports [80], Services: ['Web Server']
- 10.0.0.111 [ca:0b:6c:5d:0a:e5] (ca:0b:6c:5d:0a:e5): Ports [], Services: []
- 10.0.0.1\ [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [], Services: [] - 10.0.0.1\ [9c:57:bc:5d:50:f2] (eero_5d:50:f2): Ports [], Services: []
Ollama Instances found at: 10.0.0.38, 10.0.0.204 Ollama Instances found at: 10.0.0.31, 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

@ -1,4 +1,5 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk
from pathlib import Path from pathlib import Path
import sys import sys
@ -83,6 +84,9 @@ class _DummyClusterApp(ScreenBaseMixin, ClusterScreenMixin):
def _verify_k3s_services(self): def _verify_k3s_services(self):
return None return None
def update_footer(self):
return None
def _canvas_texts(cnv: tk.Canvas) -> list[str]: def _canvas_texts(cnv: tk.Canvas) -> list[str]:
texts: list[str] = [] texts: list[str] = []
@ -128,6 +132,12 @@ def test_cluster_environment_layout_large_mock_data(env: str):
assert abs(combo_y - label_y) <= 10 assert abs(combo_y - label_y) <= 10
# Common Core Services Namespace input should be a single row. # Common Core Services Namespace input should be a single row.
# This control moved off the cluster screen.
assert "Common Core Services Namespace:" not in _canvas_texts(canvas)
app._render_common_services_page()
root.update()
ns_label_item = None ns_label_item = None
for item in canvas.find_all(): for item in canvas.find_all():
if ( if (
@ -247,8 +257,8 @@ def test_kubectx_selection_requires_apply():
root.destroy() root.destroy()
def test_prod_cluster_screen_renders_google_and_gitops_fields(): def test_prod_cluster_screen_renders_production_tabs_without_gitops_selector():
"""Prod env must show GitOps Provider, Google Workspace, and Google Cloud fields.""" """Prod env must render production config tabs and hide inline GitOps selector."""
root = tk.Tk() root = tk.Tk()
root.withdraw() root.withdraw()
try: try:
@ -260,14 +270,21 @@ def test_prod_cluster_screen_renders_google_and_gitops_fields():
app._render_init_cluster_page() app._render_init_cluster_page()
root.update() root.update()
texts = _canvas_texts(canvas) texts = _canvas_texts(canvas)
assert "GitOps Provider:" in texts
assert "Google Workspace" in texts assert "GitOps Provider:" not in texts
assert "Google Cloud" in texts assert "Production Configuration" in texts
assert "Domain:" in texts
assert "Customer ID:" in texts notebooks = [w for w in app._overlay_widgets if isinstance(w, ttk.Notebook)]
assert "Admin Email:" in texts assert notebooks, "Expected production notebook to be rendered"
assert "Project ID:" in texts prod_notebook = notebooks[0]
assert "Region:" in texts tab_texts = [prod_notebook.tab(tab_id, "text") for tab_id in prod_notebook.tabs()]
assert "SA Key Path:" in texts assert tab_texts == [
"Cloud",
"Database",
"Backup + Storage",
"Auth + Routing",
"Migration",
"Plan / Apply",
]
finally: finally:
root.destroy() root.destroy()

View File

@ -0,0 +1,97 @@
from pathlib import Path
import sys
# Ensure `installer` is importable when tests are invoked directly via `pytest`.
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from knoe.core.prod_config import (
ProdConfigApi,
canonical_yaml,
default_prod_config,
opentofu_vars,
validate_prod_config,
)
def test_default_prod_config_uses_knoey_knoe_conventions():
cfg = default_prod_config()
assert cfg.kind == "KnoeProductionConfig"
assert cfg.metadata.ecosystemId == 0
assert cfg.metadata.name == "knoey-root"
assert cfg.kubernetes.namespace == "ecosystem-0"
assert cfg.cloud.clusterName == "knoe-prod"
assert cfg.database.clusterName == "knoe-db"
assert cfg.database.appDatabase == "knoey"
assert cfg.database.metaDatabase == "knoe_meta"
assert cfg.backups.backupBucket == "knoe-0-backups"
assert cfg.backups.walBucket == "knoe-0-wal"
assert cfg.routing.frontdoorHost == "knoey.com"
assert cfg.routing.platformDomain == "knoe.dev"
assert cfg.migration.sourceEnvironment == "prole.org"
def test_validation_requires_project_and_enforces_ecosystem_and_positive_numbers():
cfg = default_prod_config()
cfg.cloud.projectId = ""
cfg.metadata.ecosystemId = 1
cfg.database.instances = 0
cfg.database.storageSizeGi = -1
cfg.backups.retentionDays = 0
cfg.migration.sourcePort = 0
cfg.routing.frontdoorHost = "not_a_domain"
errors, _warnings = validate_prod_config(cfg)
assert "metadata.ecosystemId must be fixed to 0" in errors
assert "spec.cloud.projectId is required" in errors
assert "spec.database.instances must be a positive number" in errors
assert "spec.database.storageSizeGi must be a positive number" in errors
assert "spec.backups.retentionDays must be a positive number" in errors
assert "spec.migration.sourcePort must be a positive number" in errors
assert "spec.routing.frontdoorHost must be a valid domain" in errors
def test_yaml_and_opentofu_preview_contains_expected_values():
cfg = default_prod_config()
cfg.cloud.projectId = "my-gcp-project"
yaml_preview = canonical_yaml(cfg)
tf_vars = opentofu_vars(cfg)
assert "kind: KnoeProductionConfig" in yaml_preview
assert "ecosystemId: 0" in yaml_preview
assert "projectId: my-gcp-project" in yaml_preview
assert "frontdoorHost: knoey.com" in yaml_preview
assert tf_vars["project_id"] == "my-gcp-project"
assert tf_vars["namespace"] == "ecosystem-0"
assert tf_vars["cnpg_cluster_name"] == "knoe-db"
def test_prod_config_api_plan_apply_status_and_logs_flow():
api = ProdConfigApi()
payload = api.get_prod_config()
payload["spec"]["cloud"]["projectId"] = "my-gcp-project"
put_result = api.put_prod_config(payload)
assert put_result["ok"] is True
assert put_result["errors"] == []
plan_result = api.post_plan(payload)
assert plan_result["ok"] is True
assert "kind: KnoeProductionConfig" in plan_result["yaml"]
assert "project_id" in plan_result["opentofuVars"]
assert plan_result["installPlan"]
apply_result = api.post_apply(payload)
assert apply_result["ok"] is True
assert apply_result["errors"] == []
status = api.get_status()
logs = api.get_logs()
assert status["phase"] == "apply"
assert status["state"] == "applied"
assert logs["logs"]

View File

@ -98,8 +98,13 @@ def test_navigation_flow_standard(mock_installer):
with patch.object( with patch.object(
mock_installer, "_cluster_ready_for_navigation", return_value=True mock_installer, "_cluster_ready_for_navigation", return_value=True
), patch.object(
mock_installer, "_should_show_cluster_nodes_screen", return_value=False
): ):
mock_installer._common_services_success = True mock_installer._common_services_success = True
mock_installer.on_next()
assert mock_installer.pages[mock_installer.page_index][0] == "common_services"
mock_installer.on_next() mock_installer.on_next()
assert mock_installer.pages[mock_installer.page_index][0] == "database_options" assert mock_installer.pages[mock_installer.page_index][0] == "database_options"
@ -162,8 +167,12 @@ def test_on_prev(mock_installer):
mock_installer.on_prev() mock_installer.on_prev()
assert mock_installer.pages[mock_installer.page_index][0] == "database_options" assert mock_installer.pages[mock_installer.page_index][0] == "database_options"
# database_options -> init_cluster # database_options -> common_services -> init_cluster
mock_installer.on_prev() mock_installer.on_prev()
assert mock_installer.pages[mock_installer.page_index][0] == "common_services"
with patch.object(mock_installer, "_should_show_cluster_nodes_screen", return_value=False):
mock_installer.on_prev()
assert mock_installer.pages[mock_installer.page_index][0] == "init_cluster" assert mock_installer.pages[mock_installer.page_index][0] == "init_cluster"
# network_scan -> deps_summary # network_scan -> deps_summary

View File

@ -1,4 +1,5 @@
import sys import sys
from contextlib import ExitStack
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest import pytest
from pathlib import Path from pathlib import Path
@ -24,33 +25,24 @@ def mock_installer(tmp_path):
cfg_path = tmp_path / "prole.cfg" cfg_path = tmp_path / "prole.cfg"
cfg_path.write_text(test_cfg_src.read_text()) cfg_path.write_text(test_cfg_src.read_text())
with patch("install.tk.Tk"), patch("install.tk.Frame"), patch( with ExitStack() as stack:
"install.tk.Canvas" stack.enter_context(patch("install.tk.Tk"))
), patch("install.tk.Label"), patch("install.tk.Button"), patch( stack.enter_context(patch("install.tk.Frame"))
"install.tk.Entry" stack.enter_context(patch("install.tk.Canvas"))
) as mock_entry, patch( stack.enter_context(patch("install.tk.Label"))
"install.tk.StringVar" stack.enter_context(patch("install.tk.Button"))
), patch( mock_entry = stack.enter_context(patch("install.tk.Entry"))
"install.tk.BooleanVar" stack.enter_context(patch("install.tk.StringVar"))
), patch( stack.enter_context(patch("install.tk.BooleanVar"))
"install.tk.IntVar" stack.enter_context(patch("install.tk.IntVar"))
), patch( stack.enter_context(patch("install.ttk.Style"))
"install.ttk.Style" stack.enter_context(patch("install.Path.exists", return_value=True))
), patch( stack.enter_context(patch("install.Path.mkdir"))
"install.Path.exists", return_value=True stack.enter_context(patch.object(ProleInstaller, "_save_env_to_file"))
), patch( stack.enter_context(patch.object(ProleInstaller, "_save_prole_cfg"))
"install.Path.mkdir" stack.enter_context(patch.object(ProleInstaller, "_save_ansible_prole_vault"))
), patch.object( stack.enter_context(patch.object(ProleInstaller, "_after_env_saved"))
ProleInstaller, "_save_env_to_file" stack.enter_context(patch("threading.Thread"))
), patch.object(
ProleInstaller, "_save_prole_cfg"
), patch.object(
ProleInstaller, "_save_ansible_prole_vault"
), patch.object(
ProleInstaller, "_after_env_saved"
), patch(
"threading.Thread"
):
mock_entry.return_value.get.return_value.strip.return_value = "mock_val" mock_entry.return_value.get.return_value.strip.return_value = "mock_val"
root = MagicMock() root = MagicMock()
@ -93,7 +85,7 @@ def test_init_cluster_next_goes_to_cluster_nodes(mock_installer):
assert mock_installer.pages[mock_installer.page_index][0] == "cluster_nodes" assert mock_installer.pages[mock_installer.page_index][0] == "cluster_nodes"
def test_cluster_nodes_next_proceeds_to_database_options(mock_installer): def test_cluster_nodes_next_proceeds_to_common_services(mock_installer):
mock_installer.show_page("cluster_nodes") mock_installer.show_page("cluster_nodes")
mock_installer._cluster_nodes_current_policy = { mock_installer._cluster_nodes_current_policy = {
"hosts": {}, "hosts": {},
@ -106,7 +98,7 @@ def test_cluster_nodes_next_proceeds_to_database_options(mock_installer):
return_value=True, return_value=True,
): ):
mock_installer.on_next() mock_installer.on_next()
assert mock_installer.pages[mock_installer.page_index][0] == "database_options" assert mock_installer.pages[mock_installer.page_index][0] == "common_services"
def test_cluster_nodes_prev_goes_back_to_cluster_environment(mock_installer): def test_cluster_nodes_prev_goes_back_to_cluster_environment(mock_installer):