# shellcheck shell=bash # shellcheck disable=SC2034 # globals here are read by bin/* (DRY_RUN, API_BODY, GITOPS_TOKEN) # Shared helpers for bin/new-service and bin/delete-service. # Sourced, not executed. Callers set `set -euo pipefail` themselves. PLATFORM_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DRY_RUN=${DRY_RUN:-0} START_TS=$(date +%s) # ---------------------------------------------------------------- logging if [[ -t 2 ]]; then _c_info=$'\033[1;34m' _c_ok=$'\033[1;32m' _c_warn=$'\033[1;33m' _c_err=$'\033[1;31m' _c_dim=$'\033[2m' _c_off=$'\033[0m' else _c_info='' _c_ok='' _c_warn='' _c_err='' _c_dim='' _c_off='' fi _ts() { local s=$(($(date +%s) - START_TS)); printf '%02d:%02d' $((s / 60)) $((s % 60)); } log() { printf '%s[%s] ==>%s %s\n' "$_c_info" "$(_ts)" "$_c_off" "$*" >&2; } ok() { printf '%s[%s] ok%s %s\n' "$_c_ok" "$(_ts)" "$_c_off" "$*" >&2; } warn() { printf '%s[%s] !!%s %s\n' "$_c_warn" "$(_ts)" "$_c_off" "$*" >&2; } die() { printf '%s[%s] ERR%s %s\n' "$_c_err" "$(_ts)" "$_c_off" "$*" >&2 exit 1 } dry() { printf '%s[dry-run]%s %s\n' "$_c_dim" "$_c_off" "$*" >&2; } is_dry() { [[ $DRY_RUN == 1 ]]; } elapsed() { echo $(($(date +%s) - START_TS)); } # run CMD...: execute, or print it under --dry-run. run() { if is_dry; then dry "$(printf '%q ' "$@")" else "$@" fi } # ---------------------------------------------------------------- config _cleanup_files=() _cleanup() { ((${#_cleanup_files[@]})) && rm -rf "${_cleanup_files[@]}"; return 0; } trap _cleanup EXIT tmpfile() { local f f=$(mktemp "${TMPDIR:-/tmp}/platform.XXXXXX") _cleanup_files+=("$f") echo "$f" } tmpdir() { local d d=$(mktemp -d "${TMPDIR:-/tmp}/platform.XXXXXX") _cleanup_files+=("$d") echo "$d" } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing prerequisite: $1"; } # kustomize_build DIR: standalone kustomize if installed, else kubectl's. kustomize_build() { if command -v kustomize >/dev/null 2>&1; then kustomize build "$1"; else kubectl kustomize "$1"; fi } # load_config: source platform.env, check every path/tool/credential, export # what children need. Refuses to run if anything is missing. load_config() { local env_file=${PLATFORM_ENV:-$PLATFORM_ROOT/platform.env} [[ -f $env_file ]] || die "config not found: $env_file (copy platform.env.example)" # shellcheck source=/dev/null source "$env_file" local v for v in FORGEJO_URL FORGEJO_OWNER FORGEJO_ADMIN_TOKEN_FILE REGISTRY_PUSH_TOKEN_FILE \ GITOPS_TOKEN_FILE GITOPS_DIR CODE_DIR KUBECONFIG INGRESS_DOMAIN INGRESS_IP; do [[ -n ${!v:-} ]] || die "platform.env: $v is not set" done for v in FORGEJO_ADMIN_TOKEN_FILE REGISTRY_PUSH_TOKEN_FILE GITOPS_TOKEN_FILE KUBECONFIG; do [[ -r ${!v} ]] || die "platform.env: $v=${!v} is not a readable file" done [[ -d $GITOPS_DIR/.git ]] || die "platform.env: GITOPS_DIR=$GITOPS_DIR is not a git clone" [[ -d $CODE_DIR ]] || die "platform.env: CODE_DIR=$CODE_DIR does not exist" for v in curl jq git kubectl; do need_cmd "$v"; done export KUBECONFIG # Environment layer (Plan 14): the gitops dir this service's Application goes in. APPS_DIR=${APPS_DIR:-apps-homelab} [[ -d $GITOPS_DIR/$APPS_DIR ]] || die "platform.env: APPS_DIR=$APPS_DIR does not exist in $GITOPS_DIR" # Public access is optional config; only --public / delete-service use it. PUBLIC_DOMAIN=${PUBLIC_DOMAIN:-} CF_ZONE_ID=${CF_ZONE_ID:-} CF_TUNNEL_ID=${CF_TUNNEL_ID:-} CLOUDFLARE_TOKEN_FILE=${CLOUDFLARE_TOKEN_FILE:-} FORGEJO_TOKEN=$(tr -d '\n' <"$FORGEJO_ADMIN_TOKEN_FILE") REGISTRY_TOKEN=$(tr -d '\n' <"$REGISTRY_PUSH_TOKEN_FILE") GITOPS_TOKEN=$(tr -d '\n' <"$GITOPS_TOKEN_FILE") # Read by the inline git credential helper (git_auth) without hitting argv. export PLATFORM_GIT_USER=$FORGEJO_OWNER PLATFORM_GIT_TOKEN=$FORGEJO_TOKEN # Header files keep tokens out of `ps` output. ADMIN_HDR=$(tmpfile) REGISTRY_HDR=$(tmpfile) chmod 600 "$ADMIN_HDR" "$REGISTRY_HDR" printf 'Authorization: token %s\n' "$FORGEJO_TOKEN" >"$ADMIN_HDR" printf 'Authorization: token %s\n' "$REGISTRY_TOKEN" >"$REGISTRY_HDR" api GET /user [[ $API_CODE == 200 ]] || die "Forgejo admin token rejected ($API_CODE) at $FORGEJO_URL" kubectl get namespace argocd >/dev/null 2>&1 || die "kubectl cannot reach the cluster (KUBECONFIG=$KUBECONFIG)" } # ---------------------------------------------------------------- Forgejo API # api METHOD PATH [JSON] [admin|registry]: sets API_CODE and API_BODY. # Never fails on HTTP status; callers check API_CODE. API_CODE='' API_BODY='' api() { local method=$1 path=$2 body=${3:-} as=${4:-admin} hdr out [[ $as == registry ]] && hdr=$REGISTRY_HDR || hdr=$ADMIN_HDR out=$(tmpfile) local args=(-sS -o "$out" -w '%{http_code}' -X "$method" -H "@$hdr" -H 'Accept: application/json') [[ -n $body ]] && args+=(-H 'Content-Type: application/json' --data-binary "$body") API_CODE=$(curl "${args[@]}" "$FORGEJO_URL/api/v1$path") || die "curl failed: $method $path" API_BODY=$(cat "$out") } # api_write METHOD PATH [JSON] [as] [redact]: a mutating call. Under --dry-run # it is printed (with the body redacted if asked) and reports success. api_write() { local method=$1 path=$2 body=${3:-} as=${4:-admin} redact=${5:-0} if is_dry; then local shown=$body [[ $redact == 1 ]] && shown='{"data":""}' dry "$method $FORGEJO_URL/api/v1$path ${shown}" API_CODE=200 API_BODY='{}' return 0 fi api "$method" "$path" "$body" "$as" } # ---------------------------------------------------------------- Cloudflare (public DNS) # cf_require: the Plan 13 settings must be present before touching DNS. cf_require() { local v for v in PUBLIC_DOMAIN CF_ZONE_ID CF_TUNNEL_ID CLOUDFLARE_TOKEN_FILE; do [[ -n ${!v:-} ]] || die "platform.env: $v is not set (needed for public access, see platform.env.example)" done [[ -r $CLOUDFLARE_TOKEN_FILE ]] || die "platform.env: CLOUDFLARE_TOKEN_FILE=$CLOUDFLARE_TOKEN_FILE is not readable" } # cf_api METHOD PATH [JSON]: sets CF_BODY; dies if Cloudflare reports failure. # The token goes through a header file so it never appears in argv. CF_BODY='' cf_api() { local method=$1 path=$2 body=${3:-} hdr args hdr=$(tmpfile) chmod 600 "$hdr" printf 'Authorization: Bearer %s\n' "$(tr -d '\n' <"$CLOUDFLARE_TOKEN_FILE")" >"$hdr" args=(-sS -X "$method" -H "@$hdr" -H 'Content-Type: application/json') [[ -n $body ]] && args+=(--data-binary "$body") CF_BODY=$(curl "${args[@]}" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID$path") || die "curl failed: Cloudflare $method $path" [[ $(jq -r .success <<<"$CF_BODY") == true ]] || die "Cloudflare $method $path failed: $(jq -c .errors <<<"$CF_BODY")" } # cf_record_id NAME: id of the DNS record for NAME.$PUBLIC_DOMAIN, or ''. cf_record_id() { cf_api GET "/dns_records?name=$1.$PUBLIC_DOMAIN&type=CNAME" jq -r '.result[0].id // empty' <<<"$CF_BODY" } # cf_cname_ensure NAME: proxied CNAME NAME -> .cfargotunnel.com. cf_cname_ensure() { local name=$1 id body cf_require body=$(jq -nc --arg n "$name" --arg t "$CF_TUNNEL_ID.cfargotunnel.com" \ '{type:"CNAME", name:$n, content:$t, proxied:true, comment:"platform new-service --public"}') if is_dry; then dry "Cloudflare: ensure proxied CNAME $name.$PUBLIC_DOMAIN -> $CF_TUNNEL_ID.cfargotunnel.com ($body)" return 0 fi id=$(cf_record_id "$name") if [[ -n $id ]]; then ok "DNS $name.$PUBLIC_DOMAIN exists" else cf_api POST /dns_records "$body" ok "DNS $name.$PUBLIC_DOMAIN -> tunnel created" fi } # cf_cname_delete NAME: remove NAME.$PUBLIC_DOMAIN if it is one of ours (points at the tunnel). cf_cname_delete() { local name=$1 id [[ -n $PUBLIC_DOMAIN && -n $CF_ZONE_ID && -n $CF_TUNNEL_ID && -r $CLOUDFLARE_TOKEN_FILE ]] || return 0 if is_dry; then dry "Cloudflare: delete CNAME $name.$PUBLIC_DOMAIN if it points at $CF_TUNNEL_ID.cfargotunnel.com" return 0 fi cf_api GET "/dns_records?name=$name.$PUBLIC_DOMAIN&type=CNAME" id=$(jq -r --arg t "$CF_TUNNEL_ID.cfargotunnel.com" '.result[] | select(.content == $t) | .id' <<<"$CF_BODY" | head -1) if [[ -n $id ]]; then cf_api DELETE "/dns_records/$id" ok "DNS $name.$PUBLIC_DOMAIN deleted" else ok "no tunnel DNS record for $name.$PUBLIC_DOMAIN" fi } repo_exists() { api GET "/repos/$FORGEJO_OWNER/$1" [[ $API_CODE == 200 ]] } # ---------------------------------------------------------------- git # git_auth ARGS...: git with the admin token as an inline credential helper # (overrides the user's global helper; the token never lands in a remote URL). git_auth() { # shellcheck disable=SC2016 # expanded by the helper's shell, from the env git -c credential.helper= \ -c 'credential.helper=!f() { echo "username=$PLATFORM_GIT_USER"; echo "password=$PLATFORM_GIT_TOKEN"; }; f' "$@" } # gitops_sync: GITOPS_DIR must be a clean checkout of main; fast-forward it. gitops_sync() { local branch branch=$(git -C "$GITOPS_DIR" rev-parse --abbrev-ref HEAD) [[ $branch == main ]] || die "gitops clone is on '$branch', expected main" [[ -z $(git -C "$GITOPS_DIR" status --porcelain) ]] || die "gitops clone $GITOPS_DIR has uncommitted changes" git_auth -C "$GITOPS_DIR" pull -q --ff-only origin main || die "gitops pull failed" } # gitops_commit_push MESSAGE PATH...: stage PATHs, commit, push; rebase and # retry if CI's gitops-bump pushed in between. gitops_commit_push() { local msg=$1 shift if is_dry; then dry "git -C $GITOPS_DIR add -A $* && git commit -m '$msg' && git push origin main" return 0 fi local p for p in "$@"; do # paths removed with `git rm` are already staged; add errors on them if [[ -e $GITOPS_DIR/$p ]]; then git -C "$GITOPS_DIR" add -A -- "$p"; fi done if git -C "$GITOPS_DIR" diff --cached --quiet; then ok "gitops: nothing to commit for: $msg" return 0 fi git -C "$GITOPS_DIR" commit -q -m "$msg" local i for i in 1 2 3 4 5; do if git_auth -C "$GITOPS_DIR" push -q origin main; then ok "gitops: pushed '$msg' ($(git -C "$GITOPS_DIR" rev-parse --short HEAD))" return 0 fi warn "gitops push rejected (attempt $i); rebasing on origin/main" git_auth -C "$GITOPS_DIR" pull -q --rebase origin main done die "gitops push failed after 5 attempts" } # app_namespace NAME: destination namespace from gitops $APPS_DIR/NAME.yaml (empty if absent). app_namespace() { local f=$GITOPS_DIR/$APPS_DIR/$1.yaml [[ -f $f ]] || return 0 awk '/^ destination:/{d=1; next} d && /^ namespace:/{print $2; exit}' "$f" } # ---------------------------------------------------------------- cluster argocd_refresh() { run kubectl annotate application -n argocd "$1" argocd.argoproj.io/refresh=normal --overwrite >/dev/null } # wait_until TIMEOUT_S INTERVAL_S DESCRIPTION CMD...: poll CMD until it succeeds. wait_until() { local timeout=$1 interval=$2 desc=$3 shift 3 local deadline=$(($(date +%s) + timeout)) log "waiting for $desc (timeout ${timeout}s)" until "$@"; do (($(date +%s) < deadline)) || die "timed out after ${timeout}s waiting for $desc" sleep "$interval" done ok "$desc" } validate_name() { [[ $1 =~ ^[a-z][a-z0-9-]{1,30}$ ]] || die "invalid name '$1': must match ^[a-z][a-z0-9-]{1,30}\$" [[ $1 != *- ]] || die "invalid name '$1': must not end with '-'" case $1 in argocd | default | logging | unleash | platform | gitops | workflows | demo-app | \ kube-* | cnpg-system | elastic-system | service-template-*) die "name '$1' is reserved" ;; esac } # Print the URLs a human wants after a deploy. print_urls() { local name=$1 kind=$2 public=${3:-0} local host="$name.$INGRESS_DOMAIN" local kql kql=$(jq -rn --arg s "$name" '"service:\"\($s)\"" | @uri') cat <