platform/lib/common.sh
nholtware 253b6033c3 feat: new-service and delete-service scaffolder
One command from nothing to a running service: generate repo from template,
Actions secrets, namespace + unleash token, gitops render, kick CI, register
with ArgoCD once the first image exists, optional --wait health check.
delete-service reverses all of it. Idempotent, --dry-run supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:03:55 -06:00

230 lines
8.6 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
git -C "$GITOPS_DIR" add -A -- "$@"
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 : http://$host/
health : http://$host/$([[ $kind == backend ]] && echo readyz || echo healthz)
repo : $FORGEJO_URL/$FORGEJO_OWNER/$name
argocd : http://argocd.$INGRESS_DOMAIN/applications/argocd/$name
kibana : http://kibana.$INGRESS_DOMAIN/app/discover#/?_g=(time:(from:now-1h,to:now))&_a=(query:(language:kuery,query:'$kql'))
unleash : http://unleash.$INGRESS_DOMAIN/projects/default
EOF
}