Skip to content

Instantly share code, notes, and snippets.

@fabriziosalmi
Created April 4, 2025 07:27
Show Gist options
  • Save fabriziosalmi/58978330bd6782bf681158ee6d56d0d3 to your computer and use it in GitHub Desktop.
Save fabriziosalmi/58978330bd6782bf681158ee6d56d0d3 to your computer and use it in GitHub Desktop.
manage_k8s_apps.sh
#!/bin/bash
# --- Configuration ---
HOST_DATA_BASE_DIR="/srv/k8s-apps-data"
NODE_IP=""
# --- Terminal Colors ---
RESET='\033[0m'; BOLD='\033[1m'; RED='\033[0;31m'; GREEN='\033[0;32m';
YELLOW='\033[0;33m'; BLUE='\033[0;34m'; MAGENTA='\033[0;35m'; CYAN='\033[0;36m';
# --- Script Timer ---
SCRIPT_START_TIME=$(date +%s)
# --- Helper Functions ---
log_step() { echo -e "\n${MAGENTA}${BOLD}--------------------------------------------------${RESET}"; echo -e "${MAGENTA}${BOLD}STEP: $1${RESET}"; echo -e "${MAGENTA}${BOLD}--------------------------------------------------${RESET}"; }
log_warn() { echo -e "${YELLOW}${BOLD}⚠️ WARNING:${RESET}${YELLOW} $1${RESET}"; }
log_info() { echo -e "${CYAN}ℹ️ INFO:${RESET} $1"; }
error_exit() { echo -e "\n${RED}${BOLD}❌ ERROR:${RESET}${RED} $1${RESET}\n" >&2; exit 1; }
success_msg() { echo -e "${GREEN}✅ SUCCESS:${RESET} $1"; }
check_root() { if [ "$(id -u)" -ne 0 ]; then error_exit "This script must be run as root OR ensure kubectl is configured for your user."; fi; }
check_command() { if ! command -v "$1" &> /dev/null; then error_exit "Required command '${BOLD}$1${RESET}${RED}' not found. Please install it."; fi; }
check_kubectl() { if ! kubectl cluster-info > /dev/null 2>&1; then error_exit "Cannot connect to Kubernetes cluster. Check kubectl config (${BOLD}KUBECONFIG=${KUBECONFIG:-'default'}${RESET}${RED})."; fi; log_info "Successfully connected to Kubernetes cluster."; }
# Function to create host path and PV/PVC (INSECURE - for demo only)
# Metadata format: "namespace;pvc_suffixes;pv_suffixes" (comma-separated suffixes)
setup_hostpath_pv_pvc() {
local app_name="$1"; local namespace="$2"; local pvc_name="$3"; local host_path_suffix="$4"; local pv_suffix="$5"; local size="${6:-1Gi}"
local host_path="${HOST_DATA_BASE_DIR}/${namespace}/${host_path_suffix}"; local pv_name="${namespace}-${pv_suffix}-pv"
log_info "[${app_name}] Ensuring host directory: ${CYAN}${host_path}${RESET}"; mkdir -p "$host_path" || error_exit "Failed to create host directory: $host_path"
chmod -R 777 "$host_path" || log_warn "Failed to chmod 777 $host_path."
log_warn "[${app_name}] Set insecure permissions (777) on ${host_path}. DEMO ONLY."
log_info "[${app_name}] Applying PV (${pv_name}) and PVC (${pvc_name})";
cat <<EOF | kubectl apply -n "$namespace" -f - >/dev/null
apiVersion: v1
kind: PersistentVolume
metadata:
name: ${pv_name}
labels:
app.kubernetes.io/name: ${app_name}
app.kubernetes.io/instance: ${app_name}-${host_path_suffix}
managed-by: selfhost-deploy-script
spec: { capacity: { storage: ${size} }, volumeMode: Filesystem, accessModes: [ReadWriteOnce], persistentVolumeReclaimPolicy: Retain, storageClassName: "", hostPath: { path: "${host_path}", type: DirectoryOrCreate } }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ${pvc_name}
labels: { managed-by: selfhost-deploy-script }
spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: ${size} } }, storageClassName: "", volumeName: ${pv_name} }
EOF
success_msg "[${app_name}] PV/PVC (${pvc_name} -> ${pv_name}) setup complete."
}
# Function to deploy a generic app
deploy_generic_app() {
local app_name="$1"; local namespace="$2"; local image="$3"; local pvc_name="$4"; local container_port="$5"; local host_path_suffix="$6"; local pv_suffix="$7"; local pvc_size="${8:-1Gi}"; local extra_env_yaml="${9:-}"
log_step "Deploying: ${app_name}"
log_info "[${app_name}] Creating Namespace: ${namespace}"; kubectl create namespace "$namespace" >/dev/null 2>&1 || log_info "[${app_name}] Namespace ${namespace} already exists."
setup_hostpath_pv_pvc "$app_name" "$namespace" "$pvc_name" "$host_path_suffix" "$pv_suffix" "$pvc_size"
log_info "[${app_name}] Applying Deployment and Service"
cat <<EOF | kubectl apply -n "$namespace" -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata: { name: ${app_name}-deployment, labels: { app: ${app_name}, managed-by: selfhost-deploy-script } }
spec: { replicas: 1, selector: { matchLabels: { app: ${app_name} } }, template: { metadata: { labels: { app: ${app_name} } }, spec: { volumes: [ { name: data-volume, persistentVolumeClaim: { claimName: ${pvc_name} } } ], containers: [ { name: ${app_name}, image: ${image}, ports: [ { containerPort: ${container_port}, name: http } ], volumeMounts: [ { name: data-volume, mountPath: /data } ], env: [ { name: PUID, value: "1000" }, { name: PGID, value: "1000" }, { name: TZ, value: "Etc/UTC" } ${extra_env_yaml:+, ${extra_env_yaml}} ] } ] } } } # Note: simplified env structure here, ensure extra_env_yaml starts with comma if needed or adjust structure
---
apiVersion: v1
kind: Service
metadata: { name: ${app_name}-service, labels: { managed-by: selfhost-deploy-script } }
spec: { selector: { app: ${app_name} }, ports: [ { protocol: TCP, port: ${container_port}, targetPort: ${container_port} } ], type: NodePort }
EOF
log_info "[${app_name}] Waiting for deployment to be ready..."
if kubectl wait --for=condition=available --timeout=300s deployment/${app_name}-deployment -n "$namespace"; then
success_msg "[${app_name}] Deployment successful!"
local node_port=$(kubectl get svc ${app_name}-service -n "$namespace" -o=jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null || echo "N/A")
if [[ "$node_port" != "N/A" ]] && [[ -n "$NODE_IP" ]]; then
local access_url="http://${NODE_IP}:${node_port}"; echo -e "${GREEN}--> Access ${app_name} at: ${BOLD}${CYAN}${access_url}${RESET}${RESET}";
else log_warn "[${app_name}] Could not get NodePort or Node IP. Check service manually."; fi
else log_warn "[${app_name}] Deployment did not become available."; fi
}
# --- Specific App Deployment Functions (Install) ---
# (Functions deploy_portainer, deploy_nextcloud, deploy_gitea, etc. remain the same)
# ... (omitted for brevity, no changes needed in these functions from previous version) ...
deploy_portainer() {
local app_name="portainer"; local ns="portainer"; local image="portainer/portainer-ce:latest"; local pvc="portainer-data-pvc"; local host_suffix="data"; local pv_suffix="data"; local size="2Gi"
log_step "Deploying: Portainer (Container Management UI)"
if kubectl get deployment portainer -n $ns > /dev/null 2>&1; then
log_info "[${app_name}] Portainer deployment exists. Ensuring service is NodePort."
kubectl patch service portainer -n "$ns" -p '{"spec": {"type": "NodePort"}}' >/dev/null 2>&1 || log_warn "[${app_name}] Failed to patch existing service."
else
log_info "[${app_name}] Creating Namespace: ${ns}"; kubectl create namespace "$ns" >/dev/null 2>&1 || true
setup_hostpath_pv_pvc "$app_name" "$ns" "$pvc" "$host_suffix" "$pv_suffix" "$size"
log_info "[${app_name}] Applying Deployment and Service"
cat <<EOF | kubectl apply -n "$ns" -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata: { name: portainer, labels: { app: portainer, managed-by: selfhost-deploy-script } }
spec: { replicas: 1, selector: { matchLabels: { app: portainer } }, template: { metadata: { labels: { app: portainer } }, spec: { volumes: [ { name: data, persistentVolumeClaim: { claimName: ${pvc} } } ], containers: [ { name: portainer, image: ${image}, ports: [ { containerPort: 8000, name: http-edge }, { containerPort: 9443, name: https-ui }, { containerPort: 9000, name: http-legacy } ], volumeMounts: [ { name: data, mountPath: /data } ] } ] } } }
---
apiVersion: v1
kind: Service
metadata: { name: portainer, labels: { managed-by: selfhost-deploy-script } }
spec: { selector: { app: portainer }, ports: [ { name: https-ui, protocol: TCP, port: 9443, targetPort: 9443 }, { name: http-edge, protocol: TCP, port: 8000, targetPort: 8000 } ], type: NodePort }
EOF
fi
log_info "[${app_name}] Waiting for deployment to be ready..."
if kubectl wait --for=condition=available --timeout=300s deployment/portainer -n "$ns"; then
success_msg "[${app_name}] Deployment ready!"
local node_port=$(kubectl get svc portainer -n "$ns" -o=jsonpath='{.spec.ports[?(@.name=="https-ui")].nodePort}' 2>/dev/null || echo "N/A")
if [[ "$node_port" != "N/A" ]] && [[ -n "$NODE_IP" ]]; then
local access_url="https://${NODE_IP}:${node_port}"; echo -e "${GREEN}--> Access ${app_name} at: ${BOLD}${CYAN}${access_url}${RESET}${RESET}";
echo -e "${YELLOW} On first login, create an admin user.${RESET}"; echo -e "${YELLOW} (Accept browser warning).${RESET}"
else log_warn "[${app_name}] Could not get NodePort/IP."; fi
else log_warn "[${app_name}] Deployment did not become available."; fi
}
deploy_nextcloud() {
local app_name="nextcloud"; local ns="nextcloud"; local image="nextcloud:latest"; local pvc="nextcloud-data-pvc"; local port=80; local host_suffix="data"; local pv_suffix="data"; local size="10Gi"; local extra_env="- name: SQLITE_DATABASE\n value: nextcloud.db"
deploy_generic_app "$app_name" "$ns" "$image" "$pvc" "$port" "$host_suffix" "$pv_suffix" "$size" "$extra_env"; echo -e "${YELLOW} On first login, create admin.${RESET}"; log_warn "[${app_name}] Using SQLite.";
}
deploy_gitea() {
local app_name="gitea"; local ns="gitea"; local image="gitea/gitea:latest"; local pvc="gitea-data-pvc"; local port=3000; local host_suffix="data"; local pv_suffix="data"; local size="5Gi";
log_step "Deploying: ${app_name} (Git Server)"; log_info "[${app_name}] Creating Namespace: ${ns}"; kubectl create namespace "$ns" >/dev/null 2>&1 || true
setup_hostpath_pv_pvc "$app_name" "$ns" "$pvc" "$host_suffix" "$pv_suffix" "$size"
log_info "[${app_name}] Applying Deployment and Service"
cat <<EOF | kubectl apply -n "$ns" -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata: { name: ${app_name}-deployment, labels: { app: ${app_name}, managed-by: selfhost-deploy-script } }
spec: { replicas: 1, selector: { matchLabels: { app: ${app_name} } }, template: { metadata: { labels: { app: ${app_name} } }, spec: { volumes: [ { name: d, persistentVolumeClaim: { claimName: ${pvc} } } ], containers: [ { name: ${app_name}, image: ${image}, ports: [ { containerPort: ${port}, name: http }, { containerPort: 22, name: ssh } ], volumeMounts: [ { name: d, mountPath: /data } ], env: [ { name: USER_UID, value: "1000" }, { name: USER_GID, value: "1000" } ] } ] } } }
---
apiVersion: v1
kind: Service
metadata: { name: ${app_name}-service, labels: { managed-by: selfhost-deploy-script } }
spec: { selector: { app: ${app_name} }, ports: [ { name: http, protocol: TCP, port: ${port}, targetPort: ${port} } ], type: NodePort }
EOF
log_info "[${app_name}] Waiting for deployment..."; if kubectl wait --for=condition=available --timeout=300s deployment/${app_name}-deployment -n "$ns"; then
success_msg "[${app_name}] Deployment successful!"; local node_port=$(kubectl get svc ${app_name}-service -n "$ns" -o=jsonpath='{.spec.ports[?(@.name=="http")].nodePort}' 2>/dev/null || echo "N/A");
if [[ "$node_port" != "N/A" ]] && [[ -n "$NODE_IP" ]]; then local access_url="http://${NODE_IP}:${node_port}"; echo -e "${GREEN}--> Access ${app_name} at: ${BOLD}${CYAN}${access_url}${RESET}${RESET}"; echo -e "${YELLOW} Complete initial setup (Database: SQLite3).${RESET}"; else log_warn "[${app_name}] Could not get NodePort/IP."; fi
else log_warn "[${app_name}] Deployment failed."; fi
}
deploy_vaultwarden() {
local app_name="vaultwarden"; local ns="vaultwarden"; local image="vaultwarden/server:latest"; local pvc="vw-data-pvc"; local port=80; local host_suffix="data"; local pv_suffix="data"; local size="1Gi"; local extra_env="- name: WEBSOCKET_ENABLED\n value: \"true\""
deploy_generic_app "$app_name" "$ns" "$image" "$pvc" "$port" "$host_suffix" "$pv_suffix" "$size" "$extra_env"; echo -e "${YELLOW} Create account via web UI or client.${RESET}"; echo -e "${YELLOW} Configure client API/Server URL.${RESET}"; log_warn "[${app_name}] Set ADMIN_TOKEN env var for admin panel.";
}
deploy_uptime_kuma() {
local app_name="uptime-kuma"; local ns="uptime-kuma"; local image="louislam/uptime-kuma:latest"; local pvc="uk-data-pvc"; local port=3001; local host_suffix="data"; local pv_suffix="data"; local size="1Gi"
deploy_generic_app "$app_name" "$ns" "$image" "$pvc" "$port" "$host_suffix" "$pv_suffix" "$size"; echo -e "${YELLOW} Create admin account on first access.${RESET}";
}
deploy_jellyfin() {
local app_name="jellyfin"; local ns="jellyfin"; local image="jellyfin/jellyfin:latest"; local cfg_pvc="jf-config-pvc"; local med_pvc="jf-media-pvc"; local port=8096; local cfg_host_sfx="config"; local med_host_sfx="media"; local cfg_pv_sfx="config"; local med_pv_sfx="media"; local cfg_size="2Gi"; local med_size="1Gi"
log_step "Deploying: ${app_name} (Media Server)"; log_info "[${app_name}] Creating Namespace: ${ns}"; kubectl create namespace "$ns" >/dev/null 2>&1 || true
setup_hostpath_pv_pvc "$app_name" "$ns" "$cfg_pvc" "$cfg_host_sfx" "$cfg_pv_sfx" "$cfg_size"
setup_hostpath_pv_pvc "$app_name" "$ns" "$med_pvc" "$med_host_sfx" "$med_pv_sfx" "$med_size"
log_warn "[${app_name}] Media dir: ${HOST_DATA_BASE_DIR}/${ns}/${med_host_sfx}. Update PV if needed.";
log_info "[${app_name}] Applying Deployment and Service"
cat <<EOF | kubectl apply -n "$ns" -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata: { name: ${app_name}-deployment, labels: { app: ${app_name}, managed-by: selfhost-deploy-script } }
spec: { replicas: 1, selector: { matchLabels: { app: ${app_name} } }, template: { metadata: { labels: { app: ${app_name} } }, spec: { volumes: [ { name: vcfg, persistentVolumeClaim: { claimName: ${cfg_pvc} } }, { name: vmed, persistentVolumeClaim: { claimName: ${med_pvc} } } ], containers: [ { name: ${app_name}, image: ${image}, ports: [ { containerPort: ${port}, name: http } ], volumeMounts: [ { name: vcfg, mountPath: /config }, { name: vmed, mountPath: /media } ], env: [ { name: PUID, value: "1000" }, { name: PGID, value: "1000" }, { name: TZ, value: "Etc/UTC" } ] } ] } } }
---
apiVersion: v1
kind: Service
metadata: { name: ${app_name}-service, labels: { managed-by: selfhost-deploy-script } }
spec: { selector: { app: ${app_name} }, ports: [ { name: http, protocol: TCP, port: ${port}, targetPort: ${port} } ], type: NodePort }
EOF
log_info "[${app_name}] Waiting for deployment..."; if kubectl wait --for=condition=available --timeout=300s deployment/${app_name}-deployment -n "$ns"; then
success_msg "[${app_name}] Deployment successful!"; local node_port=$(kubectl get svc ${app_name}-service -n "$ns" -o=jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null || echo "N/A");
if [[ "$node_port" != "N/A" ]] && [[ -n "$NODE_IP" ]]; then local access_url="http://${NODE_IP}:${node_port}"; echo -e "${GREEN}--> Access ${app_name} at: ${BOLD}${CYAN}${access_url}${RESET}${RESET}"; echo -e "${YELLOW} Complete initial setup.${RESET}"; echo -e "${YELLOW} Configure media libraries pointing to ${BOLD}/media${RESET}${YELLOW}.${RESET}"; else log_warn "[${app_name}] Could not get NodePort/IP."; fi
else log_warn "[${app_name}] Deployment failed."; fi
}
deploy_home_assistant() {
local app_name="home-assistant"; local ns="home-assistant"; local image="ghcr.io/home-assistant/home-assistant:stable"; local pvc="ha-config-pvc"; local port=8123; local host_suffix="config"; local pv_suffix="config"; local size="5Gi"
log_step "Deploying: ${app_name} (Home Automation)"; log_info "[${app_name}] Creating Namespace: ${ns}"; kubectl create namespace "$ns" >/dev/null 2>&1 || true
setup_hostpath_pv_pvc "$app_name" "$ns" "$pvc" "$host_suffix" "$pv_suffix" "$size"
log_info "[${app_name}] Applying Deployment and Service"
cat <<EOF | kubectl apply -n "$ns" -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata: { name: ${app_name}-deployment, labels: { app: ${app_name}, managed-by: selfhost-deploy-script } }
spec: { replicas: 1, selector: { matchLabels: { app: ${app_name} } }, template: { metadata: { labels: { app: ${app_name} } }, spec: { volumes: [ { name: vcfg, persistentVolumeClaim: { claimName: ${pvc} } } ], containers: [ { name: ${app_name}, image: ${image}, ports: [ { containerPort: ${port}, name: http } ], volumeMounts: [ { name: vcfg, mountPath: /config } ], env: [ { name: TZ, value: "Etc/UTC" } ] } ] } } }
---
apiVersion: v1
kind: Service
metadata: { name: ${app_name}-service, labels: { managed-by: selfhost-deploy-script } }
spec: { selector: { app: ${app_name} }, ports: [ { name: http, protocol: TCP, port: ${port}, targetPort: ${port} } ], type: NodePort }
EOF
log_info "[${app_name}] Waiting for deployment (can take a while)..."; if kubectl wait --for=condition=available --timeout=480s deployment/${app_name}-deployment -n "$ns"; then # Longer timeout
success_msg "[${app_name}] Deployment successful!"; local node_port=$(kubectl get svc ${app_name}-service -n "$ns" -o=jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null || echo "N/A");
if [[ "$node_port" != "N/A" ]] && [[ -n "$NODE_IP" ]]; then local access_url="http://${NODE_IP}:${node_port}"; echo -e "${GREEN}--> Access ${app_name} at: ${BOLD}${CYAN}${access_url}${RESET}${RESET}"; echo -e "${YELLOW} Create account during onboarding.${RESET}"; else log_warn "[${app_name}] Could not get NodePort/IP."; fi
else log_warn "[${app_name}] Deployment failed."; fi
}
deploy_filebrowser() {
local app_name="filebrowser"; local ns="filebrowser"; local image="filebrowser/filebrowser:latest"; local cfg_pvc="fb-config-pvc"; local data_pvc="fb-data-pvc"; local port=80; local cfg_host_sfx="config"; local data_host_sfx="files"; local cfg_pv_sfx="config"; local data_pv_sfx="files"; local cfg_size="1Gi"; local data_size="10Gi"
log_step "Deploying: ${app_name} (Web File Manager)"; log_info "[${app_name}] Creating Namespace: ${ns}"; kubectl create namespace "$ns" >/dev/null 2>&1 || true
setup_hostpath_pv_pvc "$app_name" "$ns" "$cfg_pvc" "$cfg_host_sfx" "$cfg_pv_sfx" "$cfg_size"
setup_hostpath_pv_pvc "$app_name" "$ns" "$data_pvc" "$data_host_sfx" "$data_pv_sfx" "$data_size"
log_warn "[${app_name}] Files dir: ${HOST_DATA_BASE_DIR}/${ns}/${data_host_sfx}. Place files here.";
log_info "[${app_name}] Applying Deployment and Service"
cat <<EOF | kubectl apply -n "$ns" -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata: { name: ${app_name}-deployment, labels: { app: ${app_name}, managed-by: selfhost-deploy-script } }
spec: { replicas: 1, selector: { matchLabels: { app: ${app_name} } }, template: { metadata: { labels: { app: ${app_name} } }, spec: { volumes: [ { name: vcfg, persistentVolumeClaim: { claimName: ${cfg_pvc} } }, { name: vdata, persistentVolumeClaim: { claimName: ${data_pvc} } } ], containers: [ { name: ${app_name}, image: ${image}, ports: [ { containerPort: ${port}, name: http } ], volumeMounts: [ { name: vcfg, mountPath: /database }, { name: vdata, mountPath: /srv } ], args: ["--database=/database/filebrowser.db", "--root=/srv"] } ] } } }
---
apiVersion: v1
kind: Service
metadata: { name: ${app_name}-service, labels: { managed-by: selfhost-deploy-script } }
spec: { selector: { app: ${app_name} }, ports: [ { name: http, protocol: TCP, port: ${port}, targetPort: ${port} } ], type: NodePort }
EOF
log_info "[${app_name}] Waiting for deployment..."; if kubectl wait --for=condition=available --timeout=300s deployment/${app_name}-deployment -n "$ns"; then
success_msg "[${app_name}] Deployment successful!"; local node_port=$(kubectl get svc ${app_name}-service -n "$ns" -o=jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null || echo "N/A");
if [[ "$node_port" != "N/A" ]] && [[ -n "$NODE_IP" ]]; then local access_url="http://${NODE_IP}:${node_port}"; echo -e "${GREEN}--> Access ${app_name} at: ${BOLD}${CYAN}${access_url}${RESET}${RESET}"; echo -e "${YELLOW} Default login: ${BOLD}admin / admin${RESET}${YELLOW} - CHANGE IMMEDIATELY!${RESET}"; else log_warn "[${app_name}] Could not get NodePort/IP."; fi
else log_warn "[${app_name}] Deployment failed."; fi
}
# --- Uninstall Functions ---
detect_installed_apps() {
log_info "Detecting potentially installed apps (checking namespaces)..."
declare -g -a detected_apps # Make array global for selection later
detected_apps=()
local found_count=0
for display_name in "${!app_metadata[@]}"; do
local meta_string="${app_metadata[$display_name]}"
local namespace=$(echo "$meta_string" | cut -d';' -f1)
# Also check for our label on the namespace for more certainty
if kubectl get namespace "$namespace" -L managed-by=selfhost-deploy-script -o name > /dev/null 2>&1; then
log_info "Found managed namespace: ${BOLD}${namespace}${RESET} (App: ${display_name})"
detected_apps+=("$display_name") # Store display name
found_count=$((found_count + 1))
elif kubectl get namespace "$namespace" -o name > /dev/null 2>&1; then
log_warn "Found namespace ${BOLD}${namespace}${RESET} (App: ${display_name}) but it lacks 'managed-by=selfhost-deploy-script' label. Including anyway, but use caution during uninstall."
detected_apps+=("$display_name") # Store display name
found_count=$((found_count + 1))
fi
done
if [ $found_count -eq 0 ]; then log_info "No application namespaces managed by this script were detected."; return 1; fi
return 0
}
uninstall_selected_apps() {
local passed_array_name="$1"
# FIX: Use a different local name for the nameref
local -n apps_to_uninstall_ref="${passed_array_name}"
if [ ${#apps_to_uninstall_ref[@]} -eq 0 ]; then
log_info "No applications selected for uninstallation."
return
fi
log_step "Starting Uninstallation of Selected Applications (${#apps_to_uninstall_ref[@]} apps)"
local app_count=0
local total_apps=${#apps_to_uninstall_ref[@]}
for app_display_name in "${apps_to_uninstall_ref[@]}"; do
app_count=$((app_count + 1))
log_info "Processing uninstall for app ${app_count} of ${total_apps}: ${BOLD}${app_display_name}${RESET}"
local meta_string="${app_metadata[$app_display_name]}"
if [ -z "$meta_string" ]; then log_warn "No metadata for '${app_display_name}'. Skipping."; continue; fi
local namespace=$(echo "$meta_string" | cut -d';' -f1)
local pv_suffixes_str=$(echo "$meta_string" | cut -d';' -f3)
# 1. Confirm K8s Resource Deletion
read -p "$(echo -e "${YELLOW}❓ Delete ALL K8s resources in namespace ${BOLD}${namespace}${RESET}${YELLOW} for ${BOLD}${app_display_name}${RESET}${YELLOW}? [y/N]: ${RESET}")" confirm_k8s
if [[ ! "$confirm_k8s" =~ ^[Yy]$ ]]; then
log_info "[${app_display_name}] Skipping Kubernetes resource deletion."
else
# Delete associated PVs first (if any) - This might help release claims before NS delete
if [ -n "$pv_suffixes_str" ]; then
log_info "[${app_display_name}] Attempting to delete associated PersistentVolumes..."
IFS=',' read -r -a pv_suffixes <<< "$pv_suffixes_str"
for pv_suffix in "${pv_suffixes[@]}"; do
local pv_name="${namespace}-${pv_suffix}-pv"
log_info "[${app_display_name}] Deleting PV ${BOLD}${pv_name}${RESET}..."
if kubectl delete pv "$pv_name" --ignore-not-found=true --timeout=30s; then
success_msg "[${app_display_name}] Deleted PV ${pv_name}."
else log_warn "[${app_display_name}] Failed/Timeout deleting PV ${pv_name} (may be bound or already gone)."; fi
done
fi
log_info "[${app_display_name}] Deleting Namespace ${BOLD}${namespace}${RESET} (wait=false)..."
if kubectl delete namespace "$namespace" --ignore-not-found=true --wait=false; then
success_msg "[${app_display_name}] Namespace deletion initiated."
log_info "[${app_display_name}] Note: Namespace termination can take time in the background."
else log_warn "[${app_display_name}] Failed to initiate namespace deletion."; fi
fi # End K8s resource deletion
# 2. Confirm Host Data Deletion
local host_data_path="${HOST_DATA_BASE_DIR}/${namespace}"
if [ -d "$host_data_path" ]; then
echo # Add newline
read -p "$(echo -e "${RED}${BOLD}❓ PERMANENTLY DELETE host data for ${app_display_name} at ${CYAN}${host_data_path}${RESET}${RED}? Cannot be undone! [y/N]: ${RESET}")" confirm_host_data
if [[ "$confirm_host_data" =~ ^[Yy]$ ]]; then
log_warn "[${app_display_name}] Deleting host data directory ${BOLD}${host_data_path}${RESET}..."
if rm -rf "$host_data_path"; then success_msg "[${app_display_name}] Host data directory deleted.";
else log_warn "[${app_display_name}] Failed to delete host data directory: $host_data_path"; fi # Warn, don't exit
else log_info "[${app_display_name}] Skipping host data deletion."; fi
else log_info "[${app_display_name}] Host data directory ${host_data_path} not found, skipping deletion."; fi # End Host data deletion
success_msg "Finished uninstall process for ${app_display_name}."
echo
done
}
# --- Main Execution ---
log_step "Self-Hosted App Deployment / Uninstallation Script"
# Basic command checks needed for core functionality
check_command kubectl
check_command cut
check_command sort
check_command printf
check_command date
if ! command -v mapfile &> /dev/null && ! command -v readarray &> /dev/null; then error_exit "Requires 'mapfile' or 'readarray' command (Bash 4+)."; fi
# --- App Definitions and Metadata ---
# Metadata stores "namespace;pvc_names_comma_sep;pv_suffixes_comma_sep"
# Used for install (function mapping) and uninstall (finding resources)
declare -A app_install_funcs
declare -A app_metadata
# App Display Name -> Install Function
app_install_funcs["Portainer"]="deploy_portainer"
app_metadata["Portainer"]="portainer;portainer-data-pvc;data"
app_install_funcs["Nextcloud (SQLite)"]="deploy_nextcloud"
app_metadata["Nextcloud (SQLite)"]="nextcloud;nextcloud-data-pvc;data"
app_install_funcs["Gitea (SQLite)"]="deploy_gitea"
app_metadata["Gitea (SQLite)"]="gitea;gitea-data-pvc;data"
app_install_funcs["Vaultwarden (Bitwarden Server)"]="deploy_vaultwarden"
app_metadata["Vaultwarden (Bitwarden Server)"]="vaultwarden;vw-data-pvc;data"
app_install_funcs["Uptime Kuma (Monitoring)"]="deploy_uptime_kuma"
app_metadata["Uptime Kuma (Monitoring)"]="uptime-kuma;uk-data-pvc;data"
app_install_funcs["Jellyfin (Media Server)"]="deploy_jellyfin"
app_metadata["Jellyfin (Media Server)"]="jellyfin;jf-config-pvc,jf-media-pvc;config,media"
app_install_funcs["Home Assistant"]="deploy_home_assistant"
app_metadata["Home Assistant"]="home-assistant;ha-config-pvc;config"
app_install_funcs["File Browser"]="deploy_filebrowser"
app_metadata["File Browser"]="filebrowser;fb-config-pvc,fb-data-pvc;config,files"
# --- Mode Selection ---
SCRIPT_MODE=""
echo -e "${BOLD}Choose Operation Mode:${RESET}"
echo -e " [${CYAN}i${RESET}] ${BOLD}Install${RESET} new applications"
echo -e " [${CYAN}u${RESET}] ${BOLD}Uninstall${RESET} existing applications"
echo -e " [${CYAN}q${RESET}] ${BOLD}Quit${RESET}"
while true; do
read -p "$(echo -e "${CYAN}Enter mode (i/u/q) [q]: ${RESET}")" mode_choice; mode_choice=${mode_choice:-q}; mode_choice=$(echo "$mode_choice" | tr '[:upper:]' '[:lower:]')
case "$mode_choice" in
i) SCRIPT_MODE="install"; break ;; u) SCRIPT_MODE="uninstall"; break ;; q) log_info "Exiting script."; exit 0 ;; *) log_warn "Invalid choice." ;;
esac
done
# --- Execution Flow ---
check_kubectl # Check connection after mode selection
# Auto-detect Node IP (needed for install instructions)
if [ "$SCRIPT_MODE" == "install" ]; then
if [ -z "$NODE_IP" ]; then
log_info "Attempting to auto-detect Node IP address..."; NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}' 2>/dev/null || true)
[ -z "$NODE_IP" ] && NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}' 2>/dev/null || true)
[ -z "$NODE_IP" ] && NODE_IP=$(hostname -I | awk '{print $1}')
if [ -n "$NODE_IP" ]; then log_info "Auto-detected Node IP: ${BOLD}${NODE_IP}${RESET}"; else log_warn "Failed to auto-detect Node IP."; fi
else log_info "Using manually specified Node IP: ${BOLD}${NODE_IP}${RESET}"; fi; echo
fi
# --- INSTALL MODE ---
if [ "$SCRIPT_MODE" == "install" ]; then
available_apps=(); for key in "${!app_install_funcs[@]}"; do available_apps+=("$key"); done
IFS=$'\n' sorted_apps=($(sort <<<"${available_apps[*]}")); unset IFS
log_step "Select Applications to Install"
echo -e "The following applications can be installed:"; declare -a selected_apps=(); declare -a temp_selected_apps=()
for i in "${!sorted_apps[@]}"; do printf " [%2d] %s\n" $((i+1)) "${sorted_apps[i]}"; done
echo -e "Enter numbers (e.g., '1 3 5'), or '${BOLD}a${RESET}' for all, or '${BOLD}n${RESET}' for none:"
read -p "$(echo -e "${CYAN}Your choices: ${RESET}")" user_choice
if [[ "$user_choice" =~ ^[Aa]([Ll][Ll])?$ ]]; then log_info "Selecting all."; selected_apps=("${sorted_apps[@]}");
elif [[ "$user_choice" =~ ^[Nn]([Oo][Nn][Ee]?)?$ ]] || [[ -z "$user_choice" ]]; then log_info "None selected."; selected_apps=();
else for num in $user_choice; do if [[ "$num" =~ ^[0-9]+$ ]] && [ "$num" -ge 1 ] && [ "$num" -le "${#sorted_apps[@]}" ]; then index=$((num-1)); temp_selected_apps+=("${sorted_apps[index]}"); else log_warn "Ignoring: $num"; fi; done
declare -a unique_selected_apps; mapfile -t unique_selected_apps < <(printf "%s\n" "${temp_selected_apps[@]}" | sort -u); selected_apps=("${unique_selected_apps[@]}"); unset temp_selected_apps unique_selected_apps; fi
if [ ${#selected_apps[@]} -eq 0 ]; then log_info "No applications selected."; else
log_step "Starting Deployment (${#selected_apps[@]} apps)"; log_warn "Using ${BOLD}hostPath${RESET}${YELLOW} storage in ${CYAN}${HOST_DATA_BASE_DIR}${RESET}${YELLOW}. ${RED}${BOLD}INSECURE - DEMO ONLY.${RESET}"
app_count=0; total_apps=${#selected_apps[@]}; for app_display_name in "${selected_apps[@]}"; do app_count=$((app_count + 1))
log_info "Deploying app ${app_count}/${total_apps}: ${BOLD}${app_display_name}${RESET}"; app_func_name="${app_install_funcs[$app_display_name]}"
if [ -n "$app_func_name" ] && declare -f "$app_func_name" > /dev/null; then "$app_func_name"; echo; else log_warn "Install func missing for '${app_display_name}'."; fi
done; success_msg "Finished deployment attempts."
fi
# --- UNINSTALL MODE ---
elif [ "$SCRIPT_MODE" == "uninstall" ]; then
declare -g -a detected_apps # Needs to be global for detect function
if ! detect_installed_apps; then log_info "Exiting uninstall mode."; else
log_step "Select Applications to Uninstall"
echo -e "Detected applications (managed by script):"; declare -a apps_to_uninstall=(); declare -a temp_selected_apps=()
IFS=$'\n' sorted_detected_apps=($(sort <<<"${detected_apps[*]}")); unset IFS
for i in "${!sorted_detected_apps[@]}"; do printf " [%2d] %s\n" $((i+1)) "${sorted_detected_apps[i]}"; done
echo -e "Enter numbers to uninstall (e.g., '1 3'), '${BOLD}a${RESET}' for all detected, '${BOLD}n${RESET}' for none:"
read -p "$(echo -e "${CYAN}Your choices: ${RESET}")" user_choice
if [[ "$user_choice" =~ ^[Aa]([Ll][Ll])?$ ]]; then log_info "Selecting all detected for uninstall."; apps_to_uninstall=("${sorted_detected_apps[@]}");
elif [[ "$user_choice" =~ ^[Nn]([Oo][Nn][Ee]?)?$ ]] || [[ -z "$user_choice" ]]; then log_info "None selected for uninstall."; apps_to_uninstall=();
else for num in $user_choice; do if [[ "$num" =~ ^[0-9]+$ ]] && [ "$num" -ge 1 ] && [ "$num" -le "${#sorted_detected_apps[@]}" ]; then index=$((num-1)); temp_selected_apps+=("${sorted_detected_apps[index]}"); else log_warn "Ignoring: $num"; fi; done
declare -a unique_selected_apps; mapfile -t unique_selected_apps < <(printf "%s\n" "${temp_selected_apps[@]}" | sort -u); apps_to_uninstall=("${unique_selected_apps[@]}"); unset temp_selected_apps unique_selected_apps; fi
# Perform uninstall
uninstall_selected_apps apps_to_uninstall # Pass array NAME
fi
fi
# --- Script End ---
SCRIPT_END_TIME=$(date +%s)
DURATION=$((SCRIPT_END_TIME - SCRIPT_START_TIME))
MINUTES=$((DURATION / 60))
SECONDS=$((DURATION % 60))
echo -e "\n${BLUE}--------------------------------------------------${RESET}"
log_info "Script finished in ${BOLD}${MINUTES} minutes and ${SECONDS} seconds${RESET}."
echo -e "${BLUE}${BOLD}##################################################${RESET}"
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment