From 973920db07c8a390362f9a942dca45c801436b5c Mon Sep 17 00:00:00 2001 From: chrisfu Date: Tue, 30 Dec 2025 22:15:14 -0800 Subject: [PATCH] feat: implement encrypted development database with OpenBao and CNPG - Update install.py with password matching visual feedback and OpenBao integration - Enhance etc/init_openbao.sh to store database password in OpenBao KV - Update etc/init_cloudnative_pg.sh to synchronize database password from OpenBao to K8s secrets - Configure CNPG cluster in k8s/prole/prole-db.yaml with pg_hba for password authentication - Update various scripts and app sources for better service integration --- README.md | 8 +- docker-root-prole-db.sh | 2 +- etc/init_cloudnative_pg.sh | 45 +- etc/init_openbao.sh | 453 +++++++++++++++++++ install.py | 82 +++- installer/screen.py | 26 +- k8s/prole/prole-db.yaml | 10 +- k8s/prole/prole-deployment.yaml | 3 +- prole-db/Dockerfile | 2 +- prole-tools-app/Sources/Config.swift | 13 +- prole-tools-app/Sources/OverlayWindow.swift | 2 +- prole-tools-app/Sources/PFScriptBridge.swift | 6 +- prole-tools-app/Sources/ServiceChecker.swift | 109 ++--- prole-tools-app/Sources/StatusView.swift | 101 ++--- src/get.sh | 8 +- 15 files changed, 716 insertions(+), 154 deletions(-) create mode 100755 etc/init_openbao.sh diff --git a/README.md b/README.md index 1fda3d9..7d109ed 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ k3d cluster create prole-dev-cluster -a 2 --api-port 0.0.0.0:6443 # build prole-db Docker image ```commandline cd prole-db -docker build -t prole-db:17.5-027 . -k3d image import prole-db:17.5-027 -c prole-service-cluster -docker tag prole-db:17.5-027 k8s.prole.org:5000/prole-db:17.5-027 -docker push k8s.prole.org:5000/prole-db:17.5-027 +docker build -t prole-db:17.7-031 . +k3d image import prole-db:17.7-031 -c prole-service-cluster +docker tag prole-db:17.7-031 k8s.prole.org:5000/prole-db:17.7-031 +docker push k8s.prole.org:5000/prole-db:17.7-031 # vim ~/.docker/daemon.json # "insecure-registries": [ "k3.localhost:5000", "k8s.prole.org:5000" ] ``` diff --git a/docker-root-prole-db.sh b/docker-root-prole-db.sh index c543eec..f6e894a 100755 --- a/docker-root-prole-db.sh +++ b/docker-root-prole-db.sh @@ -12,4 +12,4 @@ docker run --rm -it -u root \ --network=bridge \ --entrypoint bash \ --restart=no \ -prole-db:17.5-025 +prole-db:17.7-031 diff --git a/etc/init_cloudnative_pg.sh b/etc/init_cloudnative_pg.sh index e11502f..8357f6b 100755 --- a/etc/init_cloudnative_pg.sh +++ b/etc/init_cloudnative_pg.sh @@ -78,7 +78,7 @@ bao_service_url() { echo "http://$OPENBAO_NAME.$NAMESPACE.svc.cluster.local:8200" } -fetch_admin_keys_from_bao_or_local() { +fetch_admin_keys_and_db_pass_from_bao_or_local() { local token url if [[ -f "$OPENBAO_TOKEN_FILE" ]]; then token=$(cat "$OPENBAO_TOKEN_FILE") @@ -95,9 +95,28 @@ fetch_admin_keys_from_bao_or_local() { printf "%s" "$priv_b64" | base64 -d >"$ADMIN_PRIV" printf "%s" "$pub_b64" | base64 -d >"$ADMIN_PUB" chmod 0600 "$ADMIN_PRIV" - return 0 + fi + + echo "Attempting to read database password from OpenBao kv/prole/db ..." + if curl -sS -H "X-Vault-Token: $token" "$url/v1/kv/data/prole/db" | jq -e '.data.data' >/dev/null 2>&1; then + local db_pass + db_pass=$(curl -sS -H "X-Vault-Token: $token" "$url/v1/kv/data/prole/db" | jq -r '.data.data.password') + if [[ -n "$db_pass" ]]; then + echo "Updating database user secret 'prole-db-user' from OpenBao ..." + kubectl create secret generic prole-db-user -n "$NAMESPACE" \ + --from-literal=username=prole \ + --from-literal=password="$db_pass" \ + --dry-run=client -o yaml | kubectl apply -f - + + echo "Updating database superuser secret 'prole-db-superuser' from OpenBao ..." + kubectl create secret generic prole-db-superuser -n "$NAMESPACE" \ + --from-literal=username=postgres \ + --from-literal=password="$db_pass" \ + --dry-run=client -o yaml | kubectl apply -f - + fi fi fi + if [[ -f "$ADMIN_PRIV" && -f "$ADMIN_PUB" ]]; then echo "Using local admin key pair at $SECRETS_DIR" return 0 @@ -177,6 +196,7 @@ patch_cnpg_cluster_for_auth() { \"parameters\": {\"krb_srvname\": null}, \"pg_hba\": [ \"local all postgres trust\", + \"host all postgres all scram-sha-256\", \"host all all all gss include_realm=1 krb_realm=$REALM\", \"host all all all scram-sha-256\" ] @@ -196,7 +216,7 @@ initialize() { echo "Ensuring port-forward for OpenBao is active ..." "$SCRIPT_DIR/init_port_forwards.sh" restart openbao - fetch_admin_keys_from_bao_or_local + fetch_admin_keys_and_db_pass_from_bao_or_local apply_cnpg_admin_secret ensure_krb5_conf_configmap # generate_tls_if_missing @@ -225,7 +245,26 @@ case "$ACTION" in kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.27/releases/cnpg-1.27.0.yaml echo "Creating CloudNative-PG cluster and resources for '$CNPG_CLUSTER_NAME' ..." + kubectl apply -k "$SCRIPT_DIR/../k8s/prole" + + # Ensure prole-index-html exists for prole deployment readiness probe + if ! kubectl get configmap prole-index-html -n prole >/dev/null 2>&1; then + echo "Creating prole-index-html configmap..." + printf "

