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-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=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> svc=garage address=0.0.0.0
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=<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
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
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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
import sys
import os
from pathlib import 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
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)):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,54 +4503,53 @@ 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
# 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
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])
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('<<ComboboxSelected>>', 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':
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)
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._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
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)
@ -4545,29 +4558,58 @@ class ProleInstaller:
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))
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
service_label = ui.canvas_text(self, x_label, y, '', fill='black', font=('SF Pro Text', 12, 'bold'))
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)
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:
self.service_namespace.trace_remove('write', self._service_ns_trace)
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
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)

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