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-editable values; derived values below reference these by default.
; No user values captured yet for this section.
[Inputs]
; Screen-scoped inputs used for unattended replays (-S)
build.deploy_env = Dev
build.run_build = false
database_options.distribution = percona
@ -44,67 +51,57 @@ init_cluster.cluster_env = prod
init_cluster.deployment_target = prole-prod-cluster
init_cluster.gitops_enabled = false
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.mode = k8s
init_cluster.start_cluster = true
init_cluster.supabase_analytics_enabled = true
init_cluster.supabase_auth_enabled = true
init_cluster.supabase_enabled = false
init_cluster.supabase_meta_enabled = true
init_cluster.supabase_realtime_enabled = true
init_cluster.supabase_studio_enabled = false
init_cluster.supabase_studio_url = db.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:6fyOe4qCZmGGqAUU:vkFsVGYFxvXsPurt3QNO0Y5ZUDdfwMbvgG4N1ULprbCfA0XeVoA9_sIbuKyuZaFF}
init_password.db_password_confirm = ${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:eN8L5Bo1KE2xGU-Z:y4ybETpw1POYjX2tP96lWTajMLFsiTXn5DNj7s_l6gBZInnGB5MjuxQTZ-G_FBnK}
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.196
kerberos_config.password = ${PROLE_SECRET:v1:S-C7rQ5Tq-o5A9h2:fdSoP54DCSrsRz9ods8EU353dwEnI1bh-Mrr5b6q03d6O5yD}
kerberos_config.enabled = true
kerberos_config.kdc = 10.0.0.3
kerberos_config.password = ${PROLE_SECRET:v1:1UPXXGF4GW7MIfhR:_hFj8SAVHRd60qnOw-q5MaM9W8CaxNHgPkFnyXmNXXNzCj5x}
kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = false
kerberos_config.user = administrator
network_scan.run = true
ollama_config.model =
ollama_config.server_host =
ollama_config.server_host = fairyland.prole.org
ollama_config.server_port = 11434
[Global]
; Variables used by name in more than one place or assumed global scope
ARGOCD_NAMESPACE = argocd
CLUSTER_ENV = prod
CLUSTER_NAME = knoe-db
DATABASE_NAMESPACE = knoe-db-17-7-043-18-140-17-7-043-18-140
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_TARGET = prole-prod-cluster
DOCKER_IMPORT_DIR =
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
OPENTOFU_URL = http://127.0.0.1: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 = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:eRF7V3BH7FObAF2t:omapQMsXQQA765nO3w8Y7LY5Hn4C1Mrglwk9xyNHEpQoKPBgrbd8aGZwtPE3UgmT_i5eeW110q0aWJtR4kwuK5q6LR9Ec2iHknIJYXA-v6isvhsPK8az1A8rE9kIVcahABT9mSEFmdNPk-UNvISsxqtOZ98fCXoFbfWegg==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system
[Welcome]
; No configuration values captured yet for this section.
[Dependencies]
STATUS = All installed
; No configuration values captured yet for this section.
[Network]
AD_DC_HOST = myrddin.prole.org
@ -115,18 +112,11 @@ ANSIBLE_INVENTORY = /Users/chrisfu/dev/prole/infrastructure/inventory
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"]}
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
[Port Forwards]
PORT_FORWARD_K3D_MAPPING_1 = id=argocd;namespace=argocd;target=svc/argocd-server;address=0.0.0.0;hostPort=8081;servicePort=80;protocol=TCP;description=ArgoCD
PORT_FORWARD_K3D_MAPPING_2 = id=garage;namespace=knoe-system;target=svc/garage;address=0.0.0.0;hostPort=3900;servicePort=3900;protocol=TCP;description=Garage S3
PORT_FORWARD_K3D_MAPPING_3 = id=openbao;namespace=knoe-system;target=svc/openbao;address=0.0.0.0;hostPort=8200;servicePort=8200;protocol=TCP;description=OpenBao
PORT_FORWARD_K3D_MAPPING_4 = id=opentofu;namespace=knoe-system;target=svc/opentofu;address=0.0.0.0;hostPort=8080;servicePort=8080;protocol=TCP;description=OpenTofu
PORT_FORWARD_K3D_MAPPING_5 = id=dashboard;namespace=kubernetes-dashboard;target=svc/kubernetes-dashboard-kong-proxy;address=127.0.0.1;hostPort=8443;servicePort=443;protocol=TCP;description=Kubernetes Dashboard
PORT_FORWARD_K3D_MAPPING_6 = id=postgres;namespace=${DATABASE_NAMESPACE};target=svc/knoe-db-rw;address=0.0.0.0;hostPort=5432;servicePort=5432;protocol=TCP;description=PostgreSQL (primary)
PORT_FORWARD_K3D_MAPPING_7 = id=prometheus;namespace=monitoring;target=svc/kps-kube-prometheus-stack-prometheus;address=127.0.0.1;hostPort=9090;servicePort=9090;protocol=TCP;description=Prometheus UI
PORT_FORWARD_K3D_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/kps-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana UI
; No configuration values captured yet for this section.
[System Environment]
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
[Monitoring]
; No configuration values captured yet for this section.
[Kerberos Authentication]
; No configuration values captured yet for this section.
[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]
AT_REST_ENCRYPTION_ENABLED = true
GITOPS_ENABLED = false
KERBEROS_ENABLED = true
SUPABASE_ENABLED = false
GITOPS_ENABLED = False
[GitOps]
; No configuration values captured yet for this section.
[Database Creation]
DB_USER = root
; No configuration values captured yet for this section.
[Initialize Cluster]
; No configuration values captured yet for this section.
[Dev Cluster (k3d)]
CLUSTER_ENV = k3d-knoe-dev-cluster
CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = prod
KUBECTL_CONTEXT =
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 = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
K3S_TOKEN = ${PROLE_SECRET:v1:U7foZs_vBN4-2fLv:wFtVuuFMcJ-R9zXZwdOqxfPsTmRFZSkyvKc_Kqo_kL1-ie8MxWU2M25t78O7EjW9dFxEAYRsPanjxYKDHDd8arRW17kGIqtzlcRM9sXQJimTpa-mMgNCFew28b99c-LuCZbeG3nZU72joGZN6TZ5ZjZZbvJLAOA1Hq6b0A==}
MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080
[Prod Cluster (k8s)]
ARTIFACTS_DIR =
ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster
MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080
[Docker Build]
; No configuration values captured yet for this section.
[Initialization Scripts]
; No configuration values captured yet for this section.
[Deployment]
MODE = k8s
TARGET = prole-prod-cluster
[Install]
STATUS = Failed
; No configuration values captured yet for this section.

