prole/knoe/ui/screens/deploy.py
chrisfu 00f0ebec07 Merge claude/crazy-bose-fec256 into main
Bringing the long-running session-feature branch back into main in one
deliberate sweep. The branch carried the cluster work that's been live for
weeks (cross-cluster CNPG metrics, Grafana w/ Google OAuth, supabase
oauth2-proxy, cluster recovery, pg.0.knoe.dev + per-engineer onboarding,
GCS-backed CNPG backups via Workload Identity, the env-contamination
guard, the Junie brief queue, the cnpg-grafana CSRF + memory-request
fixes from today), while main accumulated Junie's parallel knoe-auth
Phase 2 OIDC work (full provider surface: discovery, authorize, token,
userinfo, JWKS, RS256 signing, code exchange, session services).

Key decision: the two branches did COMPETING rebrands off the same
starting point (5ba9b63, 2026-04-27):

  - claude branch (commit b355855, earlier): org.prole.authority.* →
                                              dev.knoe.auth.*
                                              (artifact renamed to
                                              knoe-auth.jar)
  - main (commit 9daa94b, recent): org.prole.authority.* →
                                    dev.knoe.authority.*
                                    (kept "authority" artifact name)

dev.knoe.auth wins: cluster runs from this name, the Maven artifact is
already knoe-auth.jar, and the broader rename is the documented
namespace direction (per ~/.claude/projects/-Users-chrisfu-dev-knoe-db/
memory/MEMORY.md). All of main's recent Phase 2 OIDC content was ported
from authority/src/.../dev/knoe/authority/ into
authority/src/.../dev/knoe/auth/ with package declarations rewritten.

== File-level resolution summary ==

Textual conflicts (4):

  authority/pom.xml
    - Took our artifactId="auth"
    - Took our branch's removal of spring-security-kerberos-client
      (verified: Junie's Phase 2 OIDC code does not import it; the dep
      was already-dead config)

  docs/pipeline-phases.md
    - Took our branch's "Phase 1 not started" status. Main had a
      misplaced "✅ Complete" with a knoe-auth-Phase-1 commit ref
      in the autobuild Phase 1 section — different domain.

  docs/plans/knoe-auth-round-1.md
    - Took our branch's dev.knoe.auth file table (vs main's
      dev.knoe.authority listing). Pure rename mismatch.

  supabase/helm/knoe-supabase/templates/kong/config.yaml
    - Took our branch's onboard route + plain dashboard wiring.
      Main had an oauth2proxy.enabled toggle that put oauth2-proxy as
      a Kong upstream — but the deployed architecture (commit 25f1b2e)
      has oauth2-proxy in FRONT of Kong, not behind. Main's wrapper
      reflected an architecture that was never deployed.
    - Took our branch's removal of basic-auth from dashboard route
      (queue #15 brief still tracks the matching values.yaml /
      kong/deployment.yaml cleanup).

Java tree reconciliation (44 file-pairs):

  20 dual-path source files + 2 dual-path tests
    Body-identical between main's authority/ and our branch's auth/
    after stripping package decls — main's commit 9daa94b was a pure
    rebrand. Took our branch's auth/ version for all 22.

  8 main-only source files (Phase 2 OIDC), ported into auth/:
    web/JwksController.java
    web/OidcAuthorizeController.java
    web/OidcDiscoveryController.java
    web/OidcTokenController.java
    web/OidcUserInfoController.java
    session/OidcCodeService.java
    session/OidcTokenService.java
    session/SessionService.java

  12 main-only test files, ported into auth/:
    HealthControllerTest.java
    enroll/EnrollValueTypesTest.java
    enroll/EnrollmentControllerTest.java
    enroll/TotpServiceTest.java
    kerberos/KadminClientTest.java
    kerberos/KerberosSpnegoResultTest.java
    web/LoginControllerTest.java
    admin/AdminControllerTest.java
    user/PrincipalNormalizerTest.java
    regression/IdentityRegressionTest.java
    session/OidcCodeServiceTest.java
    session/SessionServiceTest.java

  Port mechanics: read main:authority/...<file> via git show, then sed
  rewrite `package dev.knoe.authority` → `package dev.knoe.auth` and
  `import dev.knoe.authority` → `import dev.knoe.auth`. Body content
  unchanged.

  authority/src/main/java/dev/knoe/authority/ — DELETED (duplicate)
  authority/src/test/java/dev/knoe/authority/  — DELETED (duplicate)

== Verification ==

- grep -rln '<<<<<<<' across .java/.md/.yaml/.yml/.sh/.xml/.tpl: clean
- find authority/src -path '*/dev/knoe/authority*': empty (subtree gone)
- grep 'package dev.knoe.authority' across repo: clean
- bash -n install.sh deploy.sh etc/preflight_kubecontext.sh: clean
- git ls-files -u | wc -l: 0 unmerged paths
- helm lint supabase/helm/knoe-supabase: pre-existing failure on
  studioIngress.enabled undefined in values.yaml (introduced by Junie
  on main; unrelated to this merge — flagging as follow-up).

