prole/installer/ui/screens/deploy.py
chrisfu 5618b662dd Remove prole-db-manager; simplify deployment via prole-authority; fix pg18 downgrade & cluster name
Summary:
Removed the prole-db-manager microservice and simplified deployment to use
prole-authority as the internal management and authorization point. Fixed two
blocking bugs that prevented silent install from completing on knoe-dev-cluster.

Removed: prole-db-manager
- Deleted db-manager-deployment.yaml and db-manager-service.yaml from opentofu manifests
- Deleted src/db-manager/ (Dockerfile, server.js, package.json, tests)
- Removed prole-db-manager port-forward mapping from installer/core/env.py
- Removed init_db_manager.sh from Initialization Scripts (milestones.py, actions.py)
- Removed init_certmgr.sh and init_db_manager.sh tabs from services screen (services.py)
- Removed live k8s Deployment/Service from knoe-dev-cluster

Fixed: PostgreSQL version downgrade error (pg17 -> pg18)
- Created conf/postgresql/.version with value 18
- Updated k8s/prole/prole-db.yaml and prole-db-recovery.yaml.tpl imageName to prole-db:18-089
- Fixed _init_database_options_state() to restore saved version_type from prole.cfg
  so db_version_type defaults to v18 (pg18) instead of silently reverting to pg17
- Added database_options.* keys to _collect_input_snapshot() in cfg.py so
  distribution, version_type, and all extension toggles persist to prole.cfg

Fixed: Cluster name inconsistency
- Removed stale prole-dev-cluster references; all scripts now use knoe-dev-cluster
- Added knoe-dev-cluster to mode-detection case in etc/prole_cfg.sh

Config: conf/prole.cfg
- Set kerberos_config.enabled = False, KERBEROS_AUTO_ENABLED = False
- Added database_options.distribution = percona, version_type = v18
- Added all 13 extension flags set to True (postgis, pgvector, pgcrypto, pgaudit,
  pg_repack, pg_stat_statements, pg_buffercache, pg_freespacemap, pgrowlocks,
  postgres_fdw, dblink, pg_stat_monitor, pgbadger)

Verification:
./install.py -s -l -v -c conf/prole.cfg completed successfully.
CNPG deployed prole-db:18-089 to knoe-dev-cluster; all milestones passed.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-01 20:40:44 -08:00

992 lines
35 KiB
Python

