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

This commit is contained in:
chrisfu 2026-02-20 01:28:52 -08:00
parent 6e2d3e9011
commit 9094d10117
18 changed files with 584 additions and 783 deletions

View File

@ -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-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-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 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 garage: local=3900 remote=3900 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> svc=garage address=0.0.0.0
openbao: local=8200 remote=8200 ns=default svc=openbao address=127.0.0.1 openbao: local=8200 remote=8200 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> svc=openbao address=127.0.0.1
opentofu: local=8080 remote=8080 ns=default svc=opentofu address=0.0.0.0 opentofu: local=8080 remote=8080 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> 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 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=<MagicMock name='StringVar().get().strip().strip()' id='4766698528'> remote=5432 ns=<MagicMock name='StringVar().get().strip().strip()' id='4766698528'> 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 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 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 supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0

View File

@ -27,45 +27,45 @@ env_setup.PROLE_HOME = ${PROLE_HOME}
env_setup.PROLE_LOGS = ${PROLE_LOGS} env_setup.PROLE_LOGS = ${PROLE_LOGS}
env_setup.PROLE_SERVICE = ${PROLE_SERVICE} env_setup.PROLE_SERVICE = ${PROLE_SERVICE}
init_cluster.at_rest_encryption_enabled = true 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.deployment_target = prole-dev-cluster
init_cluster.k3s_server_url = https://myrddin.prole.org:6443 init_cluster.k3s_server_url = https://myrddin.prole.org:6443
init_cluster.k3s_token = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b init_cluster.k3s_token = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
init_cluster.kerberos_enabled = true init_cluster.kerberos_enabled = true
init_cluster.mode = k3d init_cluster.mode = k3d
init_cluster.start_cluster = true init_cluster.start_cluster = true
init_cluster.supabase_enabled = true init_cluster.supabase_enabled = false
init_cnpg_deploy.force_rollout = false init_cnpg_deploy.force_rollout = false
init_cnpg_deploy.run_deploy = true init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true init_db_build.run_build = true
init_password.db_host_port = 5432 init_password.db_host_port = 5432
init_password.db_password = ${OPENBAO:kv/prole/prole-db-a0001/db#password} 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_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_password.generate_ssh_key = true
init_scripts.run_scripts = true init_scripts.run_scripts = true
kerberos_config.enabled = true kerberos_config.enabled = true
kerberos_config.kdc = 10.0.0.3 kerberos_config.kdc = 10.0.0.3
kerberos_config.password = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password} kerberos_config.password = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password}
kerberos_config.realm = PROLE.ORG kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = true kerberos_config.test_connection = false
kerberos_config.user = chrisfu kerberos_config.user =
network_scan.run = true network_scan.run = true
ollama_config.model = ollama_config.model =
ollama_config.server_host = morgoth.prole.org ollama_config.server_host =
ollama_config.server_port = 11434 ollama_config.server_port = 11434
[Global] [Global]
CLUSTER_ENV = k3d-prole-dev-cluster
CNPG_RELEASE_SERIES = 1.28
CNPG_VERSION = 1.28.1 CNPG_VERSION = 1.28.1
CNPG_RELEASE_SERIES = 1.28
CLUSTER_ENV = dev
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DB_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/db#password} DB_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/db#password}
DEPLOYMENT_MODE = k3d DEPLOYMENT_MODE = k3d
DEPLOYMENT_TARGET = prole-dev-cluster DEPLOYMENT_TARGET = prole-dev-cluster
DOCKER_IMPORT_DIR = DOCKER_IMPORT_DIR =
NAMESPACE = ${NAMESPACE} NAMESPACE = ${NAMESPACE}
PROLE_DB_USER = chrisfu PROLE_DB_USER = root
PROLE_HOME = ${PROLE_HOME} PROLE_HOME = ${PROLE_HOME}
PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
@ -75,7 +75,6 @@ SERVICE_NAMESPACE = default
[Welcome] [Welcome]
[Dependencies] [Dependencies]
STATUS = All installed
[Network] [Network]
AD_DC_HOST = myrddin.prole.org AD_DC_HOST = myrddin.prole.org
@ -90,20 +89,6 @@ KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True KERBEROS_AUTO_ENABLED = True
[Port Forwards] [Port Forwards]
PORT_FORWARD_K3D_MAPPING_1 = id=argocd;namespace=argocd;target=svc/argocd-server;address=0.0.0.0;hostPort=8081;servicePort=80;protocol=TCP;description=ArgoCD
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] [System Environment]
PROLE_CONF = ${PROLE_CONF} 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} GRAFANA_ADMIN_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/monitoring#grafana_admin_password}
[Kerberos Authentication] [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} PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password}
REALM = PROLE.ORG
SERVER = 10.0.0.3
USER = chrisfu
[Ollama] [Ollama]
OLLAMA_HOST = http://morgoth.prole.org:11434
OLLAMA_SERVER_HOST = morgoth.prole.org
OLLAMA_SERVER_PORT = 11434
[Optional Features] [Optional Features]
AT_REST_ENCRYPTION_ENABLED = True
KERBEROS_ENABLED = True
SUPABASE_ENABLED = False
[Database Creation] [Database Creation]
DB_NAME = prole-db-a0001
DB_USER = chrisfu
[Initialize Cluster] [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)] [Dev Cluster (k3d)]
CLUSTER_ENV = k3d-prole-dev-cluster CLUSTER_ENV = dev
DISPLAY_NAME = prole-dev-cluster DISPLAY_NAME = prole-dev-cluster
KUBECTL_CONTEXT = k3d-prole-dev-cluster KUBECTL_CONTEXT =
MODE = k3d MODE = k3d
[Service Cluster (k3s)] [Service Cluster (k3s)]
@ -163,23 +126,19 @@ MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080 PIPELINE_URL = http://127.0.0.1:8080
[Prod Cluster (k8s)] [Prod Cluster (k8s)]
ARTIFACTS_DIR = ${PROLE_DATA}/staging ARTIFACTS_DIR = /Users/chrisfu/dev/prole/data/staging
CLUSTER_ENV = prole-prod-cluster CLUSTER_ENV = prole-prod-cluster
DISPLAY_NAME = prole-prod-cluster DISPLAY_NAME = prole-prod-cluster
MODE = k8s MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080 PIPELINE_URL = http://127.0.0.1:8080
[Docker Build] [Docker Build]
LOCAL_REGISTRY = localhost:5000
LOCAL_REGISTRY_INTERNAL = k3d-prole-registry.localhost:5000
[Initialization Scripts] [Initialization Scripts]
STATUS = Completed
[Deployment] [Deployment]
MODE = k3d MODE = k3d
TARGET = prole-dev-cluster TARGET = prole-dev-cluster
[Install] [Install]
STATUS = Finished