View File

@ -1,5 +1,5 @@
; 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.
[User]
@ -51,32 +51,25 @@ init_cluster.cluster_env = service
init_cluster.deployment_target = prole-service-cluster
init_cluster.gitops_enabled = false
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.mode = k3s
init_cluster.start_cluster = true
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_cluster.supabase_enabled = false
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:7h2BjxdDiVq6dPdK:21sXsPKsCfG2IPA4H3tOd_2UmlKLZRh-}
init_password.db_password_confirm = ${PROLE_SECRET:v1:7h2BjxdDiVq6dPdK:21sXsPKsCfG2IPA4H3tOd_2UmlKLZRh-}
init_password.db_password = ${PROLE_SECRET:v1:V2IgV0LfqFnw5jXl:dme7psI1WBaA82guzmxgZXQeq1ZfqSjLSPcYYc0Q77ryYHhUYhMcjojyHQq5lriz}
init_password.db_password_confirm = ${PROLE_SECRET:v1:V2IgV0LfqFnw5jXl:dme7psI1WBaA82guzmxgZXQeq1ZfqSjLSPcYYc0Q77ryYHhUYhMcjojyHQq5lriz}
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: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.test_connection = false
kerberos_config.user = administrator
@ -91,37 +84,28 @@ ARGOCD_NAMESPACE = argocd
CLUSTER_ENV = service
CLUSTER_NAME = knoe-db
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
DATABASE_NAMESPACE = knoe-db
DATABASE_NAMESPACE = knoe-db-17-7-043-18-140-17-7-043-18-140
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_TARGET = prole-service-cluster
DOCKER_IMPORT_DIR =
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
KUBECONTEXT = prole-k3s
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 = 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
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system
SUPABASE_PRIMARY_NODE = merlin.prole.org
SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d003,/synology/d004
[Welcome]
; No configuration values captured yet for this section.
[Dependencies]
STATUS = All installed
; No configuration values captured yet for this section.
[Network]
AD_DC_HOST = myrddin.prole.org
@ -136,19 +120,7 @@ KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True
[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
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)
; No configuration values captured yet for this section.
[System Environment]
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.
[Kerberos Authentication]
STATUS = Initialized
; No configuration values captured yet for this section.
[Ollama]
OLLAMA_HOST = http://fairyland.prole.org:11434
@ -170,55 +142,52 @@ 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 = true
SUPABASE_PRIMARY_NODE = merlin.prole.org
AT_REST_ENCRYPTION_ENABLED = True
GITOPS_ENABLED = False
KERBEROS_ENABLED = True
SUPABASE_ENABLED = False
[GitOps]
STATUS = Skipped
; No configuration values captured yet for this section.
[Database Creation]
DB_USER = root
; No configuration values captured yet for this section.
[Initialize Cluster]
ENVIRONMENT = service
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)]
CLUSTER_ENV = k3d-knoe-dev-cluster
CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = service
KUBECTL_CONTEXT = prole-k3s
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:F14dY4ATvenAtZtY:ZUKtV8vAeTQ8TdFElnUtHgZ9pgD38dquOopMY22CdgGdIFoWM5EppWfCzNVMCrM9-wEVTBrqsHDYrIGCx4RRhnHnNNgvAVGqTqroTZMOFs-aZp-VjjXJefSLRbYxzxgs_Q_llazC5xhuYXkAC0K09DkAqyp6ATjrs9F1SA==}
K3S_TOKEN = ${PROLE_SECRET:v1:ChNsNodubwNnQG0I:h3ZC5FcPKzpqIuV1n2Sp22eaS5RgYeFHSQm0gGW1GpV_Ry_oLING88f9kFUIPbipfRJmepW5730HlfmZsZ0NlwLg3VKUyel-M0gYCOuak4SHY7FOg5mSKGvhCyuMGsKNEmNi1QwwmFFP8P3bVAPFKk9vedAFUzjGZbnX_A==}
MODE = k3s
PIPELINE_URL = http://myrddin.prole.org:8080
PIPELINE_URL = http://127.0.0.1:8080
[Prod Cluster (k8s)]
ARTIFACTS_DIR =
ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster
MODE = k8s
PIPELINE_URL = http://myrddin.prole.org:8080
PIPELINE_URL = http://127.0.0.1:8080
[Docker Build]
LOCAL_REGISTRY = myrddin.prole.org:5000
STATUS = Built
; No configuration values captured yet for this section.
[Initialization Scripts]
STATUS = Attempted
; No configuration values captured yet for this section.
[Deployment]
MODE = k3s
STATUS = Deployed
TARGET = prole-service-cluster
[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
retropie.prole.org
merlin.prole.org
gandalf.prole.org
[pihole]
pi.prole.org
@ -18,6 +19,7 @@ myrddin.prole.org
[k3s_agents]
pi.prole.org
merlin.prole.org
gandalf.prole.org
[k3s_hosts:children]
k3s_servers
@ -29,6 +31,7 @@ raspberry.prole.org
myrddin.prole.org
retropie.prole.org
merlin.prole.org
gandalf.prole.org
[ssl_hosts]
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"),
("Network", "network_scan"),
("System Environment", "env_setup"),
("Database Options", "database_options"),
("Docker Build", "init_db_build"),
("Cluster Environment", "init_cluster"),
("Cluster Nodes", "cluster_nodes"),
("Common Services", "common_services"),
("Database Options", "database_options"),
("Docker Build", "init_db_build"),
("Database Creation", "init_password"),
("Initialization Scripts", "init_scripts"),
("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("init_cluster", self._render_init_cluster_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("database_options", self._render_database_options_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("init_cluster", None)
self._register_page("cluster_nodes", None)
self._register_page("common_services", None)
self._register_page("database_options", None)
self._register_page("init_password", None)
self._register_page("init_db_build", None)
self._register_page("init_password", None)
self._register_page("init_scripts", None)
self._register_page("kerberos_config", None)
self._register_page("gitops_choice", None)

View File

@ -150,6 +150,7 @@ class ScreenBaseMixin:
"deps_summary",
"init_cluster",
"cluster_nodes",
"common_services",
"init_password",
"init_db_build",
"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":
self.show_page("init_cluster")
return
if current_id == "database_options":
if current_id == "common_services":
if self._should_show_cluster_nodes_screen():
self.show_page("cluster_nodes")
else:
self.show_page("init_cluster")
return
if current_id == "database_options":
self.show_page("common_services")
return
if current_id == "init_password":
self.show_page("init_db_build")
return
@ -571,7 +574,7 @@ class NavigationMixin:
if self._should_show_cluster_nodes_screen():
self.show_page("cluster_nodes")
else:
self.show_page("database_options")
self.show_page("common_services")
return
if current_id == "cluster_nodes":
@ -579,6 +582,19 @@ class NavigationMixin:
if hasattr(self, "_validate_and_save_cluster_nodes_policy"):
if not self._validate_and_save_cluster_nodes_policy(policy):
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")
return
@ -800,6 +816,11 @@ class NavigationMixin:
self.next_button.configure(state="normal")
elif pid == "gitops_choice":
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":
if self.argocd_enabled.get():
if getattr(self, "_argocd_success", False):

View File

@ -1 +1 @@
140
141

View File

@ -1,51 +1,56 @@
Network Discovery Summary:
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:
- 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.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.1 [9c:57:bc:5d:50:f2] (eero_5d:50:f2): 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.33 [ec:b5:fa:b0:76:e4] (PhilipsLight_b0:76:e4): Ports [80, 443], Services: ['Web Server']
- 10.0.0.3 [2c:cf:67:8c:2b:47] (RaspberryPi_8c:2b:47): Ports [22, 53, 80, 443, 2049, 445, 5900, 88, 389, 636], Services: ['DNS', 'SSH', 'NFS', 'VNC', 'Web Server', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related']
- 10.0.0.32 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: []
- 10.0.0.30 [2c:cf:67:ca:93:ce] (RaspberryPi_ca:93:ce): Ports [22], Services: ['SSH']
- 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, 2049], Services: ['DNS', 'SSH', 'NFS']
- 10.0.0.22 [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.107 [b8:06:0d:b7:7c:56] (TuyaSmart_b7:7c:56): 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.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.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.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.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.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.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.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.179 [48:a6:b8:a4:8e:cc] (Sonos_a4:8e:cc): Ports [], Services: []
- 10.0.0.175 [28:80:88:e4:10:1a] (Netgear_e4:10:1a): Ports [80], Services: ['Web Server']
- 10.0.0.196 [60:5f:8d:88:08:f2] (eero_88:08:f2): 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.199 [7c:a6:b0:01:a7:33] (7c:a6:b0:01:a7:33): Ports [], Services: []
- 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 [22, 445, 5900, 88], Services: ['SSH', 'VNC', 'SMB/CIFS (Possible Windows/AD)', 'Active Directory Related']
- 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.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.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: []
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:
Inventory directory: /Users/chrisfu/dev/prole/infrastructure

View File

@ -1,4 +1,5 @@
import tkinter as tk
from tkinter import ttk
from pathlib import Path
import sys
@ -83,6 +84,9 @@ class _DummyClusterApp(ScreenBaseMixin, ClusterScreenMixin):
def _verify_k3s_services(self):
return None
def update_footer(self):
return None
def _canvas_texts(cnv: tk.Canvas) -> 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
# 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
for item in canvas.find_all():
if (
@ -247,8 +257,8 @@ def test_kubectx_selection_requires_apply():
root.destroy()
def test_prod_cluster_screen_renders_google_and_gitops_fields():
"""Prod env must show GitOps Provider, Google Workspace, and Google Cloud fields."""
def test_prod_cluster_screen_renders_production_tabs_without_gitops_selector():
"""Prod env must render production config tabs and hide inline GitOps selector."""
root = tk.Tk()
root.withdraw()
try:
@ -260,14 +270,21 @@ def test_prod_cluster_screen_renders_google_and_gitops_fields():
app._render_init_cluster_page()
root.update()
texts = _canvas_texts(canvas)
assert "GitOps Provider:" in texts
assert "Google Workspace" in texts
assert "Google Cloud" in texts
assert "Domain:" in texts
assert "Customer ID:" in texts
assert "Admin Email:" in texts
assert "Project ID:" in texts
assert "Region:" in texts
assert "SA Key Path:" in texts
assert "GitOps Provider:" not in texts
assert "Production Configuration" in texts
notebooks = [w for w in app._overlay_widgets if isinstance(w, ttk.Notebook)]
assert notebooks, "Expected production notebook to be rendered"
prod_notebook = notebooks[0]
tab_texts = [prod_notebook.tab(tab_id, "text") for tab_id in prod_notebook.tabs()]
assert tab_texts == [
"Cloud",
"Database",
"Backup + Storage",
"Auth + Routing",
"Migration",
"Plan / Apply",
]
finally:
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(
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.on_next()
assert mock_installer.pages[mock_installer.page_index][0] == "common_services"
mock_installer.on_next()
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()
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()
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"
# network_scan -> deps_summary

View File

@ -1,4 +1,5 @@
import sys
from contextlib import ExitStack
from unittest.mock import patch, MagicMock
import pytest
from pathlib import Path
@ -24,33 +25,24 @@ def mock_installer(tmp_path):
cfg_path = tmp_path / "prole.cfg"
cfg_path.write_text(test_cfg_src.read_text())
with patch("install.tk.Tk"), patch("install.tk.Frame"), patch(
"install.tk.Canvas"
), patch("install.tk.Label"), patch("install.tk.Button"), patch(
"install.tk.Entry"
) as mock_entry, patch(
"install.tk.StringVar"
), patch(
"install.tk.BooleanVar"
), patch(
"install.tk.IntVar"
), patch(
"install.ttk.Style"
), patch(
"install.Path.exists", return_value=True
), patch(
"install.Path.mkdir"
), patch.object(
ProleInstaller, "_save_env_to_file"
), patch.object(
ProleInstaller, "_save_prole_cfg"
), patch.object(
ProleInstaller, "_save_ansible_prole_vault"
), patch.object(
ProleInstaller, "_after_env_saved"
), patch(
"threading.Thread"
):
with ExitStack() as stack:
stack.enter_context(patch("install.tk.Tk"))
stack.enter_context(patch("install.tk.Frame"))
stack.enter_context(patch("install.tk.Canvas"))
stack.enter_context(patch("install.tk.Label"))
stack.enter_context(patch("install.tk.Button"))
mock_entry = stack.enter_context(patch("install.tk.Entry"))
stack.enter_context(patch("install.tk.StringVar"))
stack.enter_context(patch("install.tk.BooleanVar"))
stack.enter_context(patch("install.tk.IntVar"))
stack.enter_context(patch("install.ttk.Style"))
stack.enter_context(patch("install.Path.exists", return_value=True))
stack.enter_context(patch("install.Path.mkdir"))
stack.enter_context(patch.object(ProleInstaller, "_save_env_to_file"))
stack.enter_context(patch.object(ProleInstaller, "_save_prole_cfg"))
stack.enter_context(patch.object(ProleInstaller, "_save_ansible_prole_vault"))
stack.enter_context(patch.object(ProleInstaller, "_after_env_saved"))
stack.enter_context(patch("threading.Thread"))
mock_entry.return_value.get.return_value.strip.return_value = "mock_val"
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"
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._cluster_nodes_current_policy = {
"hosts": {},
@ -106,7 +98,7 @@ def test_cluster_nodes_next_proceeds_to_database_options(mock_installer):
return_value=True,
):
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):