prole/installer/ui/screens/deploy.py
chrisfu b03efa8f69 Kong API gateway, docker-import preload, OpenTofu graceful fallback, milestone fix
Kong API Gateway (replacing prole nginx):
- Add etc/init_kong.sh provisioning script (DB-less mode, prole-db namespace)
- Add kong-deployment.yaml and kong-service.yaml manifests
- Rewire ingress rules (svc/git/api.prole.org) to prole-db-kong:8000
- Update kustomization.yaml to reference kong manifests

PostgREST & DB Manager in prole-db namespace:
- Add etc/init_postgrest.sh and etc/init_db_manager.sh scripts
- Add postgrest/db-manager deployment and service manifests
- Add src/db-manager/ Node.js REST endpoint for backup triggers
- Default NAMESPACE changed to prole-db in both scripts

Docker image pre-load from PROLE_DATA/docker-import:
- Add _preload_docker_images() to init_common_services.sh
- Scan for .tar files exported by final_deployment.sh
- Import via k3d image import (k3d) or ctr (k3s) before deployments
- Increase rollout timeouts to 300s (configurable via ROLLOUT_TIMEOUT) in init_openbao.sh, init_opentofu.sh, init_garage_store.sh, init_registry.sh

OpenTofu password resolution fix:
- Add Kubernetes secret fallback in resolve_admin_password()
- Change hard exit 1 to graceful return 1 with warning
- Wrap call in if-guard so set -e doesn't abort the script chain

Milestone fix (init scripts not running):
- Add init_kong.sh, init_postgrest.sh, init_db_manager.sh to InitializationScriptsMilestone.execute() script list and arg branches
- Previously only actions.py had these; milestones.py was missing them

Installer integration:
- Add Kong/PostgREST/DB Manager to silent installer _step_init_scripts
- Add corresponding tabs and execution blocks in UI services.py
2026-02-22 00:57:49 -08:00

684 lines
30 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 (prole-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 (5 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
# 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='prole-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 ---------------