diff --git a/README.md b/README.md index 6ec524b..9d32576 100644 --- a/README.md +++ b/README.md @@ -159,3 +159,34 @@ WeOwn Cloud enables **cohort-based deployment** where each team or organization **Documentation**: This repository contains complete deployment guides **Security**: All deployments include enterprise-grade security by default **Scaling**: Resource optimization across all supported cluster sizes + +## 🛠️ **WeOwn CLI (DigitalOcean K8s Deployer)** + +The `cli/weown` module provides an interactive interface for deploying and +managing WeOwn stacks on DigitalOcean Kubernetes: + +- **Cluster management**: Scale, create, and delete node pools; delete the + entire cluster via `doctl` with confirmation prompts. +- **Infrastructure deployment**: Install shared components such as + `ingress-nginx`, `cert-manager`, ExternalDNS, and the monitoring stack. +- **Application stacks**: Deploy WordPress, Matomo, n8n, AnythingLLM and + other apps into their dedicated namespaces using the curated Helm values. +- **Status & visibility**: List Helm deployments and inspect cluster + resources from a single entry point. + +### CLI Setup + +1. Ensure `kubectl`, `helm`, and `doctl` are installed and authenticated + against your DigitalOcean account. +2. Create `cli/.env` with at least: + - `DO_TOKEN`, `DO_REGION`, `PROJECT_NAME`, `CLUSTER_NAME` + - `BASE_DOMAIN`, `WP_DOMAIN`, `MATOMO_DOMAIN`, `LETSENCRYPT_EMAIL` + - `WP_ADMIN_PASSWORD`, `MATOMO_DB_ROOT_PASSWORD`, `MATOMO_DB_PASSWORD` +3. From the repository root, run: + + ```bash + ./cli/weown + ``` + +Use the interactive menu to manage node pools, deploy infrastructure and +applications, and verify cluster health for your WeOwn cloud instance. diff --git a/cli/lib/do_k8s.sh b/cli/lib/do_k8s.sh new file mode 100644 index 0000000..df69460 --- /dev/null +++ b/cli/lib/do_k8s.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/styles.sh" + +# Config: load env for CLI +# Prefer cli/.env, fall back to project-root .env +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +if [ -f "$BASE_DIR/cli/.env" ]; then + ENV_FILE="$BASE_DIR/cli/.env" +elif [ -f "$BASE_DIR/.env" ]; then + ENV_FILE="$BASE_DIR/.env" +else + ENV_FILE="" +fi + +if [ -n "$ENV_FILE" ]; then + export $(grep -v '^#' "$ENV_FILE" | xargs) +fi + +check_doctl() { + if ! command -v doctl &> /dev/null; then + log_error "doctl is not installed." + exit 1 + fi + + # Basic auth check + if ! doctl account get >/dev/null 2>&1; then + log_warn "doctl not authenticated. Trying to use DO_TOKEN..." + if [ -n "$DO_TOKEN" ]; then + if ! doctl auth init -t "$DO_TOKEN" >/dev/null 2>&1; then + log_error "Failed to authenticate doctl with DO_TOKEN." + exit 1 + fi + else + log_error "DO_TOKEN not set in .env and doctl not logged in." + exit 1 + fi + fi +} + +ensure_cluster() { + local cluster_name=${1:-${CLUSTER_NAME:-weown-cluster}} + local region=${2:-${DO_REGION:-nyc3}} + local version="latest" + + log_info "Checking for cluster: $cluster_name..." + + if doctl kubernetes cluster get "$cluster_name" >/dev/null 2>&1; then + log_success "Cluster '$cluster_name' exists." + else + log_info "Creating cluster '$cluster_name'..." + # Default pool: web-pool + doctl kubernetes cluster create "$cluster_name" \ + --region "$region" \ + --version "$version" \ + --node-pool "name=web-pool;size=s-2vcpu-4gb;count=2" \ + --wait + log_success "Cluster created." + fi + + log_info "Configuring kubectl context..." + doctl kubernetes cluster kubeconfig save "$cluster_name" +} + +list_node_pools() { + local cluster_name=${1:-${CLUSTER_NAME:-weown-cluster}} + doctl kubernetes cluster node-pool list "$cluster_name" +} + +scale_node_pool() { + local cluster_name=${1:-${CLUSTER_NAME:-weown-cluster}} + local pool_name=$2 + local count=$3 + + if [ -z "$pool_name" ] || [ -z "$count" ]; then + log_error "Usage: scale_node_pool " + return 1 + fi + + log_info "Scaling node pool '$pool_name' to $count nodes..." + doctl kubernetes cluster node-pool update "$cluster_name" "$pool_name" --count "$count" + log_success "Scaling initiated." +} + +create_node_pool() { + local cluster_name=${1:-${CLUSTER_NAME:-weown-cluster}} + local pool_name=$2 + local size=$3 + local count=$4 + local tags=$5 # Optional setup as "role=value" + + log_info "Creating node pool '$pool_name'..." + local cmd="doctl kubernetes cluster node-pool create $cluster_name --name $pool_name --size $size --count $count" + if [ -n "$tags" ]; then + cmd="$cmd --label $tags" + fi + eval $cmd +} + +delete_node_pool() { + local cluster_name=${1:-${CLUSTER_NAME:-weown-cluster}} + local pool_name=$2 + + if [ -z "$pool_name" ]; then + log_error "Usage: delete_node_pool " + return 1 + fi + + log_warn "Deleting node pool '$pool_name' from cluster '$cluster_name' (this removes all its nodes)..." + doctl kubernetes cluster node-pool delete "$cluster_name" "$pool_name" --force +} + +delete_cluster() { + local cluster_name=${1:-${CLUSTER_NAME:-weown-cluster}} + log_warn "You are about to DELETE the Kubernetes cluster '$cluster_name'. This will stop all workloads and remove the control plane." + read -p "Type the cluster name ('$cluster_name') to confirm, or anything else to cancel: " confirm + if [ "$confirm" != "$cluster_name" ]; then + log_info "Cluster deletion cancelled." + return 0 + fi + + log_warn "Deleting cluster '$cluster_name' via doctl..." + doctl kubernetes cluster delete "$cluster_name" --force +} diff --git a/cli/lib/droplet_apps.sh b/cli/lib/droplet_apps.sh new file mode 100644 index 0000000..ad096e3 --- /dev/null +++ b/cli/lib/droplet_apps.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +# Helpers for deploying applications directly on DigitalOcean Droplets +# (non-Kubernetes), using doctl and DigitalOcean DNS. + +source "$(dirname "${BASH_SOURCE[0]}")/styles.sh" +source "$(dirname "${BASH_SOURCE[0]}")/do_k8s.sh" + +# Deploy a WordPress One-Click Droplet and wire a domain to it. +# This is intentionally interactive so it can be used from the legacy CLI. + +deploy_wordpress_droplet_interactive() { + check_doctl + + local default_region=${DO_REGION:-nyc3} + local default_size="s-1vcpu-1gb" + + log_info "Deploying WordPress on a DigitalOcean Droplet (non-Kubernetes)." + + read -rp "Droplet name [wp-${BASE_DOMAIN:-site}]: " droplet_name + if [ -z "$droplet_name" ]; then + droplet_name="wp-${BASE_DOMAIN:-site}" + fi + + read -rp "Region slug [${default_region}]: " region + if [ -z "$region" ]; then + region="$default_region" + fi + + read -rp "Size slug [${default_size}]: " size + if [ -z "$size" ]; then + size="$default_size" + fi + + read -rp "Domain to map to this droplet (e.g. example.com): " domain + if [ -z "$domain" ]; then + log_error "Domain is required. Aborting." + return 1 + fi + + log_info "Discovering WordPress image slug from DigitalOcean public images..." + local wp_slug + wp_slug=$(doctl compute image list --public --format Slug --no-header | grep '^wordpress-' | head -n 1) + if [ -z "$wp_slug" ]; then + log_error "Could not find a WordPress image slug via doctl. Please check doctl compute image list." + return 1 + fi + log_info "Using WordPress image slug: $wp_slug" + + log_info "Creating droplet '$droplet_name' in region '$region' with size '$size'..." + if ! doctl compute droplet create "$droplet_name" \ + --region "$region" \ + --image "$wp_slug" \ + --size "$size" \ + --tag-name "wordpress" \ + --enable-monitoring \ + --wait; then + log_error "Droplet creation failed. Check doctl output above." + return 1 + fi + + local ip + ip=$(doctl compute droplet list "$droplet_name" --format PublicIPv4 --no-header | tr -d ' ') + if [ -z "$ip" ]; then + log_error "Could not determine droplet IP address." + return 1 + fi + log_success "Droplet '$droplet_name' created with IP $ip." + + log_info "Ensuring domain '$domain' exists in DigitalOcean DNS..." + if ! doctl compute domain get "$domain" >/dev/null 2>&1; then + if doctl compute domain create "$domain"; then + log_success "Created DNS zone for $domain." + else + log_warn "Failed to create DNS zone for $domain. It may already exist or be managed elsewhere." + fi + fi + + log_info "Creating A records (@ and www) pointing to $ip..." + if ! doctl compute domain records create "$domain" \ + --record-type A \ + --record-name @ \ + --record-data "$ip" \ + --record-ttl 600; then + log_warn "Failed to create root A record for $domain. It may already exist." + fi + + if ! doctl compute domain records create "$domain" \ + --record-type A \ + --record-name www \ + --record-data "$ip" \ + --record-ttl 600; then + log_warn "Failed to create www A record for $domain. It may already exist." + fi + + log_success "WordPress droplet deployment completed." + echo + echo "You can now visit: http://$domain (or http://$ip) to finish WordPress setup." + echo "Next steps:" + echo " - SSH into the droplet: ssh root@$ip" + echo " - Complete the WordPress installation wizard." + echo " - Optionally run Certbot on the droplet to enable HTTPS for $domain." +} + +# Simple sub-menu for droplet-based app deployments + +droplet_apps_menu() { + while true; do + echo + log_info "Droplet-based Application Deployment" + echo " 1) Deploy WordPress on a new Droplet" + echo " 2) Back to main menu" + read -rp "Choice: " choice + case "$choice" in + 1) + deploy_wordpress_droplet_interactive + ;; + 2) + return 0 + ;; + *) + log_warn "Invalid option." + ;; + esac + done +} diff --git a/cli/lib/helm_utils.sh b/cli/lib/helm_utils.sh new file mode 100644 index 0000000..f920624 --- /dev/null +++ b/cli/lib/helm_utils.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/styles.sh" + +# Helper to find chart path +# Assumes running from ai/cli/ +CHART_BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../" && pwd)" + +check_helm() { + if ! command -v helm &> /dev/null; then + log_error "Helm not installed." + exit 1 + fi +} + +deploy_chart() { + local release_name=$1 + local namespace=$2 + local chart_path=$3 + local values_file=$4 + + log_info "Deploying '$release_name' to namespace '$namespace'..." + + # Create namespace if needed + kubectl create namespace "$namespace" --dry-run=client -o yaml | kubectl apply -f - + + local cmd="helm upgrade --install $release_name $chart_path --namespace $namespace" + + if [ -f "$values_file" ]; then + cmd="$cmd -f $values_file" + fi + + # Execute + if eval $cmd; then + log_success "Deployed $release_name successfully." + else + log_error "Failed to deploy $release_name." + return 1 + fi +} + +list_deployments() { + helm list -A +} + +uninstall_chart() { + local release_name=$1 + local namespace=$2 + + log_warn "Uninstalling '$release_name' from '$namespace'..." + helm uninstall "$release_name" -n "$namespace" +} diff --git a/cli/lib/stacks.sh b/cli/lib/stacks.sh new file mode 100644 index 0000000..faa30f0 --- /dev/null +++ b/cli/lib/stacks.sh @@ -0,0 +1,232 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/helm_utils.sh" +source "$(dirname "${BASH_SOURCE[0]}")/do_k8s.sh" + +# Define available stacks/apps +# Format: "DisplayName|ReleaseName|Description|ChartPath|Namespace|ValuesFile" +APPS=( + "Infra: Nginx Ingress|ingress-nginx|Core ingress controller|ingress-nginx/ingress-nginx|infra|" + "Infra: Cert-Manager|cert-manager|SSL Certificates|jetstack/cert-manager|infra|" + "Infra: ExternalDNS|external-dns|DO DNS Sync|bitnami/external-dns|infra|" + "Infra: Monitoring|kube-prometheus-stack|Prometheus & Grafana|prometheus-community/kube-prometheus-stack|infra|" + "App: WordPress|wordpress|CMS Blog|wordpress/helm|wordpress|wordpress/helm/values.yaml" + "App: n8n|n8n|Workflow Automation|n8n/helm|n8n|n8n/helm/values.yaml" + "App: Matomo|matomo|Web Analytics|matomo/helm|matomo|matomo/helm/values.yaml" + "App: LLM-d|llm-d|LLM Daemon (Beta)|llm-d/helm|llm-d|llm-d/helm/values.yaml" + "App: Nextcloud|nextcloud|File Storage|nextcloud/helm|nextcloud|nextcloud/helm/values.yaml" + "App: Vaultwarden|vaultwarden|Password Manager|vaultwarden/helm|vaultwarden|vaultwarden/helm/values.yaml" +) + +# Function to parse and install a selection +install_selection() { + local index=$1 + local entry="${APPS[$index]}" + + IFS='|' read -r display_name release_name desc chart ns values <<< "$entry" + + # Normalize release name for comparisons (lowercase, trimmed) + local rn + rn=$(echo "$release_name" | tr '[:upper:]' '[:lower:]' | xargs) + + # Resolve Chart Path (local vs repo) + local resolved_chart="" + if [[ "$chart" == *"/"* ]] && [[ -d "$CHART_BASE_DIR/$chart" ]]; then + resolved_chart="$CHART_BASE_DIR/$chart" + else + # Assume repo/chart format + # Ensure repos are added (basic set) + helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null 2>&1 + helm repo add jetstack https://charts.jetstack.io >/dev/null 2>&1 + helm repo add bitnami https://charts.bitnami.com/bitnami >/dev/null 2>&1 + helm repo add prometheus-community https://prometheus-community.github.io/helm-charts >/dev/null 2>&1 + helm repo update >/dev/null 2>&1 + resolved_chart="$chart" + fi + + # Resolve Values File + local resolved_values="" + if [ -n "$values" ] && [ -f "$CHART_BASE_DIR/$values" ]; then + resolved_values="$CHART_BASE_DIR/$values" + fi + + # Special handling for infra apps (flags passed via cli often, not just values) + # This is a simplified logic. Real "extensive" logic would have specific functions per app. + + # E.g., Cert-Manager needs --set installCRDs=true + local extra_args="" + if [[ "$rn" == "cert-manager" ]]; then + extra_args="--set installCRDs=true" + fi + + # External DNS needs DO Token + if [[ "$rn" == "external-dns" ]]; then + extra_args="--set provider=digitalocean --set digitalocean.apiToken=$DO_TOKEN --set policy=sync --set txtOwnerId=${CLUSTER_NAME:-weown-cluster}" + fi + + # WordPress: derive domain & email from env if not set in values + if [[ "$rn" == "wordpress" ]]; then + local wp_domain="${WP_DOMAIN:-}" + # Fallback: wordpress.BASE_DOMAIN if WP_DOMAIN not provided + if [ -z "$wp_domain" ] && [ -n "${BASE_DOMAIN:-}" ]; then + wp_domain="wordpress.${BASE_DOMAIN}" + fi + if [ -z "$wp_domain" ]; then + log_error "WordPress deployment requires WP_DOMAIN or BASE_DOMAIN in .env to derive the ingress host." + return 1 + fi + # Admin password for WordPress (required so secret has 'wordpress-password' key) + local wp_admin_password="${WP_ADMIN_PASSWORD:-}" + if [ -z "$wp_admin_password" ]; then + log_error "WP_ADMIN_PASSWORD must be set in cli/.env (use a strong alphanumeric string) for WordPress admin." + return 1 + fi + # Populate chart values overriding placeholders / empty hosts + extra_args+=" --set wordpress.domain=${wp_domain}" + extra_args+=" --set wordpress.wordpressPassword=${wp_admin_password}" + extra_args+=" --set ingress.hosts[0].host=${wp_domain}" + extra_args+=" --set ingress.tls[0].hosts[0]=${wp_domain}" + # Optionally wire email into WordPress config + if [ -n "${LETSENCRYPT_EMAIL:-}" ]; then + extra_args+=" --set wordpress.wordpressEmail=${LETSENCRYPT_EMAIL}" + fi + fi + + # n8n: derive domain from env to replace DOMAIN_PLACEHOLDER + if [[ "$rn" == "n8n" ]]; then + local n8n_domain="${N8N_DOMAIN:-}" + if [ -z "$n8n_domain" ] && [ -n "${BASE_DOMAIN:-}" ]; then + n8n_domain="n8n.${BASE_DOMAIN}" + fi + if [ -z "$n8n_domain" ]; then + log_error "n8n deployment requires N8N_DOMAIN or BASE_DOMAIN in .env to derive the ingress host." + return 1 + fi + extra_args+=" --set global.domain=${n8n_domain}" + if [ -n "${LETSENCRYPT_EMAIL:-}" ]; then + extra_args+=" --set global.email=${LETSENCRYPT_EMAIL}" + fi + extra_args+=" --set n8n.config.N8N_HOST=${n8n_domain}" + extra_args+=" --set n8n.config.WEBHOOK_URL=https://${n8n_domain}/" + extra_args+=" --set ingress.hosts[0].host=${n8n_domain}" + extra_args+=" --set ingress.tls[0].hosts[0]=${n8n_domain}" + extra_args+=" --set networkPolicy.ingress[0].from[0].namespaceSelector.matchLabels.name=infra" + fi + + # Nextcloud: derive domain from env to replace DOMAIN_PLACEHOLDER + if [[ "$rn" == "nextcloud" ]]; then + local nextcloud_domain="${NEXTCLOUD_DOMAIN:-}" + if [ -z "$nextcloud_domain" ] && [ -n "${BASE_DOMAIN:-}" ]; then + nextcloud_domain="nextcloud.${BASE_DOMAIN}" + fi + if [ -z "$nextcloud_domain" ]; then + log_error "Nextcloud deployment requires NEXTCLOUD_DOMAIN or BASE_DOMAIN in .env to derive the ingress host." + return 1 + fi + extra_args+=" --set global.domain=${nextcloud_domain}" + if [ -n "${LETSENCRYPT_EMAIL:-}" ]; then + extra_args+=" --set global.email=${LETSENCRYPT_EMAIL}" + fi + extra_args+=" --set nextcloud.config.NEXTCLOUD_HOST=${nextcloud_domain}" + extra_args+=" --set nextcloud.config.NEXTCLOUD_TRUSTED_DOMAINS=${nextcloud_domain}" + extra_args+=" --set ingress.hosts[0].host=${nextcloud_domain}" + extra_args+=" --set ingress.tls[0].hosts[0]=${nextcloud_domain}" + extra_args+=" --set networkPolicy.ingress[0].from[0].namespaceSelector.matchLabels.name=infra" + fi + + # Matomo: derive domain & tracking host from env to replace DOMAIN_PLACEHOLDER + if [[ "$rn" == "matomo" ]]; then + local matomo_domain="${MATOMO_DOMAIN:-}" + if [ -z "$matomo_domain" ] && [ -n "${BASE_DOMAIN:-}" ]; then + matomo_domain="matomo.${BASE_DOMAIN}" + fi + if [ -z "$matomo_domain" ]; then + log_error "Matomo deployment requires MATOMO_DOMAIN or BASE_DOMAIN in .env to derive the ingress host." + return 1 + fi + # WordPress tracking site host for Matomo (defaults to WordPress domain) + local wp_tracking_host="${WP_DOMAIN:-}" + if [ -z "$wp_tracking_host" ] && [ -n "${BASE_DOMAIN:-}" ]; then + wp_tracking_host="wordpress.${BASE_DOMAIN}" + fi + # Database auth for MariaDB used by Matomo + local matomo_db_root_password="${MATOMO_DB_ROOT_PASSWORD:-}" + local matomo_db_password="${MATOMO_DB_PASSWORD:-$matomo_db_root_password}" + if [ -z "$matomo_db_root_password" ]; then + log_error "MATOMO_DB_ROOT_PASSWORD must be set in cli/.env (use a strong alphanumeric string) for Matomo MariaDB." + return 1 + fi + # Override ingress + global + website host and DB auth values + extra_args+=" --set global.domain=${matomo_domain}" + if [ -n "${LETSENCRYPT_EMAIL:-}" ]; then + extra_args+=" --set global.email=${LETSENCRYPT_EMAIL}" + extra_args+=" --set matomo.admin.email=${LETSENCRYPT_EMAIL}" + fi + extra_args+=" --set mariadb.auth.rootPassword=${matomo_db_root_password}" + extra_args+=" --set mariadb.auth.password=${matomo_db_password}" + extra_args+=" --set ingress.hosts[0].host=${matomo_domain}" + extra_args+=" --set ingress.tls[0].hosts[0]=${matomo_domain}" + extra_args+=" --set networkPolicy.ingress[0].from[0].namespaceSelector.matchLabels.name=infra" + if [ -n "$wp_tracking_host" ]; then + extra_args+=" --set matomo.website.host=${wp_tracking_host}" + fi + fi + + # Vaultwarden: derive domain from env and align NetworkPolicy ingress + if [[ "$rn" == "vaultwarden" ]]; then + local vaultwarden_domain="${VAULTWARDEN_DOMAIN:-}" + local vaultwarden_subdomain="${VAULTWARDEN_SUBDOMAIN:-vault}" + if [ -z "$vaultwarden_domain" ] && [ -n "${BASE_DOMAIN:-}" ]; then + vaultwarden_domain="${BASE_DOMAIN}" + fi + if [ -z "$vaultwarden_domain" ]; then + log_error "Vaultwarden deployment requires VAULTWARDEN_DOMAIN or BASE_DOMAIN in .env to derive the domain." + return 1 + fi + extra_args+=" --set global.domain=${vaultwarden_domain}" + extra_args+=" --set global.subdomain=${vaultwarden_subdomain}" + if [ -n "${LETSENCRYPT_EMAIL:-}" ]; then + extra_args+=" --set certManager.email=${LETSENCRYPT_EMAIL}" + fi + extra_args+=" --set vaultwarden.domain=https://${vaultwarden_subdomain}.${vaultwarden_domain}" + extra_args+=" --set security.networkPolicy.ingress[0].from[0].namespaceSelector.matchLabels.name=infra" + fi + + log_info "Processing $display_name ($release_name)..." + log_info "Helm extra args: ${extra_args:-}" + + # Call helm deploy + # Note: extra_args handling needs to be passed to deploy_chart or handled here + # We'll modify deploy_chart to accept extra args in a future refactor, + # for now, we just append to the helm command line inside the function via a hack or direct call. + + # Direct call for flexibility + local cmd="helm upgrade --install $release_name $resolved_chart --namespace $ns --create-namespace $extra_args" + if [ -n "$resolved_values" ]; then + cmd="$cmd -f $resolved_values" + fi + + log_info "Executing: $cmd" + if eval $cmd; then + log_success "$display_name Installed." + + # Post-deploy status logs + log_info "Helm status for $release_name (namespace: $ns):" + helm status "$release_name" -n "$ns" || log_warn "helm status failed for $release_name in $ns" + + echo + log_info "Kubernetes resources in namespace '$ns' after deploying $release_name:" + kubectl get pods,svc,ingress -n "$ns" || log_warn "kubectl get pods/svc/ingress failed for namespace $ns" + + # Optional: DigitalOcean cluster status (if doctl & CLUSTER_NAME are available) + if command -v doctl >/dev/null 2>&1 && [ -n "${CLUSTER_NAME:-}" ]; then + echo + log_info "DigitalOcean cluster status for $CLUSTER_NAME:" + doctl kubernetes cluster get "$CLUSTER_NAME" \ + --format Name,Status,Region,Version,NodePools --no-header \ + || log_warn "doctl cluster get failed for $CLUSTER_NAME" + fi + else + log_error "$display_name Installation Failed." + fi +} diff --git a/cli/lib/styles.sh b/cli/lib/styles.sh new file mode 100644 index 0000000..f66e96b --- /dev/null +++ b/cli/lib/styles.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# ANSI Color Codes +export BLACK='\033[0;30m' +export RED='\033[0;31m' +export GREEN='\033[0;32m' +export YELLOW='\033[0;33m' +export BLUE='\033[0;34m' +export PURPLE='\033[0;35m' +export CYAN='\033[0;36m' +export WHITE='\033[0;37m' +export BOLD='\033[1m' +export NC='\033[0m' # No Color + +# Banner Function +show_banner() { + clear + echo -e "${CYAN}${BOLD}" + cat << "EOF" + _ __ ____ + | | / /__ / __ \_ ______ + | | /| / / _ \ / / / / | /| / / __ \ + | |/ |/ / __/ / /_/ /| |/ |/ / / / / + |__/|__/\___/ \____/ |__/|__/_/ /_/ + + DigitalOcean K8s Deployer +EOF + echo -e "${NC}" + echo -e "${PURPLE}:: Extensive CLI for custom deployment of WeOwn AI Infrastructure ::${NC}" + echo -e "${BLUE}:: Managing Clusters, Droplets & Helm Stacks for weown ai ecosystem ::${NC}" + echo +} + +show_cli_help() { + echo -e "${BOLD}WeOwn CLI navigation:${NC}" + echo " 1) Manage Kubernetes Cluster (Droplets)" + echo " - List, scale, create or delete node pools on your DOKS cluster" + echo " - Delete the entire Kubernetes cluster (with confirmation prompts)" + echo " 2) Deploy Solutions (Stacks)" + echo " - Deploy infra (Ingress, cert-manager, ExternalDNS, monitoring)" + echo " - Deploy apps (WordPress, Matomo, n8n, AnythingLLM, etc.) into namespaces" + echo " - Uses settings from cli/.env (DO_TOKEN, CLUSTER_NAME, BASE_DOMAIN, *DOMAIN, passwords)" + echo " 3) Deploy Apps on Droplets" + echo " - Deploy WordPress (and later other apps) directly on DigitalOcean Droplets" + echo " - Uses doctl and DigitalOcean DNS to create droplets and A records" + echo " 4) List Deployments" + echo " - Show Helm releases and namespaces on the current cluster" + echo " 5) Check Infrastructure Status" + echo " - Verify doctl/kubectl connectivity, nodes, and pods across namespaces" + echo +} + +# Helper logging +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Interactive Menu Helper +# Usage: checkbox_menu "Prompt" "Option1" "Option2" ... +# Returns: Selected indices in SELECTED_INDICES array +checkbox_menu() { + local prompt="$1" + shift + local options=("$@") + local count=${#options[@]} + + clear + show_banner + echo -e "${BOLD}$prompt${NC}" + echo + + # Print numbered options + local i + for ((i=0; i= 0 && idx < count )); then + SELECTED_INDICES+=("$idx") + fi + fi + done +} diff --git a/cli/weown b/cli/weown new file mode 100755 index 0000000..f2d6f98 --- /dev/null +++ b/cli/weown @@ -0,0 +1,125 @@ +#!/bin/bash + +# WeOwn CLI - Main Entry Point + +# Path Setup +CLI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$CLI_ROOT/lib" + +# Load Libraries +source "$LIB_DIR/styles.sh" +source "$LIB_DIR/do_k8s.sh" +source "$LIB_DIR/helm_utils.sh" +source "$LIB_DIR/stacks.sh" +source "$LIB_DIR/droplet_apps.sh" + +# Main Loop +while true; do + show_banner + + echo -e "${BOLD}Cluster Context:${NC} $(kubectl config current-context 2>/dev/null || echo 'None')" + echo -e "${BOLD}Project:${NC} ${PROJECT_NAME:-Unknown} | ${BOLD}Domain:${NC} ${BASE_DOMAIN:-Unknown}" + echo + + show_cli_help + + echo "Select an operation:" + options=( + "Manage Kubernetes Cluster (Droplets)" + "Deploy Solutions (Stacks)" + "Deploy Apps on Droplets" + "List Deployments" + "Check Infrastructure Status" + "Exit" + ) + + # Simple Select for Main Menu + select opt in "${options[@]}"; do + case $opt in + "Manage Kubernetes Cluster (Droplets)") + # Node Pool CRUD + echo + log_info "Fetching node pools..." + list_node_pools + echo + echo "1) Scale Node Pool" + echo "2) Create Node Pool" + echo "3) Delete Node Pool" + echo "4) Delete Cluster" + echo "5) Back" + read -p "Choice: " pool_opt + case $pool_opt in + 1) + read -p "Pool Name: " p_name + read -p "New Node Count: " p_count + scale_node_pool "$CLUSTER_NAME" "$p_name" "$p_count" + ;; + 2) + read -p "Pool Name: " p_name + read -p "Size (e.g. s-2vcpu-4gb): " p_size + read -p "Count: " p_count + read -p "Labels (role=web): " p_labels + create_node_pool "$CLUSTER_NAME" "$p_name" "$p_size" "$p_count" "$p_labels" + ;; + 3) + read -p "Pool Name or ID to delete: " p_name + delete_node_pool "$CLUSTER_NAME" "$p_name" + ;; + 4) + delete_cluster "$CLUSTER_NAME" + ;; + esac + break + ;; + + "Deploy Solutions (Stacks)") + # Multi-select menu for Apps + app_names=() + for app in "${APPS[@]}"; do + IFS='|' read -r name rest <<< "$app" + app_names+=("$name") + done + + checkbox_menu "Select Solutions to Deploy:" "${app_names[@]}" + + if [ ${#SELECTED_INDICES[@]} -eq 0 ]; then + log_warn "No solutions selected." + else + log_info "Deploying selected solutions..." + for idx in "${SELECTED_INDICES[@]}"; do + install_selection "$idx" + done + fi + read -p "Press Enter to continue..." + break + ;; + "Deploy Apps on Droplets") + droplet_apps_menu + read -p "Press Enter to continue..." + break + ;; + + "List Deployments") + list_deployments + read -p "Press Enter to continue..." + break + ;; + + "Check Infrastructure Status") + check_doctl + ensure_cluster "$CLUSTER_NAME" + kubectl get nodes + echo + kubectl get pods -A + read -p "Press Enter to continue..." + break + ;; + + "Exit") + echo "Thanks for using WeOwn CLI, do share and onboard sovereignty for your infra" + exit 0 + ;; + *) echo "Invalid option";; + esac + done +done diff --git a/n8n/deploy.sh b/n8n/deploy.sh index f879655..d986823 100755 --- a/n8n/deploy.sh +++ b/n8n/deploy.sh @@ -229,65 +229,34 @@ check_prerequisites() { install_ingress_nginx() { log_step "Checking NGINX Ingress Controller..." - if kubectl get deployment ingress-nginx-controller -n ingress-nginx &> /dev/null; then - log_success "NGINX Ingress Controller already installed" + # Prefer shared ingress-nginx installed by infra add-ons in namespace 'infra' + if kubectl get svc ingress-nginx-controller -n infra &> /dev/null; then + log_success "Using shared ingress-nginx controller in namespace 'infra'" return 0 fi - - log_info "Installing NGINX Ingress Controller..." - if ! kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.0/deploy/static/provider/cloud/deploy.yaml; then - log_error "Failed to install NGINX Ingress Controller" - log_info "You can install it manually with:" - log_info " kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.0/deploy/static/provider/cloud/deploy.yaml" - exit 1 - fi - - log_info "Waiting for NGINX Ingress Controller to be ready (this may take a few minutes)..." - if ! kubectl wait --namespace ingress-nginx \ - --for=condition=ready pod \ - --selector=app.kubernetes.io/component=controller \ - --timeout=300s 2>/dev/null; then - log_warning "Ingress controller is taking longer than expected..." - log_info "Checking status..." - kubectl get pods -n ingress-nginx - log_info "The deployment will continue. Check ingress-nginx namespace for status." + + # Fallback: check legacy ingress-nginx namespace (not recommended for new installs) + if kubectl get deployment ingress-nginx-controller -n ingress-nginx &> /dev/null; then + log_success "NGINX Ingress Controller already installed in namespace 'ingress-nginx'" + return 0 fi - - # Ensure proper namespace labeling for NetworkPolicy - kubectl label namespace ingress-nginx name=ingress-nginx --overwrite 2>/dev/null || true - - log_success "NGINX Ingress Controller installed and configured" + + log_info "NGINX Ingress Controller not found. Please run ./scripts/03_infra_addons.sh to install shared ingress-nginx before deploying n8n." + exit 1 } # cert-manager installation install_cert_manager() { log_step "Checking cert-manager..." - if kubectl get deployment cert-manager -n cert-manager &> /dev/null; then - log_success "cert-manager already installed" + # Prefer shared cert-manager and ClusterIssuer created by infra add-ons + if kubectl get clusterissuer letsencrypt-prod &> /dev/null; then + log_success "Using shared cert-manager / ClusterIssuer 'letsencrypt-prod'" return 0 fi - - log_info "Installing cert-manager..." - if ! kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml; then - log_error "Failed to install cert-manager" - log_info "You can install it manually with:" - log_info " kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml" - exit 1 - fi - - log_info "Waiting for cert-manager to be ready (this may take a few minutes)..." - if ! kubectl wait --namespace cert-manager \ - --for=condition=ready pod \ - --selector=app.kubernetes.io/component=controller \ - --timeout=300s 2>/dev/null; then - log_warning "cert-manager is taking longer than expected..." - log_info "Checking status..." - kubectl get pods -n cert-manager - log_info "The deployment will continue. Check cert-manager namespace for status." - fi - - log_success "cert-manager installed and ready" + + log_info "cert-manager / ClusterIssuer not detected. Please run ./scripts/03_infra_addons.sh before deploying n8n." + exit 1 } # Get external IP @@ -298,7 +267,7 @@ get_external_ip() { local attempt=1 while [[ $attempt -le $max_attempts ]]; do - EXTERNAL_IP=$(kubectl get service ingress-nginx-controller -n ingress-nginx \ + EXTERNAL_IP=$(kubectl get service ingress-nginx-controller -n infra \ -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "") if [[ -n "$EXTERNAL_IP" && "$EXTERNAL_IP" != "null" ]]; then @@ -318,6 +287,77 @@ get_external_ip() { exit 1 } +validate_dns() { + log_step "Validating DNS configuration for Let's Encrypt..." + + if [[ -z "${DOMAIN:-}" ]]; then + log_warning "DOMAIN is not set; skipping DNS validation." + return 0 + fi + + if [[ -z "${EXTERNAL_IP:-}" || "${EXTERNAL_IP}" == "null" ]]; then + log_warning "EXTERNAL_IP not detected; skipping DNS validation." + log_warning "Ensure your DNS A record for $DOMAIN points to the ingress LoadBalancer IP." + return 0 + fi + + local resolver="" + if command -v dig >/dev/null 2>&1; then + resolver="dig" + elif command -v host >/dev/null 2>&1; then + resolver="host" + elif command -v nslookup >/dev/null 2>&1; then + resolver="nslookup" + else + log_warning "No DNS tools (dig/host/nslookup) found; skipping DNS validation." + return 0 + fi + + local dns_ips_raw="" + local dns_ips_list="" + + if [[ "$resolver" == "dig" ]]; then + dns_ips_raw="$( { dig +short A "$DOMAIN" ; dig +short AAAA "$DOMAIN"; } 2>/dev/null || true )" + dns_ips_list="$(echo "$dns_ips_raw" | grep -E '(^[0-9]+(\.[0-9]+){3}$)|(^[0-9a-fA-F:]+$)' || true)" + elif [[ "$resolver" == "host" ]]; then + dns_ips_raw="$(host "$DOMAIN" 2>/dev/null || true)" + dns_ips_list="$(echo "$dns_ips_raw" | awk '/has address/ {print $NF} /IPv6 address/ {print $NF}')" + else + dns_ips_raw="$(nslookup "$DOMAIN" 2>/dev/null || true)" + dns_ips_list="$(echo "$dns_ips_raw" | awk '/Address:/ {print $2}')" + fi + + if [[ -z "$dns_ips_list" ]]; then + log_error "Domain '$DOMAIN' does not have any A/AAAA records yet." + echo + echo "Expected configuration:" + echo " • Create an A record: $DOMAIN → $EXTERNAL_IP" + echo " • Wait for DNS propagation, then re-run ./deploy.sh" + exit 1 + fi + + local matched=0 + for ip in $dns_ips_list; do + if [[ "$ip" == "$EXTERNAL_IP" ]]; then + matched=1 + break + fi + done + + if [[ "$matched" -eq 1 ]]; then + log_success "Domain '$DOMAIN' correctly resolves to $EXTERNAL_IP." + return 0 + fi + + log_error "Domain '$DOMAIN' resolves to: $dns_ips_list (expected: $EXTERNAL_IP)" + echo + echo "This will likely cause Let's Encrypt HTTP-01 challenges to fail." + echo "Fix DNS so that:" + echo " • A record: $DOMAIN → $EXTERNAL_IP" + echo "Then re-run ./deploy.sh." + exit 1 +} + # Interactive configuration interactive_config() { if [[ -n "$DOMAIN" && -n "$EMAIL" ]]; then @@ -332,7 +372,7 @@ interactive_config() { # Subdomain and domain configuration local subdomain="" - local base_domain="" + local base_domain="${BASE_DOMAIN:-}" while [[ -z "$subdomain" ]]; do echo -e "${BLUE}Enter the subdomain for your n8n installation:${NC}" @@ -505,7 +545,7 @@ detect_external_ip() { local attempt=1 while [[ $attempt -le $max_attempts ]]; do - EXTERNAL_IP=$(kubectl get service ingress-nginx-controller -n ingress-nginx -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "") + EXTERNAL_IP=$(kubectl get service ingress-nginx-controller -n infra -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "") if [[ -n "$EXTERNAL_IP" && "$EXTERNAL_IP" != "null" ]]; then log_success "External IP detected: $EXTERNAL_IP" diff --git a/wordpress/deploy.sh b/wordpress/deploy.sh index a611cb3..548dbfc 100755 --- a/wordpress/deploy.sh +++ b/wordpress/deploy.sh @@ -594,11 +594,17 @@ check_prerequisites() { # Infrastructure setup setup_infrastructure() { - log_step "Setting Up Infrastructure Prerequisites" - - # Get cluster name for LoadBalancer naming (extract from current context) - local cluster_context=$(kubectl config current-context) - local cluster_name=$(echo "$cluster_context" | sed 's/.*@//' | sed 's/do-.*-k8s-//' | head -c 20) + log_step "Setting Up Infrastructure Prerequisites" + + if kubectl get svc ingress-nginx-controller -n infra >/dev/null 2>&1 \ + && kubectl get clusterissuer letsencrypt-prod >/dev/null 2>&1; then + log_substep "Using existing ingress-nginx controller and ClusterIssuer 'letsencrypt-prod' (shared infra); skipping local installation" + return 0 + fi + + # Get cluster name for LoadBalancer naming (extract from current context) + local cluster_context=$(kubectl config current-context) + local cluster_name=$(echo "$cluster_context" | sed 's/.*@//' | sed 's/do-.*-k8s-//' | head -c 20) # Check and install NGINX Ingress Controller log_substep "Checking NGINX Ingress Controller..." diff --git a/wordpress/helm/templates/secrets.yaml b/wordpress/helm/templates/secrets.yaml index 441aafb..d0dc485 100644 --- a/wordpress/helm/templates/secrets.yaml +++ b/wordpress/helm/templates/secrets.yaml @@ -7,8 +7,9 @@ metadata: {{- include "wordpress.labels" . | nindent 4 }} type: Opaque data: - # WordPress admin account created via installation wizard - + # WordPress admin account created via installation wizard ( to extensively define the visa) + wordpress-password: {{ .Values.wordpress.wordpressPassword | default "" | b64enc | quote }} + # MariaDB passwords (generated by deployment script) {{- if .Values.mariadb.enabled }} mariadb-root-password: {{ .Values.mariadb.auth.rootPassword | b64enc | quote }} @@ -32,3 +33,4 @@ data: wp-secure-auth-salt: {{ .Values.wordpress.security.secureAuthSalt | default "GENERATED_SECURE_AUTH_SALT" | b64enc | quote }} wp-logged-in-salt: {{ .Values.wordpress.security.loggedInSalt | default "GENERATED_LOGGED_IN_SALT" | b64enc | quote }} wp-nonce-salt: {{ .Values.wordpress.security.nonceSalt | default "GENERATED_NONCE_SALT" | b64enc | quote }} + \ No newline at end of file