From 9094d10117fa5e57118f4156cf1e650a55bc671f Mon Sep 17 00:00:00 2001 From: chrisfu Date: Fri, 20 Feb 2026 01:28:52 -0800 Subject: [PATCH] Installer refactor: k3d cluster lifecycle, kubeconfig handling, UI polish & cleanup - Fix KUBECONFIG not generated for dev/k3d clusters by merging k3d kubeconfig in _script_env_for_namespace() and after cluster creation - Fix Add/Delete button macOS black focus ring with highlightbackground/highlightthickness styling - Refactor milestones, actions, controller, deploy pipeline, and env helpers - Simplify prole.cfg and port-mapping.cfg defaults - Improve archive_garage_backup.py and init_cloudnative_pg.sh - Remove unused img/LOADING_GIF_README.md and img/generate_loading_gif.py - Add qodana.yaml for static analysis configuration --- conf/port-mapping.cfg | 8 +- conf/prole.cfg | 65 +--- etc/archive_garage_backup.py | 63 ++-- etc/init_cloudnative_pg.sh | 10 +- etc/sync-prole-cfg.py | 1 - img/LOADING_GIF_README.md | 84 ----- img/generate_loading_gif.py | 345 ------------------- installer/core/actions.py | 52 ++- installer/core/controller.py | 1 - installer/core/env.py | 19 +- installer/core/milestones.py | 42 ++- installer/deploy.py | 9 +- installer/main.py | 4 - installer/milestone.py | 4 +- installer/ncurses_installer.py | 11 +- installer/screen.py | 1 - installer/ui/screens.py | 600 +++++++++++++++++++++------------ qodana.yaml | 48 +++ 18 files changed, 584 insertions(+), 783 deletions(-) delete mode 100644 img/LOADING_GIF_README.md delete mode 100755 img/generate_loading_gif.py create mode 100644 qodana.yaml diff --git a/conf/port-mapping.cfg b/conf/port-mapping.cfg index 4c9b0ba..5b5c013 100644 --- a/conf/port-mapping.cfg +++ b/conf/port-mapping.cfg @@ -6,11 +6,11 @@ supabase-studio: local=8088 remote=3000 ns=supabase svc=studio address=0.0.0.0 supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=0.0.0.0 supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0 supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0 -garage: local=3900 remote=3900 ns=default svc=garage address=0.0.0.0 -openbao: local=8200 remote=8200 ns=default svc=openbao address=127.0.0.1 -opentofu: local=8080 remote=8080 ns=default svc=opentofu address=0.0.0.0 +garage: local=3900 remote=3900 ns= svc=garage address=0.0.0.0 +openbao: local=8200 remote=8200 ns= svc=openbao address=127.0.0.1 +opentofu: local=8080 remote=8080 ns= svc=opentofu address=0.0.0.0 dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1 -postgres: local=5432 remote=5432 ns=prole-db-a0001 svc=prole-db-rw address=0.0.0.0 +postgres: local= remote=5432 ns= svc=prole-db-rw address=0.0.0.0 prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1 grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0 supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0 diff --git a/conf/prole.cfg b/conf/prole.cfg index 9a31821..181c324 100644 --- a/conf/prole.cfg +++ b/conf/prole.cfg @@ -27,45 +27,45 @@ env_setup.PROLE_HOME = ${PROLE_HOME} env_setup.PROLE_LOGS = ${PROLE_LOGS} env_setup.PROLE_SERVICE = ${PROLE_SERVICE} init_cluster.at_rest_encryption_enabled = true -init_cluster.cluster_env = k3d-prole-dev-cluster +init_cluster.cluster_env = dev init_cluster.deployment_target = prole-dev-cluster init_cluster.k3s_server_url = https://myrddin.prole.org:6443 init_cluster.k3s_token = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b init_cluster.kerberos_enabled = true init_cluster.mode = k3d init_cluster.start_cluster = true -init_cluster.supabase_enabled = true +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.db_host_port = 5432 init_password.db_password = ${OPENBAO:kv/prole/prole-db-a0001/db#password} init_password.db_password_confirm = ${OPENBAO:kv/prole/prole-db-a0001/db#password} -init_password.db_username = chrisfu +init_password.db_username = root init_password.generate_ssh_key = true init_scripts.run_scripts = true kerberos_config.enabled = true kerberos_config.kdc = 10.0.0.3 kerberos_config.password = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password} kerberos_config.realm = PROLE.ORG -kerberos_config.test_connection = true -kerberos_config.user = chrisfu +kerberos_config.test_connection = false +kerberos_config.user = network_scan.run = true ollama_config.model = -ollama_config.server_host = morgoth.prole.org +ollama_config.server_host = ollama_config.server_port = 11434 [Global] -CLUSTER_ENV = k3d-prole-dev-cluster -CNPG_RELEASE_SERIES = 1.28 CNPG_VERSION = 1.28.1 +CNPG_RELEASE_SERIES = 1.28 +CLUSTER_ENV = dev DB_HOST_PORT = 5432 DB_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/db#password} DEPLOYMENT_MODE = k3d DEPLOYMENT_TARGET = prole-dev-cluster DOCKER_IMPORT_DIR = NAMESPACE = ${NAMESPACE} -PROLE_DB_USER = chrisfu +PROLE_DB_USER = root PROLE_HOME = ${PROLE_HOME} PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b @@ -75,7 +75,6 @@ SERVICE_NAMESPACE = default [Welcome] [Dependencies] -STATUS = All installed [Network] AD_DC_HOST = myrddin.prole.org @@ -90,20 +89,6 @@ 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_10 = id=supabase-studio;namespace=supabase;target=svc/studio;address=0.0.0.0;hostPort=8088;servicePort=3000;protocol=TCP;description=Supabase Studio -PORT_FORWARD_K3D_MAPPING_11 = id=supabase-auth;namespace=supabase;target=svc/auth;address=0.0.0.0;hostPort=9999;servicePort=9999;protocol=TCP;description=Supabase Auth (GoTrue) -PORT_FORWARD_K3D_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_K3D_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_K3D_MAPPING_2 = id=garage;namespace=default;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=default;target=svc/openbao;address=127.0.0.1;hostPort=8200;servicePort=8200;protocol=TCP;description=OpenBao -PORT_FORWARD_K3D_MAPPING_4 = id=opentofu;namespace=default;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=${NAMESPACE};target=svc/prole-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 -PORT_FORWARD_K3D_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) -STATUS = Failed [System Environment] PROLE_CONF = ${PROLE_CONF} @@ -116,42 +101,20 @@ PROLE_SERVICE = ${PROLE_SERVICE} GRAFANA_ADMIN_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/monitoring#grafana_admin_password} [Kerberos Authentication] -AD_PORT_FORWARD = 1 -AD_PROXY_HOST_NETWORK = 1 -AD_PROXY_IMAGE = alpine/socat -AD_PROXY_SERVICE = prole-kerberos-ad-dc -AD_TCP_PORTS = 88 389 445 464 636 -AD_UDP_PORTS = 88 464 -ENABLED = True -KDC = 10.0.0.3 PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password} -REALM = PROLE.ORG -SERVER = 10.0.0.3 -USER = chrisfu [Ollama] -OLLAMA_HOST = http://morgoth.prole.org:11434 -OLLAMA_SERVER_HOST = morgoth.prole.org -OLLAMA_SERVER_PORT = 11434 [Optional Features] -AT_REST_ENCRYPTION_ENABLED = True -KERBEROS_ENABLED = True -SUPABASE_ENABLED = False [Database Creation] -DB_NAME = prole-db-a0001 -DB_USER = chrisfu [Initialize Cluster] -ENVIRONMENT = k3d-prole-dev-cluster -K3S_SERVER_URL = https://myrddin.prole.org:6443 -K3S_TOKEN = ${PROLE_SECRET:v1:lmSCzQB0YbB3fQ32:KUlh4KkEvD18Q3UddLIRLfBddA_WhfPOHIoquMN6dboK0SUJf2BG4yJKEg8ZKjhEQXeOl7ciDzSEQcHeErBVL2F4ZMwcLGcsymCTN5icxqqzs3MUBkDDfTaFNg12RdiycO1rPEZlqd7RZSLsSqT2clRvaDR03UGhCLxFvw==} [Dev Cluster (k3d)] -CLUSTER_ENV = k3d-prole-dev-cluster +CLUSTER_ENV = dev DISPLAY_NAME = prole-dev-cluster -KUBECTL_CONTEXT = k3d-prole-dev-cluster +KUBECTL_CONTEXT = MODE = k3d [Service Cluster (k3s)] @@ -163,23 +126,19 @@ MODE = k3s PIPELINE_URL = http://127.0.0.1:8080 [Prod Cluster (k8s)] -ARTIFACTS_DIR = ${PROLE_DATA}/staging +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] -LOCAL_REGISTRY = localhost:5000 -LOCAL_REGISTRY_INTERNAL = k3d-prole-registry.localhost:5000 [Initialization Scripts] -STATUS = Completed [Deployment] MODE = k3d TARGET = prole-dev-cluster [Install] -STATUS = Finished diff --git a/etc/archive_garage_backup.py b/etc/archive_garage_backup.py index def7727..4c11b9b 100755 --- a/etc/archive_garage_backup.py +++ b/etc/archive_garage_backup.py @@ -45,16 +45,17 @@ def _canonical_uri(path: str) -> str: return "/".join(parts) -def _aws_request(method: str, endpoint: str, path: str, params: dict, access_key: str, secret_key: str, region: str): - parsed = urlparse(endpoint) - host = parsed.netloc - canonical_uri = _canonical_uri(path) - canonical_querystring = _canonical_query(params) - payload_hash = hashlib.sha256(b"").hexdigest() - - now = datetime.datetime.utcnow() +def _sign_request( + method: str, host: str, path: str, params: dict, access_key: str, secret_key: str, region: str, service: str +) -> tuple[dict[str, str], str, str]: + """Sign an AWS request and return headers, canonical URI, and canonical query string.""" + now = datetime.datetime.now(datetime.timezone.utc) amz_date = now.strftime("%Y%m%dT%H%M%SZ") date_stamp = now.strftime("%Y%m%d") + payload_hash = hashlib.sha256(b"").hexdigest() + + canonical_uri = _canonical_uri(path) + canonical_querystring = _canonical_query(params) canonical_headers = ( f"host:{host}\n" @@ -63,24 +64,21 @@ def _aws_request(method: str, endpoint: str, path: str, params: dict, access_key ) signed_headers = "host;x-amz-content-sha256;x-amz-date" - canonical_request = "\n".join( - [ - method, - canonical_uri, - canonical_querystring, - canonical_headers, - signed_headers, - payload_hash, - ] - ) + canonical_request = "\n".join([ + method, + canonical_uri, + canonical_querystring, + canonical_headers, + signed_headers, + payload_hash, + ]) algorithm = "AWS4-HMAC-SHA256" - credential_scope = f"{date_stamp}/{region}/s3/aws4_request" - string_to_sign = "\n".join( - [algorithm, amz_date, credential_scope, hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()] - ) + credential_scope = f"{date_stamp}/{region}/{service}/aws4_request" + hashed_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest() + string_to_sign = "\n".join([algorithm, amz_date, credential_scope, hashed_request]) - signing_key = _signature_key(secret_key, date_stamp, region, "s3") + signing_key = _signature_key(secret_key, date_stamp, region, service) signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() authorization_header = ( @@ -88,16 +86,27 @@ def _aws_request(method: str, endpoint: str, path: str, params: dict, access_key f"SignedHeaders={signed_headers}, Signature={signature}" ) - url = f"{parsed.scheme}://{host}{canonical_uri}" - if canonical_querystring: - url = f"{url}?{canonical_querystring}" - headers = { "x-amz-date": amz_date, "x-amz-content-sha256": payload_hash, "Authorization": authorization_header, } + return headers, canonical_uri, canonical_querystring + + +def _aws_request(method: str, endpoint: str, path: str, params: dict, access_key: str, secret_key: str, region: str): + parsed = urlparse(endpoint) + host = parsed.netloc + + headers, canonical_uri, canonical_querystring = _sign_request( + method, host, path, params, access_key, secret_key, region, "s3" + ) + + url = f"{parsed.scheme}://{host}{canonical_uri}" + if canonical_querystring: + url = f"{url}?{canonical_querystring}" + req = urllib.request.Request(url, method=method, headers=headers) return urllib.request.urlopen(req) diff --git a/etc/init_cloudnative_pg.sh b/etc/init_cloudnative_pg.sh index 2bb29e8..cd33ab1 100755 --- a/etc/init_cloudnative_pg.sh +++ b/etc/init_cloudnative_pg.sh @@ -1101,6 +1101,11 @@ fetch_admin_keys_and_db_pass_from_bao_or_local() { fi fi + if ! kubectl -n "$NAMESPACE" get secret prole-db-user >/dev/null 2>&1; then + echo "ERROR: 'prole-db-user' secret is missing in namespace '$NAMESPACE' and could not be resolved from OpenBao or DB_PASSWORD env." >&2 + return 1 + fi + if [[ -f "$ADMIN_PRIV_GENERIC" && -f "$ADMIN_PUB_GENERIC" ]]; then echo "Using local admin key pair at $SECRETS_DIR" return 0 @@ -1162,7 +1167,10 @@ initialize() { ensure_barman_plugin echo "Using namespace: $NAMESPACE" - fetch_admin_keys_and_db_pass_from_bao_or_local + if ! fetch_admin_keys_and_db_pass_from_bao_or_local; then + echo "ERROR: Failed to fetch/ensure database secrets and admin keys." >&2 + return 1 + fi apply_cnpg_admin_secret ensure_prole_stack_resources diff --git a/etc/sync-prole-cfg.py b/etc/sync-prole-cfg.py index f625ae4..1805851 100644 --- a/etc/sync-prole-cfg.py +++ b/etc/sync-prole-cfg.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import sys -import os from pathlib import Path # Add project root to path diff --git a/img/LOADING_GIF_README.md b/img/LOADING_GIF_README.md deleted file mode 100644 index a59ff33..0000000 --- a/img/LOADING_GIF_README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Prole Loading Screen Animation - -An animated GIF loader inspired by 1980s Electronic Arts floppy disk loaders, featuring: -- **Crackling energy effects** on the magnetic field lines (fast animation, 8-frame cycle) -- **Science mice from Douglas Adams** standing on the rings taking notes (slower animation, 30-frame cycle) -- **Alternating animation speeds** to reflect installation progress - -## Generated Files - -- `proleLoading.gif` - The animated loading screen (1024x768, 60 frames) -- `generate_loading_gif.py` - Script to generate/regenerate the animation - -## Usage - -### Generate the Animation - -**Default (1024x768, 60 frames, ~53MB):** -```bash -cd img -source ../bin/activate # Activate virtual environment -python3 generate_loading_gif.py -``` - -**Optimized for smaller file size:** -```bash -# Smaller resolution and fewer frames -python3 generate_loading_gif.py --size 800x600 --frames 40 --colors 128 --output proleLoading_small.gif - -# Even smaller -python3 generate_loading_gif.py --size 640x480 --frames 30 --colors 64 --output proleLoading_tiny.gif -``` - -**Command-line options:** -- `--size WIDTHxHEIGHT`: Output resolution (default: 1024x768) -- `--frames N`: Total animation frames (default: 60) -- `--energy-frames N`: Energy animation cycle (default: 8) -- `--mice-frames N`: Mice animation cycle (default: 30) -- `--colors N`: Color palette size (default: 256, lower = smaller file) -- `--output FILENAME`: Output filename (default: proleLoading.gif) - -### Integration with Installer - -The GIF can be displayed in the installer during deployment. The animation runs while `install.py` deploys Prole.app and configures local services. - -## Animation Details - -- **Resolution**: 1024x768 -- **Total Frames**: 60 (complete cycle) -- **Energy Animation**: 8 frames (fast, cycles ~7.5 times per full cycle) -- **Mice Animation**: 30 frames (slow, cycles 2 times per full cycle) -- **Frame Duration**: 50-80ms (varies with energy intensity) -- **Loop**: Infinite - -## Customization - -Edit `generate_loading_gif.py` to adjust: - -- `OUTPUT_SIZE`: Change resolution (default: 1024x768) -- `FRAMES_PER_CYCLE`: Total animation frames (default: 60) -- `ENERGY_FRAMES`: Speed of energy animation (default: 8) -- `MICE_FRAMES`: Speed of mice animation (default: 30) -- Colors: Modify `BLUE_DARK`, `BLUE_MEDIUM`, `ENERGY_BRIGHT`, etc. - -## File Size Optimization - -The current GIF is ~53MB. To reduce file size: - -1. **Reduce frames**: Lower `FRAMES_PER_CYCLE` (e.g., 30-40 frames) -2. **Reduce resolution**: Lower `OUTPUT_SIZE` (e.g., 800x600) -3. **Reduce color palette**: Change quantization from 256 to 128 colors -4. **Use external tools**: Use `gifsicle` or `ffmpeg` for further compression - -Example with gifsicle: -```bash -gifsicle -O3 --colors 128 proleLoading.gif -o proleLoading_optimized.gif -``` - -## Design Notes - -- The energy effects overlay on the existing field lines from the blueprint logo -- Mice are positioned on the rings in an elliptical path matching the logo's perspective -- Animation speeds are intentionally mismatched (like old EA loaders) to create visual interest -- A "stuck" animation would indicate a failure, just like the old floppy loaders - diff --git a/img/generate_loading_gif.py b/img/generate_loading_gif.py deleted file mode 100755 index 4b919eb..0000000 --- a/img/generate_loading_gif.py +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate an animated loading GIF for Prole installer. -Inspired by 1980s Electronic Arts floppy disk loaders with alternating -animation speeds to reflect installation progress. -""" - -import os -import argparse -from PIL import Image, ImageDraw, ImageFont -import math -import random - -# Default Configuration -DEFAULT_OUTPUT_SIZE = (1024, 768) -DEFAULT_FRAMES = 60 -DEFAULT_ENERGY_FRAMES = 8 -DEFAULT_MICE_FRAMES = 30 -DEFAULT_COLORS = 256 -DEFAULT_OUTPUT_FILE = "proleLoading.gif" - -# Colors (blueprint theme) -BLUE_DARK = (30, 60, 120) -BLUE_MEDIUM = (60, 120, 200) -BLUE_LIGHT = (100, 150, 220) -ENERGY_BRIGHT = (150, 200, 255) -ENERGY_CRACKLE = (200, 230, 255) -BG_COLOR = (245, 248, 252) - - -def draw_mouse(draw, x, y, size=15, angle=0, note_taking=False): - """Draw a simple mouse figure (Douglas Adams style)""" - # Mouse body (oval) - body_width = size - body_height = size * 0.7 - body_bbox = [ - x - body_width // 2, - y - body_height // 2, - x + body_width // 2, - y + body_height // 2 - ] - draw.ellipse(body_bbox, fill=BLUE_DARK, outline=BLUE_MEDIUM, width=1) - - # Mouse head - head_size = size * 0.5 - head_x = x + int(math.cos(angle) * body_width * 0.3) - head_y = y - int(math.sin(angle) * body_width * 0.3) - head_bbox = [ - head_x - head_size // 2, - head_y - head_size // 2, - head_x + head_size // 2, - head_y + head_size // 2 - ] - draw.ellipse(head_bbox, fill=BLUE_DARK, outline=BLUE_MEDIUM, width=1) - - # Ears - ear_size = size * 0.3 - ear1_x = head_x - head_size * 0.3 - ear1_y = head_y - head_size * 0.3 - ear2_x = head_x + head_size * 0.3 - ear2_y = head_y - head_size * 0.3 - draw.ellipse([ear1_x - ear_size//2, ear1_y - ear_size//2, - ear1_x + ear_size//2, ear1_y + ear_size//2], - fill=BLUE_MEDIUM, outline=BLUE_DARK, width=1) - draw.ellipse([ear2_x - ear_size//2, ear2_y - ear_size//2, - ear2_x + ear_size//2, ear2_y + ear_size//2], - fill=BLUE_MEDIUM, outline=BLUE_DARK, width=1) - - # Tail - tail_points = [ - (x - body_width * 0.4, y), - (x - body_width * 0.6, y + size * 0.3), - (x - body_width * 0.8, y + size * 0.1) - ] - draw.line(tail_points, fill=BLUE_DARK, width=2) - - # If taking notes, draw a clipboard/notepad - if note_taking: - clipboard_x = x + body_width * 0.4 - clipboard_y = y - size * 0.2 - clipboard_size = size * 0.6 - # Clipboard - draw.rectangle( - [clipboard_x - clipboard_size//2, clipboard_y - clipboard_size//2, - clipboard_x + clipboard_size//2, clipboard_y + clipboard_size//2], - fill=(250, 250, 245), outline=BLUE_DARK, width=1 - ) - # Lines on clipboard - for i in range(3): - line_y = clipboard_y - clipboard_size//3 + i * (clipboard_size//3) - draw.line( - [clipboard_x - clipboard_size//3, line_y, - clipboard_x + clipboard_size//3, line_y], - fill=BLUE_MEDIUM, width=1 - ) - # Pencil/pen - pencil_x = clipboard_x + clipboard_size * 0.3 - pencil_y = clipboard_y - draw.line( - [pencil_x, pencil_y - clipboard_size//2, - pencil_x, pencil_y + clipboard_size//2], - fill=BLUE_DARK, width=2 - ) - - -def draw_energy_crackle(draw, start_x, start_y, end_x, end_y, intensity, energy_frame, seed=None): - """Draw crackling energy along a field line""" - if seed is not None: - random.seed(seed + energy_frame) # Consistent randomness per field line - - # Calculate points along the curve (field line) - curved path - num_points = 25 - points = [] - for i in range(num_points + 1): - t = i / num_points - # Create a curved path (field line) - more pronounced curve - curve_amount = 40 * math.sin(t * math.pi) - x = (1 - t) * start_x + t * end_x - y = (1 - t) * start_y + t * end_y + curve_amount - points.append((x, y)) - - # Draw crackling energy along the line - crackle_intensity = intensity * (0.4 + 0.6 * abs(math.sin(energy_frame * math.pi * 2 / DEFAULT_ENERGY_FRAMES))) - - # Draw main energy path with varying intensity - for i in range(len(points) - 1): - p1 = points[i] - p2 = points[i + 1] - - # Vary line width based on intensity - line_width = max(1, int(1 + crackle_intensity * 2)) - energy_color = tuple(int(c * (0.7 + 0.3 * crackle_intensity)) for c in ENERGY_BRIGHT) - - # Main energy line - draw.line([p1, p2], fill=energy_color, width=line_width) - - # Add crackling branches (more frequent when intensity is high) - if random.random() < crackle_intensity * 0.6: - branch_length = 4 + random.random() * 12 - branch_angle = random.random() * math.pi * 2 - branch_start_x = (p1[0] + p2[0]) / 2 - branch_start_y = (p1[1] + p2[1]) / 2 - branch_end_x = branch_start_x + math.cos(branch_angle) * branch_length - branch_end_y = branch_start_y + math.sin(branch_angle) * branch_length - draw.line( - [branch_start_x, branch_start_y, - branch_end_x, branch_end_y], - fill=ENERGY_CRACKLE, width=1 - ) - # Small spark at end - spark_size = 1 + int(random.random() * 2) - draw.ellipse( - [branch_end_x - spark_size, branch_end_y - spark_size, - branch_end_x + spark_size, branch_end_y + spark_size], - fill=ENERGY_CRACKLE - ) - - -def generate_frame(base_image, frame_num, energy_frame, mice_frame, mice_frames=30): - """Generate a single animation frame""" - # Create a copy of the base image - frame = base_image.copy() - draw = ImageDraw.Draw(frame) - - # Get image dimensions - width, height = frame.size - center_x, center_y = width // 2, height // 2 - - # Draw energy crackling on field lines - # Top field lines (curved lines from top pole) - matching logo structure - top_pole_x = center_x - top_pole_y = center_y - 180 # Approximate top pole position - - # Create several field lines from top pole (curved outward) - num_top_lines = 7 - for i in range(num_top_lines): - angle = (i - num_top_lines // 2) * 0.35 # Spread out symmetrically - line_length = 140 + (i % 2) * 20 # Vary length - end_x = center_x + math.cos(angle) * line_length - end_y = top_pole_y + 80 + math.sin(angle) * 40 - intensity = 0.4 + (i % 3) * 0.2 # Vary intensity - draw_energy_crackle(draw, top_pole_x, top_pole_y, end_x, end_y, - intensity, energy_frame, seed=i) - - # Bottom field lines - bottom_pole_x = center_x - bottom_pole_y = center_y + 180 # Approximate bottom pole position - - num_bottom_lines = 7 - for i in range(num_bottom_lines): - angle = (i - num_bottom_lines // 2) * 0.35 - line_length = 140 + (i % 2) * 20 - end_x = center_x + math.cos(angle) * line_length - end_y = bottom_pole_y - 80 - math.sin(angle) * 40 - intensity = 0.4 + (i % 3) * 0.2 - draw_energy_crackle(draw, bottom_pole_x, bottom_pole_y, end_x, end_y, - intensity, energy_frame, seed=i + 100) - - # Draw science mice on the rings - # Ring positions (elliptical, perspective) - ring_center_y = center_y - ring_radius_x = 180 - ring_radius_y = 50 - - # Place 3-4 mice around the rings - num_mice = 4 - for i in range(num_mice): - # Position along ring (ellipse) - t = (i / num_mice + mice_frame / mice_frames) * 2 * math.pi - mouse_x = center_x + ring_radius_x * math.cos(t) - mouse_y = ring_center_y + ring_radius_y * math.sin(t) - - # Mouse angle (facing outward from center) - mouse_angle = t + math.pi / 2 - - # Alternate between taking notes and observing - note_taking = (i + mice_frame // 10) % 2 == 0 - - draw_mouse(draw, int(mouse_x), int(mouse_y), size=18, - angle=mouse_angle, note_taking=note_taking) - - return frame - - -def main(): - """Generate the animated loading GIF""" - parser = argparse.ArgumentParser( - description="Generate animated loading GIF for Prole installer" - ) - parser.add_argument( - "--size", type=str, default="1024x768", - help="Output size as WIDTHxHEIGHT (default: 1024x768)" - ) - parser.add_argument( - "--frames", type=int, default=DEFAULT_FRAMES, - help=f"Total animation frames (default: {DEFAULT_FRAMES})" - ) - parser.add_argument( - "--energy-frames", type=int, default=DEFAULT_ENERGY_FRAMES, - help=f"Energy animation cycle length (default: {DEFAULT_ENERGY_FRAMES})" - ) - parser.add_argument( - "--mice-frames", type=int, default=DEFAULT_MICE_FRAMES, - help=f"Mice animation cycle length (default: {DEFAULT_MICE_FRAMES})" - ) - parser.add_argument( - "--colors", type=int, default=DEFAULT_COLORS, - help=f"Color palette size for optimization (default: {DEFAULT_COLORS})" - ) - parser.add_argument( - "--output", type=str, default=DEFAULT_OUTPUT_FILE, - help=f"Output filename (default: {DEFAULT_OUTPUT_FILE})" - ) - parser.add_argument( - "--optimize", action="store_true", default=True, - help="Enable GIF optimization (default: True)" - ) - - args = parser.parse_args() - - # Parse size - try: - width, height = map(int, args.size.split('x')) - OUTPUT_SIZE = (width, height) - except ValueError: - print(f"Error: Invalid size format '{args.size}'. Use WIDTHxHEIGHT (e.g., 1024x768)") - return - - FRAMES_PER_CYCLE = args.frames - ENERGY_FRAMES = args.energy_frames | DEFAULT_ENERGY_FRAMES - MICE_FRAMES = args.mice_frames - NUM_COLORS = args.colors - OUTPUT_FILE = args.output - - print("Loading base logo image...") - base_path = os.path.join(os.path.dirname(__file__), "proleLogoBlueprint.png") - - if not os.path.exists(base_path): - print(f"Error: Base image not found at {base_path}") - return - - # Load and resize base image - base_image = Image.open(base_path) - base_image = base_image.convert("RGB") # Ensure RGB mode - base_image = base_image.resize(OUTPUT_SIZE, Image.Resampling.LANCZOS) - - print(f"Generating {FRAMES_PER_CYCLE} animation frames...") - frames = [] - - for frame_num in range(FRAMES_PER_CYCLE): - # Calculate sub-frame indices for different animation speeds - energy_frame = frame_num % ENERGY_FRAMES # Fast energy animation - mice_frame = frame_num % MICE_FRAMES # Slower mice animation - - frame = generate_frame(base_image, frame_num, energy_frame, mice_frame, MICE_FRAMES) - frames.append(frame) - - if (frame_num + 1) % 10 == 0: - print(f" Generated {frame_num + 1}/{FRAMES_PER_CYCLE} frames...") - - print("Saving animated GIF...") - # Save as animated GIF - # Use different durations: faster for energy-heavy frames, slower for others - durations = [] - for i in range(FRAMES_PER_CYCLE): - # Faster frames when energy is more intense - energy_intensity = abs(math.sin(i * math.pi * 2 / ENERGY_FRAMES)) - duration = 50 + int(energy_intensity * 30) # 50-80ms per frame - durations.append(duration) - - output_path = os.path.join(os.path.dirname(__file__), OUTPUT_FILE) - - # Optimize: quantize to reduce colors and file size - if args.optimize: - print(f"Optimizing GIF (quantizing to {NUM_COLORS} colors)...") - quantized_frames = [] - for frame in frames: - # Quantize to reduce colors for better compression - quantized = frame.quantize(colors=NUM_COLORS, method=Image.Quantize.MEDIANCUT) - quantized_frames.append(quantized.convert("P")) - frames_to_save = quantized_frames - else: - frames_to_save = frames - - frames_to_save[0].save( - output_path, - save_all=True, - append_images=frames_to_save[1:], - duration=durations, - loop=0, # Infinite loop - optimize=args.optimize # Enable optimization - ) - - file_size = os.path.getsize(output_path) / (1024 * 1024) # Size in MB - print(f"✓ Animation saved to {output_path}") - print(f" Size: {OUTPUT_SIZE[0]}x{OUTPUT_SIZE[1]}") - print(f" Frames: {FRAMES_PER_CYCLE}") - print(f" Energy cycle: {ENERGY_FRAMES} frames (fast)") - print(f" Mice cycle: {MICE_FRAMES} frames (slow)") - print(f" File size: {file_size:.1f} MB") - - -if __name__ == "__main__": - main() - diff --git a/installer/core/actions.py b/installer/core/actions.py index 9178fce..11e6863 100644 --- a/installer/core/actions.py +++ b/installer/core/actions.py @@ -4,22 +4,30 @@ Installer actions and unattended workflow helpers. from __future__ import annotations -import configparser import logging -import os -import subprocess -import sys -import time -from pathlib import Path +import secrets +import shlex +import socket +import string +import threading +import uuid -from installer import config as inst_config +from installer.config import _expand_path, _collect_cfg_vars, _expand_cfg_value, _parse_bool, _is_openbao_ref, \ + _is_prole_secret, _decrypt_prole_secret, _encrypt_prole_secret, _write_k3s_kubeconfig, _encrypt_cfg_secret from installer.core.controller import ProleController +from installer.core.env import * # noqa: F401,F403 +from installer.core.env import _parse_ollama_host, _bool_str, _deployment_mode_from_env, _openbao_placeholder, \ + _looks_like_k8s_bearer_token, _find_kubeconfig_file, _normalize_cluster_env, _pf_upsert_mapping, \ + _build_required_port_forwards, _local_registry_enabled, _collect_images_from_files, _http_ping_registry, \ + _resolve_supabase_home, _push_docker_image, _detect_ansible_topology, _deployment_target_label, \ + _default_opentofu_pipeline_url, _format_ollama_host, _render_prole_cfg, _k3d_prole_data_volume_args, \ + _sync_opentofu_pipeline from installer.core.milestones import ( DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone, ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone ) -from installer.core.env import * # noqa: F401,F403 + def _configure_unbuffered_io(): os.environ.setdefault('PYTHONUNBUFFERED', '1') @@ -1691,11 +1699,18 @@ class ProleSilentInstaller: raise Exception("Invalid database namespace.") user = (self._get_input('init_password.db_username', '') or '').strip() if not user: - raise Exception("Database owner cannot be empty.") + import getpass + try: + user = getpass.getuser() + except Exception: + user = "prole" + self.log(f"[INFO] No database owner provided; using default: {user}") p1 = self._get_input('init_password.db_password', '') p2 = self._get_input('init_password.db_password_confirm', '') or p1 if not p1: - raise Exception("Database password cannot be empty.") + p1 = self._generate_db_password() + p2 = p1 + self.log("[INFO] No database password provided; generated default password.") if p1 != p2: raise Exception("Database passwords do not match.") @@ -2213,7 +2228,22 @@ class ProleSilentInstaller: self.err(f"[ERROR] {script} failed (code {rc})") overall_success = False self._scripts_success = overall_success - self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if overall_success else 'Attempted' + + # Verify critical secrets + self.log(f"==> Verifying critical secrets in namespace {ns}") + critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] + missing_secrets = [] + for secret in critical_secrets: + rc_s = self._run_cmd(["kubectl", "get", "secret", secret, "-n", ns]) + if rc_s != 0: + missing_secrets.append(secret) + + if missing_secrets: + self.err(f"[CRITICAL] Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}") + self.err("Database initialization will fail without these secrets.") + self._scripts_success = False + + self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if self._scripts_success else 'Attempted' def _step_cnpg_deploy(self) -> None: if not self._get_input_bool('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True)): diff --git a/installer/core/controller.py b/installer/core/controller.py index 43b2e64..2c5127e 100644 --- a/installer/core/controller.py +++ b/installer/core/controller.py @@ -7,7 +7,6 @@ import logging from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Sequence -from installer import config as inst_config from installer.state import InstallerState if TYPE_CHECKING: diff --git a/installer/core/env.py b/installer/core/env.py index 5428f87..0983580 100644 --- a/installer/core/env.py +++ b/installer/core/env.py @@ -8,15 +8,10 @@ import base64 import configparser import getpass import json -import logging import os import platform import re -import secrets -import shlex import shutil -import socket -import string import subprocess import sys import tempfile @@ -24,8 +19,6 @@ import time import urllib.error import urllib.parse import urllib.request -import uuid -from datetime import datetime from pathlib import Path from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -627,11 +620,11 @@ def _normalize_cluster_env(env: str | None) -> str: if not env: return '' s = str(env).strip().lower() - if s in ('dev', 'k3d', 'k3d-dev', 'k3d-prole-dev-cluster', 'prole-dev-cluster'): + if s in ('dev', 'k3d', 'k3d-dev', 'k3d-prole-dev-cluster', 'prole-dev-cluster') or s.startswith('k3d-') or s.startswith('prole-dev-'): return 'dev' - if s in ('service', 'k3s', 'k3s-service', 'prole-service-cluster'): + if s in ('service', 'k3s', 'k3s-service', 'prole-service-cluster') or s.startswith('prole-service-'): return 'service' - if s in ('prod', 'production', 'k8s', 'prole-prod-cluster'): + if s in ('prod', 'production', 'k8s', 'prole-prod-cluster') or s.startswith('prole-prod-'): return 'prod' return s @@ -639,11 +632,11 @@ def _normalize_cluster_env(env: str | None) -> str: def _cluster_env_radio_value(env: str | None) -> str: key = _normalize_cluster_env(env) if key == 'dev': - return 'k3d-prole-dev-cluster' + return 'dev' if key == 'service': - return 'prole-service-cluster' + return 'service' if key == 'prod': - return 'prole-prod-cluster' + return 'prod' return (env or '') def _deployment_target_label(env: str | None) -> str: diff --git a/installer/core/milestones.py b/installer/core/milestones.py index bc5e902..3ec57d1 100644 --- a/installer/core/milestones.py +++ b/installer/core/milestones.py @@ -238,6 +238,23 @@ class DatabaseCreationMilestone(Milestone): user = getpass.getuser() state.inputs['init_password.db_username'] = user + if not p1: + import secrets + import string + alphabet = string.ascii_letters + string.digits + p1 = ''.join(secrets.choice(alphabet) for i in range(24)) + state.inputs['init_password.db_password'] = p1 + state.inputs['init_password.db_password_confirm'] = p1 + self.logger.info("Generated default database password.") + + if p1 and not p1.startswith('${') and not inst_config._is_prole_secret(p1): + if 'Inputs' not in state.config_data: + state.config_data['Inputs'] = {} + # Encrypt and save to config data for persistence in prole.cfg + encrypted_pw = inst_config._encrypt_cfg_secret(p1) + state.config_data['Inputs']['init_password.db_password'] = encrypted_pw + state.config_data['Inputs']['init_password.db_password_confirm'] = encrypted_pw + state.inputs['init_password.db_namespace'] = ns state.inputs['env_setup.NAMESPACE'] = ns @@ -444,8 +461,25 @@ class InitializationScriptsMilestone(Milestone): rc = state.controller.run_script(script, args=args, env=env, on_line=_stream_line) if rc != 0: - self.logger.error(f"Script {script} failed (code {rc})") - # Should we stop? Silent installer continues but logs error. + msg = f"Script {script} failed (code {rc})" + self.logger.error(msg) + raise Exception(msg) + + # Verify critical secrets + ns = env.get("NAMESPACE", "default") + self.logger.info(f"Verifying critical secrets in namespace {ns}...") + critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] + missing_secrets = [] + for secret in critical_secrets: + if self._run_cmd(f"kubectl get secret {secret} -n {ns}") != 0: + missing_secrets.append(secret) + + if missing_secrets: + msg = f"CRITICAL: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}. Database initialization will fail." + self.logger.error(msg) + if progress: + progress(msg, 1.0) + raise Exception(msg) # For k3d: start port-forward daemon and verify Grafana port-forward. pf_ok = True @@ -522,7 +556,9 @@ class DeploymentMilestone(Milestone): if rc == 0: self.logger.info("Deployment successful") else: - self.logger.error(f"Deployment failed (code {rc})") + msg = f"Deployment failed (code {rc})" + self.logger.error(msg) + raise Exception(msg) if progress: progress("Deployment complete", 1.0) diff --git a/installer/deploy.py b/installer/deploy.py index d64c21a..56364fd 100644 --- a/installer/deploy.py +++ b/installer/deploy.py @@ -9,17 +9,16 @@ Also defines the Deploy page (final milestone): """ from __future__ import annotations -from pathlib import Path -import os -import threading import platform -import subprocess import shutil +import subprocess +import threading +from pathlib import Path from typing import Callable +from . import config as cfg from .milestone import Milestone from .state import InstallerState -from . import config as cfg def check_xcode_tools() -> bool: diff --git a/installer/main.py b/installer/main.py index 1bad6d7..dd97706 100644 --- a/installer/main.py +++ b/installer/main.py @@ -6,10 +6,6 @@ to feature modules. The legacy install.py will import and invoke run(). """ from __future__ import annotations -import tkinter as tk -from tkinter import ttk, scrolledtext - -from . import screen as ui from . import build as build_mod from . import deploy as deploy_mod diff --git a/installer/milestone.py b/installer/milestone.py index 915fe1c..2902cab 100644 --- a/installer/milestone.py +++ b/installer/milestone.py @@ -2,10 +2,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Callable, Iterable, Sequence +from typing import Callable, Sequence -from .state import InstallerState from . import config as inst_config +from .state import InstallerState ProgressCallback = Callable[[str, float | None], None] ValidationResult = str | Sequence[str] | None diff --git a/installer/ncurses_installer.py b/installer/ncurses_installer.py index cc4f8e6..5afd09a 100644 --- a/installer/ncurses_installer.py +++ b/installer/ncurses_installer.py @@ -7,18 +7,15 @@ Maintains the same screen order and operations. from __future__ import annotations import curses -import sys -import threading +import getpass import os import subprocess -import getpass -import json -import tempfile +import sys +import threading from pathlib import Path -from typing import Optional, Callable from . import config as inst_config -from .ncurses_ui import CursesWindow, TerminalConsole, NavFooter, InputField, Checkbox +from .ncurses_ui import CursesWindow, NavFooter, InputField class ProleNcursesInstaller: diff --git a/installer/screen.py b/installer/screen.py index e4add35..89d4a61 100644 --- a/installer/screen.py +++ b/installer/screen.py @@ -7,7 +7,6 @@ Tk Canvas used by the installer, to reduce clutter in install.py. from __future__ import annotations import tkinter as tk -from tkinter import ttk import platform diff --git a/installer/ui/screens.py b/installer/ui/screens.py index 1ac454a..1de6915 100755 --- a/installer/ui/screens.py +++ b/installer/ui/screens.py @@ -4,47 +4,33 @@ Prole Service Dependencies Installer Desktop application for installing, deploying, and validating Prole services """ -import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox, filedialog -import subprocess -import threading -import os -import sys -import webbrowser -import time import platform -from pathlib import Path import shlex -import socket import signal -import shutil -import json -import configparser -import tempfile -import getpass -import uuid -import re -import base64 -import secrets -import string -from datetime import datetime -import urllib.request -import urllib.parse +import socket +import threading +import tkinter as tk import urllib.error -import logging -from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import urllib.parse +import urllib.request +import uuid +import webbrowser +from datetime import datetime +from tkinter import ttk, messagebox, filedialog -# Refactor: import shared helpers from the root-level installer package -from installer import config as inst_config -from installer.build import get_build_command as inst_get_build_command from installer import deploy as inst_deploy from installer import screen as ui +# Refactor: import shared helpers from the root-level installer package +from installer.build import get_build_command as inst_get_build_command +from installer.config import _expand_cfg_value, _collect_cfg_vars, _is_prole_secret, _decrypt_prole_secret, \ + _is_openbao_ref, _write_k3s_kubeconfig, _encrypt_cfg_secret, _encrypt_prole_secret from installer.core.controller import ProleController -from installer.core.milestones import ( - DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, - SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone, - ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone -) +from installer.core.env import _detect_ansible_topology, _format_ansible_topology_summary, _parse_ollama_host, \ + _normalize_k3s_token, _deployment_target_label, _cluster_env_radio_value, _find_kubeconfig_file, _bool_str, \ + _looks_like_k8s_bearer_token, _deployment_mode_from_env, _pf_extract_id, _ensure_ansible_vault_credentials, \ + _openbao_placeholder, _pf_upsert_mapping, _format_ollama_host, _default_opentofu_pipeline_url, \ + _build_required_port_forwards, _render_prole_cfg, _normalize_cluster_env, _k3d_prole_data_volume_args, \ + _push_docker_image, _http_ping_registry, _local_registry_enabled, _collect_images_from_files # On macOS, set the process name as early as possible so the menu bar shows 'Prole Database Installer' if platform.system() == 'Darwin': @@ -55,7 +41,7 @@ if platform.system() == 'Darwin': pass from installer.core.env import * # noqa: F401,F403 -from installer.core.actions import ProleSilentInstaller, _prepare_k3s_pipeline +from installer.core.actions import ProleSilentInstaller, _prepare_k3s_pipeline, _reset_k3s_namespace # Initialize global frames list to keep them in memory GLOBAL_SCAN_FRAMES = [] @@ -329,7 +315,19 @@ class ProleInstaller: self.page_index = 0 # Initialize variables for new Initialize screens - self.cluster_env = tk.StringVar(value='k3d-prole-dev-cluster') + self.cluster_env = tk.StringVar(value='dev') + self.k3d_cluster_name = tk.StringVar(value='prole-dev-cluster') + self.k3d_cluster_list = tk.Variable(value=[]) + self.selected_k3d_cluster = tk.StringVar() + + # Pre-populate selected_k3d_cluster from prole.cfg if applicable + try: + cfg_env = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '') + if _normalize_cluster_env(cfg_env) == 'dev': + self.selected_k3d_cluster.set(cfg_env) + except Exception: + pass + self.deploy_target = tk.StringVar(value=_deployment_target_label(self.cluster_env.get())) self._syncing_deploy_target = False self._deploy_target_trace = self.deploy_target.trace_add('write', self._on_deploy_target_change) @@ -791,7 +789,7 @@ class ProleInstaller: current = (self.cluster_env.get() or '').strip() except Exception: current = '' - if not current or current == 'k3d-prole-dev-cluster': + if not current or current == 'dev': try: self.cluster_env.set(_cluster_env_radio_value(cfg_env)) except Exception: @@ -1664,7 +1662,23 @@ class ProleInstaller: env["KRB5_USER"] = self.kerberos_user.get().strip() if self.kerberos_password.get().strip(): env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() - if self._cluster_env_key() == 'service': + if self._cluster_env_key() == 'dev': + # For k3d dev clusters, use the default kubeconfig (~/.kube/config) + if not (env.get("KUBECONFIG") or "").strip(): + cluster_name = (self.selected_k3d_cluster.get() or '').strip() + if cluster_name: + # Ensure k3d kubeconfig is merged and context is switched + try: + subprocess.run( + ['k3d', 'kubeconfig', 'merge', cluster_name, '--kubeconfig-switch-context'], + capture_output=True, text=True, timeout=10 + ) + except Exception: + pass + default_kube = str(Path.home() / '.kube' / 'config') + if Path(default_kube).exists(): + env["KUBECONFIG"] = default_kube + elif self._cluster_env_key() == 'service': raw_server, raw_token = self._k3s_connection_raw() if raw_server: env["PROLE_K3S_SERVER"] = raw_server @@ -2151,9 +2165,9 @@ class ProleInstaller: _set_bool('init_db_build.run_build', _action('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True))) # Cluster init + optional features - _set('init_cluster.cluster_env', _get_var(self.cluster_env, 'k3d-prole-dev-cluster')) - _set('init_cluster.mode', _deployment_mode_from_env(_get_var(self.cluster_env, 'k3d-prole-dev-cluster'))) - _set('init_cluster.deployment_target', _deployment_target_label(_get_var(self.cluster_env, 'k3d-prole-dev-cluster'))) + _set('init_cluster.cluster_env', _get_var(self.cluster_env, 'dev')) + _set('init_cluster.mode', _deployment_mode_from_env(_get_var(self.cluster_env, 'dev'))) + _set('init_cluster.deployment_target', _deployment_target_label(_get_var(self.cluster_env, 'dev'))) _set('init_cluster.k3s_server_url', _get_var(self.k3s_server_url, '')) _set('init_cluster.k3s_token', _encrypt_cfg_secret(_get_var(self.k3s_token, ''))) _set_bool('init_cluster.supabase_enabled', _get_var(self.supabase_enabled, False)) @@ -2685,7 +2699,7 @@ class ProleInstaller: sections['Dev Cluster (k3d)'] = { **self.prole_cfg_data.get('Dev Cluster (k3d)', {}), 'MODE': 'k3d', - 'CLUSTER_ENV': 'k3d-prole-dev-cluster', + 'CLUSTER_ENV': 'dev', 'DISPLAY_NAME': 'prole-dev-cluster', 'KUBECTL_CONTEXT': (self.selected_kubectx.get() or '').strip() } @@ -4438,11 +4452,11 @@ class ProleInstaller: target = '' key = _normalize_cluster_env(target) if key == 'dev': - desired = 'k3d-prole-dev-cluster' + desired = 'dev' elif key == 'service': - desired = 'prole-service-cluster' + desired = 'service' elif key == 'prod': - desired = 'prole-prod-cluster' + desired = 'prod' else: return self._syncing_deploy_target = True @@ -4467,13 +4481,13 @@ class ProleInstaller: # Cluster Selection (Radio Buttons) x_label = 48 y = 280 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Cluster Name:', fill='black', font=('SF Pro Text', 14, 'bold'))) + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Environment:', fill='black', font=('SF Pro Text', 14, 'bold'))) y += 28 cluster_options = [ - ('k3d-prole-dev-cluster', 'prole-dev-cluster'), - ('prole-service-cluster', 'prole-service-cluster'), - ('prole-prod-cluster', 'prole-prod-cluster') + ('dev', 'Dev'), + ('service', 'Service'), + ('prod', 'Prod') ] # We need to trace cluster_env if not already traced @@ -4481,7 +4495,7 @@ class ProleInstaller: self._cluster_env_trace = self.cluster_env.trace_add('write', self._on_cluster_env_change) rb_x = x_label + 20 - rb_gap = 240 + rb_gap = 150 for idx, (val, name) in enumerate(cluster_options): rb = tk.Radiobutton(self.bg_canvas, text=name, variable=self.cluster_env, value=val, bg='white', fg='black', activebackground='white', selectcolor='white', @@ -4489,85 +4503,113 @@ class ProleInstaller: rb_window = self.bg_canvas.create_window(rb_x + idx * rb_gap, y, window=rb, anchor='nw') self._canvas_items.append(rb_window) self._overlay_widgets.append(rb) - y += 30 - - save_x = content_width - 140 - save_y = y - 4 - btn = tk.Button(self.bg_canvas, text='Save', command=self._on_save_cluster_config, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=10, pady=2) - self._init_cluster_button = btn - btn_window = self.bg_canvas.create_window(save_x, save_y, window=btn, anchor='nw') - self._canvas_items.append(btn_window) - self._overlay_widgets.append(btn) - - selected_env = self.cluster_env.get() - selected_env_key = self._cluster_env_key(selected_env) - y += 16 + + selected_env_key = self._cluster_env_key() + y += 44 if selected_env_key == 'dev': - # kubectx dropdown - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Available kubectx contexts:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 24 - - values = self._get_kubectx_list() - combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_kubectx, values=values, state='readonly', width=40) - # Preselect from prole.cfg (cluster env) if available and present in contexts - try: - desired_ctx = (self.cluster_env.get() or '').strip() - if desired_ctx and desired_ctx in values: - self.selected_kubectx.set(desired_ctx) - elif not self.selected_kubectx.get() and values: - self.selected_kubectx.set(values[0]) - except Exception: - if not self.selected_kubectx.get() and values: - self.selected_kubectx.set(values[0]) - + # Combined Select / Add Cluster widget + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Cluster:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 28 + + clusters = self._get_k3d_cluster_list() + combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_k3d_cluster, values=clusters, width=30) + if not self.selected_k3d_cluster.get() and clusters: + self.selected_k3d_cluster.set(clusters[0]) + + combo.bind('<>', lambda _evt: self._on_k3d_cluster_select()) + combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') self._canvas_items.append(combo_win) self._overlay_widgets.append(combo) - y += 34 - elif selected_env_key == 'service': - # K3s connection settings - try: - self._apply_k3s_defaults() - except Exception: - pass - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 24 + add_btn = tk.Button(self.bg_canvas, text='Add', command=self._on_create_k3d_cluster, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10) + add_win = self.bg_canvas.create_window(x_label + 290, y - 4, window=add_btn, anchor='nw') + self._canvas_items.append(add_win) + self._overlay_widgets.append(add_btn) - ui.canvas_text(self, x_label + 20, y, 'Server URL:', fill='black', font=('SF Pro Text', 11)) - server_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_server_url, width=60) - server_win = self.bg_canvas.create_window(x_label + 140, y - 8, window=server_entry, anchor='nw') - self._canvas_items.append(server_win) - self._overlay_widgets.append(server_entry) - y += 28 - - ui.canvas_text(self, x_label + 20, y, 'Token (vault_k3s_token):', fill='black', font=('SF Pro Text', 11)) - token_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_token, show='*', width=60) - token_win = self.bg_canvas.create_window(x_label + 200, y - 8, window=token_entry, anchor='nw') - self._canvas_items.append(token_win) - self._overlay_widgets.append(token_entry) - y += 28 - - service_label = ui.canvas_text(self, x_label, y, '', fill='black', font=('SF Pro Text', 12, 'bold')) - self._canvas_items.append(service_label) - - def _update_service_label(*_args): - ns_val = 'default' - if self.bg_canvas.winfo_exists(): - self.bg_canvas.itemconfig(service_label, text=f"Common Services (namespace: {ns_val})") - - if hasattr(self, '_service_ns_trace'): + delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._on_delete_k3d_cluster, + bg='#F5F5DC', fg='#cc0000', activebackground='#E5E5D5', activeforeground='#cc0000', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10) + delete_win = self.bg_canvas.create_window(x_label + 350, y - 4, window=delete_btn, anchor='nw') + self._canvas_items.append(delete_win) + self._overlay_widgets.append(delete_btn) + y += 40 + + else: + # Service / Prod Section + if selected_env_key == 'service': + # K3s connection settings try: - self.service_namespace.trace_remove('write', self._service_ns_trace) + self._apply_k3s_defaults() except Exception: pass - self._service_ns_trace = self.service_namespace.trace_add('write', lambda *_a: self.safe_after(_update_service_label)) - _update_service_label() - y += 24 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 28 + + ui.canvas_text(self, x_label + 20, y, 'Server URL:', fill='black', font=('SF Pro Text', 11)) + server_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_server_url, width=60) + server_win = self.bg_canvas.create_window(x_label + 140, y - 8, window=server_entry, anchor='nw') + self._canvas_items.append(server_win) + self._overlay_widgets.append(server_entry) + y += 28 + + ui.canvas_text(self, x_label + 20, y, 'Token:', fill='black', font=('SF Pro Text', 11)) + token_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_token, show='*', width=60) + token_win = self.bg_canvas.create_window(x_label + 200, y - 8, window=token_entry, anchor='nw') + self._canvas_items.append(token_win) + self._overlay_widgets.append(token_entry) + y += 34 + + # Context selection for both Service and Prod + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Kubernetes Context:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 28 + values = self._get_kubectx_list() + combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_kubectx, values=values, state='readonly', width=40) + if not self.selected_kubectx.get() and values: + self.selected_kubectx.set(values[0]) + combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') + self._canvas_items.append(combo_win) + self._overlay_widgets.append(combo) + y += 40 + + if selected_env_key == 'prod': + # Staging directory input + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Local Artifact Staging Directory:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 30 + + entry = tk.Entry(self.bg_canvas, textvariable=self.prod_artifacts_path, width=60) + entry_win = self.bg_canvas.create_window(x_label + 20, y, window=entry, anchor='nw') + self._canvas_items.append(entry_win) + self._overlay_widgets.append(entry) + + browse_btn = tk.Button(self.bg_canvas, text="Browse...", command=lambda: self.prod_artifacts_path.set(filedialog.askdirectory() or self.prod_artifacts_path.get())) + browse_win = self.bg_canvas.create_window(x_label + 440, y - 5, window=browse_btn, anchor='nw') + self._canvas_items.append(browse_win) + self._overlay_widgets.append(browse_btn) + y += 30 + + ui.canvas_text(self, x_label + 20, y, '(Used by etc/deploy_pipeline.sh --mode gcp)', fill='#6e6e73', font=('SF Pro Text', 10)) + y += 34 + + # Common Services Status Check (Notebook) + # Show if in Service/Prod or if a cluster is selected in Dev + show_notebook = False + if selected_env_key == 'dev': + if self.selected_k3d_cluster.get(): + show_notebook = True + else: + show_notebook = True + + if show_notebook: + service_label_text = f"Common Services (namespace: {self._get_service_namespace()})" + service_label = ui.canvas_text(self, x_label, y, service_label_text, fill='black', font=('SF Pro Text', 12, 'bold')) + self._canvas_items.append(service_label) + y += 28 notebook = ttk.Notebook(self.bg_canvas) status_frame = tk.Frame(notebook, bg='white') @@ -4593,6 +4635,7 @@ class ProleInstaller: ) self._canvas_items.append(console_window) self._overlay_widgets.extend([notebook, status_frame, deploy_frame, status_console, deploy_console]) + restored = self._restore_common_services_log(deploy_console) if restored: try: @@ -4618,46 +4661,9 @@ class ProleInstaller: deploy_win = self.bg_canvas.create_window(x_label + 20, y, window=deploy_btn, anchor='nw') self._canvas_items.append(deploy_win) self._overlay_widgets.append(deploy_btn) - y += 24 + y += 40 self._verify_k3s_services() - elif selected_env_key == 'prod': - # Staging directory input - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Local Artifact Staging Directory:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 30 - - entry = tk.Entry(self.bg_canvas, textvariable=self.prod_artifacts_path, width=60) - entry_win = self.bg_canvas.create_window(x_label + 20, y, window=entry, anchor='nw') - self._canvas_items.append(entry_win) - self._overlay_widgets.append(entry) - - browse_btn = tk.Button(self.bg_canvas, text="Browse...", command=lambda: self.prod_artifacts_path.set(filedialog.askdirectory() or self.prod_artifacts_path.get())) - browse_win = self.bg_canvas.create_window(x_label + 20 + 400, y - 5, window=browse_btn, anchor='nw') - self._canvas_items.append(browse_win) - self._overlay_widgets.append(browse_btn) - y += 40 - - ui.canvas_text(self, x_label + 20, y, '(Used by etc/deploy_pipeline.sh --mode gcp)', fill='#6e6e73', font=('SF Pro Text', 10)) - y += 30 - - # Cluster Status (only if dev) - if selected_env_key == 'dev': - y += 10 - self.k3d_status_label = ui.canvas_text(self, x_label, y, 'Cluster: Checking...', fill='#6e6e73', font=('SF Pro Text', 12)) - self._canvas_items.append(self.k3d_status_label) - - y += 30 - self.common_services_status_label = ui.canvas_text(self, x_label, y, 'Common Services: Checking...', fill='#6e6e73', font=('SF Pro Text', 12)) - self._canvas_items.append(self.common_services_status_label) - - y += 30 - self._cluster_env_message_label = ui.canvas_text(self, x_label, y, '', fill='#34c759', font=('SF Pro Text', 11)) - self._canvas_items.append(self._cluster_env_message_label) - - # Async status check - self.check_cluster_status_async() - y += 40 - def _openbao_url(self) -> str: url = (os.environ.get("PROLE_OPENBAO_URL") or "http://127.0.0.1:18200").strip() return url.rstrip('/') @@ -4954,6 +4960,168 @@ class ProleInstaller: msg = f"{label}: Connected" if ok else f"{label}: Not reachable" return ok, msg + def _get_k3d_cluster_list(self) -> list[str]: + """Get list of k3d clusters.""" + try: + res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True, timeout=5) + if res.returncode == 0: + # Extract first column (cluster names) + return [line.split()[0] for line in res.stdout.splitlines() if line.strip()] + except Exception: + pass + return [] + + def _on_create_k3d_cluster(self): + name = self.selected_k3d_cluster.get().strip() + if not name: + messagebox.showerror("Error", "Cluster name cannot be empty") + return + # Persist the typed name so the refreshed page keeps it selected + self.k3d_cluster_name.set(name) + + # If the cluster already exists just select it (triggers notebook display) + existing = self._get_k3d_cluster_list() + if name in existing: + self.selected_k3d_cluster.set(name) + self.show_page('init_cluster') + return + + # Refresh the page so the notebook/console is visible before streaming output + self.selected_k3d_cluster.set(name) + self.show_page('init_cluster') + + def do_create(): + console = getattr(self, '_k3s_service_deploy_console', None) + if console: + console.clear() + console.write(f"Creating k3d cluster '{name}' @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") + try: + if self._k3s_service_notebook and self._k3s_service_deploy_tab: + self._k3s_service_notebook.select(self._k3s_service_deploy_tab) + except Exception: + pass + try: + # Build the k3d create command + prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) + volume_args = _k3d_prole_data_volume_args(prole_data) + reg_args = [] + if getattr(self, 'registry_url', None): + if self.registry_url.startswith('localhost:5000'): + reg_args = ['--registry-create', 'prole-registry:0.0.0.0:5000'] + else: + reg_args = ['--registry-use', self.registry_url] + cmd = ['k3d', 'cluster', 'create', name, '-a', '2', '--wait'] + volume_args + reg_args + ['--timestamps'] + if console: + console.write(f"$ {' '.join(cmd)}\n\n") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=PROJECT_ROOT) + for line in proc.stdout: + if console: + console.write(line) + proc.wait() + if proc.returncode != 0: + if console: + console.write(f"\nk3d cluster create failed (exit {proc.returncode})\n") + return + if console: + console.write(f"\nCluster '{name}' created successfully.\n\n") + + # Merge kubeconfig so kubectl can reach the new cluster + try: + subprocess.run( + ['k3d', 'kubeconfig', 'merge', name, '--kubeconfig-switch-context'], + capture_output=True, text=True, timeout=10 + ) + if console: + console.write(f"Kubeconfig merged for cluster '{name}'.\n\n") + except Exception as kce: + if console: + console.write(f"Warning: failed to merge kubeconfig: {kce}\n") + + # Refresh UI and then deploy common services check + self.safe_after(self._refresh_k3d_ui) + # Trigger common services check in the deploy console + self.safe_after(self._deploy_k3s_services) + except Exception as e: + if console: + console.write(f"\nError: {e}\n") + self.safe_after(lambda: messagebox.showerror("Error", f"Failed to create k3d cluster: {e}")) + + threading.Thread(target=do_create, daemon=True).start() + + def _refresh_k3d_ui(self): + clusters = self._get_k3d_cluster_list() + self.k3d_cluster_list.set(clusters) + name = self.k3d_cluster_name.get().strip() + if name in clusters: + self.selected_k3d_cluster.set(name) + self.show_page('init_cluster') + + def _on_k3d_cluster_select(self, *args): + """Called when a k3d cluster is selected from the dropdown.""" + cluster_name = self.selected_k3d_cluster.get() + if cluster_name: + try: + # Merge kubeconfig and switch context to the selected cluster + subprocess.run(['k3d', 'kubeconfig', 'merge', cluster_name, '--switch-context'], capture_output=True, text=True) + except Exception: + pass + self.show_page('init_cluster') + + def _on_delete_k3d_cluster(self): + """Delete the selected k3d cluster after user confirmation.""" + name = self.selected_k3d_cluster.get().strip() + if not name: + messagebox.showerror("Error", "No cluster selected to delete.") + return + + confirmed = messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete cluster '{name}'?") + if not confirmed: + return + + # Show the notebook/console before streaming output + self.show_page('init_cluster') + + def do_delete(): + console = getattr(self, '_k3s_service_deploy_console', None) + if console: + console.clear() + console.write(f"Deleting k3d cluster '{name}' @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") + try: + if self._k3s_service_notebook and self._k3s_service_deploy_tab: + self._k3s_service_notebook.select(self._k3s_service_deploy_tab) + except Exception: + pass + try: + cmd = ['k3d', 'cluster', 'delete', name] + if console: + console.write(f"$ {' '.join(cmd)}\n\n") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=PROJECT_ROOT) + for line in proc.stdout: + if console: + console.write(line) + proc.wait() + if proc.returncode != 0: + if console: + console.write(f"\nk3d cluster delete failed (exit {proc.returncode})\n") + return + if console: + console.write(f"\nCluster '{name}' deleted successfully.\n") + + # Clear selection and refresh UI + self.selected_k3d_cluster.set('') + self.k3d_cluster_name.set('') + self.safe_after(self._refresh_k3d_ui) + except Exception as e: + if console: + console.write(f"\nError: {e}\n") + self.safe_after(lambda: messagebox.showerror("Error", f"Failed to delete k3d cluster: {e}")) + + threading.Thread(target=do_delete, daemon=True).start() + def _get_kubectx_list(self) -> list[str]: """Get list of kubernetes contexts.""" try: @@ -4970,7 +5138,7 @@ class ProleInstaller: return ["default"] def _verify_k3s_services(self): - if self._cluster_env_key() != 'service' or not self._k3s_service_status_console: + if not self._k3s_service_status_console: self._stop_k3s_service_status_updates() return self._start_k3s_service_status_updates() @@ -5022,10 +5190,10 @@ class ProleInstaller: try: ns = self._get_service_namespace() env = self._script_env_for_namespace(ns) - env["PROLE_MODE"] = "k3s" + env["PROLE_MODE"] = self._deployment_mode() if not env.get("KUBECONFIG"): - console.write("KUBECONFIG not generated. Check K3s credentials in prole.cfg.\n") + console.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") return status_args = ["-n", ns] @@ -5059,7 +5227,7 @@ class ProleInstaller: service_ns = self._get_service_namespace() env = self._script_env_for_namespace(service_ns) - env["PROLE_MODE"] = "k3s" + env["PROLE_MODE"] = self._deployment_mode() log_path = self._common_services_log_path() env["COMMON_SERVICES_INIT_LOG"] = str(log_path) try: @@ -5073,10 +5241,10 @@ class ProleInstaller: if not env.get("KUBECONFIG"): if console: - console.write("KUBECONFIG not generated. Check K3s credentials in prole.cfg.\n") + console.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") if log_fp: try: - log_fp.write("KUBECONFIG not generated. Check K3s credentials in prole.cfg.\n") + log_fp.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") log_fp.close() except Exception: pass @@ -5121,86 +5289,72 @@ class ProleInstaller: threading.Thread(target=_deploy, daemon=True).start() - def _on_save_cluster_config(self): + def _validate_and_save_cluster_config(self) -> bool: """Verify the config then write the values to prole.cfg.""" - # Verification logic env_key = self._cluster_env_key() if env_key == 'dev': - ctx = self.selected_kubectx.get() - if not ctx: - messagebox.showwarning("Validation", "Please select a kubectx context.") - return - # Switch context + cluster_val = self.selected_k3d_cluster.get() or 'prole-dev-cluster' + if not cluster_val: + messagebox.showwarning("Validation", "Please select or create a k3d cluster.") + return False + + # Context switch (if context matches cluster name) try: - subprocess.run(['kubectx', ctx], check=True) + subprocess.run(['kubectl', 'config', 'use-context', f"k3d-{cluster_val}"], capture_output=True, text=True) except Exception: - try: - subprocess.run(['kubectl', 'config', 'use-context', ctx], check=True) - except Exception as e: - messagebox.showerror("Error", f"Failed to switch to context {ctx}: {e}") - return + pass elif env_key == 'service': if not (self.k3s_server_url.get() or '').strip(): messagebox.showwarning("Validation", "Please specify the K3s server URL.") - return + return False if not (self.k3s_token.get() or '').strip(): messagebox.showwarning("Validation", "Please specify the K3s token.") - return + return False + cluster_val = self.cluster_env.get() elif env_key == 'prod': path = self.prod_artifacts_path.get().strip() if not path: messagebox.showwarning("Validation", "Please specify an artifact staging directory.") - return + return False p = Path(path).expanduser() if not p.exists(): try: p.mkdir(parents=True, exist_ok=True) except Exception as e: messagebox.showerror("Error", f"Failed to create directory {path}: {e}") - return - + return False + cluster_val = self.cluster_env.get() + else: + cluster_val = self.cluster_env.get() + # Capture cluster info for config - self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get() + self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = cluster_val self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = (self.k3s_server_url.get() or '').strip() self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = self._get_service_namespace() + self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) + self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) + self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = str(self.at_rest_encryption_enabled.get()) + + if env_key == 'prod': + self.prole_cfg_data['Prod Cluster (k8s)']['STAGING_DIRECTORY'] = self.prod_artifacts_path.get() # Save config self._save_prole_cfg() - - # Apply common services configuration for service namespace, then refresh statuses - ns = self._get_service_namespace() - def worker(): - try: - env_vars = self._script_env_for_namespace(ns) - # Use 'update' to (re)apply manifests and config in the selected namespace - svc_args = ["-n", ns] - if self.kerberos_enabled.get(): - svc_args.append("-k") - svc_args.append("update") - self.controller.run_script("init_common_services.sh", args=svc_args, env=env_vars) - except Exception as e: - print(f"Cluster service update failed: {e}") - finally: - # Refresh status labels - self.safe_after(self.check_cluster_status_async) - - threading.Thread(target=worker, daemon=True).start() - - messagebox.showinfo("Success", "Cluster configuration saved and common services applied.") + return True def _cluster_status_snapshot(self) -> dict: env = self._cluster_env_key() cluster_ok = False if env == 'dev': - cluster_name = "prole-dev-cluster" + cluster_name = self.selected_k3d_cluster.get() or "prole-dev-cluster" try: res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) cluster_ok = cluster_name in (res.stdout or '') except Exception: cluster_ok = False - cluster_msg = f"K3D Cluster ({cluster_name}): Running" if cluster_ok else f"K3D Cluster ({cluster_name}): Not found/stopped" + cluster_msg = f"K3D Cluster ({cluster_name}): Running" if cluster_ok else f"K3D Cluster ({cluster_name}): Not found" else: cluster_ok, cluster_msg = self._check_k8s_cluster(env) cluster_fill = '#34c759' if cluster_ok else '#ff9f0a' @@ -5221,17 +5375,12 @@ class ProleInstaller: def _cluster_ready_for_navigation(self) -> bool: status = self._cluster_status_snapshot() - env = status['env'] - if not status['cluster_ok']: try: messagebox.showerror('Cluster', 'Cluster is not reachable. Please verify your cluster and try again.') except Exception: pass return False - - # Refresh status display for any changes - self.check_cluster_status_async() return True def check_cluster_status_async(self): @@ -6607,6 +6756,22 @@ class ProleInstaller: else: self.script_consoles[script].write("\nPort-forwards running (grafana ok).\n") + # Verify critical secrets + ns = env.get("NAMESPACE", "default") + self.script_consoles["init_cloudnative_pg.sh"].write(f"\nVerifying critical secrets in namespace {ns}...\n") + critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] + missing_secrets = [] + for secret in critical_secrets: + rc_s, _ = self._run_cmd_capture(["kubectl", "get", "secret", secret, "-n", ns]) + if rc_s != 0: + missing_secrets.append(secret) + + if missing_secrets: + self.script_consoles["init_cloudnative_pg.sh"].write(f"CRITICAL: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}.\n") + self.script_consoles["init_cloudnative_pg.sh"].write("Database initialization will fail without these secrets.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Critical secret missing.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + overall_success = False + if overall_success: self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization complete!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) self._scripts_success = True @@ -7124,15 +7289,8 @@ class ProleInstaller: return if current_id == 'init_cluster': - # Capture cluster info - self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get() - self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = (self.k3s_server_url.get() or '').strip() - self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') - self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = self._get_service_namespace() - self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) - self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) - self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = str(self.at_rest_encryption_enabled.get()) - self._save_prole_cfg() + if not self._validate_and_save_cluster_config(): + return if not self._cluster_ready_for_navigation(): return if not getattr(self, '_common_services_success', False): @@ -8595,6 +8753,7 @@ echo "-------------------------------------------------------------------"; def create_dmg(self): """Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink.""" + global installer_name if platform.system() != 'Darwin': return @@ -9797,9 +9956,8 @@ esac else: raise Exception(f"Unknown environment: {env}") - def create_or_recreate_k3d_dev_cluster(self): - """Create or restart local k3d cluster named prole-dev-cluster and wire it to the chosen registry.""" - cluster_name = 'prole-dev-cluster' + def create_or_recreate_k3d_dev_cluster(self, cluster_name='prole-dev-cluster'): + """Create or restart local k3d cluster and wire it to the chosen registry.""" result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True) if cluster_name in (result.stdout or ''): subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True) diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..4cfb240 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,48 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "25" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm:2025.3