Prole

" > /tmp/index.html + kubectl create configmap prole-index-html --from-file=/tmp/index.html -n prole + rm /tmp/index.html + fi + + # Ensure prole-nginx-tls exists (self-signed for dev) + if ! kubectl get secret prole-nginx-tls -n prole >/dev/null 2>&1; then + echo "Generating self-signed prole-nginx-tls for development..." + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /tmp/nginx-tls.key -out /tmp/nginx-tls.crt \ + -subj "/CN=prole.org" >/dev/null 2>&1 + kubectl create secret tls prole-nginx-tls --key /tmp/nginx-tls.key --cert /tmp/nginx-tls.crt -n prole + rm /tmp/nginx-tls.key /tmp/nginx-tls.crt + fi echo "Initializing and patching cluster ..." initialize diff --git a/etc/init_openbao.sh b/etc/init_openbao.sh new file mode 100755 index 0000000..a2502a0 --- /dev/null +++ b/etc/init_openbao.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# init_openbao.sh +# Purpose: +# - Deploy OpenBao to Kubernetes (dev mode) and store admin ed25519 key pair +# Generate and apply a Kerberos krb5.conf ConfigMap for an external realm +# - Local Docker helpers for OpenBao (optional) + +# Initialize SCRIPT_DIR +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +# Preserve positional args while sourcing env +__PROLE_SAVED_ARGS=("$@") +if [[ -n "${PROLE_HOME:-}" && -f "$PROLE_HOME/env.sh" ]]; then + set -- + # shellcheck disable=SC1090 + source "$PROLE_HOME/env.sh" +elif [[ -f "$HOME/.prole/env.sh" ]]; then + set -- + # shellcheck disable=SC1090 + source "$HOME/.prole/env.sh" +fi +set -- "${__PROLE_SAVED_ARGS[@]}" +unset __PROLE_SAVED_ARGS + +if [[ -z "${PROLE_SERVICE:-}" ]]; then + echo "ERROR: PROLE_SERVICE is not defined in env. Provide PROLE_HOME/env.sh or ~/.prole/env.sh" >&2 + exit 1 +fi + +ACTION=${1:-} + +# Defaults +NAMESPACE=${NAMESPACE:-default} +OPENBAO_NAME=${OPENBAO_NAME:-openbao} +OPENBAO_IMAGE=${OPENBAO_IMAGE:-ghcr.io/openbao/openbao:latest} +OPENBAO_MANIFEST_DIR="$SCRIPT_DIR/../k8s/openbao" + +# Kerberos realm defaults +KRB5_REALM=${KRB5_REALM:-PROLE.ORG} +DOMAIN=${DOMAIN:-$(echo "$KRB5_REALM" | tr 'A-Z' 'a-z')} +KRB5_KDC=${KRB5_KDC:-kdc.$DOMAIN} +KRB5_ADMIN=${KRB5_ADMIN:-} + +# Secrets and token locations +SECRETS_DIR="$PROLE_SERVICE/secrets" +mkdir -p "$SECRETS_DIR" +admin_key_priv="$SECRETS_DIR/admin_ed25519.key" +admin_key_pub="$SECRETS_DIR/admin_ed25519.pub" +root_token_file="$SECRETS_DIR/openbao-root-token" + +ensure_tools() { + for t in kubectl curl openssl base64; do + command -v "$t" >/dev/null || { echo "Missing required tool: $t" >&2; exit 1; } + done +} + +ensure_docker() { + command -v docker >/dev/null || { echo "Missing required tool: docker" >&2; exit 1; } +} + +ensure_admin_keypair() { + # Ensure admin ed25519 keypair exists and a root token is available + mkdir -p "$SECRETS_DIR" + if [[ ! -f "$admin_key_priv" || ! -f "$admin_key_pub" ]]; then + echo "Generating admin ed25519 keypair in $SECRETS_DIR ..." + openssl genpkey -algorithm ED25519 -out "$admin_key_priv" + openssl pkey -in "$admin_key_priv" -pubout -out "$admin_key_pub" + chmod 0600 "$admin_key_priv" + fi + # Ensure a root token exists for OpenBao dev server interactions + if [[ ! -f "$root_token_file" || ! -s "$root_token_file" ]]; then + openssl rand -hex 24 >"$root_token_file" + chmod 0600 "$root_token_file" + fi +} + +generate_openbao_manifests() { + mkdir -p "$OPENBAO_MANIFEST_DIR" + # Only create a simple deployment if none exists; otherwise respect current file + local deploy="$OPENBAO_MANIFEST_DIR/deployment.yaml" + if [[ ! -f "$deploy" ]]; then + cat >"$deploy" <"$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" </dev/null 2>&1 || true + fi + + local priv pub + priv=$(base64 <"$admin_key_priv" | tr -d '\n') + pub=$(base64 <"$admin_key_pub" | tr -d '\n') + + # Check if admin key already exists and matches + local existing_data + existing_data=$(curl -sS -H "X-Vault-Token: $token" "$svc/v1/kv/data/prole/admin" 2>/dev/null || true) + if [[ -n "$existing_data" ]]; then + local ex_priv ex_pub + ex_priv=$(echo "$existing_data" | grep -o '"admin_private_key_b64":"[^"]*' | cut -d'"' -f4 || true) + ex_pub=$(echo "$existing_data" | grep -o '"admin_public_key_b64":"[^"]*' | cut -d'"' -f4 || true) + + if [[ "$ex_priv" == "$priv" && "$ex_pub" == "$pub" ]]; then + echo "Admin key pair in OpenBao kv/prole/admin is already up to date." + else + echo "Writing admin key pair to kv/prole/admin ..." + curl -sS -H "X-Vault-Token: $token" -H 'Content-Type: application/json' \ + -X POST "$svc/v1/kv/data/prole/admin" \ + -d "{\"data\":{\"admin_private_key_b64\":\"$priv\",\"admin_public_key_b64\":\"$pub\"}}" >/dev/null + echo "Stored admin key pair in OpenBao kv/prole/admin." + fi + else + echo "Writing admin key pair to kv/prole/admin ..." + curl -sS -H "X-Vault-Token: $token" -H 'Content-Type: application/json' \ + -X POST "$svc/v1/kv/data/prole/admin" \ + -d "{\"data\":{\"admin_private_key_b64\":\"$priv\",\"admin_public_key_b64\":\"$pub\"}}" >/dev/null + echo "Stored admin key pair in OpenBao kv/prole/admin." + fi + + if [[ -n "$db_pass" ]]; then + echo "Writing database user password to kv/prole/db ..." + curl -sS -H "X-Vault-Token: $token" -H 'Content-Type: application/json' \ + -X POST "$svc/v1/kv/data/prole/db" \ + -d "{\"data\":{\"username\":\"prole\",\"password\":\"$db_pass\"}}" >/dev/null + echo "Stored database password in OpenBao kv/prole/db." + fi +} + +# Removed: prompt_admin_password_and_apply_secret (no AD admin secret required) + +docker_start() { + echo "Starting local Docker container for OpenBao ..." + ensure_docker + docker rm -f "$OPENBAO_NAME" >/dev/null 2>&1 || true + # OpenBao dev + local token + token=$(cat "$root_token_file" 2>/dev/null || true) + if [[ -z "$token" ]]; then + token=$(openssl rand -hex 24) + printf "%s" "$token" >"$root_token_file" + chmod 0600 "$root_token_file" + fi + docker run -d --name "$OPENBAO_NAME" -p 18200:8200 \ + "$OPENBAO_IMAGE" server -dev -dev-listen-address=0.0.0.0:8200 -dev-root-token-id="$token" + echo "Docker container started: $OPENBAO_NAME" +} + +docker_stop() { + docker rm -f "$OPENBAO_NAME" >/dev/null 2>&1 || true + echo "Stopped OpenBao container if it was running." +} + +docker_restart() { + docker_stop + docker_start +} + +# status command implementation wrapped in a function to avoid top-level 'local' +cmd_status() { + echo "--- init_primary_domain status ---" + echo "Namespace: $NAMESPACE" + echo "OpenBao name: $OPENBAO_NAME" + echo "Manifest dir: $OPENBAO_MANIFEST_DIR" + + # Files/manifests present + local ok=0 + if [[ -f "$OPENBAO_MANIFEST_DIR/deployment.yaml" ]]; then + echo "[OK] OpenBao deployment manifest exists" + else + echo "[MISSING] $OPENBAO_MANIFEST_DIR/deployment.yaml" + ok=1 + fi + if [[ -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" ]]; then + echo "[OK] Kerberos ConfigMap manifest exists" + else + echo "[MISSING] $OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" + ok=1 + fi + + # K8s resources + if kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" >/dev/null 2>&1; then + # Get readiness + local ready desired + ready=$(kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0") + desired=$(kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" -o jsonpath='{.status.replicas}' 2>/dev/null || echo "0") + ready=${ready:-0} + desired=${desired:-0} + if [[ "$ready" == "$desired" && "$ready" != "0" ]]; then + echo "[OK] OpenBao Deployment running ($ready/$desired ready)" + else + echo "[WARN] OpenBao Deployment not fully ready ($ready/$desired)" + ok=1 + fi + else + echo "[MISSING] OpenBao Deployment '$OPENBAO_NAME' in namespace '$NAMESPACE'" + ok=1 + fi + + if kubectl -n "$NAMESPACE" get configmap prole-krb5-conf >/dev/null 2>&1; then + echo "[OK] Kerberos ConfigMap 'prole-krb5-conf' present" + else + echo "[MISSING] Kerberos ConfigMap 'prole-krb5-conf' in namespace '$NAMESPACE'" + ok=1 + fi + + # Docker (optional local mode) + if command -v docker >/dev/null 2>&1; then + if docker ps --format '{{.Names}}' | grep -Fxq "$OPENBAO_NAME"; then + echo "[OK] Docker container '$OPENBAO_NAME' is running" + else + # Not necessarily an error; we primarily use k8s + echo "[INFO] Docker container '$OPENBAO_NAME' not running (k8s mode may be in use)" + fi + fi + + # Secrets and tokens + if [[ -f "$admin_key_priv" && -f "$admin_key_pub" ]]; then + echo "[OK] Admin ed25519 keypair present in $SECRETS_DIR" + else + echo "[MISSING] Admin keypair files in $SECRETS_DIR" + ok=1 + fi + if [[ -s "$root_token_file" ]]; then + echo "[OK] OpenBao root token file present" + else + echo "[MISSING] OpenBao root token file ($root_token_file)" + ok=1 + fi + + # OpenBao reachability and token validity (best-effort) + # Prefer explicit override, then localhost port-forward, then cluster DNS + local svc_url + if [[ -n "${PROLE_OPENBAO_URL:-}" ]]; then + svc_url="$PROLE_OPENBAO_URL" + elif curl -sS "http://127.0.0.1:18200/v1/sys/health" >/dev/null 2>&1; then + svc_url="http://127.0.0.1:18200" + else + svc_url="http://$OPENBAO_NAME.$NAMESPACE.svc.cluster.local:8200" + fi + if curl -sS "$svc_url/v1/sys/health" >/dev/null 2>&1; then + echo "[OK] OpenBao service reachable" + if [[ -s "$root_token_file" ]]; then + local code + code=$(curl -s -o /dev/null -w "%{http_code}" -H "X-Vault-Token: $(cat "$root_token_file")" "$svc_url/v1/sys/mounts" || echo "000") + if [[ "$code" == "200" ]]; then + echo "[OK] OpenBao root token authenticates" + else + echo "[WARN] OpenBao root token did not authenticate (HTTP $code)" + ok=1 + fi + fi + else + echo "[WARN] OpenBao service not reachable at $svc_url" + ok=1 + fi + + # Service certificate existence (CNPG TLS) + local CNPG_CLUSTER_NAME + CNPG_CLUSTER_NAME=${CNPG_CLUSTER_NAME:-prole-db} + if kubectl -n "$NAMESPACE" get secret "${CNPG_CLUSTER_NAME}-tls" >/dev/null 2>&1; then + echo "[OK] Service certificate secret '${CNPG_CLUSTER_NAME}-tls' exists" + else + echo "[INFO] Service certificate secret '${CNPG_CLUSTER_NAME}-tls' not found" + fi + + echo "-------------------------------------" + if [[ $ok -eq 0 ]]; then + echo "Status: OK" + return 0 + else + echo "Status: Issues detected" + return 1 + fi +} + +case "$ACTION" in + start) + ensure_tools + ensure_docker + ensure_admin_keypair + docker_start + ;; + status) + ensure_tools + cmd_status + ;; + stop) + ensure_docker + docker_stop + ;; + restart) + ensure_tools + ensure_docker + ensure_admin_keypair + docker_restart + ;; + initialize) + ensure_tools + # Read password from stdin if provided + db_pass="" + if [[ ! -t 0 ]]; then + read -r db_pass + fi + + ensure_admin_keypair + generate_openbao_manifests + generate_kerberos_configmap + apply_k8s + + if [[ -n "$db_pass" ]]; then + echo "Creating database user secret 'prole-db-user' ..." + kubectl create secret generic prole-db-user -n "$NAMESPACE" \ + --from-literal=username=prole \ + --from-literal=password="$db_pass" \ + --dry-run=client -o yaml | kubectl apply -f - + + echo "Creating database superuser secret 'prole-db-superuser' ..." + kubectl create secret generic prole-db-superuser -n "$NAMESPACE" \ + --from-literal=username=postgres \ + --from-literal=password="$db_pass" \ + --dry-run=client -o yaml | kubectl apply -f - + fi + + wait_for_openbao + + # Ensure port-forward is running for OpenBao + echo "Ensuring port-forward for OpenBao is active ..." + "$SCRIPT_DIR/init_port_forwards.sh" restart openbao + + if ! curl -sS http://127.0.0.1:18200/v1/sys/health >/dev/null 2>&1; then + echo "ERROR: OpenBao not reachable at http://127.0.0.1:18200." >&2 + echo "Please start port-forwards: $SCRIPT_DIR/init_port_forwards.sh start openbao" >&2 + exit 1 + fi + init_openbao_kv_and_store_admin_key + echo "Initialization complete. Manifests in: $OPENBAO_MANIFEST_DIR" + ;; + update|reload) + generate_openbao_manifests + generate_kerberos_configmap + kubectl apply -f "$OPENBAO_MANIFEST_DIR/deployment.yaml" + kubectl apply -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" + echo "Re-applied manifests." + ;; + *) + echo "Usage: $0 {start|stop|status|restart|initialize|update|reload}" >&2 + exit 2 + ;; +esac diff --git a/install.py b/install.py index bbd43ce..f332ef5 100755 --- a/install.py +++ b/install.py @@ -1110,6 +1110,26 @@ class ProleInstaller: p2.place(x=x_field, y=y-12, width=300) self._overlay_widgets.append(p2) + # Indicator for password match (X or ✓) + self.password_indicator = ui.canvas_text(self, x_field + 310, y, '✘', fill='#dc3545', font=('Helvetica', 16, 'bold'), state='hidden') + self._canvas_items.append(self.password_indicator) + + def on_password_change(*args): + p = self.db_password.get() + c = self.db_password_confirm.get() + + if not p: + self.bg_canvas.itemconfigure(self.password_indicator, state='hidden') + elif p == c: + self.bg_canvas.itemconfigure(self.password_indicator, text='✓', fill='#28a745', state='normal') + else: + self.bg_canvas.itemconfigure(self.password_indicator, text='✘', fill='#dc3545', state='normal') + + self.db_password.trace_add('write', on_password_change) + self.db_password_confirm.trace_add('write', on_password_change) + # Trigger once in case they are already set + on_password_change() + def _render_init_scripts_page(self): self._render_title('Initialization Scripts', y=40) self._render_paragraph('Running initialization scripts to set up OpenBao, CloudNative-PG, and Port Forwards.', y=80) @@ -3055,6 +3075,46 @@ exec "$DIR/ProleTools.bin" "$@" """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 +""" + props_path.write_text(content) + print(f"Generated {props_path} for {env} environment") + def run_deployment(self): """Run the deployment steps""" try: @@ -3062,11 +3122,16 @@ exec "$DIR/ProleTools.bin" "$@" 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})" - self.deploy_widgets['Ensure target cluster']['label'].master.master.children['!label'].configure(text=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: Build Prole macOS app + # 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') @@ -3352,13 +3417,22 @@ esac version_file = PROJECT_ROOT / 'conf' / 'postgresql' / '.version' if version_file.exists(): return version_file.read_text().strip() - return '17.5-027' # Fallback + return '17.7-031' # Fallback def build_docker_image(self): """Build prole-db Docker image""" # Base local image tag (before pushing to registry) version = self.get_prole_db_version() image_tag = f'prole-db:{version}' + + # Prepare build context: copy conf/postgresql to prole-db/postgresql + conf_src = PROJECT_ROOT / 'conf' / 'postgresql' + conf_dst = PROJECT_ROOT / 'prole-db' / 'postgresql' + + if conf_dst.exists(): + shutil.rmtree(conf_dst) + shutil.copytree(conf_src, conf_dst) + build_cmd = ['docker', 'build', '-t', image_tag] # Add platform flag for Apple Silicon (ARM64 needs amd64 for compatibility) @@ -3387,7 +3461,7 @@ esac content = manifest_path.read_text() import re - # Update imageName: prole-db:17.5-027 + # Update imageName: prole-db:17.7-031 new_content = re.sub(r'imageName: prole-db:.*', f'imageName: prole-db:{version}', content) if new_content != content: manifest_path.write_text(new_content) diff --git a/installer/screen.py b/installer/screen.py index 2084d88..eca79c2 100644 --- a/installer/screen.py +++ b/installer/screen.py @@ -29,7 +29,7 @@ def render_paragraph(app, text: str, y: int, wrap: int = 860): def canvas_text(app, x: int, y: int, text: str, *, fill: str = '#1d1d1f', font: tuple = ('Helvetica', 12), anchor: str = 'nw', width: int | None = None, - justify: str | None = None) -> int: + justify: str | None = None, state: str | None = None) -> int: """Create a text item on the app's main canvas and track it. Returns the created canvas item id. @@ -41,34 +41,46 @@ def canvas_text(app, x: int, y: int, text: str, *, fill: str = '#1d1d1f', kwargs['width'] = width if justify is not None: kwargs['justify'] = justify + if state is not None: + kwargs['state'] = state item = app.bg_canvas.create_text(x, y, **kwargs) app._canvas_items.append(item) return item def canvas_oval(app, x1: int, y1: int, x2: int, y2: int, *, fill: str | None = None, - outline: str | None = None, width: int = 1) -> int: + outline: str | None = None, width: int = 1, state: str | None = None) -> int: """Create an oval on the app's main canvas and track it.""" if getattr(app, 'bg_canvas', None) is None: return -1 - item = app.bg_canvas.create_oval(x1, y1, x2, y2, fill=fill or '', outline=outline or '', width=width) + kwargs = dict(fill=fill or '', outline=outline or '', width=width) + if state is not None: + kwargs['state'] = state + item = app.bg_canvas.create_oval(x1, y1, x2, y2, **kwargs) app._canvas_items.append(item) return item def canvas_rectangle(app, x1: int, y1: int, x2: int, y2: int, *, outline: str = '#6e6e73', - width: int = 1, fill: str | None = None) -> int: + width: int = 1, fill: str | None = None, state: str | None = None) -> int: if getattr(app, 'bg_canvas', None) is None: return -1 - item = app.bg_canvas.create_rectangle(x1, y1, x2, y2, outline=outline, width=width, fill=fill or '') + kwargs = dict(outline=outline, width=width, fill=fill or '') + if state is not None: + kwargs['state'] = state + item = app.bg_canvas.create_rectangle(x1, y1, x2, y2, **kwargs) app._canvas_items.append(item) return item -def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = '#1d1d1f', width: int = 2) -> int: +def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = '#1d1d1f', + width: int = 2, state: str | None = None) -> int: if getattr(app, 'bg_canvas', None) is None: return -1 - item = app.bg_canvas.create_line(x1, y1, x2, y2, fill=fill, width=width) + kwargs = dict(fill=fill, width=width) + if state is not None: + kwargs['state'] = state + item = app.bg_canvas.create_line(x1, y1, x2, y2, **kwargs) app._canvas_items.append(item) return item diff --git a/k8s/prole/prole-db.yaml b/k8s/prole/prole-db.yaml index 63df221..31d0dc5 100644 --- a/k8s/prole/prole-db.yaml +++ b/k8s/prole/prole-db.yaml @@ -4,7 +4,7 @@ metadata: name: prole-db spec: instances: 3 - imageName: prole-db:17.5-027 + imageName: prole-db:17.7-031 postgresUID: 100 postgresGID: 101 maxSyncReplicas: 1 @@ -14,7 +14,14 @@ spec: shared_buffers: 256MB pg_stat_statements.max: '10000' pg_stat_statements.track: all + shared_preload_libraries: + - pg_stat_statements + - pg_tde pg_hba: + # allow password access from remote hosts if environment is "#dev" + - local all postgres trust + - host all postgres all scram-sha-256 + - host prole prole-db all scram-sha-256 - host all all all scram-sha-256 - hostssl prole prole-db all scram-sha-256 @@ -28,6 +35,7 @@ spec: name: prole-db-user postInitTemplateSQL: - CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + postInitSQL: - CREATE EXTENSION IF NOT EXISTS pg_tde; enableSuperuserAccess: true diff --git a/k8s/prole/prole-deployment.yaml b/k8s/prole/prole-deployment.yaml index 01471f3..48b3470 100644 --- a/k8s/prole/prole-deployment.yaml +++ b/k8s/prole/prole-deployment.yaml @@ -44,4 +44,5 @@ spec: secret: secretName: prole-nginx-tls - name: web-root - emptyDir: {} + configMap: + name: prole-index-html diff --git a/prole-db/Dockerfile b/prole-db/Dockerfile index 8eb1446..a8a9c84 100644 --- a/prole-db/Dockerfile +++ b/prole-db/Dockerfile @@ -79,7 +79,7 @@ COPY prole-db-entrypoint.sh /usr/local/bin/prole-entrypoint.sh COPY 10_pg_tde_openbao.sh /docker-entrypoint-initdb.d/10_pg_tde_openbao.sh # staged copies of the pgdata/*.conf files -COPY conf/postgresql/*.conf /etc/postgresql/17/main/ +COPY postgresql/*.conf /etc/postgresql/17/main/ RUN chown postgres:postgres /etc/postgresql/17/main/*.conf RUN set -eux; \ diff --git a/prole-tools-app/Sources/Config.swift b/prole-tools-app/Sources/Config.swift index 3d74d26..1b5a62b 100644 --- a/prole-tools-app/Sources/Config.swift +++ b/prole-tools-app/Sources/Config.swift @@ -89,7 +89,7 @@ final class Config { } } - // Services list (name + hostname + port). Defaults: k3d + svc.prole.org + // Services list (name + hostname + port). var services: [ServiceEndpoint] { get { let items: [ServiceEndpoint] = loadIndexed(prefix: "svc") { idx in @@ -100,8 +100,11 @@ final class Config { } if !items.isEmpty { return items } return [ - ServiceEndpoint(name: "k3d", host: "localhost", port: 6443), - ServiceEndpoint(name: "svc.prole.org", host: "svc.prole.org", port: 443) + ServiceEndpoint(name: "K3D", host: "localhost", port: 6443), + ServiceEndpoint(name: "Prometheus", host: "localhost", port: 9090), + ServiceEndpoint(name: "Grafana", host: "localhost", port: 3000), + ServiceEndpoint(name: "OpenBAO", host: "localhost", port: 8200), + ServiceEndpoint(name: "PostgreSQL", host: "localhost", port: 5432) ] } set { @@ -147,8 +150,8 @@ final class Config { // Convenience computed values used by Status/Checker var primaryService: ServiceEndpoint? { - // Prefer "svc.prole.org" entry, else first - return services.first(where: { $0.name == "svc.prole.org" }) ?? services.first + // Prefer "K3D" entry, else first + return services.first(where: { $0.name == "K3D" }) ?? services.first } var localService: ServiceEndpoint? { return services.first(where: { $0.name.lowercased() == "k3d" }) diff --git a/prole-tools-app/Sources/OverlayWindow.swift b/prole-tools-app/Sources/OverlayWindow.swift index 4160be7..b44c5a5 100644 --- a/prole-tools-app/Sources/OverlayWindow.swift +++ b/prole-tools-app/Sources/OverlayWindow.swift @@ -92,7 +92,7 @@ final class OverlayWindowController: NSWindowController { func showContextMenu() { guard let window = window, window.isVisible else { return } let menu = NSMenu(title: "Prole Tools") - menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "") + menu.addItem(withTitle: "Prole Tools — System Status", action: nil, keyEquivalent: "") menu.addItem(.separator()) menu.addItem(withTitle: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r").target = self menu.addItem(withTitle: "Restart Port Forwards", action: #selector(resetPortForwards), keyEquivalent: "").target = self diff --git a/prole-tools-app/Sources/PFScriptBridge.swift b/prole-tools-app/Sources/PFScriptBridge.swift index c8523e0..ed7639c 100644 --- a/prole-tools-app/Sources/PFScriptBridge.swift +++ b/prole-tools-app/Sources/PFScriptBridge.swift @@ -16,7 +16,11 @@ enum PFScriptBridge { static func status() -> (code: Int32, out: String, err: String) { runScript(arg: "status") } static func dbStatus() -> (code: Int32, out: String, err: String) { - runRawCommand(command: "kubecolor cnpg status prole-db", description: "kubecolor cnpg status prole-db") + runRawCommand(command: "kubectl cnpg status prole-db", description: "kubectl cnpg status prole-db") + } + + static func cnpgStatus() -> (code: Int32, out: String, err: String) { + runRawCommand(command: "kubectl cnpg status prole-db | head -10", description: "kubectl cnpg status prole-db | head -10") } private static func runRawCommand(command: String, description: String) -> (code: Int32, out: String, err: String) { diff --git a/prole-tools-app/Sources/ServiceChecker.swift b/prole-tools-app/Sources/ServiceChecker.swift index 16de4a1..2a6f0e9 100644 --- a/prole-tools-app/Sources/ServiceChecker.swift +++ b/prole-tools-app/Sources/ServiceChecker.swift @@ -13,22 +13,18 @@ final class ServiceChecker { private let refreshInterval: TimeInterval = 30 // Public reachability flags - private(set) var svcReachable = false - private(set) var raspberryReachable = false - private(set) var piReachable = false - private(set) var localReachable = false - - // Latency measurements (ms) - private(set) var svcLatency: Int = -1 - private(set) var raspberryLatency: Int = -1 - private(set) var piLatency: Int = -1 - private(set) var localLatency: Int = -1 + private(set) var k3dReachable = false + private(set) var prometheusReachable = false + private(set) var grafanaReachable = false + private(set) var openbaoReachable = false + private(set) var postgresReachable = false // Last error messages (for tooltips when red) - private(set) var svcError: String? = nil - private(set) var raspberryError: String? = nil - private(set) var piError: String? = nil - private(set) var localError: String? = nil + private(set) var k3dError: String? = nil + private(set) var prometheusError: String? = nil + private(set) var grafanaError: String? = nil + private(set) var openbaoError: String? = nil + private(set) var postgresError: String? = nil // Generic per-endpoint state so UI can query dynamically from Preferences struct EndpointState: Equatable { @@ -89,7 +85,7 @@ final class ServiceChecker { let group = DispatchGroup() // clear previous errors before a new round (legacy fields) - svcError = nil; raspberryError = nil; piError = nil; localError = nil + k3dError = nil; prometheusError = nil; grafanaError = nil; openbaoError = nil; postgresError = nil let cfg = Config.shared // Iterate Services from Preferences @@ -100,16 +96,26 @@ final class ServiceChecker { let key = svc.name let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date()) self.serviceStates[key] = st - // Update legacy convenience fields for UI compatibility - if let primary = cfg.primaryService, primary.name == svc.name { - self.svcReachable = ok - self.svcLatency = ms - self.svcError = err - } - if let local = cfg.localService, local.name == svc.name { - self.localReachable = ok - self.localLatency = ms - self.localError = err + + // Update specific flags + switch svc.name { + case "K3D": + self.k3dReachable = ok + self.k3dError = err + case "Prometheus": + self.prometheusReachable = ok + self.prometheusError = err + case "Grafana": + self.grafanaReachable = ok + self.grafanaError = err + case "OpenBAO": + self.openbaoReachable = ok + self.openbaoError = err + case "PostgreSQL": + self.postgresReachable = ok + self.postgresError = err + default: + break } group.leave() } @@ -117,10 +123,6 @@ final class ServiceChecker { // Iterate Kubernetes endpoints from Preferences let kubes = cfg.kubernetes - if kubes.isEmpty { - raspberryReachable = false; raspberryLatency = -1; raspberryError = "no kubernetes endpoints configured" - piReachable = false; piLatency = -1; piError = nil - } for (idx, k) in kubes.enumerated() { group.enter() tcpPing(host: k.host, port: UInt16(k.port)) { [weak self] ok, ms, err in @@ -128,16 +130,6 @@ final class ServiceChecker { let key = "\(k.host):\(k.port)" let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date()) self.kubeStates[key] = st - // Maintain two-LED aggregate legacy fields for first two entries - if idx == 0 { - self.raspberryReachable = ok - self.raspberryLatency = ms - self.raspberryError = err - } else if idx == 1 { - self.piReachable = ok - self.piLatency = ms - self.piError = err - } group.leave() } } @@ -209,34 +201,17 @@ final class ServiceChecker { } // Tooltips - func tooltipForSvc() -> String { - guard let svc = Config.shared.primaryService else { return "No primary service configured" } - if svcReachable { return "\(svc.host):\(svc.port) — reachable (\(svcLatency) ms)" } - var s = "\(svc.host):\(svc.port) — unreachable" - if let e = svcError { s += "\nError: \(e)" } - return s - } - func tooltipForAggregateK3s() -> String { - let kubes = Config.shared.kubernetes - let rHost = kubes.first?.host ?? "-" - let pHost = kubes.count > 1 ? kubes[1].host : "-" - var r = raspberryReachable ? "\(rHost) ✓ (\(raspberryLatency) ms)" : "\(rHost) ✗" - var p = piReachable ? "\(pHost) ✓ (\(piLatency) ms)" : "\(pHost) ✗" - if !raspberryReachable, let e = raspberryError { r += " — \(e)" } - if !piReachable, let e = piError { p += " — \(e)" } - let overall: String - switch (raspberryReachable, piReachable) { - case (true, true): overall = "overall: green" - case (true, false), (false, true): overall = "overall: yellow" - default: overall = "overall: red" - } - return "k3s aggregate — \(overall)\n\(r)\n\(p)" - } - func tooltipForLocal() -> String { - guard let local = Config.shared.localService else { return "No local service configured" } - if localReachable { return "k3d \(local.host):\(local.port) — reachable (\(localLatency) ms)" } - var s = "k3d \(local.host):\(local.port) — unreachable" - if let e = localError { s += "\nError: \(e)" } + func tooltipFor(name: String) -> String { + let st = serviceStates[name] + let reachable = st?.reachable ?? false + let error = st?.error + let latency = st?.latencyMs ?? -1 + let host = Config.shared.services.first(where: { $0.name == name })?.host ?? "unknown" + let port = Config.shared.services.first(where: { $0.name == name })?.port ?? 0 + + if reachable { return "\(name) (\(host):\(port)) — reachable (\(latency) ms)" } + var s = "\(name) (\(host):\(port)) — unreachable" + if let e = error { s += "\nError: \(e)" } return s } diff --git a/prole-tools-app/Sources/StatusView.swift b/prole-tools-app/Sources/StatusView.swift index c177d6b..fd063fd 100644 --- a/prole-tools-app/Sources/StatusView.swift +++ b/prole-tools-app/Sources/StatusView.swift @@ -4,15 +4,8 @@ final class StatusView: NSView { private let light1 = TrafficLight() private let light2 = TrafficLight() private let light3 = TrafficLight() - private let timestampLabel: NSTextField = { - let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00") - tf.textColor = .secondaryLabelColor - tf.alignment = .left - tf.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) - tf.setContentHuggingPriority(.required, for: .horizontal) - tf.setContentCompressionResistancePriority(.required, for: .horizontal) - return tf - }() + private let light4 = TrafficLight() + private let light5 = TrafficLight() private let marquee = MarqueeView() private let maximizeButton: NSButton = { let b = NSButton(title: "□", target: nil, action: nil) @@ -49,7 +42,7 @@ final class StatusView: NSView { wantsLayer = true translatesAutoresizingMaskIntoConstraints = false - let stack = NSStackView(views: [light1, light2, light3, timestampLabel, marquee, maximizeButton]) + let stack = NSStackView(views: [light1, light2, light3, light4, light5, marquee, maximizeButton]) stack.orientation = .horizontal stack.alignment = .centerY stack.distribution = .equalSpacing @@ -102,56 +95,56 @@ final class StatusView: NSView { @objc private func updateLights() { guard let c = checker else { return } - // Light 1: svc.prole.org:443 - light1.state = c.svcReachable ? .green : .red - light1.toolTip = c.tooltipForSvc() + let services = Config.shared.services + + // Light 1: K3D + if let s = services.first(where: { $0.name == "K3D" }) { + let state = c.serviceStates[s.name] + light1.state = (state?.reachable ?? false) ? .green : .red + light1.toolTip = c.tooltipFor(name: s.name) + } - // Light 2: aggregate raspberry + pi - let count = (c.raspberryReachable ? 1 : 0) + (c.piReachable ? 1 : 0) - light2.state = count == 2 ? .green : (count == 1 ? .yellow : .red) - light2.toolTip = c.tooltipForAggregateK3s() + // Light 2: Prometheus + if let s = services.first(where: { $0.name == "Prometheus" }) { + let state = c.serviceStates[s.name] + light2.state = (state?.reachable ?? false) ? .green : .red + light2.toolTip = c.tooltipFor(name: s.name) + } - // Light 3: localhost k3d - light3.state = c.localReachable ? .green : .red - light3.toolTip = c.tooltipForLocal() - - // Timestamp label - let now = Date() - timestampLabel.stringValue = timeFormatter.string(from: now) - timestampLabel.toolTip = "Last updated: " + fullFormatter.string(from: now) + // Light 3: Grafana + if let s = services.first(where: { $0.name == "Grafana" }) { + let state = c.serviceStates[s.name] + light3.state = (state?.reachable ?? false) ? .green : .red + light3.toolTip = c.tooltipFor(name: s.name) + } + + // Light 4: OpenBAO + if let s = services.first(where: { $0.name == "OpenBAO" }) { + let state = c.serviceStates[s.name] + light4.state = (state?.reachable ?? false) ? .green : .red + light4.toolTip = c.tooltipFor(name: s.name) + } + + // Light 5: PostgreSQL + if let s = services.first(where: { $0.name == "PostgreSQL" }) { + let state = c.serviceStates[s.name] + light5.state = (state?.reachable ?? false) ? .green : .red + light5.toolTip = c.tooltipFor(name: s.name) + } // Update marquee text with status summary and ensure it scrolls - let msg = aggregateStatusSummary() - marquee.setText(msg) - marquee.toolTip = msg - needsDisplay = true + // For the scrolling status, use new contents: scroll the output of kubectl cnpg status | head -10 + DispatchQueue.global(qos: .utility).async { + let res = PFScriptBridge.cnpgStatus() + let msg = res.out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Waiting for PostgreSQL status..." : res.out.replacingOccurrences(of: "\n", with: " • ") + DispatchQueue.main.async { [weak self] in + self?.marquee.setText(msg) + self?.marquee.toolTip = msg + self?.needsDisplay = true + } + } } - func aggregateStatusSummary() -> String { - guard let c = checker else { return "No status" } - let cfg = Config.shared - let svcText: String = { - if let svc = cfg.primaryService { - return c.svcReachable ? "\(svc.host):\(svc.port) ✓" : "\(svc.host):\(svc.port) ✗" - } - return "svc (unset) ✗" - }() - let aggText: String = { - let kube = cfg.kubernetes.first - let name = kube?.host ?? "k3s" - return "k3s: \(name) \(c.raspberryReachable ? "✓" : "✗")" - }() - let locText: String = { - if let local = cfg.localService { - return c.localReachable ? "k3d \(local.host):\(local.port) ✓" : "k3d \(local.host):\(local.port) ✗" - } - return "k3d (unset) ✗" - }() - let svc = svcText - let agg = aggText - let loc = locText - return [svc, agg, loc].joined(separator: " • ") - } // MARK: - Button actions @objc private func didTapMaximize() { diff --git a/src/get.sh b/src/get.sh index f1b6511..7b3c612 100755 --- a/src/get.sh +++ b/src/get.sh @@ -4,9 +4,9 @@ set -ex brew update && brew install wget -if [ ! -e postgresql-17.5.tar.gz ]; then - wget https://ftp.postgresql.org/pub/source/v17.5/postgresql-17.5.tar.gz +if [ ! -e postgresql-17.7.tar.gz ]; then + wget https://ftp.postgresql.org/pub/source/v17.7/postgresql-17.7.tar.gz fi -if [ ! -e postgresql-17.5.tar.gz.md5 ]; then - wget https://ftp.postgresql.org/pub/source/v17.5/postgresql-17.5.tar.gz.md5 +if [ ! -e postgresql-17.7.tar.gz.md5 ]; then + wget https://ftp.postgresql.org/pub/source/v17.7/postgresql-17.7.tar.gz.md5 fi