#!/usr/bin/env bash set -euo pipefail PROG="init_gitlab" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1090 source "$SCRIPT_DIR/prole_cfg.sh" MODE="$(prole_normalize_mode "${PROLE_MODE:-${DEPLOYMENT_MODE:-k3d}}")" # NAMESPACE: init_gitlab.sh ALWAYS targets 'gitlab' unless explicitly overridden. # Do NOT fall back to $NAMESPACE (may be 'knoe-db' or 'gitea' from other pipeline steps). NAMESPACE="${GITLAB_NAMESPACE:-gitlab}" CFG_PATH="" FORCE=0 # NODE_SELECTOR intentionally blank: only gitaly+minio are pinned (via STORAGE_NODE). # Setting this would pin ALL global components to one node, exhausting RAM. NODE_SELECTOR="" usage() { cat < Deployment mode (default: ${MODE:-k3d}) -n, --namespace Target namespace (default: gitlab) -c, --config Path to config file (defaults to detected) --node-selector Node to pin GitLab workloads (default: gandalf.prole.org) --force Remove existing GitLab and Gitea releases before deploy --help Show this help Behavior: - Removes any pre-existing GitLab-domain Gitea configurations (helm release, namespace, Kong routes) when --force is specified or when gitea is detected. - Installs the GitLab Operator via Helm into the gitlab namespace. - Provisions a GitLab CR that uses the knoe-db CloudNativePG cluster as its external PostgreSQL data store. - Configures the GitLab public hostname as Kubernetes ingress pointing to GitLab. - Pins all GitLab workloads to the selected node (default: gandalf.prole.org). EOF } die() { echo "[ERROR] $*" >&2; exit 2; } log() { echo "[INFO] $*"; } warn() { echo "[WARN] $*" >&2; } is_truthy() { case "${1:-}" in 1|true|TRUE|True|yes|YES|on|ON|y|Y) return 0 ;; *) return 1 ;; esac } assert_public_ingress_targeting() { local ingress_class="${1:-}" shift || true local hosts=("$@") if [[ "$MODE" != "k8s" ]]; then return 0 fi local app_ctx="${APP_CLUSTER_KUBECONTEXT:-${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}}" local db_ctx="${DB_CLUSTER_KUBECONTEXT:-}" local active_ctx="${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}" if [[ -z "$active_ctx" ]]; then active_ctx="$(kubectl config current-context 2>/dev/null || true)" fi local host_count=0 local h for h in "${hosts[@]}"; do [[ -n "${h:-}" ]] && host_count=$((host_count + 1)) done if [[ "$host_count" -gt 0 && -n "$db_ctx" && "$active_ctx" == "$db_ctx" ]]; then die "Refusing to render/apply GitLab public ingress in DB cluster context '${active_ctx}' (hosts: ${hosts[*]})." fi if [[ "$host_count" -gt 0 && -n "$app_ctx" && -n "$active_ctx" && "$active_ctx" != "$app_ctx" ]]; then die "GitLab public ingress must target APP cluster context '${app_ctx}', active context is '${active_ctx}'." fi local normalized_class="${ingress_class,,}" if [[ "$normalized_class" == traefik* ]] && ! is_truthy "${ALLOW_TRAEFIK_PUBLIC_INGRESS:-${GITLAB_ALLOW_TRAEFIK_INGRESS:-0}}"; then die "Ingress class '${ingress_class}' is incompatible with k8s mode unless Traefik public ingress is explicitly enabled." fi } assert_unique_ingress_host_claims() { local ingress_name="${1:-}" local ingress_namespace="${2:-}" local host_csv="${3:-}" [[ -n "$host_csv" ]] || return 0 if ! python3 - "$host_csv" "$ingress_namespace" "$ingress_name" <<'PY' import json import subprocess import sys requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()} target_ns = sys.argv[2] target_name = sys.argv[3] try: raw = subprocess.check_output(["kubectl", "get", "ingress", "-A", "-o", "json"], text=True) except Exception: raise SystemExit(0) if not raw.strip(): raise SystemExit(0) payload = json.loads(raw) conflicts: list[str] = [] for item in payload.get("items", []) or []: md = item.get("metadata", {}) or {} ns = (md.get("namespace") or "").strip() name = (md.get("name") or "").strip() if ns == target_ns and name == target_name: continue spec = item.get("spec", {}) or {} rules = spec.get("rules", []) or [] for rule in rules: host = (rule.get("host") or "").strip().lower() if not host or host not in requested_hosts: continue http = rule.get("http", {}) or {} paths = http.get("paths", []) or [{"path": "/"}] for path_item in paths: path = (path_item.get("path") or "/").strip() or "/" if path in {"/", ""}: conflicts.append(f"{host}{path} already owned by {ns}/{name}") if conflicts: raise SystemExit("; ".join(conflicts)) PY then die "Duplicate ingress host/path claim detected for GitLab ingress '${ingress_namespace}/${ingress_name}'." fi } while [[ $# -gt 0 ]]; do case "$1" in --mode) MODE="$(prole_normalize_mode "${2:-}")"; shift 2 ;; --mode=*) MODE="$(prole_normalize_mode "${1#*=}")"; shift 1 ;; -n|--namespace) NAMESPACE="${2:-}"; shift 2 ;; --namespace=*) NAMESPACE="${1#*=}"; shift 1 ;; -c|--config) CFG_PATH="${2:-}"; shift 2 ;; --config=*) CFG_PATH="${1#*=}"; shift 1 ;; --node-selector) NODE_SELECTOR="${2:-}"; shift 2 ;; --node-selector=*) NODE_SELECTOR="${1#*=}"; shift 1 ;; --force) FORCE=1; shift 1 ;; -h|--help) usage; exit 0 ;; --trace-namespace) # Resolve namespace then exit — used by diagnostic scripts only _TRACE_NS=1; shift 1 ;; *) break ;; esac done # --------------------------------------------------------------------------- # Resolve config path # --------------------------------------------------------------------------- if [[ -z "$CFG_PATH" && -n "${PROLE_CONF:-}" && -f "${PROLE_CONF}/prole.cfg" ]]; then CFG_PATH="$(_prole_cfg_select_cfg_file "${PROLE_CONF}")" fi if [[ -z "$CFG_PATH" ]]; then CFG_PATH="$(_prole_cfg_select_cfg_file "$SCRIPT_DIR/../conf")" fi # cfg-based namespace override (only GITLAB_NAMESPACE; GITOPS_NAMESPACE intentionally excluded) if [[ -z "$NAMESPACE" || "$NAMESPACE" == "gitlab" ]] && [[ -n "$CFG_PATH" ]]; then maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "GITLAB_NAMESPACE")" [[ -n "$maybe_ns" ]] && NAMESPACE="$maybe_ns" fi # Final safety net — always default to 'gitlab' NAMESPACE="${NAMESPACE:-gitlab}" export GITLAB_NAMESPACE="$NAMESPACE" # Diagnostic exit — used by tmp/sim_installer_ns.sh if [[ "${_TRACE_NS:-0}" == "1" ]]; then echo "TRACE: NAMESPACE='$NAMESPACE' GITLAB_NAMESPACE='$GITLAB_NAMESPACE'" >&2 echo "TRACE: env GITLAB_NAMESPACE_ENV='${GITLAB_NAMESPACE:-}' NAMESPACE_ENV_PRE='${PROLE_NAMESPACE:-}'" >&2 exit 0 fi case "$MODE" in k3d|k3s|k8s|local) ;; *) die "Unsupported mode '$MODE' (use k3d, k3s, k8s, or local)" ;; esac export PROLE_MODE="$MODE" command -v kubectl >/dev/null || die "kubectl not found" command -v helm >/dev/null || die "helm not found (required for GitLab Operator install)" # --------------------------------------------------------------------------- # knoe-db (CNPG) settings — reuse the same cluster gitea uses # --------------------------------------------------------------------------- DB_NAMESPACE="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db}}" CNPG_CLUSTER_NAME="${CNPG_CLUSTER_NAME:-${CLUSTER_NAME:-knoe-db}}" GITLAB_DB_NAME="${GITLAB_DB_NAME:-gitlabhq_production}" GITLAB_DB_USER="${GITLAB_DB_USER:-gitlab}" GITLAB_DB_PASSWORD="${GITLAB_DB_PASSWORD:-}" DB_HOST="${CNPG_CLUSTER_NAME}-rw.${DB_NAMESPACE}.svc.cluster.local" DB_PORT="${GITLAB_DB_PORT:-5432}" # --------------------------------------------------------------------------- # Redis — shared common service (deployed by init_redis.sh in knoe-system) # --------------------------------------------------------------------------- REDIS_NAMESPACE="${REDIS_NAMESPACE:-${SERVICE_NAMESPACE:-knoe-system}}" REDIS_HOST="${GITLAB_REDIS_HOST:-redis-master.${REDIS_NAMESPACE}.svc.cluster.local}" REDIS_PORT="${GITLAB_REDIS_PORT:-6379}" # GitLab public domain (configurable; default follows deployment mode) _default_gitlab_domain="git.prole.org" if [[ "$MODE" == "k8s" ]]; then _default_gitlab_domain="git.knoe.dev" fi GITLAB_DOMAIN="${GITLAB_DOMAIN:-${GITLAB_HOSTNAME:-$_default_gitlab_domain}}" GITLAB_PUBLIC_HOSTS_RAW="${GITLAB_PUBLIC_HOSTS:-$GITLAB_DOMAIN}" trim_csv_token() { local token="$1" token="${token#${token%%[![:space:]]*}}" token="${token%${token##*[![:space:]]}}" printf '%s' "$token" } _gitlab_hosts_csv="" GITLAB_PUBLIC_HOSTS=() IFS=',' read -r -a _gitlab_host_candidates <<< "$GITLAB_PUBLIC_HOSTS_RAW" for _gitlab_host in "${_gitlab_host_candidates[@]}"; do _gitlab_host="$(trim_csv_token "$_gitlab_host")" [[ -n "$_gitlab_host" ]] || continue case ",${_gitlab_hosts_csv}," in *,"${_gitlab_host}",*) ;; *) GITLAB_PUBLIC_HOSTS+=("$_gitlab_host") _gitlab_hosts_csv="${_gitlab_hosts_csv:+${_gitlab_hosts_csv},}${_gitlab_host}" ;; esac done unset _gitlab_host_candidates _gitlab_host if [[ ${#GITLAB_PUBLIC_HOSTS[@]} -eq 0 ]]; then GITLAB_PUBLIC_HOSTS=("$GITLAB_DOMAIN") fi PRIMARY_GITLAB_HOST="${GITLAB_PUBLIC_HOSTS[0]}" GITLAB_DOMAIN="$PRIMARY_GITLAB_HOST" PRIMARY_GITLAB_DOMAIN_ROOT="${PRIMARY_GITLAB_HOST#*.}" if [[ "$PRIMARY_GITLAB_DOMAIN_ROOT" == "$PRIMARY_GITLAB_HOST" || -z "$PRIMARY_GITLAB_DOMAIN_ROOT" ]]; then PRIMARY_GITLAB_DOMAIN_ROOT="prole.org" fi # Public ingress class (k8s/GKE defaults to gce; local clusters keep kong) _default_gitlab_ingress_class="kong" if [[ "$MODE" == "k8s" ]]; then _default_gitlab_ingress_class="gce" fi GITLAB_INGRESS_CLASS="${GITLAB_INGRESS_CLASS:-$_default_gitlab_ingress_class}" # Google Workspace OIDC — FRONTDOOR_HOST gates OmniAuth configuration. # On k3s: api.prole.org is the prole-auth SSO gateway (knoe-auth service). # Requires k8s secret 'gitlab-google-oidc' in GITLAB_NAMESPACE with Google # OAuth2 client credentials (client_id, client_secret, redirect_uri). # To disable OIDC: unset FRONTDOOR_HOST before running. _default_auth_hostname="api.prole.org" if [[ "$MODE" == "k8s" ]]; then _default_auth_hostname="api.knoe.dev" fi AUTH_HOSTNAME="${AUTH_HOSTNAME:-${FRONTDOOR_HOST:-${_default_auth_hostname}}}" FRONTDOOR_HOST="${FRONTDOOR_HOST:-$AUTH_HOSTNAME}" FRONTDOOR_AUTH_ENABLED="${FRONTDOOR_AUTH_ENABLED:-${AUTHORITY_ENABLED:-1}}" AUTH_VERIFY_PATH="${AUTH_VERIFY_PATH:-/auth/verify}" AUTH_LOGIN_PATH="${AUTH_LOGIN_PATH:-/auth/login}" AUTH_RESPONSE_HEADERS="${AUTH_RESPONSE_HEADERS:-X-Prole-User,X-Prole-Email,X-Prole-Groups}" AUTH_VERIFY_URL="${AUTH_VERIFY_URL:-https://${AUTH_HOSTNAME}${AUTH_VERIFY_PATH}}" AUTH_SIGNIN_URL="${AUTH_SIGNIN_URL:-https://${AUTH_HOSTNAME}${AUTH_LOGIN_PATH}?next=\$scheme://\$host\$escaped_request_uri}" GITLAB_RELEASE="gitlab" assert_public_ingress_targeting "$GITLAB_INGRESS_CLASS" "${GITLAB_PUBLIC_HOSTS[@]}" # --------------------------------------------------------------------------- # Garage S3 (knoe-system) — replaces embedded minio for object storage # --------------------------------------------------------------------------- GARAGE_NAMESPACE="${GARAGE_NAMESPACE:-knoe-system}" GARAGE_SVC_HOST="garage.${GARAGE_NAMESPACE}.svc.cluster.local" GARAGE_S3_KEY_NAME="${GARAGE_S3_KEY_NAME:-gitlab-s3}" # --------------------------------------------------------------------------- # Operator chart coordinates # --------------------------------------------------------------------------- OPERATOR_REPO_NAME="gitlab-operator" OPERATOR_REPO_URL="https://gitlab.com/api/v4/projects/18899486/packages/helm/stable" OPERATOR_CHART="gitlab-operator/gitlab-operator" OPERATOR_RELEASE="gitlab-operator" OPERATOR_NAMESPACE="${GITLAB_OPERATOR_NAMESPACE:-${NAMESPACE}}" # GitLab Helm chart version required in the CR; auto-detected if not set. GITLAB_CHART_VERSION="${GITLAB_CHART_VERSION:-}" GITLAB_CHART_REPO_NAME="gitlab" GITLAB_CHART_REPO_URL="https://charts.gitlab.io/" # --------------------------------------------------------------------------- # Helper: resolve knoe-db primary pod (same pattern as init_gitea.sh) # --------------------------------------------------------------------------- resolve_knoe_db_primary_pod() { local primary primary=$(kubectl -n "$DB_NAMESPACE" get pods \ -l "cnpg.io/cluster=${CNPG_CLUSTER_NAME},cnpg.io/instanceRole=primary" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [[ -z "$primary" ]]; then primary=$(kubectl -n "$DB_NAMESPACE" get pods \ -l "cnpg.io/cluster=${CNPG_CLUSTER_NAME}" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) fi printf '%s' "$primary" } sql_escape_literal() { printf '%s' "${1:-}" | sed "s/'/''/g" } # --------------------------------------------------------------------------- # Resolve GitLab DB password (pull from knoe-db-superuser secret as fallback) # --------------------------------------------------------------------------- resolve_gitlab_db_password() { if [[ -n "${GITLAB_DB_PASSWORD:-}" ]]; then return 0 fi local resolved="" if kubectl -n "$DB_NAMESPACE" get secret knoe-db-superuser >/dev/null 2>&1; then resolved=$(kubectl -n "$DB_NAMESPACE" get secret knoe-db-superuser \ -o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true) fi if [[ -n "$resolved" ]]; then GITLAB_DB_PASSWORD="$resolved" else # Generate a random password when none is available GITLAB_DB_PASSWORD="$(LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null | head -c 32 || true)" warn "Generated random GitLab DB password; store it in GITLAB_DB_PASSWORD for future runs." fi } # --------------------------------------------------------------------------- # Provision GitLab role + database in knoe-db # --------------------------------------------------------------------------- setup_knoe_db_for_gitlab() { local primary primary="$(resolve_knoe_db_primary_pod)" if [[ -z "$primary" ]]; then warn "No knoe-db pod found in namespace '${DB_NAMESPACE}'; skipping GitLab DB setup." return 0 fi resolve_gitlab_db_password local admin_user="" local candidate for candidate in postgres root; do if kubectl -n "$DB_NAMESPACE" exec "$primary" -c postgres -- \ psql -U "$candidate" -d postgres -tAc "SELECT 1" >/dev/null 2>&1; then admin_user="$candidate" break fi done if [[ -z "$admin_user" ]]; then warn "Unable to connect to knoe-db as admin; skipping GitLab DB setup." return 0 fi local esc_pw esc_pw="$(sql_escape_literal "$GITLAB_DB_PASSWORD")" kubectl -n "$DB_NAMESPACE" exec "$primary" -c postgres -- \ psql -U "$admin_user" -d postgres -c " DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='${GITLAB_DB_USER}') THEN CREATE ROLE ${GITLAB_DB_USER} LOGIN PASSWORD '${esc_pw}'; ELSE ALTER ROLE ${GITLAB_DB_USER} WITH PASSWORD '${esc_pw}'; END IF; END \$\$; " >/dev/null 2>&1 || warn "Could not create/update role '${GITLAB_DB_USER}'." local db_exists db_exists=$(kubectl -n "$DB_NAMESPACE" exec "$primary" -c postgres -- \ psql -U "$admin_user" -d postgres -tAc \ "SELECT 1 FROM pg_database WHERE datname='${GITLAB_DB_NAME}';" 2>/dev/null || true) if [[ "$db_exists" != "1" ]]; then kubectl -n "$DB_NAMESPACE" exec "$primary" -c postgres -- \ psql -U "$admin_user" -d postgres -c \ "CREATE DATABASE ${GITLAB_DB_NAME} OWNER ${GITLAB_DB_USER};" \ >/dev/null 2>&1 || warn "Could not create database '${GITLAB_DB_NAME}'." fi log "GitLab database '${GITLAB_DB_NAME}' prepared in knoe-db (ns=${DB_NAMESPACE})." } # --------------------------------------------------------------------------- # Clean up any pre-existing Gitea / git.prole.org configurations # --------------------------------------------------------------------------- cleanup_gitea() { log "Checking for pre-existing Gitea deployment to remove before GitLab install..." local gitea_ns="${GITEA_NAMESPACE:-gitea}" # Remove Gitea Helm release if helm -n "$gitea_ns" status gitea >/dev/null 2>&1; then log "Uninstalling Gitea Helm release from namespace '$gitea_ns'..." helm -n "$gitea_ns" uninstall gitea >/dev/null 2>&1 || true fi # Remove any leftover raw Gitea resources kubectl -n "$gitea_ns" delete deploy/gitea svc/gitea-http svc/gitea-ssh \ >/dev/null 2>&1 || true # Remove Kong routes/services registered for git.prole.org (best-effort) local kong_ns="${KONG_NAMESPACE:-${NAMESPACE:-kong}}" for res_type in kongplugins kongingresses; do kubectl -n "$gitea_ns" delete "$res_type" --all >/dev/null 2>&1 || true done # Remove gitea namespace Ingress objects that route git.prole.org kubectl -n "$gitea_ns" delete ingress \ -l "app.kubernetes.io/name=gitea" >/dev/null 2>&1 || true kubectl -n "$gitea_ns" delete ingress \ --field-selector="metadata.name=gitea" >/dev/null 2>&1 || true log "Gitea cleanup complete." } # --------------------------------------------------------------------------- # Ensure gitlab namespace exists # --------------------------------------------------------------------------- kubectl get ns "$NAMESPACE" >/dev/null 2>&1 || kubectl create namespace "$NAMESPACE" >/dev/null # --------------------------------------------------------------------------- # --force: remove existing GitLab operator and release first # --------------------------------------------------------------------------- if [[ "$FORCE" -eq 1 ]]; then warn "--force: removing existing GitLab resources in namespace '$NAMESPACE'..." if kubectl get crd gitlabs.apps.gitlab.com >/dev/null 2>&1; then kubectl -n "$NAMESPACE" delete gitlab "$GITLAB_RELEASE" >/dev/null 2>&1 || true # Wait briefly for operator to clean up managed resources sleep 10 fi helm -n "$NAMESPACE" uninstall "$GITLAB_RELEASE" >/dev/null 2>&1 || true helm -n "$NAMESPACE" uninstall "$OPERATOR_RELEASE" >/dev/null 2>&1 || true cleanup_gitea fi # --------------------------------------------------------------------------- # Always remove gitea if its helm release exists (non-destructive path) # gitea and gitlab both claim git.prole.org; they cannot coexist. # --------------------------------------------------------------------------- if helm -n "${GITEA_NAMESPACE:-gitea}" status gitea >/dev/null 2>&1; then warn "Gitea release detected — removing to free git.prole.org for GitLab..." cleanup_gitea fi # --------------------------------------------------------------------------- # Prepare knoe-db # --------------------------------------------------------------------------- setup_knoe_db_for_gitlab # Persist the DB password as a k8s Secret the operator CR can reference resolve_gitlab_db_password kubectl -n "$NAMESPACE" create secret generic gitlab-db-password \ --from-literal=password="$GITLAB_DB_PASSWORD" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null log "gitlab-db-password secret applied in namespace '$NAMESPACE'." # --------------------------------------------------------------------------- # Install / upgrade the GitLab Operator # --------------------------------------------------------------------------- log "Adding/updating GitLab Operator Helm repo..." helm repo add "$OPERATOR_REPO_NAME" "$OPERATOR_REPO_URL" 2>&1 || true helm repo update "$OPERATOR_REPO_NAME" 2>&1 || warn "helm repo update returned non-zero; continuing..." log "Installing GitLab Operator (release=${OPERATOR_RELEASE}, ns=${OPERATOR_NAMESPACE})..." # The operator needs cluster-scoped RBAC; it watches all namespaces by default. helm upgrade --install "$OPERATOR_RELEASE" "$OPERATOR_CHART" \ -n "$OPERATOR_NAMESPACE" \ --create-namespace \ --timeout 10m \ --wait \ --set watchNamespace="$NAMESPACE" log "GitLab Operator ready." # --------------------------------------------------------------------------- # Resolve GitLab chart version (required by the Operator CR since >= v0.28) # --------------------------------------------------------------------------- if [[ -z "$GITLAB_CHART_VERSION" ]]; then log "Auto-detecting latest GitLab chart version..." helm repo add "$GITLAB_CHART_REPO_NAME" "$GITLAB_CHART_REPO_URL" 2>&1 || true helm repo update "$GITLAB_CHART_REPO_NAME" 2>&1 || warn "gitlab chart repo update warning; continuing..." GITLAB_CHART_VERSION=$(helm search repo gitlab/gitlab --output table 2>/dev/null \ | awk 'NR==2{print $2}' || true) if [[ -z "$GITLAB_CHART_VERSION" ]]; then die "Cannot detect GitLab chart version. Set GITLAB_CHART_VERSION explicitly (e.g. export GITLAB_CHART_VERSION=8.9.1) and re-run." fi log "Auto-detected GitLab chart version: ${GITLAB_CHART_VERSION}" fi CHART_VERSION_YAML="version: \"${GITLAB_CHART_VERSION}\"" # --------------------------------------------------------------------------- # Determine node selector block for the GitLab CR # --------------------------------------------------------------------------- # Storage node for gitaly + minio only (local PVs require co-location) # Other components spread across the cluster via default scheduler. STORAGE_NODE="${STORAGE_NODE:-${GITLAB_STORAGE_NODE:-gandalf.prole.org}}" # NODE_SELECTOR intentionally NOT defaulted — only storage components get pinned NODE_SELECTOR="${NODE_SELECTOR:-}" NODE_SELECTOR_YAML="" if [[ -n "$NODE_SELECTOR" ]]; then log "Pinning all GitLab workloads to node: ${NODE_SELECTOR}" NODE_SELECTOR_YAML="kubernetes.io/hostname: ${NODE_SELECTOR}" fi STORAGE_NODE_SELECTOR_YAML="kubernetes.io/hostname: ${STORAGE_NODE}" JEMALLOC_HOSTPATH_DIR="/opt/gitlab-jemalloc" JEMALLOC_HOSTPATH_LIB="${JEMALLOC_HOSTPATH_DIR}/libjemalloc.so.2" GITLAB_JEMALLOC_MODE="${GITLAB_JEMALLOC_MODE:-auto}" if is_truthy "${GITLAB_JEMALLOC_REQUIRED:-0}"; then GITLAB_JEMALLOC_MODE="force" fi JEMALLOC_HOSTPATH_SUPPORTED=1 JEMALLOC_HOSTPATH_REASON="" JEMALLOC_HOSTPATH_WANTED=0 JEMALLOC_HOSTPATH_ACTIVE=0 detect_jemalloc_hostpath_support() { local provider_ids gke_pool_labels gke_topology_labels os_images provider_ids=$(kubectl get nodes -o jsonpath='{range .items[*]}{.spec.providerID}{"\n"}{end}' 2>/dev/null || true) gke_pool_labels=$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.labels.cloud\.google\.com/gke-nodepool}{"\n"}{end}' 2>/dev/null || true) gke_topology_labels=$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.labels.topology\.gke\.io/zone}{"\n"}{end}' 2>/dev/null || true) os_images=$(kubectl get nodes -o jsonpath='{range .items[*]}{.status.nodeInfo.osImage}{"\n"}{end}' 2>/dev/null || true) if [[ "$provider_ids" == *"gce://"* || -n "${gke_pool_labels//[[:space:]]/}" || -n "${gke_topology_labels//[[:space:]]/}" ]]; then JEMALLOC_HOSTPATH_SUPPORTED=0 JEMALLOC_HOSTPATH_REASON="Detected GKE/GCE node metadata (providerID/labels)." return 0 fi if [[ "${os_images,,}" == *"container-optimized os"* ]]; then JEMALLOC_HOSTPATH_SUPPORTED=0 JEMALLOC_HOSTPATH_REASON="Detected Container-Optimized OS node image." fi } configure_jemalloc_hostpath_mode() { local mode_normalized="${GITLAB_JEMALLOC_MODE,,}" detect_jemalloc_hostpath_support case "$mode_normalized" in auto|"") if (( JEMALLOC_HOSTPATH_SUPPORTED == 1 )); then JEMALLOC_HOSTPATH_WANTED=1 else JEMALLOC_HOSTPATH_WANTED=0 warn "Skipping hostPath jemalloc optimization: ${JEMALLOC_HOSTPATH_REASON}" fi ;; off|false|0|disabled) JEMALLOC_HOSTPATH_WANTED=0 ;; force|required|on|true|1) JEMALLOC_HOSTPATH_WANTED=1 if (( JEMALLOC_HOSTPATH_SUPPORTED == 0 )); then die "HostPath jemalloc mode is forced but unsupported in this cluster: ${JEMALLOC_HOSTPATH_REASON} Disable jemalloc (GITLAB_JEMALLOC_MODE=off) or use a non-hostPath approach." fi ;; *) die "Unsupported GITLAB_JEMALLOC_MODE='${GITLAB_JEMALLOC_MODE}' (expected: auto, off, force)." ;; esac } # --------------------------------------------------------------------------- # Create the GitLab CR (operator reconciles this into the full deployment) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Apply gitlab-google-oidc secret (OIDC provider config for OmniAuth) # --------------------------------------------------------------------------- if [[ -n "$FRONTDOOR_HOST" ]]; then PLATFORM_DOMAIN="${PLATFORM_DOMAIN:-}" oidc_secret_tmpl="${SCRIPT_DIR}/../deploy/gcp/gke/gitlab-google-oidc-secret.example.yaml" if [[ -f "$oidc_secret_tmpl" ]]; then log "Applying gitlab-google-oidc secret (issuer=https://${FRONTDOOR_HOST}/auth) ..." FRONTDOOR_HOST="$FRONTDOOR_HOST" PLATFORM_DOMAIN="$PLATFORM_DOMAIN" \ envsubst < "$oidc_secret_tmpl" | kubectl apply -n "$NAMESPACE" -f - >/dev/null || \ warn "Could not apply gitlab-google-oidc secret; create it manually from deploy/gcp/gke/gitlab-google-oidc-secret.example.yaml" else warn "gitlab-google-oidc-secret.example.yaml not found; skipping OIDC secret (create it manually)." fi fi # --------------------------------------------------------------------------- # ARM64 / 16KB-page jemalloc fix — build glibc jemalloc on every node once # All RPi nodes use 16KB kernel pages; GitLab's bundled jemalloc is 4KB-only. # We compile a compatible jemalloc-5.3.0 with --with-lg-page=14 via DaemonSet. # --------------------------------------------------------------------------- setup_jemalloc_on_nodes() { local jlib="${JEMALLOC_HOSTPATH_LIB}" local ds_name="jemalloc-builder" if (( JEMALLOC_HOSTPATH_WANTED == 0 )); then log "HostPath jemalloc optimization disabled for this environment." if kubectl -n "$NAMESPACE" get ds "$ds_name" >/dev/null 2>&1; then log "Deleting existing ${ds_name} DaemonSet because hostPath jemalloc is disabled." kubectl -n "$NAMESPACE" delete ds "$ds_name" --ignore-not-found >/dev/null || \ warn "Could not delete ${ds_name} DaemonSet; continuing." fi JEMALLOC_HOSTPATH_ACTIVE=0 return 0 fi # Check if already present on all schedulable nodes local nodes nodes=$(kubectl get nodes --no-headers -o custom-columns=NAME:.metadata.name | tr '\n' ' ') local all_ready=true for node in $nodes; do result=$(kubectl -n "$NAMESPACE" run "jcheck-${node//\./-}" \ --image=ubuntu:22.04 --restart=Never --rm --attach --quiet \ --overrides="{\"spec\":{\"nodeName\":\"${node}\",\"tolerations\":[{\"operator\":\"Exists\"}],\"volumes\":[{\"name\":\"jlib\",\"hostPath\":{\"path\":\"${JEMALLOC_HOSTPATH_DIR}\",\"type\":\"DirectoryOrCreate\"}}],\"containers\":[{\"name\":\"c\",\"image\":\"ubuntu:22.04\",\"command\":[\"bash\",\"-c\",\"test -f /jlib/libjemalloc.so.2 && echo OK || echo MISSING\"],\"volumeMounts\":[{\"name\":\"jlib\",\"mountPath\":\"/jlib\"}]}]}}" 2>/dev/null || echo "MISSING") if [[ "$result" != *"OK"* ]]; then all_ready=false break fi done if $all_ready; then log "jemalloc-16k already present on all nodes — skipping build." JEMALLOC_HOSTPATH_ACTIVE=1 return 0 fi log "Deploying jemalloc-builder DaemonSet (compiles jemalloc-5.3.0 with --with-lg-page=14 on each node)..." local _tmpds _tmpds=$(mktemp /tmp/jemalloc-ds-XXXXXX.yaml) cat > "$_tmpds" <<'JEDS' apiVersion: apps/v1 kind: DaemonSet metadata: name: jemalloc-builder namespace: NAMESPACE_PLACEHOLDER labels: app: jemalloc-builder spec: selector: matchLabels: app: jemalloc-builder template: metadata: labels: app: jemalloc-builder spec: tolerations: - operator: Exists initContainers: - name: build image: ubuntu:22.04 command: - bash - -c - | set -e TARGET=/hostlib/libjemalloc.so.2 if [ -f "$TARGET" ]; then echo "Already built"; exit 0; fi apt-get update -qq && apt-get install -y -q gcc make wget bzip2 2>&1 cd /tmp wget -q -O jemalloc.tar.bz2 \ https://github.com/jemalloc/jemalloc/releases/download/5.3.0/jemalloc-5.3.0.tar.bz2 tar xjf jemalloc.tar.bz2 && cd jemalloc-5.3.0 ./configure --with-lg-page=14 --disable-stats --disable-prof --disable-fill 2>&1 make -j2 lib/libjemalloc.so.2 2>&1 cp lib/libjemalloc.so.2 "$TARGET" && chmod 755 "$TARGET" echo "Done: $(ls -lh $TARGET)" volumeMounts: - name: hostlib mountPath: /hostlib containers: - name: keepalive image: ubuntu:22.04 command: [bash, -c, "sleep infinity"] volumes: - name: hostlib hostPath: path: JEMALLOC_HOSTPATH_DIR_PLACEHOLDER type: DirectoryOrCreate JEDS sed -i.bak "s/NAMESPACE_PLACEHOLDER/${NAMESPACE}/g" "$_tmpds" sed -i.bak2 "s#JEMALLOC_HOSTPATH_DIR_PLACEHOLDER#${JEMALLOC_HOSTPATH_DIR}#g" "$_tmpds" kubectl apply -f "$_tmpds" rm -f "$_tmpds" "${_tmpds}.bak" "${_tmpds}.bak2" log "Waiting up to 20 minutes for jemalloc to build on all nodes..." local deadline=$((SECONDS + 1200)) while (( SECONDS < deadline )); do ready=$(kubectl -n "$NAMESPACE" get ds jemalloc-builder \ -o jsonpath='{.status.numberReady}' 2>/dev/null || echo 0) desired=$(kubectl -n "$NAMESPACE" get ds jemalloc-builder \ -o jsonpath='{.status.desiredNumberScheduled}' 2>/dev/null || echo 1) [[ "$ready" -ge "$desired" && "$desired" -gt 0 ]] && break log " jemalloc build progress: ${ready}/${desired} nodes ready..." sleep 30 done if [[ "$ready" -ge "$desired" && "$desired" -gt 0 ]]; then JEMALLOC_HOSTPATH_ACTIVE=1 log "jemalloc-16k ready on all nodes." return 0 fi JEMALLOC_HOSTPATH_ACTIVE=0 if [[ "${GITLAB_JEMALLOC_MODE,,}" == "force" || "${GITLAB_JEMALLOC_MODE,,}" == "required" || "${GITLAB_JEMALLOC_MODE,,}" == "1" || "${GITLAB_JEMALLOC_MODE,,}" == "true" || "${GITLAB_JEMALLOC_MODE,,}" == "on" ]]; then die "jemalloc-builder did not become ready (${ready}/${desired}) and jemalloc is required." fi kubectl -n "$NAMESPACE" delete ds "$ds_name" --ignore-not-found >/dev/null || \ warn "Could not delete ${ds_name} DaemonSet after timeout; continuing without jemalloc." warn "jemalloc-builder did not become ready (${ready}/${desired}); continuing without hostPath jemalloc optimization." } # --------------------------------------------------------------------------- # Garage S3 key + bucket provisioning for GitLab object storage # Creates a dedicated "gitlab-s3" key in Garage, provisions required buckets, # and writes the k8s secrets consumed by the GitLab CR. # --------------------------------------------------------------------------- setup_garage_for_gitlab() { log "Configuring Garage S3 (${GARAGE_NAMESPACE}) as GitLab object storage..." local garage_pod garage_pod=$(kubectl -n "$GARAGE_NAMESPACE" get pods -l app=garage \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [[ -z "$garage_pod" ]]; then warn "Garage pod not found in namespace '${GARAGE_NAMESPACE}'; skipping garage setup." return 0 fi # ---- Resolve or create the gitlab-s3 access key ---- # garage v1.x outputs plaintext (not JSON) for key list / key info / key create. # key list format: " GKxxxxxxxxx " # key create/info format includes lines: # "Key ID: GKxxxxxxxxx" # "Secret key: " local access_key secret_key existing_id key_output rotated_key_name existing_id=$(kubectl -n "$GARAGE_NAMESPACE" exec "$garage_pod" -- \ /garage key list 2>/dev/null \ | awk -v name="${GARAGE_S3_KEY_NAME}" '$2 == name {print $1; exit}' || true) if [[ -n "$existing_id" ]]; then log "Garage key '${GARAGE_S3_KEY_NAME}' already exists (id=${existing_id}); fetching info..." key_output=$(kubectl -n "$GARAGE_NAMESPACE" exec "$garage_pod" -- \ /garage key info "$existing_id" 2>/dev/null || true) else log "Creating Garage key '${GARAGE_S3_KEY_NAME}'..." key_output=$(kubectl -n "$GARAGE_NAMESPACE" exec "$garage_pod" -- \ /garage key create "$GARAGE_S3_KEY_NAME" 2>/dev/null || true) fi if [[ -z "$key_output" ]]; then warn "Could not create/retrieve Garage key '${GARAGE_S3_KEY_NAME}'; skipping garage setup." return 0 fi # Parse plaintext output — match "Key ID: GKxxx" and "Secret key: xxx" lines. # Also try JSON in case a future garage version changes format. access_key=$(printf '%s' "$key_output" | awk '/^Key ID:/{print $NF; exit}' || true) secret_key=$(printf '%s' "$key_output" | awk '/^Secret key:/{print $NF; exit}' || true) # JSON fallback if [[ -z "$access_key" ]]; then access_key=$(printf '%s' "$key_output" | python3 -c \ "import sys,json; k=json.load(sys.stdin); print(k.get('accessKeyId',''))" 2>/dev/null || true) fi if [[ -z "$secret_key" ]]; then secret_key=$(printf '%s' "$key_output" | python3 -c \ "import sys,json; k=json.load(sys.stdin); print(k.get('secretAccessKey',''))" 2>/dev/null || true) fi if [[ "$secret_key" == "(redacted)" ]]; then warn "Garage key '${GARAGE_S3_KEY_NAME}' secret is redacted; creating a fresh key for GitLab object storage." rotated_key_name="${GARAGE_S3_KEY_NAME}-$(date +%s)" key_output=$(kubectl -n "$GARAGE_NAMESPACE" exec "$garage_pod" -- \ /garage key create "$rotated_key_name" 2>/dev/null || true) access_key=$(printf '%s' "$key_output" | awk '/^Key ID:/{print $NF; exit}' || true) secret_key=$(printf '%s' "$key_output" | awk '/^Secret key:/{print $NF; exit}' || true) if [[ -z "$access_key" ]]; then access_key=$(printf '%s' "$key_output" | python3 -c \ "import sys,json; k=json.load(sys.stdin); print(k.get('accessKeyId',''))" 2>/dev/null || true) fi if [[ -z "$secret_key" ]]; then secret_key=$(printf '%s' "$key_output" | python3 -c \ "import sys,json; k=json.load(sys.stdin); print(k.get('secretAccessKey',''))" 2>/dev/null || true) fi if [[ -n "$access_key" && -n "$secret_key" && "$secret_key" != "(redacted)" ]]; then GARAGE_S3_KEY_NAME="$rotated_key_name" log "Using rotated Garage key '${GARAGE_S3_KEY_NAME}'." fi fi if [[ -z "$access_key" || -z "$secret_key" || "$secret_key" == "(redacted)" ]]; then warn "Could not extract credentials from Garage key output; skipping garage setup." warn "Raw output: ${key_output}" return 0 fi # ---- Create buckets and grant permissions ---- # --key takes the accessKeyId (not the name) in garage v1.x local buckets=( registry gitlab-artifacts-storage gitlab-lfs-storage gitlab-uploads-storage gitlab-packages-storage gitlab-dependency-proxy-storage gitlab-terraform-state gitlab-ci-secure-files ) for bucket in "${buckets[@]}"; do kubectl -n "$GARAGE_NAMESPACE" exec "$garage_pod" -- \ /garage bucket create "$bucket" 2>/dev/null || true # bucket name is positional; --read/--write/--owner are boolean flags kubectl -n "$GARAGE_NAMESPACE" exec "$garage_pod" -- \ /garage bucket allow "$bucket" --key "$access_key" \ --read --write --owner 2>/dev/null || true done log "Garage buckets provisioned for GitLab." # ---- Write k8s secrets consumed by the GitLab CR ---- # Rails object storage connection (artifacts, LFS, uploads, packages, etc.) kubectl -n "$NAMESPACE" create secret generic gitlab-object-storage \ --from-literal=connection="provider: AWS region: garage aws_access_key_id: ${access_key} aws_secret_access_key: ${secret_key} endpoint: 'http://${GARAGE_SVC_HOST}:3900' path_style: true" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null log "gitlab-object-storage secret applied." # Docker registry S3 storage driver config. # Use s3_v2 (AWS SDK v2) — s3_v1 has signature computation issues with # non-standard ports (Host: endpoint:3900) against Garage. SDK v2 always # uses SigV4; v4auth is not a recognised key and must be omitted. kubectl -n "$NAMESPACE" create secret generic gitlab-registry-storage \ --from-literal=config="s3_v2: accesskey: ${access_key} secretkey: ${secret_key} bucket: registry regionendpoint: http://${GARAGE_SVC_HOST}:3900 region: garage pathstyle: true" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null log "gitlab-registry-storage secret applied." } # --------------------------------------------------------------------------- # Static PV + synology directory setup for gitaly on gandalf # (no dynamic provisioner is available in the service cluster) # minio PV removed — object storage is provided by knoe-system/garage # --------------------------------------------------------------------------- setup_gitlab_storage() { local synology_node="${GITLAB_STORAGE_NODE:-gandalf.prole.org}" local base_path="${GITLAB_STORAGE_BASE:-/synology/d005/gitlab}" local gitaly_size="${GITLAB_GITALY_PV_SIZE:-50Gi}" log "Creating synology gitaly directory on ${synology_node}..." kubectl -n "$NAMESPACE" run gitlab-dirprep \ --image=alpine:latest --restart=Never --rm --attach \ --overrides="{\"spec\":{\"nodeName\":\"${synology_node}\",\"tolerations\":[{\"operator\":\"Exists\"}],\"volumes\":[{\"name\":\"s\",\"hostPath\":{\"path\":\"$(dirname ${base_path})\",\"type\":\"Directory\"}}],\"containers\":[{\"name\":\"dirprep\",\"image\":\"alpine:latest\",\"command\":[\"sh\",\"-c\",\"mkdir -p ${base_path}/gitaly && chmod 777 ${base_path}/gitaly && echo done\"],\"volumeMounts\":[{\"name\":\"s\",\"mountPath\":\"$(dirname ${base_path})\"}]}]}}" \ 2>&1 || warn "Could not create synology dirs (may already exist)" # A named no-provisioner StorageClass is required so the helm chart renders # storageClassName in the PVC template (an empty string is treated as falsy # by the chart and omitted, causing the PVC to use the cluster default). log "Ensuring gitlab-gitaly-static StorageClass (no-provisioner)..." kubectl apply -f - </dev/null; then warn "Timed out waiting for GitLab CR condition=Available." warn "The deployment may still be in progress. Check: kubectl -n ${NAMESPACE} describe gitlab ${GITLAB_RELEASE}" fi fi # --------------------------------------------------------------------------- # Public ingress for configured GitLab domain -> gitlab-webservice # The GitLab Operator creates the Ingress; this block ensures a stable public # class/host mapping if the CR-managed ingress is absent or not usable. # --------------------------------------------------------------------------- WEBSERVICE_SVC="${GITLAB_RELEASE}-webservice-default" GITLAB_INGRESS_RULES_YAML="" for _gitlab_host in "${GITLAB_PUBLIC_HOSTS[@]}"; do GITLAB_INGRESS_RULES_YAML+=$'\n'" - host: ${_gitlab_host} http: paths: - path: / pathType: Prefix backend: service: name: ${WEBSERVICE_SVC} port: number: 8181" done unset _gitlab_host GITLAB_AUTH_ANNOTATIONS_YAML="" case "${FRONTDOOR_AUTH_ENABLED}" in 1|true|TRUE|True|yes|YES|Yes|on|ON|On) GITLAB_AUTH_ANNOTATIONS_YAML=$(cat < ${WEBSERVICE_SVC}:8181 ..." assert_unique_ingress_host_claims "gitlab-kong-ingress" "$NAMESPACE" "$_gitlab_hosts_csv" kubectl apply -f - <