#!/usr/bin/env bash ######################################################################################### #### gitops-bootstrap.sh — Bootstrap GitOps on Kubernetes with Flux or ArgoCD #### #### Install, configure git source, sync applications, and validate deployments #### #### Requires: bash 4+, kubectl, git, flux CLI or argocd CLI #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./gitops-bootstrap.sh --install flux --repo git@github.com:org/infra.git #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail VERSION="1.00" # --- ANSI color variables (pre-initialized) --- RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" # --- Defaults --- RUN_MODE="" GITOPS_TOOL="${GITOPS_TOOL:-flux}" GIT_REPO="${GITOPS_REPO:-}" GIT_BRANCH="${GITOPS_BRANCH:-main}" GIT_PATH="${GITOPS_PATH:-./clusters/default}" NAMESPACE="${GITOPS_NAMESPACE:-}" KUBECONFIG_FILE="${KUBECONFIG:-}" KUBE_CTX="${KUBE_CONTEXT:-}" CONFIRM_YES=false VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # --- State --- readonly SCRIPT_NAME="${0##*/}" START_TIME=$(date +%s) # --- Source name used for flux commands --- SOURCE_NAME="main" KUSTOMIZATION_NAME="default" APP_NAME="" # --- Color setup --- setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' fi } # --- Logging --- log() { printf "%b\n" "${GREEN}✔${RESET} $*"; } warn() { printf "%b\n" "${YELLOW}⚠${RESET} $*" >&2; } err() { printf "%b\n" "${RED}✖${RESET} $*" >&2; } verbose() { [[ "$VERBOSE" == "true" ]] && printf "%b\n" "${DIM}▸ $*${RESET}" >&2; return 0; } die() { err "$*"; exit 1; } section_header() { printf "\n%b━━━ %s ━━━%b\n" "${BOLD}${BLUE}" "$1" "${RESET}" } field() { printf " %-22s %s\n" "$1:" "$2" } field_color() { printf " %-22s %b%s%b\n" "$1:" "$2" "$3" "${RESET}" } # --- Resolve namespace default based on tool --- resolve_namespace() { if [[ -z "$NAMESPACE" ]]; then if [[ "$GITOPS_TOOL" == "argocd" ]]; then NAMESPACE="argocd" else NAMESPACE="flux-system" fi fi } # --- kubectl wrapper --- kubectl_cmd() { local -a args=("kubectl") [[ -n "$KUBECONFIG_FILE" ]] && args+=("--kubeconfig" "$KUBECONFIG_FILE") [[ -n "$KUBE_CTX" ]] && args+=("--context" "$KUBE_CTX") "${args[@]}" "$@" } # --- Dependency checks --- require_kubectl() { command -v kubectl >/dev/null 2>&1 || die "kubectl is required but not found in PATH" } require_flux() { command -v flux >/dev/null 2>&1 || die "flux CLI is required but not found in PATH" } require_argocd() { command -v argocd >/dev/null 2>&1 || die "argocd CLI is required but not found in PATH" } require_git() { command -v git >/dev/null 2>&1 || die "git is required but not found in PATH" } # --- Confirm prompt --- confirm_action() { local prompt="${1:-Continue?}" if [[ "$CONFIRM_YES" == "true" ]]; then return 0 fi printf "%s [y/N] " "$prompt" read -r answer [[ "$answer" =~ ^[Yy]$ ]] || die "Aborted" } # --- Wait for pods ready in namespace --- wait_for_pods() { local ns="$1" local timeout="${2:-120}" log "Waiting for pods in namespace ${CYAN}${ns}${RESET} (timeout ${timeout}s)" local deadline=$(($(date +%s) + timeout)) while true; do local not_ready not_ready=$(kubectl_cmd get pods -n "$ns" --no-headers 2>/dev/null \ | grep -cvE 'Running|Completed|Succeeded' || true) if [[ "$not_ready" -eq 0 ]]; then local total total=$(kubectl_cmd get pods -n "$ns" --no-headers 2>/dev/null | wc -l) if [[ "$total" -gt 0 ]]; then log "All ${total} pod(s) ready in ${ns}" return 0 fi fi if [[ $(date +%s) -ge $deadline ]]; then warn "Timeout waiting for pods in ${ns}" kubectl_cmd get pods -n "$ns" --no-headers 2>/dev/null || true return 1 fi sleep 5 done } # ───────────────────────────────────────────────────────────────────── # Install # ───────────────────────────────────────────────────────────────────── do_install_flux() { section_header "Installing Flux" require_kubectl require_flux log "Running pre-flight checks" verbose "flux check --pre" flux check --pre || die "Flux pre-flight checks failed" log "Installing Flux components into namespace ${CYAN}${NAMESPACE}${RESET}" verbose "flux install --namespace=${NAMESPACE}" flux install --namespace="$NAMESPACE" wait_for_pods "$NAMESPACE" if [[ -n "$GIT_REPO" ]]; then log "Configuring GitRepository source" verbose "flux create source git ${SOURCE_NAME} --url=${GIT_REPO} --branch=${GIT_BRANCH} --namespace=${NAMESPACE}" flux create source git "$SOURCE_NAME" \ --url="$GIT_REPO" \ --branch="$GIT_BRANCH" \ --namespace="$NAMESPACE" log "Creating Kustomization" verbose "flux create kustomization ${KUSTOMIZATION_NAME} --source=${SOURCE_NAME} --path=${GIT_PATH} --namespace=${NAMESPACE} --prune=true" flux create kustomization "$KUSTOMIZATION_NAME" \ --source="$SOURCE_NAME" \ --path="$GIT_PATH" \ --namespace="$NAMESPACE" \ --prune=true fi section_header "Flux Installation Summary" field "Namespace" "$NAMESPACE" field "Git repository" "${GIT_REPO:-not configured}" field "Branch" "$GIT_BRANCH" field "Path" "$GIT_PATH" log "Flux installation complete" } do_install_argocd() { section_header "Installing Argo CD" require_kubectl local manifests_url="https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml" log "Creating namespace ${CYAN}${NAMESPACE}${RESET}" kubectl_cmd create namespace "$NAMESPACE" --dry-run=client -o yaml \ | kubectl_cmd apply -f - log "Applying Argo CD manifests" verbose "kubectl apply -n ${NAMESPACE} -f ${manifests_url}" kubectl_cmd apply -n "$NAMESPACE" -f "$manifests_url" wait_for_pods "$NAMESPACE" section_header "Argo CD Installation Summary" field "Namespace" "$NAMESPACE" field "Manifests" "$manifests_url" printf "\n" log "Retrieve initial admin password:" printf " %bkubectl -n %s get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d%b\n" \ "${CYAN}" "$NAMESPACE" "${RESET}" log "Argo CD installation complete" } do_install() { case "$GITOPS_TOOL" in flux) do_install_flux ;; argocd) do_install_argocd ;; *) die "Unknown GitOps tool: ${GITOPS_TOOL}. Use 'flux' or 'argocd'." ;; esac } # ───────────────────────────────────────────────────────────────────── # Status # ───────────────────────────────────────────────────────────────────── do_status_flux() { section_header "Flux Status" require_kubectl require_flux log "Sources" flux get sources all --namespace="$NAMESPACE" 2>/dev/null || warn "No sources found" printf "\n" log "Kustomizations" flux get kustomizations --namespace="$NAMESPACE" 2>/dev/null || warn "No kustomizations found" printf "\n" log "Helm releases" flux get helmreleases --all-namespaces 2>/dev/null || verbose "No helm releases found" } do_status_argocd() { section_header "Argo CD Status" require_kubectl log "Applications" kubectl_cmd get applications -n "$NAMESPACE" -o wide 2>/dev/null || warn "No applications found" printf "\n" log "App Projects" kubectl_cmd get appprojects -n "$NAMESPACE" -o wide 2>/dev/null || verbose "No app projects found" } do_status() { case "$GITOPS_TOOL" in flux) do_status_flux ;; argocd) do_status_argocd ;; *) die "Unknown GitOps tool: ${GITOPS_TOOL}" ;; esac section_header "Pod Status (${NAMESPACE})" kubectl_cmd get pods -n "$NAMESPACE" -o wide 2>/dev/null || warn "No pods found" } # ───────────────────────────────────────────────────────────────────── # Add Source # ───────────────────────────────────────────────────────────────────── do_add_source_flux() { section_header "Adding Git Source (Flux)" require_flux [[ -z "$GIT_REPO" ]] && die "--repo is required to add a source" log "Creating GitRepository source ${CYAN}${SOURCE_NAME}${RESET}" verbose "flux create source git ${SOURCE_NAME} --url=${GIT_REPO} --branch=${GIT_BRANCH} --namespace=${NAMESPACE}" flux create source git "$SOURCE_NAME" \ --url="$GIT_REPO" \ --branch="$GIT_BRANCH" \ --namespace="$NAMESPACE" log "Source added successfully" flux get sources git --namespace="$NAMESPACE" } do_add_source_argocd() { section_header "Adding Git Source (Argo CD)" require_argocd [[ -z "$GIT_REPO" ]] && die "--repo is required to add a source" log "Adding repository ${CYAN}${GIT_REPO}${RESET}" verbose "argocd repo add ${GIT_REPO}" argocd repo add "$GIT_REPO" || die "Failed to add repository" log "Repository added successfully" } do_add_source() { case "$GITOPS_TOOL" in flux) do_add_source_flux ;; argocd) do_add_source_argocd ;; *) die "Unknown GitOps tool: ${GITOPS_TOOL}" ;; esac } # ───────────────────────────────────────────────────────────────────── # Sync / Reconcile # ───────────────────────────────────────────────────────────────────── do_sync_flux() { section_header "Reconciling (Flux)" require_flux log "Reconciling source git/${SOURCE_NAME}" verbose "flux reconcile source git ${SOURCE_NAME} --namespace=${NAMESPACE}" flux reconcile source git "$SOURCE_NAME" --namespace="$NAMESPACE" log "Reconciling kustomization ${KUSTOMIZATION_NAME}" verbose "flux reconcile kustomization ${KUSTOMIZATION_NAME} --namespace=${NAMESPACE}" flux reconcile kustomization "$KUSTOMIZATION_NAME" --namespace="$NAMESPACE" log "Reconciliation triggered" } do_sync_argocd() { section_header "Syncing (Argo CD)" require_argocd if [[ -n "$APP_NAME" ]]; then log "Syncing application ${CYAN}${APP_NAME}${RESET}" verbose "argocd app sync ${APP_NAME}" argocd app sync "$APP_NAME" else log "Syncing all applications" local apps apps=$(kubectl_cmd get applications -n "$NAMESPACE" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true) if [[ -z "$apps" ]]; then warn "No applications found to sync" return fi while IFS= read -r app; do [[ -z "$app" ]] && continue log "Syncing ${app}" argocd app sync "$app" || warn "Failed to sync ${app}" done <<< "$apps" fi log "Sync triggered" } do_sync() { case "$GITOPS_TOOL" in flux) do_sync_flux ;; argocd) do_sync_argocd ;; *) die "Unknown GitOps tool: ${GITOPS_TOOL}" ;; esac } # ───────────────────────────────────────────────────────────────────── # Validate (pre-flight) # ───────────────────────────────────────────────────────────────────── do_validate() { section_header "Pre-flight Validation" require_kubectl local checks_passed=0 local checks_failed=0 # Check kubectl connectivity log "Checking kubectl connectivity" if kubectl_cmd cluster-info >/dev/null 2>&1; then field_color "Cluster access" "${GREEN}" "OK" checks_passed=$((checks_passed + 1)) else field_color "Cluster access" "${RED}" "FAILED" checks_failed=$((checks_failed + 1)) fi # Check namespace exists log "Checking namespace ${CYAN}${NAMESPACE}${RESET}" if kubectl_cmd get namespace "$NAMESPACE" >/dev/null 2>&1; then field_color "Namespace" "${GREEN}" "${NAMESPACE} exists" checks_passed=$((checks_passed + 1)) else field_color "Namespace" "${YELLOW}" "${NAMESPACE} does not exist (will be created)" checks_passed=$((checks_passed + 1)) fi # Check CRDs installed log "Checking CRDs" if [[ "$GITOPS_TOOL" == "flux" ]]; then if kubectl_cmd get crd gitrepositories.source.toolkit.fluxcd.io >/dev/null 2>&1; then field_color "Flux CRDs" "${GREEN}" "installed" checks_passed=$((checks_passed + 1)) else field_color "Flux CRDs" "${YELLOW}" "not installed" checks_passed=$((checks_passed + 1)) fi elif [[ "$GITOPS_TOOL" == "argocd" ]]; then if kubectl_cmd get crd applications.argoproj.io >/dev/null 2>&1; then field_color "ArgoCD CRDs" "${GREEN}" "installed" checks_passed=$((checks_passed + 1)) else field_color "ArgoCD CRDs" "${YELLOW}" "not installed" checks_passed=$((checks_passed + 1)) fi fi # Check git repo accessible if [[ -n "$GIT_REPO" ]]; then log "Checking git repository accessibility" require_git if git ls-remote "$GIT_REPO" HEAD >/dev/null 2>&1; then field_color "Git repository" "${GREEN}" "accessible" checks_passed=$((checks_passed + 1)) else field_color "Git repository" "${RED}" "not accessible" checks_failed=$((checks_failed + 1)) fi else verbose "No git repository specified, skipping connectivity check" fi # Check tool CLI available log "Checking CLI tools" if [[ "$GITOPS_TOOL" == "flux" ]]; then if command -v flux >/dev/null 2>&1; then local flux_ver flux_ver=$(flux version --client 2>/dev/null | head -1 || echo "unknown") field_color "flux CLI" "${GREEN}" "$flux_ver" checks_passed=$((checks_passed + 1)) else field_color "flux CLI" "${RED}" "not found" checks_failed=$((checks_failed + 1)) fi elif [[ "$GITOPS_TOOL" == "argocd" ]]; then if command -v argocd >/dev/null 2>&1; then local argocd_ver argocd_ver=$(argocd version --client --short 2>/dev/null || echo "unknown") field_color "argocd CLI" "${GREEN}" "$argocd_ver" checks_passed=$((checks_passed + 1)) else field_color "argocd CLI" "${RED}" "not found" checks_failed=$((checks_failed + 1)) fi fi section_header "Validation Summary" field_color "Passed" "${GREEN}" "$checks_passed" if [[ "$checks_failed" -gt 0 ]]; then field_color "Failed" "${RED}" "$checks_failed" die "Validation failed with ${checks_failed} error(s)" else field_color "Failed" "${GREEN}" "0" log "All pre-flight checks passed" fi } # ───────────────────────────────────────────────────────────────────── # Teardown # ───────────────────────────────────────────────────────────────────── do_teardown_flux() { section_header "Tearing Down Flux" require_flux confirm_action "Remove Flux from the cluster?" log "Uninstalling Flux" verbose "flux uninstall --namespace=${NAMESPACE} --silent" flux uninstall --namespace="$NAMESPACE" --silent log "Flux has been removed from the cluster" } do_teardown_argocd() { section_header "Tearing Down Argo CD" require_kubectl confirm_action "Remove Argo CD from the cluster (delete namespace ${NAMESPACE})?" log "Deleting namespace ${CYAN}${NAMESPACE}${RESET}" kubectl_cmd delete namespace "$NAMESPACE" --wait=true log "Argo CD has been removed from the cluster" } do_teardown() { case "$GITOPS_TOOL" in flux) do_teardown_flux ;; argocd) do_teardown_argocd ;; *) die "Unknown GitOps tool: ${GITOPS_TOOL}" ;; esac } # ───────────────────────────────────────────────────────────────────── # Help # ───────────────────────────────────────────────────────────────────── show_help() { cat <