mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 14:44:32 +00:00
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:
parent
15890187a3
commit
fb7993de1d
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
38
infrastructure/inventory/host_vars/gandalf.prole.org.yml
Normal file
38
infrastructure/inventory/host_vars/gandalf.prole.org.yml
Normal 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"
|
||||
@ -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
510
knoe/core/prod_config.py
Normal 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)}
|
||||
@ -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)
|
||||
|
||||
@ -150,6 +150,7 @@ class ScreenBaseMixin:
|
||||
"deps_summary",
|
||||
"init_cluster",
|
||||
"cluster_nodes",
|
||||
"common_services",
|
||||
"init_password",
|
||||
"init_db_build",
|
||||
"init_cnpg_deploy",
|
||||
|
||||
@ -37,6 +37,7 @@ from knoe.config import (
|
||||
_write_k3s_kubeconfig,
|
||||
)
|
||||
from knoe.core.actions import _reset_k3s_namespace
|
||||
from knoe.core.prod_config import ProdConfigApi, build_config, to_api_payload
|
||||
|
||||
|
||||
class ClusterScreenMixin:
|
||||
@ -329,191 +330,302 @@ class ClusterScreenMixin:
|
||||
font=("SF Pro Text", 10),
|
||||
)
|
||||
y += 40
|
||||
self._ensure_prod_config_state()
|
||||
|
||||
# ── GitOps Provider ────────────────────────────────────
|
||||
self._canvas_items.append(
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label,
|
||||
y,
|
||||
"GitOps Provider:",
|
||||
"Production Configuration",
|
||||
fill="black",
|
||||
font=("SF Pro Text", 12, "bold"),
|
||||
)
|
||||
)
|
||||
y += 4
|
||||
for label, value in [
|
||||
("Gitea", "gitea"),
|
||||
("ArgoCD", "argocd"),
|
||||
("None", "none"),
|
||||
]:
|
||||
rb = tk.Radiobutton(
|
||||
self.bg_canvas,
|
||||
y += 24
|
||||
|
||||
notebook = ttk.Notebook(self.bg_canvas)
|
||||
cloud_tab = tk.Frame(notebook, bg="white")
|
||||
db_tab = tk.Frame(notebook, bg="white")
|
||||
backup_tab = tk.Frame(notebook, bg="white")
|
||||
auth_tab = tk.Frame(notebook, bg="white")
|
||||
migration_tab = tk.Frame(notebook, bg="white")
|
||||
plan_tab = tk.Frame(notebook, bg="white")
|
||||
|
||||
notebook.add(cloud_tab, text="Cloud")
|
||||
notebook.add(db_tab, text="Database")
|
||||
notebook.add(backup_tab, text="Backup + Storage")
|
||||
notebook.add(auth_tab, text="Auth + Routing")
|
||||
notebook.add(migration_tab, text="Migration")
|
||||
notebook.add(plan_tab, text="Plan / Apply")
|
||||
|
||||
for frame in [cloud_tab, db_tab, backup_tab, auth_tab, migration_tab, plan_tab]:
|
||||
frame.columnconfigure(1, weight=1)
|
||||
|
||||
def _add_row(
|
||||
frame: tk.Frame,
|
||||
row: int,
|
||||
label: str,
|
||||
key: str,
|
||||
width: int = 40,
|
||||
readonly: bool = False,
|
||||
) -> int:
|
||||
tk.Label(
|
||||
frame,
|
||||
text=label,
|
||||
variable=self.gitops_provider,
|
||||
value=value,
|
||||
bg="white",
|
||||
fg="black",
|
||||
font=("SF Pro Text", 10),
|
||||
anchor="w",
|
||||
).grid(row=row, column=0, sticky="w", padx=8, pady=3)
|
||||
entry = tk.Entry(
|
||||
frame,
|
||||
textvariable=self.prod_form_vars[key],
|
||||
width=width,
|
||||
bg="white",
|
||||
fg="black",
|
||||
insertbackground="black",
|
||||
highlightbackground="#CCCCCC",
|
||||
highlightthickness=1,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 10),
|
||||
)
|
||||
if readonly:
|
||||
entry.configure(state="readonly")
|
||||
entry.grid(row=row, column=1, sticky="ew", padx=8, pady=3)
|
||||
return row + 1
|
||||
|
||||
cloud_row = 0
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "Provider", "cloud.provider", readonly=True)
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "GCP Project ID", "cloud.projectId")
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "Region", "cloud.region")
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "Cluster", "cloud.clusterName")
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "VPC Mode", "cloud.vpcMode")
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "VPC Name", "cloud.vpcName")
|
||||
cloud_row = _add_row(cloud_tab, cloud_row, "Subnet Name", "cloud.subnetName")
|
||||
cloud_row = _add_row(
|
||||
cloud_tab, cloud_row, "Artifact Registry", "cloud.artifactRegistry"
|
||||
)
|
||||
_add_row(cloud_tab, cloud_row, "DNS Zone", "cloud.dnsZone")
|
||||
|
||||
db_row = 0
|
||||
db_row = _add_row(
|
||||
db_tab, db_row, "Namespace", "kubernetes.namespace", readonly=True
|
||||
)
|
||||
db_row = _add_row(db_tab, db_row, "CNPG Cluster", "database.clusterName")
|
||||
db_row = _add_row(db_tab, db_row, "Postgres Version", "database.postgresVersion")
|
||||
db_row = _add_row(db_tab, db_row, "Instances", "database.instances")
|
||||
db_row = _add_row(db_tab, db_row, "Storage Class", "database.storageClass")
|
||||
db_row = _add_row(db_tab, db_row, "Storage Size (Gi)", "database.storageSizeGi")
|
||||
db_row = _add_row(db_tab, db_row, "App Database", "database.appDatabase")
|
||||
db_row = _add_row(db_tab, db_row, "Meta Database", "database.metaDatabase")
|
||||
db_row = _add_row(db_tab, db_row, "App User", "database.appUser")
|
||||
_add_row(db_tab, db_row, "Admin User", "database.adminUser")
|
||||
|
||||
backup_row = 0
|
||||
backup_row = _add_row(
|
||||
backup_tab, backup_row, "Backup Bucket", "backups.backupBucket"
|
||||
)
|
||||
backup_row = _add_row(backup_tab, backup_row, "WAL Bucket", "backups.walBucket")
|
||||
_add_row(backup_tab, backup_row, "Retention Days", "backups.retentionDays")
|
||||
|
||||
auth_row = 0
|
||||
auth_row = _add_row(auth_tab, auth_row, "Auth Provider", "auth.provider", readonly=True)
|
||||
auth_row = _add_row(auth_tab, auth_row, "Issuer", "auth.issuer")
|
||||
auth_row = _add_row(auth_tab, auth_row, "OIDC Client ID Ref", "auth.clientId")
|
||||
auth_row = _add_row(auth_tab, auth_row, "OIDC Client Secret Ref", "auth.clientSecret")
|
||||
auth_row = _add_row(
|
||||
auth_tab,
|
||||
auth_row,
|
||||
"Bootstrap Admin Email",
|
||||
"auth.bootstrapAdminEmail",
|
||||
)
|
||||
auth_row = _add_row(auth_tab, auth_row, "Frontdoor Host", "routing.frontdoorHost")
|
||||
auth_row = _add_row(auth_tab, auth_row, "Platform Domain", "routing.platformDomain")
|
||||
_add_row(auth_tab, auth_row, "TLS Mode", "routing.tlsMode")
|
||||
|
||||
migration_row = 0
|
||||
migration_row = _add_row(
|
||||
migration_tab,
|
||||
migration_row,
|
||||
"Source Environment",
|
||||
"migration.sourceEnvironment",
|
||||
)
|
||||
migration_row = _add_row(migration_tab, migration_row, "Mode", "migration.mode")
|
||||
migration_row = _add_row(
|
||||
migration_tab, migration_row, "Source Host", "migration.sourceHost"
|
||||
)
|
||||
migration_row = _add_row(
|
||||
migration_tab, migration_row, "Source Port", "migration.sourcePort"
|
||||
)
|
||||
migration_row = _add_row(
|
||||
migration_tab,
|
||||
migration_row,
|
||||
"Source Database",
|
||||
"migration.sourceDatabase",
|
||||
)
|
||||
migration_row = _add_row(
|
||||
migration_tab, migration_row, "Source User", "migration.sourceUser"
|
||||
)
|
||||
migration_row = _add_row(
|
||||
migration_tab,
|
||||
migration_row,
|
||||
"Source Password Ref",
|
||||
"migration.sourcePasswordRef",
|
||||
)
|
||||
tk.Checkbutton(
|
||||
migration_tab,
|
||||
text="Continuous until cutover",
|
||||
variable=self.prod_bool_vars["migration.continuousUntilCutover"],
|
||||
bg="white",
|
||||
fg="black",
|
||||
activebackground="white",
|
||||
selectcolor="white",
|
||||
font=("SF Pro Text", 11),
|
||||
font=("SF Pro Text", 10),
|
||||
).grid(
|
||||
row=migration_row,
|
||||
column=0,
|
||||
columnspan=2,
|
||||
sticky="w",
|
||||
padx=8,
|
||||
pady=6,
|
||||
)
|
||||
rb_win = self.bg_canvas.create_window(
|
||||
x_label + 160 + ["gitea", "argocd", "none"].index(value) * 110,
|
||||
y - 2,
|
||||
window=rb,
|
||||
|
||||
btn_row = tk.Frame(plan_tab, bg="white")
|
||||
btn_row.grid(row=0, column=0, columnspan=2, sticky="w", padx=8, pady=4)
|
||||
save_btn = tk.Button(
|
||||
btn_row,
|
||||
text="Save Config",
|
||||
command=lambda: self._prod_put_config(show_dialog=True),
|
||||
)
|
||||
plan_btn = tk.Button(btn_row, text="Plan", command=self._on_prod_plan)
|
||||
apply_cfg_btn = tk.Button(btn_row, text="Apply", command=self._on_prod_apply)
|
||||
refresh_btn = tk.Button(
|
||||
btn_row, text="Refresh Status", command=self._on_prod_refresh_status
|
||||
)
|
||||
for idx, btn in enumerate([save_btn, plan_btn, apply_cfg_btn, refresh_btn]):
|
||||
btn.grid(row=0, column=idx, sticky="w", padx=(0, 6))
|
||||
|
||||
tk.Label(
|
||||
plan_tab,
|
||||
textvariable=self.prod_apply_status,
|
||||
bg="white",
|
||||
fg="#1c1c1e",
|
||||
anchor="w",
|
||||
font=("SF Pro Text", 10, "bold"),
|
||||
).grid(row=1, column=0, columnspan=2, sticky="ew", padx=8, pady=2)
|
||||
|
||||
preview_text = tk.Text(
|
||||
plan_tab,
|
||||
height=8,
|
||||
width=95,
|
||||
bg="#f8f8f8",
|
||||
fg="black",
|
||||
wrap="word",
|
||||
font=("SF Mono", 9),
|
||||
)
|
||||
preview_text.grid(row=2, column=0, columnspan=2, sticky="nsew", padx=8, pady=4)
|
||||
|
||||
tk.Label(
|
||||
plan_tab,
|
||||
text="Apply Progress Logs",
|
||||
bg="white",
|
||||
fg="black",
|
||||
anchor="w",
|
||||
font=("SF Pro Text", 10, "bold"),
|
||||
).grid(row=3, column=0, columnspan=2, sticky="w", padx=8, pady=(0, 2))
|
||||
logs_text = tk.Text(
|
||||
plan_tab,
|
||||
height=4,
|
||||
width=95,
|
||||
bg="#111111",
|
||||
fg="#9de79f",
|
||||
wrap="word",
|
||||
font=("SF Mono", 9),
|
||||
)
|
||||
logs_text.grid(row=4, column=0, columnspan=2, sticky="nsew", padx=8, pady=2)
|
||||
plan_tab.rowconfigure(2, weight=1)
|
||||
|
||||
self._prod_preview_text_widget = preview_text
|
||||
self._prod_logs_text_widget = logs_text
|
||||
|
||||
notebook_win = self.bg_canvas.create_window(
|
||||
x_label + 20,
|
||||
y,
|
||||
window=notebook,
|
||||
anchor="nw",
|
||||
width=890,
|
||||
height=290,
|
||||
)
|
||||
self._canvas_items.append(rb_win)
|
||||
self._overlay_widgets.append(rb)
|
||||
y += 34
|
||||
self._canvas_items.append(notebook_win)
|
||||
self._overlay_widgets.append(notebook)
|
||||
y += 304
|
||||
self._prod_push_preview_widgets()
|
||||
if not self.prod_apply_status.get().strip():
|
||||
self.prod_apply_status.set("idle/ready: Waiting for plan")
|
||||
|
||||
# ── Google Workspace ───────────────────────────────────
|
||||
self._canvas_items.append(
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label,
|
||||
y,
|
||||
"Google Workspace",
|
||||
fill="black",
|
||||
font=("SF Pro Text", 12, "bold"),
|
||||
)
|
||||
)
|
||||
y += 28
|
||||
for field_label, var, placeholder in [
|
||||
("Domain:", self.gworkspace_domain, "example.com"),
|
||||
("Customer ID:", self.gworkspace_customer_id, "C0xxxxxxx"),
|
||||
("Admin Email:", self.gworkspace_admin_email, "admin@example.com"),
|
||||
]:
|
||||
self._canvas_items.append(
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label + 20,
|
||||
y,
|
||||
field_label,
|
||||
fill="black",
|
||||
"Common Services are configured on the next screen.",
|
||||
fill="#6e6e73",
|
||||
font=("SF Pro Text", 11),
|
||||
)
|
||||
)
|
||||
gw_entry = tk.Entry(
|
||||
self.bg_canvas,
|
||||
textvariable=var,
|
||||
width=36,
|
||||
bg="white",
|
||||
fg="black",
|
||||
insertbackground="black",
|
||||
highlightbackground="#CCCCCC",
|
||||
highlightthickness=1,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 11),
|
||||
)
|
||||
gw_win = self.bg_canvas.create_window(
|
||||
x_label + 160, y - 6, window=gw_entry, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(gw_win)
|
||||
self._overlay_widgets.append(gw_entry)
|
||||
y += 30
|
||||
|
||||
y += 6
|
||||
# ── Google Cloud ───────────────────────────────────────
|
||||
self._canvas_items.append(
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label,
|
||||
y,
|
||||
"Google Cloud",
|
||||
fill="black",
|
||||
font=("SF Pro Text", 12, "bold"),
|
||||
)
|
||||
)
|
||||
y += 28
|
||||
for field_label, var in [
|
||||
("Project ID:", self.gcloud_project_id),
|
||||
("Region:", self.gcloud_region),
|
||||
]:
|
||||
self._canvas_items.append(
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label + 20,
|
||||
y,
|
||||
field_label,
|
||||
fill="black",
|
||||
font=("SF Pro Text", 11),
|
||||
)
|
||||
)
|
||||
gc_entry = tk.Entry(
|
||||
self.bg_canvas,
|
||||
textvariable=var,
|
||||
width=36,
|
||||
bg="white",
|
||||
fg="black",
|
||||
insertbackground="black",
|
||||
highlightbackground="#CCCCCC",
|
||||
highlightthickness=1,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 11),
|
||||
)
|
||||
gc_win = self.bg_canvas.create_window(
|
||||
x_label + 160, y - 6, window=gc_entry, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(gc_win)
|
||||
self._overlay_widgets.append(gc_entry)
|
||||
y += 30
|
||||
# Service Account Key
|
||||
self._canvas_items.append(
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label + 20,
|
||||
y,
|
||||
"SA Key Path:",
|
||||
fill="black",
|
||||
font=("SF Pro Text", 11),
|
||||
)
|
||||
)
|
||||
sa_entry = tk.Entry(
|
||||
self.bg_canvas,
|
||||
textvariable=self.gcloud_sa_key_path,
|
||||
width=36,
|
||||
bg="white",
|
||||
fg="black",
|
||||
insertbackground="black",
|
||||
highlightbackground="#CCCCCC",
|
||||
highlightthickness=1,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 11),
|
||||
)
|
||||
sa_win = self.bg_canvas.create_window(
|
||||
x_label + 160, y - 6, window=sa_entry, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(sa_win)
|
||||
self._overlay_widgets.append(sa_entry)
|
||||
sa_browse = tk.Button(
|
||||
self.bg_canvas,
|
||||
text="Browse...",
|
||||
command=lambda: self.gcloud_sa_key_path.set(
|
||||
filedialog.askopenfilename(
|
||||
filetypes=[("JSON", "*.json"), ("All", "*")]
|
||||
)
|
||||
or self.gcloud_sa_key_path.get()
|
||||
),
|
||||
)
|
||||
sa_browse_win = self.bg_canvas.create_window(
|
||||
x_label + 580, y - 8, window=sa_browse, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(sa_browse_win)
|
||||
self._overlay_widgets.append(sa_browse)
|
||||
y += 34
|
||||
def _render_common_services_page(self):
|
||||
# Letterhead at top right
|
||||
content_width = self.bg_canvas.winfo_width() or 975
|
||||
right_margin = content_width - 48
|
||||
|
||||
# Common Services Status Check (Notebook)
|
||||
# Show if in Service/Prod or if a cluster is selected in Dev
|
||||
show_notebook = False
|
||||
ui.canvas_text(
|
||||
self,
|
||||
right_margin,
|
||||
40,
|
||||
"Prole",
|
||||
fill="#6e6e73",
|
||||
font=("SF Pro Text", 32, "bold"),
|
||||
anchor="ne",
|
||||
)
|
||||
ui.canvas_text(
|
||||
self,
|
||||
right_margin,
|
||||
85,
|
||||
"Infrastructure Automated.",
|
||||
fill="#6e6e73",
|
||||
font=("SF Pro Text", 18),
|
||||
anchor="ne",
|
||||
)
|
||||
|
||||
self._render_title("Common Services", y=110)
|
||||
self._render_paragraph(
|
||||
"Evaluate, deploy, or repair common services."
|
||||
" All service traffic lights must be green before continuing.",
|
||||
y=155,
|
||||
)
|
||||
|
||||
selected_env_key = self._cluster_env_key()
|
||||
has_cluster = True
|
||||
if selected_env_key == "dev":
|
||||
if self.selected_k3d_cluster.get():
|
||||
show_notebook = True
|
||||
else:
|
||||
show_notebook = True
|
||||
has_cluster = bool((self.selected_k3d_cluster.get() or "").strip())
|
||||
|
||||
x_label = 48
|
||||
y = 220
|
||||
|
||||
if not has_cluster:
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label,
|
||||
y,
|
||||
"Select or create a Dev cluster before configuring common services.",
|
||||
fill="#6e6e73",
|
||||
font=("SF Pro Text", 12),
|
||||
)
|
||||
self._common_services_success = False
|
||||
self._service_traffic_lights = {}
|
||||
self.update_footer()
|
||||
return
|
||||
|
||||
if show_notebook:
|
||||
ui.canvas_text(
|
||||
self,
|
||||
x_label,
|
||||
@ -539,7 +651,6 @@ class ClusterScreenMixin:
|
||||
)
|
||||
self._canvas_items.append(ns_win)
|
||||
self._overlay_widgets.append(ns_entry)
|
||||
# Keep references for UI tests / layout verification.
|
||||
self._service_namespace_entry = ns_entry
|
||||
self._service_namespace_entry_canvas_window = ns_win
|
||||
|
||||
@ -561,7 +672,138 @@ class ClusterScreenMixin:
|
||||
)
|
||||
self._canvas_items.append(save_win)
|
||||
self._overlay_widgets.append(save_btn)
|
||||
y += 34
|
||||
|
||||
eval_btn = tk.Button(
|
||||
self.bg_canvas,
|
||||
text="Evaluate",
|
||||
command=self._verify_k3s_services,
|
||||
bg="#F5F5DC",
|
||||
fg="black",
|
||||
activebackground="#E5E5D5",
|
||||
highlightbackground="#F5F5DC",
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 10),
|
||||
padx=10,
|
||||
)
|
||||
eval_win = self.bg_canvas.create_window(
|
||||
x_label + 560, y - 10, window=eval_btn, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(eval_win)
|
||||
self._overlay_widgets.append(eval_btn)
|
||||
|
||||
deploy_btn = tk.Button(
|
||||
self.bg_canvas,
|
||||
text="Deploy",
|
||||
command=self._deploy_k3s_services,
|
||||
bg="#F5F5DC",
|
||||
fg="black",
|
||||
activebackground="#E5E5D5",
|
||||
highlightbackground="#F5F5DC",
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 10),
|
||||
padx=10,
|
||||
)
|
||||
deploy_win = self.bg_canvas.create_window(
|
||||
x_label + 660, y - 10, window=deploy_btn, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(deploy_win)
|
||||
self._overlay_widgets.append(deploy_btn)
|
||||
self._k3s_deploy_btn = deploy_btn
|
||||
|
||||
repair_btn = tk.Button(
|
||||
self.bg_canvas,
|
||||
text="Repair",
|
||||
command=self._deploy_k3s_services,
|
||||
bg="#F5F5DC",
|
||||
fg="black",
|
||||
activebackground="#E5E5D5",
|
||||
highlightbackground="#F5F5DC",
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
font=("SF Pro Text", 10),
|
||||
padx=10,
|
||||
)
|
||||
repair_win = self.bg_canvas.create_window(
|
||||
x_label + 758, y - 10, window=repair_btn, anchor="nw"
|
||||
)
|
||||
self._canvas_items.append(repair_win)
|
||||
self._overlay_widgets.append(repair_btn)
|
||||
|
||||
y += 42
|
||||
|
||||
components = [
|
||||
("registry", "Registry"),
|
||||
("certmgr", "Cert Manager"),
|
||||
("garage", "Garage"),
|
||||
("kong", "Kong"),
|
||||
("openbao", "OpenBao"),
|
||||
("opentofu", "OpenTofu"),
|
||||
]
|
||||
lights = {}
|
||||
for idx, (key, label) in enumerate(components):
|
||||
row = idx // 3
|
||||
col = idx % 3
|
||||
lx = x_label + 20 + (col * 290)
|
||||
ly = y + 8 + (row * 36)
|
||||
indicator = self.bg_canvas.create_oval(
|
||||
lx,
|
||||
ly,
|
||||
lx + 14,
|
||||
ly + 14,
|
||||
fill="#ff9f0a",
|
||||
outline="#8e8e93",
|
||||
width=1,
|
||||
)
|
||||
self._canvas_items.append(indicator)
|
||||
ui.canvas_text(
|
||||
self,
|
||||
lx + 22,
|
||||
ly + 7,
|
||||
label,
|
||||
fill="black",
|
||||
font=("SF Pro Text", 10),
|
||||
anchor="w",
|
||||
)
|
||||
lights[key] = indicator
|
||||
self._service_traffic_lights = lights
|
||||
|
||||
y += 90
|
||||
|
||||
notebook = ttk.Notebook(self.bg_canvas)
|
||||
status_tab = tk.Frame(notebook, bg="white")
|
||||
deploy_tab = tk.Frame(notebook, bg="white")
|
||||
notebook.add(status_tab, text="Evaluate")
|
||||
notebook.add(deploy_tab, text="Deploy / Repair")
|
||||
|
||||
status_console = ui.TerminalConsole(status_tab, highlightthickness=0, bd=0)
|
||||
status_console.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
deploy_console = ui.TerminalConsole(deploy_tab, highlightthickness=0, bd=0)
|
||||
deploy_console.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
|
||||
notebook_win = self.bg_canvas.create_window(
|
||||
x_label,
|
||||
y,
|
||||
window=notebook,
|
||||
anchor="nw",
|
||||
width=890,
|
||||
height=430,
|
||||
)
|
||||
self._canvas_items.append(notebook_win)
|
||||
self._overlay_widgets.extend(
|
||||
[notebook, status_tab, deploy_tab, status_console, deploy_console]
|
||||
)
|
||||
|
||||
self._k3s_service_notebook = notebook
|
||||
self._k3s_service_status_tab = status_tab
|
||||
self._k3s_service_deploy_tab = deploy_tab
|
||||
self._k3s_service_status_console = status_console
|
||||
self._k3s_service_deploy_console = deploy_console
|
||||
|
||||
self._common_services_success = bool(getattr(self, "_all_services_green", False))
|
||||
self.update_footer()
|
||||
self._verify_k3s_services()
|
||||
|
||||
def _cluster_env_key(self, env_label: str | None = None) -> str:
|
||||
"""Return normalized cluster env key ('dev', 'service', 'prod')."""
|
||||
@ -1503,6 +1745,347 @@ class ClusterScreenMixin:
|
||||
pass
|
||||
return ["default"]
|
||||
|
||||
def _ensure_prod_config_state(self):
|
||||
if getattr(self, "_prod_config_state_ready", False):
|
||||
return
|
||||
|
||||
self._prod_config_state_ready = True
|
||||
self.prod_config_api = getattr(self, "prod_config_api", ProdConfigApi())
|
||||
self.prod_form_vars = {
|
||||
"metadata.name": tk.StringVar(value="knoey-root"),
|
||||
"metadata.environment": tk.StringVar(value="production"),
|
||||
"cloud.provider": tk.StringVar(value="gcp"),
|
||||
"cloud.projectId": tk.StringVar(value=""),
|
||||
"cloud.region": tk.StringVar(value="us-central1"),
|
||||
"cloud.clusterName": tk.StringVar(value="knoe-prod"),
|
||||
"cloud.vpcMode": tk.StringVar(value="managed"),
|
||||
"cloud.vpcName": tk.StringVar(value=""),
|
||||
"cloud.subnetName": tk.StringVar(value=""),
|
||||
"cloud.artifactRegistry": tk.StringVar(value=""),
|
||||
"cloud.dnsZone": tk.StringVar(value="knoe-dev-zone"),
|
||||
"kubernetes.namespace": tk.StringVar(value="ecosystem-0"),
|
||||
"database.clusterName": tk.StringVar(value="knoe-db"),
|
||||
"database.postgresVersion": tk.StringVar(value="16"),
|
||||
"database.instances": tk.StringVar(value="3"),
|
||||
"database.storageClass": tk.StringVar(value="premium-rwo"),
|
||||
"database.storageSizeGi": tk.StringVar(value="100"),
|
||||
"database.appDatabase": tk.StringVar(value="knoey"),
|
||||
"database.metaDatabase": tk.StringVar(value="knoe_meta"),
|
||||
"database.appUser": tk.StringVar(value="knoey_app"),
|
||||
"database.adminUser": tk.StringVar(value="knoe_admin"),
|
||||
"backups.backupBucket": tk.StringVar(value="knoe-0-backups"),
|
||||
"backups.walBucket": tk.StringVar(value="knoe-0-wal"),
|
||||
"backups.retentionDays": tk.StringVar(value="14"),
|
||||
"auth.provider": tk.StringVar(value="google-workspace-oidc"),
|
||||
"auth.issuer": tk.StringVar(value="https://accounts.google.com"),
|
||||
"auth.clientId": tk.StringVar(value="secretref://google-oidc-client-id"),
|
||||
"auth.clientSecret": tk.StringVar(
|
||||
value="secretref://google-oidc-client-secret"
|
||||
),
|
||||
"auth.bootstrapAdminEmail": tk.StringVar(value="admin@knoey.com"),
|
||||
"routing.frontdoorHost": tk.StringVar(value="knoey.com"),
|
||||
"routing.platformDomain": tk.StringVar(value="knoe.dev"),
|
||||
"routing.tlsMode": tk.StringVar(value="managed"),
|
||||
"migration.sourceEnvironment": tk.StringVar(value="prole.org"),
|
||||
"migration.mode": tk.StringVar(value="snapshot-restore"),
|
||||
"migration.sourceHost": tk.StringVar(value="knoe-local-db.prole.org"),
|
||||
"migration.sourcePort": tk.StringVar(value="5432"),
|
||||
"migration.sourceDatabase": tk.StringVar(value="knoey"),
|
||||
"migration.sourceUser": tk.StringVar(value="replication_user"),
|
||||
"migration.sourcePasswordRef": tk.StringVar(
|
||||
value="secretref://local-source-db-password"
|
||||
),
|
||||
}
|
||||
self.prod_bool_vars = {
|
||||
"migration.continuousUntilCutover": tk.BooleanVar(value=False)
|
||||
}
|
||||
self.prod_tab_messages = tk.StringVar(value="")
|
||||
self.prod_apply_status = tk.StringVar(value="")
|
||||
self.prod_preview_yaml = ""
|
||||
self.prod_preview_tf_vars = ""
|
||||
self.prod_preview_install_plan = ""
|
||||
self.prod_apply_logs = ""
|
||||
self._prod_preview_text_widget = None
|
||||
self._prod_logs_text_widget = None
|
||||
|
||||
try:
|
||||
payload = self.prod_config_api.get_prod_config()
|
||||
self._prod_apply_payload_to_vars(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _prod_apply_payload_to_vars(self, payload: dict | None):
|
||||
cfg = build_config(payload or {})
|
||||
values = {
|
||||
"metadata.name": cfg.metadata.name,
|
||||
"metadata.environment": cfg.metadata.environment,
|
||||
"cloud.provider": cfg.cloud.provider,
|
||||
"cloud.projectId": cfg.cloud.projectId,
|
||||
"cloud.region": cfg.cloud.region,
|
||||
"cloud.clusterName": cfg.cloud.clusterName,
|
||||
"cloud.vpcMode": cfg.cloud.vpcMode,
|
||||
"cloud.vpcName": cfg.cloud.vpcName or "",
|
||||
"cloud.subnetName": cfg.cloud.subnetName or "",
|
||||
"cloud.artifactRegistry": cfg.cloud.artifactRegistry,
|
||||
"cloud.dnsZone": cfg.cloud.dnsZone,
|
||||
"kubernetes.namespace": cfg.kubernetes.namespace,
|
||||
"database.clusterName": cfg.database.clusterName,
|
||||
"database.postgresVersion": cfg.database.postgresVersion,
|
||||
"database.instances": str(cfg.database.instances),
|
||||
"database.storageClass": cfg.database.storageClass,
|
||||
"database.storageSizeGi": str(cfg.database.storageSizeGi),
|
||||
"database.appDatabase": cfg.database.appDatabase,
|
||||
"database.metaDatabase": cfg.database.metaDatabase,
|
||||
"database.appUser": cfg.database.appUser,
|
||||
"database.adminUser": cfg.database.adminUser,
|
||||
"backups.backupBucket": cfg.backups.backupBucket,
|
||||
"backups.walBucket": cfg.backups.walBucket,
|
||||
"backups.retentionDays": str(cfg.backups.retentionDays),
|
||||
"auth.provider": cfg.auth.provider,
|
||||
"auth.issuer": cfg.auth.issuer,
|
||||
"auth.clientId": cfg.auth.clientId,
|
||||
"auth.clientSecret": cfg.auth.clientSecret,
|
||||
"auth.bootstrapAdminEmail": cfg.auth.bootstrapAdminEmail,
|
||||
"routing.frontdoorHost": cfg.routing.frontdoorHost,
|
||||
"routing.platformDomain": cfg.routing.platformDomain,
|
||||
"routing.tlsMode": cfg.routing.tlsMode,
|
||||
"migration.sourceEnvironment": cfg.migration.sourceEnvironment,
|
||||
"migration.mode": cfg.migration.mode,
|
||||
"migration.sourceHost": cfg.migration.sourceHost,
|
||||
"migration.sourcePort": str(cfg.migration.sourcePort),
|
||||
"migration.sourceDatabase": cfg.migration.sourceDatabase,
|
||||
"migration.sourceUser": cfg.migration.sourceUser,
|
||||
"migration.sourcePasswordRef": cfg.migration.sourcePasswordRef,
|
||||
}
|
||||
for key, value in values.items():
|
||||
if key in self.prod_form_vars:
|
||||
self.prod_form_vars[key].set(value)
|
||||
self.prod_bool_vars["migration.continuousUntilCutover"].set(
|
||||
bool(cfg.migration.continuousUntilCutover)
|
||||
)
|
||||
|
||||
def _prod_payload_from_vars(self) -> dict:
|
||||
self._ensure_prod_config_state()
|
||||
|
||||
def _to_int(key: str) -> int:
|
||||
try:
|
||||
return int((self.prod_form_vars[key].get() or "").strip())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
doc = {
|
||||
"kind": "KnoeProductionConfig",
|
||||
"metadata": {
|
||||
"ecosystemId": 0,
|
||||
"name": self.prod_form_vars["metadata.name"].get().strip(),
|
||||
"environment": self.prod_form_vars["metadata.environment"].get().strip(),
|
||||
},
|
||||
"spec": {
|
||||
"cloud": {
|
||||
"provider": self.prod_form_vars["cloud.provider"].get().strip(),
|
||||
"projectId": self.prod_form_vars["cloud.projectId"].get().strip(),
|
||||
"region": self.prod_form_vars["cloud.region"].get().strip(),
|
||||
"clusterName": self.prod_form_vars["cloud.clusterName"].get().strip(),
|
||||
"vpcMode": self.prod_form_vars["cloud.vpcMode"].get().strip(),
|
||||
"vpcName": self.prod_form_vars["cloud.vpcName"].get().strip() or None,
|
||||
"subnetName": self.prod_form_vars["cloud.subnetName"].get().strip() or None,
|
||||
"artifactRegistry": self.prod_form_vars[
|
||||
"cloud.artifactRegistry"
|
||||
].get().strip(),
|
||||
"dnsZone": self.prod_form_vars["cloud.dnsZone"].get().strip(),
|
||||
},
|
||||
"kubernetes": {
|
||||
"namespace": self.prod_form_vars["kubernetes.namespace"].get().strip()
|
||||
},
|
||||
"database": {
|
||||
"clusterName": self.prod_form_vars["database.clusterName"].get().strip(),
|
||||
"postgresVersion": self.prod_form_vars[
|
||||
"database.postgresVersion"
|
||||
].get().strip(),
|
||||
"instances": _to_int("database.instances"),
|
||||
"storageClass": self.prod_form_vars["database.storageClass"].get().strip(),
|
||||
"storageSizeGi": _to_int("database.storageSizeGi"),
|
||||
"appDatabase": self.prod_form_vars["database.appDatabase"].get().strip(),
|
||||
"metaDatabase": self.prod_form_vars["database.metaDatabase"].get().strip(),
|
||||
"appUser": self.prod_form_vars["database.appUser"].get().strip(),
|
||||
"adminUser": self.prod_form_vars["database.adminUser"].get().strip(),
|
||||
},
|
||||
"backups": {
|
||||
"backupBucket": self.prod_form_vars["backups.backupBucket"].get().strip(),
|
||||
"walBucket": self.prod_form_vars["backups.walBucket"].get().strip(),
|
||||
"retentionDays": _to_int("backups.retentionDays"),
|
||||
},
|
||||
"auth": {
|
||||
"provider": self.prod_form_vars["auth.provider"].get().strip(),
|
||||
"issuer": self.prod_form_vars["auth.issuer"].get().strip(),
|
||||
"clientId": self.prod_form_vars["auth.clientId"].get().strip(),
|
||||
"clientSecret": self.prod_form_vars["auth.clientSecret"].get().strip(),
|
||||
"bootstrapAdminEmail": self.prod_form_vars[
|
||||
"auth.bootstrapAdminEmail"
|
||||
].get().strip(),
|
||||
},
|
||||
"routing": {
|
||||
"frontdoorHost": self.prod_form_vars[
|
||||
"routing.frontdoorHost"
|
||||
].get().strip(),
|
||||
"platformDomain": self.prod_form_vars[
|
||||
"routing.platformDomain"
|
||||
].get().strip(),
|
||||
"tlsMode": self.prod_form_vars["routing.tlsMode"].get().strip(),
|
||||
},
|
||||
"migration": {
|
||||
"sourceEnvironment": self.prod_form_vars[
|
||||
"migration.sourceEnvironment"
|
||||
].get().strip(),
|
||||
"mode": self.prod_form_vars["migration.mode"].get().strip(),
|
||||
"sourceHost": self.prod_form_vars["migration.sourceHost"].get().strip(),
|
||||
"sourcePort": _to_int("migration.sourcePort"),
|
||||
"sourceDatabase": self.prod_form_vars[
|
||||
"migration.sourceDatabase"
|
||||
].get().strip(),
|
||||
"sourceUser": self.prod_form_vars["migration.sourceUser"].get().strip(),
|
||||
"sourcePasswordRef": self.prod_form_vars[
|
||||
"migration.sourcePasswordRef"
|
||||
].get().strip(),
|
||||
"continuousUntilCutover": self.prod_bool_vars[
|
||||
"migration.continuousUntilCutover"
|
||||
].get(),
|
||||
},
|
||||
},
|
||||
}
|
||||
return to_api_payload(build_config(doc))
|
||||
|
||||
def _prod_format_messages(self, errors: list[str], warnings: list[str]) -> str:
|
||||
lines = []
|
||||
if errors:
|
||||
lines.append("Errors:")
|
||||
lines.extend([f"- {line}" for line in errors])
|
||||
if warnings:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
lines.extend([f"- {line}" for line in warnings])
|
||||
return "\n".join(lines)
|
||||
|
||||
def _prod_push_preview_widgets(self):
|
||||
combined = [
|
||||
"=== Canonical YAML Config Preview ===",
|
||||
self.prod_preview_yaml or "(no plan yet)",
|
||||
"=== OpenTofu Variables Preview ===",
|
||||
self.prod_preview_tf_vars or "(no plan yet)",
|
||||
"=== install.py Module Plan ===",
|
||||
self.prod_preview_install_plan or "(no plan yet)",
|
||||
"=== Warnings / Errors ===",
|
||||
self.prod_tab_messages.get() or "(none)",
|
||||
]
|
||||
preview_text = "\n".join(combined).strip() + "\n"
|
||||
widget = getattr(self, "_prod_preview_text_widget", None)
|
||||
if widget is not None:
|
||||
try:
|
||||
widget.configure(state="normal")
|
||||
widget.delete("1.0", tk.END)
|
||||
widget.insert("1.0", preview_text)
|
||||
widget.configure(state="disabled")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_widget = getattr(self, "_prod_logs_text_widget", None)
|
||||
if log_widget is not None:
|
||||
try:
|
||||
log_widget.configure(state="normal")
|
||||
log_widget.delete("1.0", tk.END)
|
||||
log_widget.insert("1.0", (self.prod_apply_logs or "") + "\n")
|
||||
log_widget.configure(state="disabled")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _prod_sync_status_and_logs(self):
|
||||
self._ensure_prod_config_state()
|
||||
try:
|
||||
status = self.prod_config_api.get_status() or {}
|
||||
except Exception:
|
||||
status = {}
|
||||
try:
|
||||
logs = (self.prod_config_api.get_logs() or {}).get("logs", [])
|
||||
except Exception:
|
||||
logs = []
|
||||
|
||||
phase = (status.get("phase") or "unknown").strip()
|
||||
state = (status.get("state") or "unknown").strip()
|
||||
message = (status.get("message") or "").strip()
|
||||
self.prod_apply_status.set(f"{phase}/{state}: {message}".strip())
|
||||
self.prod_apply_logs = "\n".join(logs[-300:])
|
||||
self._prod_push_preview_widgets()
|
||||
|
||||
def _prod_put_config(self, show_dialog: bool = False) -> dict:
|
||||
self._ensure_prod_config_state()
|
||||
result = self.prod_config_api.put_prod_config(self._prod_payload_from_vars())
|
||||
cfg_doc = result.get("config") or {}
|
||||
if cfg_doc:
|
||||
self._prod_apply_payload_to_vars(cfg_doc)
|
||||
errors = list(result.get("errors") or [])
|
||||
warnings = list(result.get("warnings") or [])
|
||||
self.prod_tab_messages.set(self._prod_format_messages(errors, warnings))
|
||||
self._prod_push_preview_widgets()
|
||||
|
||||
if show_dialog:
|
||||
if errors:
|
||||
messagebox.showwarning("Production Config", "\n".join(errors))
|
||||
else:
|
||||
note = "Production config saved."
|
||||
if warnings:
|
||||
note += "\n\nWarnings:\n" + "\n".join(warnings)
|
||||
messagebox.showinfo("Production Config", note)
|
||||
return result
|
||||
|
||||
def _on_prod_plan(self):
|
||||
self._ensure_prod_config_state()
|
||||
put_result = self._prod_put_config(show_dialog=False)
|
||||
if put_result.get("errors"):
|
||||
self._prod_sync_status_and_logs()
|
||||
return
|
||||
|
||||
result = self.prod_config_api.post_plan(self._prod_payload_from_vars())
|
||||
self.prod_preview_yaml = result.get("yaml", "")
|
||||
self.prod_preview_tf_vars = json.dumps(
|
||||
result.get("opentofuVars") or {}, indent=2, sort_keys=True
|
||||
)
|
||||
self.prod_preview_install_plan = "\n".join(
|
||||
f"- {line}" for line in (result.get("installPlan") or [])
|
||||
)
|
||||
self.prod_tab_messages.set(
|
||||
self._prod_format_messages(
|
||||
list(result.get("errors") or []), list(result.get("warnings") or [])
|
||||
)
|
||||
)
|
||||
self._prod_sync_status_and_logs()
|
||||
|
||||
def _on_prod_apply(self):
|
||||
self._ensure_prod_config_state()
|
||||
put_result = self._prod_put_config(show_dialog=False)
|
||||
if put_result.get("errors"):
|
||||
self._prod_sync_status_and_logs()
|
||||
return
|
||||
|
||||
result = self.prod_config_api.post_apply(self._prod_payload_from_vars())
|
||||
self.prod_preview_yaml = result.get("yaml", "")
|
||||
self.prod_preview_tf_vars = json.dumps(
|
||||
result.get("opentofuVars") or {}, indent=2, sort_keys=True
|
||||
)
|
||||
self.prod_preview_install_plan = "\n".join(
|
||||
f"- {line}" for line in (result.get("installPlan") or [])
|
||||
)
|
||||
self.prod_tab_messages.set(
|
||||
self._prod_format_messages(
|
||||
list(result.get("errors") or []), list(result.get("warnings") or [])
|
||||
)
|
||||
)
|
||||
self._prod_sync_status_and_logs()
|
||||
|
||||
def _on_prod_refresh_status(self):
|
||||
self._ensure_prod_config_state()
|
||||
self._prod_sync_status_and_logs()
|
||||
|
||||
def _validate_and_save_cluster_config(self) -> bool:
|
||||
"""Verify the config then write the values to prole.cfg."""
|
||||
env_key = self._cluster_env_key()
|
||||
@ -1554,6 +2137,16 @@ class ClusterScreenMixin:
|
||||
"Validation", "Please specify an artifact staging directory."
|
||||
)
|
||||
return False
|
||||
self._ensure_prod_config_state()
|
||||
put_result = self._prod_put_config(show_dialog=False)
|
||||
prod_errors = list(put_result.get("errors") or [])
|
||||
if prod_errors:
|
||||
messagebox.showwarning(
|
||||
"Validation",
|
||||
"Production config validation failed:\n\n"
|
||||
+ "\n".join(prod_errors),
|
||||
)
|
||||
return False
|
||||
cfg_vars = _collect_cfg_vars_from_data(getattr(self, "prole_cfg_data", None))
|
||||
expanded_path = _expand_path_expr(path, cfg_vars)
|
||||
p = Path(expanded_path).expanduser()
|
||||
@ -1601,6 +2194,25 @@ class ClusterScreenMixin:
|
||||
self.prole_cfg_data["Prod Cluster (k8s)"][
|
||||
"STAGING_DIRECTORY"
|
||||
] = self.prod_artifacts_path.get()
|
||||
try:
|
||||
prod_payload = self._prod_payload_from_vars()
|
||||
prod_plan = self.prod_config_api.post_plan(prod_payload)
|
||||
self.prole_cfg_data["Prod Cluster (k8s)"][
|
||||
"PRODUCTION_CONFIG_YAML"
|
||||
] = prod_plan.get("yaml", "")
|
||||
self.prole_cfg_data["Prod Cluster (k8s)"][
|
||||
"OPENTOFU_VARS"
|
||||
] = json.dumps(
|
||||
prod_plan.get("opentofuVars") or {}, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
self.prole_cfg_data["Prod Cluster (k8s)"][
|
||||
"INSTALL_PLAN"
|
||||
] = "\n".join(prod_plan.get("installPlan") or [])
|
||||
self.prole_cfg_data["Prod Cluster (k8s)"][
|
||||
"PLAN_MESSAGES"
|
||||
] = self.prod_tab_messages.get()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save config
|
||||
self._save_prole_cfg()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -1 +1 @@
|
||||
140
|
||||
141
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
97
tests/installer/test_prod_config.py
Normal file
97
tests/installer/test_prod_config.py
Normal 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"]
|
||||
@ -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,7 +167,11 @@ 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"
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user