Plan 11 step 7. INGRESS_DOMAIN=lab.holtlabs.xyz; health checks pin the host to INGRESS_IP on 443 and verify the wildcard cert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
233 lines
8.7 KiB
Bash
233 lines
8.7 KiB
Bash
# 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
|
|
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":"<redacted>"}'
|
|
dry "$method $FORGEJO_URL/api/v1$path ${shown}"
|
|
API_CODE=200 API_BODY='{}'
|
|
return 0
|
|
fi
|
|
api "$method" "$path" "$body" "$as"
|
|
}
|
|
|
|
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/NAME.yaml (empty if absent).
|
|
app_namespace() {
|
|
local f=$GITOPS_DIR/apps/$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
|
|
local host="$name.$INGRESS_DOMAIN"
|
|
local kql
|
|
kql=$(jq -rn --arg s "$name" '"service:\"\($s)\"" | @uri')
|
|
cat <<EOF
|
|
|
|
service : https://$host/
|
|
health : https://$host/$([[ $kind == backend ]] && echo readyz || echo healthz)
|
|
repo : $FORGEJO_URL/$FORGEJO_OWNER/$name
|
|
argocd : https://argocd.$INGRESS_DOMAIN/applications/argocd/$name
|
|
kibana : https://kibana.$INGRESS_DOMAIN/app/discover#/?_g=(time:(from:now-1h,to:now))&_a=(query:(language:kuery,query:'$kql'))
|
|
unleash : https://unleash.$INGRESS_DOMAIN/projects/default
|
|
EOF
|
|
}
|