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

This commit is contained in:
chrisfu 2025-12-30 22:15:14 -08:00
parent 528e362d95
commit 973920db07
15 changed files with 716 additions and 154 deletions

View File

@ -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" ]
```

View File

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

View File

@ -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 "<html><body><h1>Prole</h1></body></html>" > /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

453
etc/init_openbao.sh Executable file
View File

@ -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" <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: $OPENBAO_NAME
namespace: $NAMESPACE
spec:
replicas: 1
selector:
matchLabels:
app: $OPENBAO_NAME
template:
metadata:
labels:
app: $OPENBAO_NAME
spec:
containers:
- name: $OPENBAO_NAME
image: $OPENBAO_IMAGE
args: ["server","-dev","-dev-listen-address=0.0.0.0:8200"]
ports:
- containerPort: 8200
---
apiVersion: v1
kind: Service
metadata:
name: $OPENBAO_NAME
namespace: $NAMESPACE
spec:
selector:
app: $OPENBAO_NAME
ports:
- name: http
port: 8200
targetPort: 8200
EOF
fi
}
generate_kerberos_configmap() {
mkdir -p "$OPENBAO_MANIFEST_DIR"
local domain_lower
domain_lower=$(echo "$DOMAIN" | tr 'A-Z' 'a-z')
# Split KRB5_KDC on comma into bash array
local IFS=','
read -r -a __kdcs <<< "$KRB5_KDC"
local kdc_lines=""
local kdc
for kdc in "${__kdcs[@]}"; do
kdc_lines+=$' kdc = '"$kdc"$'\n'
done
local admin_server admin_line
admin_server=${KRB5_ADMIN:-"${__kdcs[0]:-}"}
admin_line=""
if [[ -n "$admin_server" ]]; then
# 8 spaces to remain within the YAML literal block indentation
admin_line=$' admin_server = '"$admin_server"$'\n'
fi
cat >"$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: prole-krb5-conf
namespace: $NAMESPACE
data:
krb5.conf: |
[libdefaults]
default_realm = $KRB5_REALM
dns_lookup_realm = true
dns_lookup_kdc = true
[realms]
$KRB5_REALM = {
${kdc_lines}${admin_line} }
[domain_realm]
.$domain_lower = $KRB5_REALM
$domain_lower = $KRB5_REALM
EOF
}
apply_k8s() {
echo "Applying OpenBao manifest to namespace '$NAMESPACE' ..."
kubectl apply -f "$OPENBAO_MANIFEST_DIR/deployment.yaml"
echo "Applying Kerberos ConfigMap (external realm) to namespace '$NAMESPACE' ..."
kubectl apply -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml"
}
wait_for_openbao() {
echo "Waiting for OpenBao to become ready ..."
kubectl rollout status deploy/$OPENBAO_NAME -n "$NAMESPACE" --timeout=120s
}
init_openbao_kv_and_store_admin_key() {
local token
token=$(cat "$root_token_file")
# Enable kv (if not already) and write keys
local svc="http://127.0.0.1:18200"
echo "Configuring KV at $svc ..."
# Check if kv/ is already mounted
if curl -sS -H "X-Vault-Token: $token" "$svc/v1/sys/mounts" | grep -q '"kv/":'; then
echo "KV path 'kv/' is already enabled."
else
curl -sS -H "X-Vault-Token: $token" -X POST "$svc/v1/sys/mounts/kv" \
-d '{"type":"kv","options":{"version":"2"}}' >/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

View File

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

View File

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

View File

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

View File

@ -44,4 +44,5 @@ spec:
secret:
secretName: prole-nginx-tls
- name: web-root
emptyDir: {}
configMap:
name: prole-index-html

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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