== Followups (carried into TODO ranked queue or noted here) ==

  - helm lint failure: studioIngress block in values.yaml is missing
    enable flag; templates/studio/{ingress,oauth2proxy-deployment,
    oauth2proxy-service}.yaml all reference studioIngress.enabled with
    no default. Pre-existing on main; not introduced by this merge.
  - The five Junie briefs filed on this branch are now reachable from
    main at docs/plans/junie/{02,06,07,13,15}-*.md. Junie can pick them
    up in any order.
  - knoe-auth Phase 2 OIDC source (now at dev.knoe.auth.*) is not yet
    deployed to the cluster. Deployment is its own task.
  - The branch claude/crazy-bose-fec256 stays in place (worktree at
    .claude/worktrees/crazy-bose-fec256 may have ongoing context for
    Claude Code sessions). Safe to delete once next session starts
    cleanly from main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:39:10 -07:00

1002 lines
36 KiB
Python

"""Deployment screen, final deployment pipeline and LaunchAgent setup."""
import os
import plistlib
import subprocess
import threading
import tkinter as tk
from pathlib import Path
from tkinter import ttk, messagebox, filedialog
from knoe import screen as ui
from knoe.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 Knoe macOS app", "status": "pending"},
{"name": "Check Docker is running", "status": "pending"},
{"name": "Install Knoe 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_knoe_env()
self.reload_env_from_shell()
except Exception as e:
err = f"Failed to load environment: {e}"
try:
self._ensure_knoe_directories()
if "Install" in self.knoe_cfg_data:
self.knoe_cfg_data["Install"]["STATUS"] = "Deployed"
self._save_knoe_cfg()
except Exception as e:
err = err or f"Failed to prepare Knoe 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_knoe_dist_dir() / "Knoe 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["KNOE_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["KNOE_HOME"] = str(PROJECT_ROOT)
env["KNOE_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 Knoe 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.knoe.org:6443, Prod = stretch of knoe-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 KnoeStatus 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 knoe-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 Knoe 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 knoe-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_knoe_properties(self, env: str):
"""Dynamically generate knoe-tools-app/knoe.properties based on environment"""
props_path = Path("knoe-tools-app/knoe.properties")
env_key = (env or "").strip().lower()
dev_mode = env_key == "dev"
content = f"""# Knoe default endpoints (dynamically generated by install.py)
# UI assets
icon=img/knoeIcon.png
background=img/knoeLogoSepia.png
# Dev port-forward supervision
pf.enabled={str(dev_mode).lower()}
"""
if dev_mode:
content += """# Service endpoints (7 traffic lights)
svc.1.name=K3D
svc.1.host=localhost
svc.1.port=6443
svc.2.name=Prometheus
svc.2.host=localhost
svc.2.port=9090
svc.3.name=Grafana
svc.3.host=localhost
svc.3.port=3000
svc.4.name=OpenBAO
svc.4.host=localhost
svc.4.port=8200
svc.5.name=PostgreSQL
svc.5.host=localhost
svc.5.port=5432
svc.6.name=Kong
svc.6.host=localhost
svc.6.port=8000
svc.7.name=CertManager
svc.7.host=localhost
svc.7.port=9402
"""
else:
content += """# Service/Prod endpoint mappings are intentionally not persisted here.
# Live service endpoints are discovered from the running cluster.
"""
content += f"""# 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_selected = (self.deploy_environment.get() or "").strip().lower()
env = {"dev": "Dev", "service": "Service", "prod": "Prod"}.get(
env_selected, "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 knoe.properties
self.generate_knoe_properties(env)
# Step 1: Build Knoe macOS app
self.update_deploy_step_status("Build Knoe macOS app", "running")
self.build_knoe_app()
self.update_deploy_step_status("Build Knoe 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 knoe-db Docker image", "running")
self.build_docker_image()
self.update_deploy_step_status("Build knoe-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/Knoe/bin/knoe-kpf.sh
- LaunchAgent: ~/Library/LaunchAgents/dev.knoe.knoe-db.kpf-dev.plist
"""
try:
home = Path.home()
bin_dir = home / "Library" / "Application Support" / "Knoe" / "bin"
run_dir = home / "Library" / "Application Support" / "Knoe" / "run"
plist_path = (
home / "Library" / "LaunchAgents" / "dev.knoe.knoe-db.kpf-dev.plist"
)
script_path = bin_dir / "knoe-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="dev.knoe.knoe-db.kpf-dev"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
RUNDIR="$HOME/Library/Application Support/Knoe/run"
PIDFILE="$RUNDIR/kpf.pids"
ensure_rundir() {
mkdir -p "$RUNDIR"
}
list_cmds() {
# Enumerate KnoeCommands array using PlistBuddy
if /usr/libexec/PlistBuddy -c "Print :KnoeCommands" "$PLIST" >/dev/null 2>&1; then
i=0
while true; do
if ! val=$(/usr/libexec/PlistBuddy -c "Print :KnoeCommands:$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/knoe-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": "dev.knoe.knoe-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" / "dev.knoe.knoe-db.kpf-dev.out.log"
),
"StandardErrorPath": str(
home / "Library" / "Logs" / "dev.knoe.knoe-db.kpf-dev.err.log"
),
"ProgramArguments": [str(script_path), "start"],
"KnoeCommands": 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}/dev.knoe.knoe-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}/dev.knoe.knoe-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}/dev.knoe.knoe-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 ---------------