View File

@ -45,16 +45,17 @@ def _canonical_uri(path: str) -> str:
return "/".join(parts) return "/".join(parts)
def _aws_request(method: str, endpoint: str, path: str, params: dict, access_key: str, secret_key: str, region: str): def _sign_request(
parsed = urlparse(endpoint) method: str, host: str, path: str, params: dict, access_key: str, secret_key: str, region: str, service: str
host = parsed.netloc ) -> tuple[dict[str, str], str, str]:
canonical_uri = _canonical_uri(path) """Sign an AWS request and return headers, canonical URI, and canonical query string."""
canonical_querystring = _canonical_query(params) now = datetime.datetime.now(datetime.timezone.utc)
payload_hash = hashlib.sha256(b"").hexdigest()
now = datetime.datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ") amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d") 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 = ( canonical_headers = (
f"host:{host}\n" 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" signed_headers = "host;x-amz-content-sha256;x-amz-date"
canonical_request = "\n".join( canonical_request = "\n".join([
[ method,
method, canonical_uri,
canonical_uri, canonical_querystring,
canonical_querystring, canonical_headers,
canonical_headers, signed_headers,
signed_headers, payload_hash,
payload_hash, ])
]
)
algorithm = "AWS4-HMAC-SHA256" algorithm = "AWS4-HMAC-SHA256"
credential_scope = f"{date_stamp}/{region}/s3/aws4_request" credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
string_to_sign = "\n".join( hashed_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
[algorithm, amz_date, credential_scope, 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() signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization_header = ( 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}" f"SignedHeaders={signed_headers}, Signature={signature}"
) )
url = f"{parsed.scheme}://{host}{canonical_uri}"
if canonical_querystring:
url = f"{url}?{canonical_querystring}"
headers = { headers = {
"x-amz-date": amz_date, "x-amz-date": amz_date,
"x-amz-content-sha256": payload_hash, "x-amz-content-sha256": payload_hash,
"Authorization": authorization_header, "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) req = urllib.request.Request(url, method=method, headers=headers)
return urllib.request.urlopen(req) return urllib.request.urlopen(req)

View File

@ -1101,6 +1101,11 @@ fetch_admin_keys_and_db_pass_from_bao_or_local() {
fi fi
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 if [[ -f "$ADMIN_PRIV_GENERIC" && -f "$ADMIN_PUB_GENERIC" ]]; then
echo "Using local admin key pair at $SECRETS_DIR" echo "Using local admin key pair at $SECRETS_DIR"
return 0 return 0
@ -1162,7 +1167,10 @@ initialize() {
ensure_barman_plugin ensure_barman_plugin
echo "Using namespace: $NAMESPACE" 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 apply_cnpg_admin_secret
ensure_prole_stack_resources ensure_prole_stack_resources

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys import sys
import os
from pathlib import Path from pathlib import Path
# Add project root to path # Add project root to path

View File

@ -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

View File

@ -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()

View File

@ -4,22 +4,30 @@ Installer actions and unattended workflow helpers.
from __future__ import annotations from __future__ import annotations
import configparser
import logging import logging
import os import secrets
import subprocess import shlex
import sys import socket
import time import string
from pathlib import Path 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.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 ( from installer.core.milestones import (
DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone,
SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone, SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone,
ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone
) )
from installer.core.env import * # noqa: F401,F403
def _configure_unbuffered_io(): def _configure_unbuffered_io():
os.environ.setdefault('PYTHONUNBUFFERED', '1') os.environ.setdefault('PYTHONUNBUFFERED', '1')
@ -1691,11 +1699,18 @@ class ProleSilentInstaller:
raise Exception("Invalid database namespace.") raise Exception("Invalid database namespace.")
user = (self._get_input('init_password.db_username', '') or '').strip() user = (self._get_input('init_password.db_username', '') or '').strip()
if not user: 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', '') p1 = self._get_input('init_password.db_password', '')
p2 = self._get_input('init_password.db_password_confirm', '') or p1 p2 = self._get_input('init_password.db_password_confirm', '') or p1
if not 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: if p1 != p2:
raise Exception("Database passwords do not match.") raise Exception("Database passwords do not match.")
@ -2213,7 +2228,22 @@ class ProleSilentInstaller:
self.err(f"[ERROR] {script} failed (code {rc})") self.err(f"[ERROR] {script} failed (code {rc})")
overall_success = False overall_success = False
self._scripts_success = overall_success 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: 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)): if not self._get_input_bool('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True)):

View File

@ -7,7 +7,6 @@ import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Sequence from typing import TYPE_CHECKING, Any, Callable, Sequence
from installer import config as inst_config
from installer.state import InstallerState from installer.state import InstallerState
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@ -8,15 +8,10 @@ import base64
import configparser import configparser
import getpass import getpass
import json import json
import logging
import os import os
import platform import platform
import re import re
import secrets
import shlex
import shutil import shutil
import socket
import string
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@ -24,8 +19,6 @@ import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@ -627,11 +620,11 @@ def _normalize_cluster_env(env: str | None) -> str:
if not env: if not env:
return '' return ''
s = str(env).strip().lower() 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' 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' 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 'prod'
return s return s
@ -639,11 +632,11 @@ def _normalize_cluster_env(env: str | None) -> str:
def _cluster_env_radio_value(env: str | None) -> str: def _cluster_env_radio_value(env: str | None) -> str:
key = _normalize_cluster_env(env) key = _normalize_cluster_env(env)
if key == 'dev': if key == 'dev':
return 'k3d-prole-dev-cluster' return 'dev'
if key == 'service': if key == 'service':
return 'prole-service-cluster' return 'service'
if key == 'prod': if key == 'prod':
return 'prole-prod-cluster' return 'prod'
return (env or '') return (env or '')
def _deployment_target_label(env: str | None) -> str: def _deployment_target_label(env: str | None) -> str:

View File

@ -238,6 +238,23 @@ class DatabaseCreationMilestone(Milestone):
user = getpass.getuser() user = getpass.getuser()
state.inputs['init_password.db_username'] = user 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['init_password.db_namespace'] = ns
state.inputs['env_setup.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) rc = state.controller.run_script(script, args=args, env=env, on_line=_stream_line)
if rc != 0: if rc != 0:
self.logger.error(f"Script {script} failed (code {rc})") msg = f"Script {script} failed (code {rc})"
# Should we stop? Silent installer continues but logs error. 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. # For k3d: start port-forward daemon and verify Grafana port-forward.
pf_ok = True pf_ok = True
@ -522,7 +556,9 @@ class DeploymentMilestone(Milestone):
if rc == 0: if rc == 0:
self.logger.info("Deployment successful") self.logger.info("Deployment successful")
else: else:
self.logger.error(f"Deployment failed (code {rc})") msg = f"Deployment failed (code {rc})"
self.logger.error(msg)
raise Exception(msg)
if progress: if progress:
progress("Deployment complete", 1.0) progress("Deployment complete", 1.0)

View File

@ -9,17 +9,16 @@ Also defines the Deploy page (final milestone):
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import os
import threading
import platform import platform
import subprocess
import shutil import shutil
import subprocess
import threading
from pathlib import Path
from typing import Callable from typing import Callable
from . import config as cfg
from .milestone import Milestone from .milestone import Milestone
from .state import InstallerState from .state import InstallerState
from . import config as cfg
def check_xcode_tools() -> bool: def check_xcode_tools() -> bool:

View File

@ -6,10 +6,6 @@ to feature modules. The legacy install.py will import and invoke run().
""" """
from __future__ import annotations 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 build as build_mod
from . import deploy as deploy_mod from . import deploy as deploy_mod

View File

@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod 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 . import config as inst_config
from .state import InstallerState
ProgressCallback = Callable[[str, float | None], None] ProgressCallback = Callable[[str, float | None], None]
ValidationResult = str | Sequence[str] | None ValidationResult = str | Sequence[str] | None

View File

@ -7,18 +7,15 @@ Maintains the same screen order and operations.
from __future__ import annotations from __future__ import annotations
import curses import curses
import sys import getpass
import threading
import os import os
import subprocess import subprocess
import getpass import sys
import json import threading
import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional, Callable
from . import config as inst_config from . import config as inst_config
from .ncurses_ui import CursesWindow, TerminalConsole, NavFooter, InputField, Checkbox from .ncurses_ui import CursesWindow, NavFooter, InputField
class ProleNcursesInstaller: class ProleNcursesInstaller:

View File

@ -7,7 +7,6 @@ Tk Canvas used by the installer, to reduce clutter in install.py.
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk
import platform import platform

View File

@ -4,47 +4,33 @@ Prole Service Dependencies Installer
Desktop application for installing, deploying, and validating Prole services 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 import platform
from pathlib import Path
import shlex import shlex
import socket
import signal import signal
import shutil import socket
import json import threading
import configparser import tkinter as tk
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 urllib.error import urllib.error
import logging import urllib.parse
from cryptography.hazmat.primitives.ciphers.aead import AESGCM 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 deploy as inst_deploy
from installer import screen as ui 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.controller import ProleController
from installer.core.milestones import ( from installer.core.env import _detect_ansible_topology, _format_ansible_topology_summary, _parse_ollama_host, \
DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, _normalize_k3s_token, _deployment_target_label, _cluster_env_radio_value, _find_kubeconfig_file, _bool_str, \
SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone, _looks_like_k8s_bearer_token, _deployment_mode_from_env, _pf_extract_id, _ensure_ansible_vault_credentials, \
ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone _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' # On macOS, set the process name as early as possible so the menu bar shows 'Prole Database Installer'
if platform.system() == 'Darwin': if platform.system() == 'Darwin':
@ -55,7 +41,7 @@ if platform.system() == 'Darwin':
pass pass
from installer.core.env import * # noqa: F401,F403 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 # Initialize global frames list to keep them in memory
GLOBAL_SCAN_FRAMES = [] GLOBAL_SCAN_FRAMES = []
@ -329,7 +315,19 @@ class ProleInstaller:
self.page_index = 0 self.page_index = 0
# Initialize variables for new Initialize screens # 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.deploy_target = tk.StringVar(value=_deployment_target_label(self.cluster_env.get()))
self._syncing_deploy_target = False self._syncing_deploy_target = False
self._deploy_target_trace = self.deploy_target.trace_add('write', self._on_deploy_target_change) 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() current = (self.cluster_env.get() or '').strip()
except Exception: except Exception:
current = '' current = ''
if not current or current == 'k3d-prole-dev-cluster': if not current or current == 'dev':
try: try:
self.cluster_env.set(_cluster_env_radio_value(cfg_env)) self.cluster_env.set(_cluster_env_radio_value(cfg_env))
except Exception: except Exception:
@ -1664,7 +1662,23 @@ class ProleInstaller:
env["KRB5_USER"] = self.kerberos_user.get().strip() env["KRB5_USER"] = self.kerberos_user.get().strip()
if self.kerberos_password.get().strip(): if self.kerberos_password.get().strip():
env["KRB5_PASSWORD"] = 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() raw_server, raw_token = self._k3s_connection_raw()
if raw_server: if raw_server:
env["PROLE_K3S_SERVER"] = 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))) _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 # Cluster init + optional features
_set('init_cluster.cluster_env', _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, 'k3d-prole-dev-cluster'))) _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, 'k3d-prole-dev-cluster'))) _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_server_url', _get_var(self.k3s_server_url, ''))
_set('init_cluster.k3s_token', _encrypt_cfg_secret(_get_var(self.k3s_token, ''))) _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)) _set_bool('init_cluster.supabase_enabled', _get_var(self.supabase_enabled, False))
@ -2685,7 +2699,7 @@ class ProleInstaller:
sections['Dev Cluster (k3d)'] = { sections['Dev Cluster (k3d)'] = {
**self.prole_cfg_data.get('Dev Cluster (k3d)', {}), **self.prole_cfg_data.get('Dev Cluster (k3d)', {}),
'MODE': 'k3d', 'MODE': 'k3d',
'CLUSTER_ENV': 'k3d-prole-dev-cluster', 'CLUSTER_ENV': 'dev',
'DISPLAY_NAME': 'prole-dev-cluster', 'DISPLAY_NAME': 'prole-dev-cluster',
'KUBECTL_CONTEXT': (self.selected_kubectx.get() or '').strip() 'KUBECTL_CONTEXT': (self.selected_kubectx.get() or '').strip()
} }
@ -4438,11 +4452,11 @@ class ProleInstaller:
target = '' target = ''
key = _normalize_cluster_env(target) key = _normalize_cluster_env(target)
if key == 'dev': if key == 'dev':
desired = 'k3d-prole-dev-cluster' desired = 'dev'
elif key == 'service': elif key == 'service':
desired = 'prole-service-cluster' desired = 'service'
elif key == 'prod': elif key == 'prod':
desired = 'prole-prod-cluster' desired = 'prod'
else: else:
return return
self._syncing_deploy_target = True self._syncing_deploy_target = True
@ -4467,13 +4481,13 @@ class ProleInstaller:
# Cluster Selection (Radio Buttons) # Cluster Selection (Radio Buttons)
x_label = 48 x_label = 48
y = 280 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 y += 28
cluster_options = [ cluster_options = [
('k3d-prole-dev-cluster', 'prole-dev-cluster'), ('dev', 'Dev'),
('prole-service-cluster', 'prole-service-cluster'), ('service', 'Service'),
('prole-prod-cluster', 'prole-prod-cluster') ('prod', 'Prod')
] ]
# We need to trace cluster_env if not already traced # 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) self._cluster_env_trace = self.cluster_env.trace_add('write', self._on_cluster_env_change)
rb_x = x_label + 20 rb_x = x_label + 20
rb_gap = 240 rb_gap = 150
for idx, (val, name) in enumerate(cluster_options): for idx, (val, name) in enumerate(cluster_options):
rb = tk.Radiobutton(self.bg_canvas, text=name, variable=self.cluster_env, value=val, rb = tk.Radiobutton(self.bg_canvas, text=name, variable=self.cluster_env, value=val,
bg='white', fg='black', activebackground='white', selectcolor='white', 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') rb_window = self.bg_canvas.create_window(rb_x + idx * rb_gap, y, window=rb, anchor='nw')
self._canvas_items.append(rb_window) self._canvas_items.append(rb_window)
self._overlay_widgets.append(rb) self._overlay_widgets.append(rb)
y += 30
selected_env_key = self._cluster_env_key()
save_x = content_width - 140 y += 44
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
if selected_env_key == 'dev': if selected_env_key == 'dev':
# kubectx dropdown # Combined Select / Add Cluster widget
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Available kubectx contexts:', fill='black', font=('SF Pro Text', 12, 'bold'))) self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Cluster:', fill='black', font=('SF Pro Text', 12, 'bold')))
y += 24 y += 28
values = self._get_kubectx_list() clusters = self._get_k3d_cluster_list()
combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_kubectx, values=values, state='readonly', width=40) combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_k3d_cluster, values=clusters, width=30)
# Preselect from prole.cfg (cluster env) if available and present in contexts if not self.selected_k3d_cluster.get() and clusters:
try: self.selected_k3d_cluster.set(clusters[0])
desired_ctx = (self.cluster_env.get() or '').strip()
if desired_ctx and desired_ctx in values: combo.bind('<<ComboboxSelected>>', lambda _evt: self._on_k3d_cluster_select())
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])
combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw')
self._canvas_items.append(combo_win) self._canvas_items.append(combo_win)
self._overlay_widgets.append(combo) self._overlay_widgets.append(combo)
y += 34
elif selected_env_key == 'service': add_btn = tk.Button(self.bg_canvas, text='Add', command=self._on_create_k3d_cluster,
# K3s connection settings bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black',
try: highlightbackground='#F5F5DC', highlightthickness=0,
self._apply_k3s_defaults() relief='flat', font=('SF Pro Text', 10), padx=10)
except Exception: add_win = self.bg_canvas.create_window(x_label + 290, y - 4, window=add_btn, anchor='nw')
pass self._canvas_items.append(add_win)
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold'))) self._overlay_widgets.append(add_btn)
y += 24
ui.canvas_text(self, x_label + 20, y, 'Server URL:', fill='black', font=('SF Pro Text', 11)) delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._on_delete_k3d_cluster,
server_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_server_url, width=60) bg='#F5F5DC', fg='#cc0000', activebackground='#E5E5D5', activeforeground='#cc0000',
server_win = self.bg_canvas.create_window(x_label + 140, y - 8, window=server_entry, anchor='nw') highlightbackground='#F5F5DC', highlightthickness=0,
self._canvas_items.append(server_win) relief='flat', font=('SF Pro Text', 10), padx=10)
self._overlay_widgets.append(server_entry) delete_win = self.bg_canvas.create_window(x_label + 350, y - 4, window=delete_btn, anchor='nw')
y += 28 self._canvas_items.append(delete_win)
self._overlay_widgets.append(delete_btn)
ui.canvas_text(self, x_label + 20, y, 'Token (vault_k3s_token):', fill='black', font=('SF Pro Text', 11)) y += 40
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') else:
self._canvas_items.append(token_win) # Service / Prod Section
self._overlay_widgets.append(token_entry) if selected_env_key == 'service':
y += 28 # K3s connection settings
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'):
try: try:
self.service_namespace.trace_remove('write', self._service_ns_trace) self._apply_k3s_defaults()
except Exception: except Exception:
pass pass
self._service_ns_trace = self.service_namespace.trace_add('write', lambda *_a: self.safe_after(_update_service_label)) self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold')))
_update_service_label() y += 28
y += 24
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) notebook = ttk.Notebook(self.bg_canvas)
status_frame = tk.Frame(notebook, bg='white') status_frame = tk.Frame(notebook, bg='white')
@ -4593,6 +4635,7 @@ class ProleInstaller:
) )
self._canvas_items.append(console_window) self._canvas_items.append(console_window)
self._overlay_widgets.extend([notebook, status_frame, deploy_frame, status_console, deploy_console]) self._overlay_widgets.extend([notebook, status_frame, deploy_frame, status_console, deploy_console])
restored = self._restore_common_services_log(deploy_console) restored = self._restore_common_services_log(deploy_console)
if restored: if restored:
try: try:
@ -4618,46 +4661,9 @@ class ProleInstaller:
deploy_win = self.bg_canvas.create_window(x_label + 20, y, window=deploy_btn, anchor='nw') deploy_win = self.bg_canvas.create_window(x_label + 20, y, window=deploy_btn, anchor='nw')
self._canvas_items.append(deploy_win) self._canvas_items.append(deploy_win)
self._overlay_widgets.append(deploy_btn) self._overlay_widgets.append(deploy_btn)
y += 24 y += 40
self._verify_k3s_services() 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: def _openbao_url(self) -> str:
url = (os.environ.get("PROLE_OPENBAO_URL") or "http://127.0.0.1:18200").strip() url = (os.environ.get("PROLE_OPENBAO_URL") or "http://127.0.0.1:18200").strip()
return url.rstrip('/') return url.rstrip('/')
@ -4954,6 +4960,168 @@ class ProleInstaller:
msg = f"{label}: Connected" if ok else f"{label}: Not reachable" msg = f"{label}: Connected" if ok else f"{label}: Not reachable"
return ok, msg 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]: def _get_kubectx_list(self) -> list[str]:
"""Get list of kubernetes contexts.""" """Get list of kubernetes contexts."""
try: try:
@ -4970,7 +5138,7 @@ class ProleInstaller:
return ["default"] return ["default"]
def _verify_k3s_services(self): 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() self._stop_k3s_service_status_updates()
return return
self._start_k3s_service_status_updates() self._start_k3s_service_status_updates()
@ -5022,10 +5190,10 @@ class ProleInstaller:
try: try:
ns = self._get_service_namespace() ns = self._get_service_namespace()
env = self._script_env_for_namespace(ns) env = self._script_env_for_namespace(ns)
env["PROLE_MODE"] = "k3s" env["PROLE_MODE"] = self._deployment_mode()
if not env.get("KUBECONFIG"): 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 return
status_args = ["-n", ns] status_args = ["-n", ns]
@ -5059,7 +5227,7 @@ class ProleInstaller:
service_ns = self._get_service_namespace() service_ns = self._get_service_namespace()
env = self._script_env_for_namespace(service_ns) 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() log_path = self._common_services_log_path()
env["COMMON_SERVICES_INIT_LOG"] = str(log_path) env["COMMON_SERVICES_INIT_LOG"] = str(log_path)
try: try:
@ -5073,10 +5241,10 @@ class ProleInstaller:
if not env.get("KUBECONFIG"): if not env.get("KUBECONFIG"):
if console: 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: if log_fp:
try: 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() log_fp.close()
except Exception: except Exception:
pass pass
@ -5121,86 +5289,72 @@ class ProleInstaller:
threading.Thread(target=_deploy, daemon=True).start() 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.""" """Verify the config then write the values to prole.cfg."""
# Verification logic
env_key = self._cluster_env_key() env_key = self._cluster_env_key()
if env_key == 'dev': if env_key == 'dev':
ctx = self.selected_kubectx.get() cluster_val = self.selected_k3d_cluster.get() or 'prole-dev-cluster'
if not ctx: if not cluster_val:
messagebox.showwarning("Validation", "Please select a kubectx context.") messagebox.showwarning("Validation", "Please select or create a k3d cluster.")
return return False
# Switch context
# Context switch (if context matches cluster name)
try: try:
subprocess.run(['kubectx', ctx], check=True) subprocess.run(['kubectl', 'config', 'use-context', f"k3d-{cluster_val}"], capture_output=True, text=True)
except Exception: except Exception:
try: pass
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
elif env_key == 'service': elif env_key == 'service':
if not (self.k3s_server_url.get() or '').strip(): if not (self.k3s_server_url.get() or '').strip():
messagebox.showwarning("Validation", "Please specify the K3s server URL.") messagebox.showwarning("Validation", "Please specify the K3s server URL.")
return return False
if not (self.k3s_token.get() or '').strip(): if not (self.k3s_token.get() or '').strip():
messagebox.showwarning("Validation", "Please specify the K3s token.") messagebox.showwarning("Validation", "Please specify the K3s token.")
return return False
cluster_val = self.cluster_env.get()
elif env_key == 'prod': elif env_key == 'prod':
path = self.prod_artifacts_path.get().strip() path = self.prod_artifacts_path.get().strip()
if not path: if not path:
messagebox.showwarning("Validation", "Please specify an artifact staging directory.") messagebox.showwarning("Validation", "Please specify an artifact staging directory.")
return return False
p = Path(path).expanduser() p = Path(path).expanduser()
if not p.exists(): if not p.exists():
try: try:
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Failed to create directory {path}: {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 # 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_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['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['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 # Save config
self._save_prole_cfg() self._save_prole_cfg()
return True
# 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.")
def _cluster_status_snapshot(self) -> dict: def _cluster_status_snapshot(self) -> dict:
env = self._cluster_env_key() env = self._cluster_env_key()
cluster_ok = False cluster_ok = False
if env == 'dev': if env == 'dev':
cluster_name = "prole-dev-cluster" cluster_name = self.selected_k3d_cluster.get() or "prole-dev-cluster"
try: try:
res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True)
cluster_ok = cluster_name in (res.stdout or '') cluster_ok = cluster_name in (res.stdout or '')
except Exception: except Exception:
cluster_ok = False 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: else:
cluster_ok, cluster_msg = self._check_k8s_cluster(env) cluster_ok, cluster_msg = self._check_k8s_cluster(env)
cluster_fill = '#34c759' if cluster_ok else '#ff9f0a' cluster_fill = '#34c759' if cluster_ok else '#ff9f0a'
@ -5221,17 +5375,12 @@ class ProleInstaller:
def _cluster_ready_for_navigation(self) -> bool: def _cluster_ready_for_navigation(self) -> bool:
status = self._cluster_status_snapshot() status = self._cluster_status_snapshot()
env = status['env']
if not status['cluster_ok']: if not status['cluster_ok']:
try: try:
messagebox.showerror('Cluster', 'Cluster is not reachable. Please verify your cluster and try again.') messagebox.showerror('Cluster', 'Cluster is not reachable. Please verify your cluster and try again.')
except Exception: except Exception:
pass pass
return False return False
# Refresh status display for any changes
self.check_cluster_status_async()
return True return True
def check_cluster_status_async(self): def check_cluster_status_async(self):
@ -6607,6 +6756,22 @@ class ProleInstaller:
else: else:
self.script_consoles[script].write("\nPort-forwards running (grafana ok).\n") 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: 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.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 self._scripts_success = True
@ -7124,15 +7289,8 @@ class ProleInstaller:
return return
if current_id == 'init_cluster': if current_id == 'init_cluster':
# Capture cluster info if not self._validate_and_save_cluster_config():
self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get() return
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._cluster_ready_for_navigation(): if not self._cluster_ready_for_navigation():
return return
if not getattr(self, '_common_services_success', False): if not getattr(self, '_common_services_success', False):
@ -8595,6 +8753,7 @@ echo "-------------------------------------------------------------------";
def create_dmg(self): def create_dmg(self):
"""Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink.""" """Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink."""
global installer_name
if platform.system() != 'Darwin': if platform.system() != 'Darwin':
return return
@ -9797,9 +9956,8 @@ esac
else: else:
raise Exception(f"Unknown environment: {env}") raise Exception(f"Unknown environment: {env}")
def create_or_recreate_k3d_dev_cluster(self): def create_or_recreate_k3d_dev_cluster(self, cluster_name='prole-dev-cluster'):
"""Create or restart local k3d cluster named prole-dev-cluster and wire it to the chosen registry.""" """Create or restart local k3d cluster and wire it to the chosen registry."""
cluster_name = 'prole-dev-cluster'
result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True) result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True)
if cluster_name in (result.stdout or ''): if cluster_name in (result.stdout or ''):
subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True) subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True)

48
qodana.yaml Normal file
View File

@ -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: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
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> #(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