"""Deployment screen, final deployment pipeline and LaunchAgent setup."""
import os
import plistlib
import secrets
import subprocess
import threading
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from installer import screen as ui
from installer.core.env import PROJECT_ROOT
class DeployScreenMixin:
"""Deployment screen, final deployment pipeline and LaunchAgent setup."""
def _render_deploy_page(self):
self._render_title("Deploy", y=40)
self._render_paragraph(
"Preparing to deploy. We will verify steps and perform actions as needed.",
y=90,
)
# Render simple list of steps (static view). Runtime updates can redraw as needed.
y = 140
left = 56
if not hasattr(self, "deploy_steps"):
self.deploy_steps = [
{"name": "Build Prole macOS app", "status": "pending"},
{"name": "Check Docker is running", "status": "pending"},
{"name": "Install Prole Tools.app", "status": "pending"},
]
for step in self.deploy_steps:
# status circle (pending empty)
self._canvas_items.append(
ui.canvas_oval(self, left, y, left + 18, y + 18, outline="#b0b0b0")
)
self._canvas_items.append(
ui.canvas_text(
self,
left + 26,
y - 2,
step["name"],
fill="#1d1d1f",
font=("Helvetica", 12),
)
)
y += 26
def on_deploy(self):
if getattr(self, "_deploy_running", False):
return
self._deploy_running = True
try:
if self.deploy_button:
self.deploy_button.configure(state="disabled")
except Exception:
pass
def worker():
err = None
try:
self.ensure_prole_env()
self.reload_env_from_shell()
except Exception as e:
err = f"Failed to load environment: {e}"
try:
self._ensure_prole_directories()
if "Install" in self.prole_cfg_data:
self.prole_cfg_data["Install"]["STATUS"] = "Deployed"
self._save_prole_cfg()
except Exception as e:
err = err or f"Failed to prepare Prole directories: {e}"
if err:
self.safe_after(lambda: messagebox.showerror("Deploy", err))
else:
self.safe_after(self.open_drag_install_window)
def _finish():
self._deploy_running = False
try:
if self.deploy_button:
self.deploy_button.configure(state="normal")
except Exception:
pass
self.safe_after(_finish)
threading.Thread(target=worker, daemon=True).start()
def on_launch(self):
app_path = self._get_prole_dist_dir() / "Prole Tools.app"
if app_path.exists():
try:
subprocess.Popen(["open", str(app_path)])
except Exception:
pass
else:
try:
messagebox.showerror(
"Launch", f"App not found at {app_path}. Please build it first."
)
except Exception:
pass
def run_final_deployment(self):
initial_dir = str(self._resolve_env_dir("PROLE_DATA", "data"))
try:
if not Path(initial_dir).exists():
initial_dir = str(Path.home())
except Exception:
initial_dir = str(Path.home())
export_base = filedialog.askdirectory(
initialdir=initial_dir, title="Select deployment export directory"
)
if not export_base:
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text="Save canceled.",
fill="#6e6e73",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
return
export_base = str(Path(export_base).expanduser())
docker_export_dir = str(Path(export_base) / "docker-import")
helm_export_dir = str(Path(export_base) / "helm-chart")
kustomize_export_dir = str(Path(export_base) / "kustomize")
def worker():
self.safe_after(
lambda: (
self._save_deployment_button.configure(state="disabled")
if hasattr(self, "_save_deployment_button")
and self._save_deployment_button.winfo_exists()
else None
)
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text="Saving deployment...",
fill="blue",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
# Use the deploy console on the services screen for visible output
console = getattr(self, "_cnpg_deploy_console", None)
# Fall back to install_consoles tab if available
script_name = "final_deployment.sh"
if not console:
console = getattr(self, "install_consoles", {}).get(script_name)
if (
hasattr(self, "install_consoles")
and script_name in self.install_consoles
):
self.safe_after(
lambda: (
self.install_tabs.select(
self.install_consoles[script_name].master
)
if hasattr(self, "install_tabs")
and self.install_tabs.winfo_exists()
else None
)
)
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
env["DOCKER_IMPORT_DIR"] = docker_export_dir
env["HELM_CHART_DIR"] = helm_export_dir
env["KUSTOMIZE_DIR"] = kustomize_export_dir
if console:
console.clear()
console.write("========================================\n")
console.write("Save Deployment\n")
console.write("========================================\n\n")
console.write(f"Export base: {export_base}\n")
console.write(f"Docker images: {docker_export_dir}\n")
console.write(f"Helm chart: {helm_export_dir}\n")
console.write(f"Kustomize: {kustomize_export_dir}\n\n")
def _on_line(line):
if console:
console.write(line)
# Stage 1: Docker export, Helm chart, Kustomize
if console:
console.write("==> Stage 1: Exporting deployment artifacts...\n\n")
rc = self.controller.run_script(
script_name,
args=[
"--docker-export",
"--helm-chart",
"--kustomize",
"--output-dir",
export_base,
],
env=env,
on_line=_on_line,
)
if rc != 0:
if console:
console.write(f"\n[ERROR] Artifact export failed with code {rc}\n")
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text=f"Failed with code {rc}",
fill="#ff3b30",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
self.safe_after(
lambda: (
self._save_deployment_button.configure(state="normal")
if hasattr(self, "_save_deployment_button")
and self._save_deployment_button.winfo_exists()
else None
)
)
return
# Stage 2: Prepare k3s pipeline (OpenTofu + ArgoCD)
if console:
console.write(
"\n==> Stage 2: Preparing k3s pipeline (OpenTofu + ArgoCD)...\n\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text="Preparing k3s pipeline...",
fill="blue",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
pipeline_rc = self.controller.run_script(
script_name, args=["--prepare-k3s-pipeline"], env=env, on_line=_on_line
)
if pipeline_rc != 0:
if console:
console.write(
f"\n[WARN] k3s pipeline preparation failed with code {pipeline_rc}\n"
)
console.write(
"Deployment artifacts were saved, but pipeline staging had issues.\n"
)
if console:
console.write("\n========================================\n")
console.write("Save Deployment Complete\n")
console.write("========================================\n")
console.write(f"\nArtifacts: {export_base}\n")
console.write(
f"Pipeline: {PROJECT_ROOT / 'deploy' / 'opentofu' / 'k3s'}\n"
)
console.write(
f"ArgoCD: {PROJECT_ROOT / 'deploy' / 'opentofu' / 'k3s' / 'argocd'}\n"
)
if rc == 0 and pipeline_rc == 0:
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text="Deployment saved successfully.",
fill="#34c759",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
elif rc == 0:
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text="Saved (pipeline warnings)",
fill="#ff9500",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
else:
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._save_deployment_status_label,
text=f"Failed with code {rc}",
fill="#ff3b30",
)
if hasattr(self, "_save_deployment_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
self.safe_after(
lambda: (
self._save_deployment_button.configure(state="normal")
if hasattr(self, "_save_deployment_button")
and self._save_deployment_button.winfo_exists()
else None
)
)
threading.Thread(target=worker, daemon=True).start()
def run_build_a_bao(self):
def worker():
self.safe_after(
lambda: (
self._build_a_bao_button.configure(state="disabled")
if hasattr(self, "_build_a_bao_button")
and self._build_a_bao_button.winfo_exists()
else None
)
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._build_a_bao_status_label,
text="Saving secrets to OpenBao...",
fill="blue",
)
if hasattr(self, "_build_a_bao_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
script_name = "build-a-bao.sh"
if (
hasattr(self, "install_consoles")
and script_name in self.install_consoles
):
self.safe_after(
lambda: (
self.install_tabs.select(
self.install_consoles[script_name].master
)
if hasattr(self, "install_tabs")
and self.install_tabs.winfo_exists()
else None
)
)
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
env["NAMESPACE"] = (self.db_namespace.get() or "").strip()
console = getattr(self, "install_consoles", {}).get(script_name)
if console:
console.clear()
console.write(f"Running {script_name} ...\n")
def _on_line(line):
if console:
console.write(line)
rc = self.controller.run_script(script_name, env=env, on_line=_on_line)
if rc == 0:
self._secrets_finalized = True
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._build_a_bao_status_label,
text="Secrets saved to OpenBao.",
fill="#34c759",
)
if hasattr(self, "_build_a_bao_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
else:
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._build_a_bao_status_label,
text=f"Failed with code {rc}",
fill="#ff3b30",
)
if hasattr(self, "_build_a_bao_status_label")
and self.bg_canvas.winfo_exists()
else None
)
)
self.safe_after(
lambda: (
self._build_a_bao_button.configure(state="normal")
if hasattr(self, "_build_a_bao_button")
and self._build_a_bao_button.winfo_exists()
else None
)
)
threading.Thread(target=worker, daemon=True).start()
def create_deploy_screen(self):
"""Create the Deploy screen"""
frame = tk.Frame(self.content_area, bg="#1a1a1a")
self.screens["deploy"] = frame
# Title
title = ttk.Label(frame, text="Build and Deploy", style="Title.TLabel")
title.pack(pady=(0, 30))
# Instructions
instructions = tk.Label(
frame,
text="Build and deploy Prole services to k3d cluster",
bg="#1a1a1a",
fg="#aaaaaa",
font=("Helvetica", 11),
)
instructions.pack(pady=(0, 20))
# Environment selector
env_frame = tk.Frame(frame, bg="#1a1a1a")
env_frame.pack(pady=(0, 10), fill="x")
env_label = tk.Label(
env_frame,
text="Target Environment:",
bg="#1a1a1a",
fg="#dddddd",
font=("Helvetica", 11),
)
env_label.pack(side="left", padx=(0, 10))
self.deploy_environment = tk.StringVar(value="Dev")
env_combo = ttk.Combobox(
env_frame,
textvariable=self.deploy_environment,
values=["Dev", "Service", "Prod"],
state="readonly",
width=18,
)
env_combo.pack(side="left")
env_help = tk.Label(
env_frame,
text="Dev = local k3d (knoe-dev-cluster), Service = remote k3s at pi.prole.org:6443, Prod = stretch of prole-service-cluster",
bg="#1a1a1a",
fg="#888888",
font=("Helvetica", 9),
)
env_help.pack(side="left", padx=(12, 0))
# Deploy button
deploy_btn = tk.Button(
frame,
text="Build and Deploy",
command=self.start_deployment,
bg="#4a9eff",
fg="white",
activebackground="#3a8eef",
font=("Helvetica", 16, "bold"),
padx=40,
pady=20,
cursor="hand2",
relief="flat",
)
deploy_btn.pack(pady=20)
# Progress frame
progress_frame = tk.Frame(frame, bg="#1a1a1a")
progress_frame.pack(fill="both", expand=True, pady=20)
# Progress list
# Initialize deploy steps; some names are updated dynamically when deployment starts
self.deploy_steps = [
{"name": "Build ProleStatus macOS app", "status": "pending"},
{"name": "Check Docker is running", "status": "pending"},
{"name": "Check or configure container registry", "status": "pending"},
{"name": "Ensure target cluster", "status": "pending"},
{"name": "Build prole-db Docker image", "status": "pending"},
{"name": "Tag Docker image for registry", "status": "pending"},
{"name": "Push image to registry", "status": "pending"},
{"name": "Import image to k3d cluster (Dev only)", "status": "pending"},
]
self.deploy_widgets = {}
for step in self.deploy_steps:
self.create_deploy_step_widget(progress_frame, step)
def _create_page_deploy(self):
f = self._page_container()
ttk.Label(f, text="Deploy", style="Title.TLabel").pack(
anchor="w", padx=24, pady=(24, 6)
)
ttk.Label(
f,
text="Preparing to deploy. We will verify steps and perform actions as needed.",
style="Body.TLabel",
wraplength=800,
).pack(anchor="w", padx=24)
# Reuse existing deploy steps UI but on light theme
self.deploy_steps = [
{"name": "Build Prole macOS app", "status": "pending"},
{"name": "Check Docker is running", "status": "pending"},
{"name": "Check or configure container registry", "status": "pending"},
{"name": "Ensure target cluster", "status": "pending"},
{"name": "Build prole-db Docker image", "status": "pending"},
{"name": "Tag Docker image for registry", "status": "pending"},
{"name": "Push image to registry", "status": "pending"},
{"name": "Import image to k3d cluster (Dev only)", "status": "pending"},
{
"name": "Install LaunchAgent for port-forwards (Dev)",
"status": "pending",
},
]
self.deploy_widgets = {}
container = ttk.Frame(f)
container.pack(fill="both", expand=True, padx=16, pady=8)
for step in self.deploy_steps:
self._create_deploy_row(container, step)
# self._register_page('deploy', f)
def create_deploy_step_widget(self, parent, step):
"""Create a widget for a deployment step"""
step_frame = tk.Frame(parent, bg="#2a2a2a", relief="flat")
step_frame.pack(fill="x", pady=5, padx=10)
# Status indicator
status_canvas = tk.Canvas(
step_frame, width=30, height=30, bg="#2a2a2a", highlightthickness=0
)
status_canvas.pack(side="left", padx=15, pady=15)
# Step name
name_label = tk.Label(
step_frame,
text=step["name"],
bg="#2a2a2a",
fg="#ffffff",
font=("Helvetica", 11),
anchor="w",
)
name_label.pack(side="left", fill="x", expand=True, padx=10)
# Status text
status_label = tk.Label(
step_frame,
text="Pending",
bg="#2a2a2a",
fg="#aaaaaa",
font=("Helvetica", 10),
)
status_label.pack(side="right", padx=15)
self.deploy_widgets[step["name"]] = {
"canvas": status_canvas,
"label": status_label,
"step": step,
}
# Draw initial pending state
self.update_deploy_step_status(step["name"], "pending")
def _create_deploy_row(self, parent, step):
row = ttk.Frame(parent)
row.pack(fill="x", pady=6)
canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0)
canvas.pack(side="left", padx=8)
lbl = ttk.Label(row, text=step["name"], style="Body.TLabel")
lbl.pack(side="left")
status = ttk.Label(row, text="Pending", style="Dim.TLabel")
status.pack(side="right", padx=8)
self.deploy_widgets[step["name"]] = {
"canvas": canvas,
"label": status,
"step": step,
}
self._draw_status(canvas, "pending")
def update_deploy_step_status(self, step_name, status):
"""Update the status of a deployment step"""
widget = self.deploy_widgets[step_name]
canvas = widget["canvas"]
label = widget["label"]
step = widget["step"]
step["status"] = status
canvas.delete("all")
if status == "pending":
ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline="#666", width=2)
label.configure(text="Pending", fg="#aaaaaa")
elif status == "running":
ui.canvas_oval_on(
canvas, 5, 5, 25, 25, outline="#ffaa00", width=2, fill="#ffaa00"
)
label.configure(text="Running...", fg="#ffaa00")
elif status == "completed":
ui.canvas_oval_on(
canvas, 5, 5, 25, 25, outline="#28a745", width=2, fill="#28a745"
)
ui.canvas_text_on(
canvas, 15, 15, "", fill="white", font=("Helvetica", 16, "bold")
)
label.configure(text="Completed", fg="#28a745")
elif status == "error":
ui.canvas_oval_on(
canvas, 5, 5, 25, 25, outline="#dc3545", width=2, fill="#dc3545"
)
ui.canvas_text_on(
canvas, 15, 15, "", fill="white", font=("Helvetica", 16, "bold")
)
label.configure(text="Error", fg="#dc3545")
elif status == "skipped":
ui.canvas_oval_on(
canvas, 5, 5, 25, 25, outline="#666", width=2, fill="#444444"
)
label.configure(text="Skipped", fg="#888888")
def start_deployment(self):
"""Start the deployment process"""
threading.Thread(target=self.run_deployment, daemon=True).start()
def generate_prole_properties(self, env: str):
"""Dynamically generate prole-tools-app/prole.properties based on environment"""
props_path = Path("prole-tools-app/prole.properties")
# Determine host based on environment
# For now use localhost as a placeholder for Service/Prod
host = "localhost"
content = f"""# Prole default endpoints (dynamically generated by install.py)
# UI assets
icon=img/proleIcon.png
background=img/proleLogoSepia.png
# Dev port-forward supervision
pf.enabled=true
# Service endpoints (7 traffic lights)
svc.1.name=K3D
svc.1.host={host}
svc.1.port=6443
svc.2.name=Prometheus
svc.2.host={host}
svc.2.port=9090
svc.3.name=Grafana
svc.3.host={host}
svc.3.port=3000
svc.4.name=OpenBAO
svc.4.host={host}
svc.4.port=8200
svc.5.name=PostgreSQL
svc.5.host={host}
svc.5.port=5432
svc.6.name=Kong
svc.6.host={host}
svc.6.port=8000
svc.7.name=CertManager
svc.7.host={host}
svc.7.port=9402
# Kerberos configuration
kerberos.enabled={str(self.kerberos_enabled.get()).lower()}
kerberos.realm={self.kerberos_realm.get()}
kerberos.user={self.kerberos_user.get()}
kerberos.kdc={self.kerberos_kdc.get()}
"""
props_path.write_text(content)
print(f"Generated {props_path} for {env} environment")
def run_deployment(self):
"""Run the deployment steps"""
try:
# Capture environment selection and prepare dynamic labels
env = self.deploy_environment.get().strip()
if env not in ("Dev", "Service", "Prod"):
env = "Dev"
# Update step labels to reflect environment
self.deploy_widgets["Ensure target cluster"]["step"][
"name"
] = f"Ensure target cluster ({env})"
# Use the correct way to update the label text in the UI
self.deploy_widgets["Ensure target cluster"][
"label"
].master.winfo_children()[1].configure(
text=f"Ensure target cluster ({env})"
)
# Step 0: Generate prole.properties
self.generate_prole_properties(env)
# Step 1: Build Prole macOS app
self.update_deploy_step_status("Build Prole macOS app", "running")
self.build_prole_app()
self.update_deploy_step_status("Build Prole macOS app", "completed")
# Step 1: Check Docker
self.update_deploy_step_status("Check Docker is running", "running")
if not self.check_docker_running():
self.update_deploy_step_status("Check Docker is running", "error")
messagebox.showerror(
"Error", "Docker is not running. Please start Docker Desktop."
)
return
self.update_deploy_step_status("Check Docker is running", "completed")
# Step 2: Registry discovery/config
self.update_deploy_step_status(
"Check or configure container registry", "running"
)
self.registry_url = self.ensure_registry_available(env)
self.update_deploy_step_status(
"Check or configure container registry", "completed"
)
# Step 3: Ensure target cluster as per environment
self.update_deploy_step_status("Ensure target cluster", "running")
self.create_or_select_cluster(env)
self.update_deploy_step_status("Ensure target cluster", "completed")
# Step 3: Build Docker image
self.update_deploy_step_status("Build prole-db Docker image", "running")
self.build_docker_image()
self.update_deploy_step_status("Build prole-db Docker image", "completed")
# Step 4: Tag image
self.update_deploy_step_status("Tag Docker image for registry", "running")
self.tag_docker_image()
self.update_deploy_step_status("Tag Docker image for registry", "completed")
# Step 5: Push to registry
self.update_deploy_step_status("Push image to registry", "running")
self.push_docker_image()
self.update_deploy_step_status("Push image to registry", "completed")
# Step 6: Import to k3d (Dev only)
if env == "Dev":
self.update_deploy_step_status(
"Import image to k3d cluster (Dev only)", "running"
)
self.import_k3d_image(cluster_name="knoe-dev-cluster")
self.update_deploy_step_status(
"Import image to k3d cluster (Dev only)", "completed"
)
else:
self.update_deploy_step_status(
"Import image to k3d cluster (Dev only)", "skipped"
)
# Step 7: Install LaunchAgent for port-forwards (Dev)
if env == "Dev":
self.update_deploy_step_status(
"Install LaunchAgent for port-forwards (Dev)", "running"
)
ok = self.install_launchagent_port_forwards()
if ok:
self.update_deploy_step_status(
"Install LaunchAgent for port-forwards (Dev)", "completed"
)
else:
self.update_deploy_step_status(
"Install LaunchAgent for port-forwards (Dev)", "error"
)
return
else:
self.update_deploy_step_status(
"Install LaunchAgent for port-forwards (Dev)", "skipped"
)
messagebox.showinfo("Success", "Deployment completed successfully!")
except Exception as e:
messagebox.showerror("Error", f"Deployment failed: {str(e)}")
def install_launchagent_port_forwards(self) -> bool:
"""Create/update the user LaunchAgent and helper script to manage kubectl port-forwards.
- Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh
- LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist
"""
try:
home = Path.home()
bin_dir = home / "Library" / "Application Support" / "Prole" / "bin"
run_dir = home / "Library" / "Application Support" / "Prole" / "run"
plist_path = (
home / "Library" / "LaunchAgents" / "org.prole.prole-db.kpf-dev.plist"
)
script_path = bin_dir / "prole-kpf.sh"
bin_dir.mkdir(parents=True, exist_ok=True)
run_dir.mkdir(parents=True, exist_ok=True)
plist_path.parent.mkdir(parents=True, exist_ok=True)
helper_script = """#!/bin/sh
set -eu
LABEL="org.prole.prole-db.kpf-dev"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
RUNDIR="$HOME/Library/Application Support/Prole/run"
PIDFILE="$RUNDIR/kpf.pids"
ensure_rundir() {
mkdir -p "$RUNDIR"
}
list_cmds() {
# Enumerate ProleCommands array using PlistBuddy
if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then
i=0
while true; do
if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then
break
fi
echo "$val"
i=$((i+1))
done
fi
}
start() {
ensure_rundir
: > "$PIDFILE"
IFS='\n'
for cmd in $(list_cmds); do
[ -z "$cmd" ] && continue
(sh -lc "$cmd") &
echo $! >> "$PIDFILE"
done
wait
}
stop() {
if [ -f "$PIDFILE" ]; then
while read -r pid; do
[ -z "$pid" ] && continue
kill "$pid" 2>/dev/null || true
done < "$PIDFILE"
rm -f "$PIDFILE"
fi
}
status() {
if [ ! -f "$PIDFILE" ]; then
echo "not running"
exit 3
fi
alive=0
total=0
while read -r pid; do
[ -z "$pid" ] && continue
total=$((total+1))
if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi
done < "$PIDFILE"
echo "$alive/$total running"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;;
esac
"""
script_path.write_text(helper_script)
os.chmod(script_path, 0o755)
# Default commands for Dev environment (k3d)
default_cmds = [
"kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090",
"kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443",
"kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0",
"kubectl port-forward -n monitoring svc/kps-grafana 3000:80 --address 0.0.0.0",
]
# Create plist dict
from plistlib import dumps as plist_dumps
plist_dict = {
"Label": "org.prole.prole-db.kpf-dev",
"RunAtLoad": True,
"KeepAlive": True,
# Ensure PATH has common locations for kubectl
"EnvironmentVariables": {
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
},
"StandardOutPath": str(
home / "Library" / "Logs" / "org.prole.prole-db.kpf-dev.out.log"
),
"StandardErrorPath": str(
home / "Library" / "Logs" / "org.prole.prole-db.kpf-dev.err.log"
),
"ProgramArguments": [str(script_path), "start"],
"ProleCommands": default_cmds,
}
plist_bytes = plist_dumps(plist_dict)
plist_path.write_bytes(plist_bytes)
# Reload the agent
uid = os.getuid()
# Try to kickstart first
subprocess.run(
[
"launchctl",
"kickstart",
"-k",
f"gui/{uid}/org.prole.prole-db.kpf-dev",
],
capture_output=True,
)
# Bootout and bootstrap to ensure it's loaded, then kickstart
subprocess.run(
[
"launchctl",
"bootout",
f"gui/{uid}",
f"gui/{uid}/org.prole.prole-db.kpf-dev",
],
capture_output=True,
)
subprocess.run(
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)], check=True
)
subprocess.run(
[
"launchctl",
"kickstart",
"-k",
f"gui/{uid}/org.prole.prole-db.kpf-dev",
],
check=True,
)
return True
except Exception as e:
print(f"LaunchAgent setup failed: {e}")
return False
def _draw_status(self, canvas, status):
ui.canvas_clear(canvas)
if status == "success":
ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill="#34c759", outline="")
ui.canvas_line_on(canvas, 5, 10, 9, 14, fill="white", width=2)
ui.canvas_line_on(canvas, 9, 14, 16, 6, fill="white", width=2)
elif status == "running":
ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill="#ffd60a", outline="")
elif status == "error":
ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill="#ff3b30", outline="")
else:
ui.canvas_oval_on(canvas, 2, 2, 18, 18, outline="#b0b0b0")
# --------------- Dependency helpers ---------------