From 253b6033c32467c40f84cce5924d8b9df1e87bd3 Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 12:03:55 -0600 Subject: [PATCH] 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 --- .gitignore | 1 + .shellcheckrc | 2 + README.md | 96 +++++++++++++ bin/delete-service | 152 +++++++++++++++++++++ bin/new-service | 312 +++++++++++++++++++++++++++++++++++++++++++ lib/common.sh | 230 +++++++++++++++++++++++++++++++ platform.env.example | 16 +++ 7 files changed, 809 insertions(+) create mode 100644 .gitignore create mode 100644 .shellcheckrc create mode 100644 README.md create mode 100755 bin/delete-service create mode 100755 bin/new-service create mode 100644 lib/common.sh create mode 100644 platform.env.example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37012e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +platform.env diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..25c98c8 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +source-path=SCRIPTDIR +external-sources=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccfbbf8 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# platform + +One command from nothing to a running, observed service on the homelab k3s +cluster, and one to take it all away again. Bash + `curl`, `jq`, `git`, +`kubectl` (`kustomize` optional; falls back to `kubectl kustomize`). + +```sh +new-service orders --kind backend --with postgres,redis --wait +new-service orders-web --kind frontend --api orders --wait +delete-service orders-web --yes && delete-service orders --yes +``` + +## Setup + +```sh +git clone http://192.168.1.230:3000/nholtware/platform.git ~/Code/forgejo.local/nholtware/platform +cd ~/Code/forgejo.local/nholtware/platform +cp platform.env.example platform.env # gitignored; defaults match the homelab +export PATH="$PWD/bin:$PATH" # not installed globally +``` + +`platform.env` points at the homelab repo's gitignored `.secrets/` token files, +the gitops clone, the kubeconfig and the ingress domain. The scripts refuse to +run if any of them is missing, the gitops clone is dirty or not on `main`, the +Forgejo token is rejected, or the cluster is unreachable. + +## new-service + +``` +new-service NAME --kind backend|frontend [--with postgres,redis] [--api BACKEND] + [--wait] [--timeout SECONDS] [--dry-run] +``` + +| Step | What | Skipped when | +|------|------|--------------| +| 1 | validate `NAME` (`^[a-z][a-z0-9-]{1,30}$`, not reserved), kind, flags; `--api` must be an existing backend | — | +| 2 | generate public repo from `service-template-express` / `service-template-react` | repo exists | +| 3 | set Actions secrets `REGISTRY_USER`, `REGISTRY_TOKEN`, `GITOPS_TOKEN` | never (PUT is idempotent) | +| 4 | create namespace + Secret `unleash-client-token` (copied from `unleash/unleash-init-tokens`) | they exist | +| 5 | render gitops `templates/` (+ `postgres`, `redis`) → `manifests/NAME`, `kustomize build` lint, commit, push | dir exists | +| 6 | clone repo to `$CODE_DIR/NAME`, push empty commit `chore: kick ci` | CI already deployed, or a kick is running/succeeded | +| 7 | wait for CI's `gitops-bump` to replace `newTag: latest` (fails fast if the CI run fails) | tag already bumped | +| 8 | write `apps/NAME.yaml`, commit, push, refresh ArgoCD `root` | file exists | +| 9 | `--wait`: ArgoCD app `Synced/Healthy`, then `/readyz` (backend) or `/healthz` + `/api/readyz` (frontend) → 200 | no `--wait` | + +Then it prints the service, ArgoCD, Kibana (pre-filtered on `service:"NAME"`) +and Unleash URLs, and the elapsed time. + +**Why `apps/NAME.yaml` comes last:** ArgoCD only sees the service once CI has +pushed a real image. Registering it earlier made the backend's migrate Job sit +in `ImagePullBackOff` on `:latest` and hold the sync forever (Plan 04 caveat). + +**Resumable:** every step checks before acting. If a run dies (CI failure, +timeout, Ctrl-C), fix the cause and re-run the same command; a failed CI run is +re-kicked automatically. + +**Kinds:** + +- `backend` — Express/TS. Runtime config (`NAME-runtime-config` ConfigMap) is + always included. `--with postgres` adds a CNPG `NAME-db` cluster + migrate + Job, `--with redis` adds `NAME-redis`. Without them the env vars and Job are + dropped. +- `frontend` — Vite/React on nginx. `--api BACKEND` deploys into the backend's + namespace and routes `NAME./api/*` to it (prefix stripped). Without + `--api` it gets its own namespace and no `/api` route. + +## delete-service + +``` +delete-service NAME [--yes] [--dry-run] [--timeout SECONDS] +``` + +Removes `apps/NAME.yaml` + `manifests/NAME` from gitops (the Application's +`resources-finalizer` makes ArgoCD delete its workloads when `root` prunes it), +waits for the Application to go, deletes the namespace if it is the service's +own (CNPG PVCs go with it), deletes every version of the container package and +the Forgejo repo, and removes the local clone. Asks you to type the name unless +`--yes`. Refuses while a frontend still deploys into the service's namespace +(delete the frontend first). Leaves a shared namespace and its +`unleash-client-token` Secret alone. + +## --dry-run + +Runs every read-only check for real (config, repo exists?, namespace exists?, +render + `kustomize build` lint into a temp dir) and prints each mutating API +call (secret values redacted), git push and kubectl change instead of doing it. + +## Notes + +- The Forgejo admin token has no package scope; package list/delete uses the + registry push token (same user). +- Git pushes use the admin token through an inline credential helper; it is + never written into a remote URL or `argv`. +- Platform UIs: ArgoCD, Kibana and Unleash at `argocd.` / `kibana.` / + `unleash.192.168.1.83.nip.io`. Credentials and conventions: homelab + `PLATFORM.md`. diff --git a/bin/delete-service b/bin/delete-service new file mode 100755 index 0000000..f6a3122 --- /dev/null +++ b/bin/delete-service @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# delete-service: reverse new-service. gitops entry (ArgoCD prunes), namespace +# (and with it CNPG PVCs), Forgejo repo, container package versions, local clone. +# delete-service orders-web --yes && delete-service orders --yes +set -euo pipefail +# shellcheck source=../lib/common.sh +source "$(dirname "$(readlink -f "$0")")/../lib/common.sh" + +usage() { + cat <<'EOF' +usage: delete-service NAME [--yes] [--dry-run] [--timeout SECONDS] + + --yes don't ask for confirmation + --dry-run print every API call, git push and kubectl mutation instead + --timeout per-wait timeout (default 600) +EOF +} + +NAME='' YES=0 TIMEOUT=600 +while (($#)); do + case $1 in + --yes | -y) YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --timeout) TIMEOUT=${2:?--timeout needs a value}; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) usage >&2; die "unknown option: $1" ;; + *) [[ -z $NAME ]] || die "unexpected argument: $1"; NAME=$1; shift ;; + esac +done +[[ -n $NAME ]] || { usage >&2; die "NAME is required"; } +validate_name "$NAME" + +is_dry && log "DRY RUN: nothing below is executed except read-only checks" +load_config +gitops_sync + +# ------------------------------------------------------------ what exists +NS=$(app_namespace "$NAME") +if [[ -z $NS ]]; then + NS=$(kubectl get application -n argocd "$NAME" -o jsonpath='{.spec.destination.namespace}' 2>/dev/null || true) +fi +NS=${NS:-$NAME} +OWN_NS=0 +[[ $NS == "$NAME" ]] && OWN_NS=1 + +# Refuse to pull a namespace out from under a frontend co-located with us. +dependents=() +for f in "$GITOPS_DIR"/apps/*.yaml; do + other=$(basename "$f" .yaml) + [[ $other != "$NAME" && $(app_namespace "$other") == "$NAME" ]] && dependents+=("$other") +done +((${#dependents[@]} == 0)) || die "${dependents[*]} deploy(s) into namespace $NAME; delete-service them first" + +APP_DIR=$CODE_DIR/$NAME +cat >&2 </dev/null 2>&1; then + run kubectl patch application -n argocd "$NAME" --type merge \ + -p '{"metadata":{"finalizers":["resources-finalizer.argocd.argoproj.io"]}}' >/dev/null +fi +paths=() +[[ -f $GITOPS_DIR/apps/$NAME.yaml ]] && paths+=("apps/$NAME.yaml") +[[ -d $GITOPS_DIR/manifests/$NAME ]] && paths+=("manifests/$NAME") +if ((${#paths[@]})); then + if is_dry; then + dry "git -C $GITOPS_DIR rm -r ${paths[*]}" + else + git -C "$GITOPS_DIR" rm -rq -- "${paths[@]}" + fi + gitops_commit_push "chore($NAME): delete service" "${paths[@]}" + argocd_refresh root +else + ok "gitops has no entry for $NAME" +fi + +if ! is_dry; then + app_gone() { ! kubectl get application -n argocd "$NAME" >/dev/null 2>&1; } + if ! app_gone; then + # If root never managed it (e.g. scaffold stopped before registering), delete directly. + sleep 15 + app_gone || kubectl get application -n argocd root -o json | + jq -e --arg n "$NAME" '.status.resources[]? | select(.kind == "Application" and .name == $n)' >/dev/null || + kubectl delete application -n argocd "$NAME" --wait=false >/dev/null + wait_until "$TIMEOUT" 5 "ArgoCD to prune application $NAME and its resources" app_gone + else + ok "no ArgoCD application $NAME" + fi +fi + +# ------------------------------------------------------------ 2. namespace +if ((OWN_NS)); then + if kubectl get namespace "$NAME" >/dev/null 2>&1; then + run kubectl delete namespace "$NAME" --wait=false >/dev/null + if ! is_dry; then + ns_gone() { ! kubectl get namespace "$NAME" >/dev/null 2>&1; } + wait_until "$TIMEOUT" 5 "namespace $NAME to terminate" ns_gone + fi + else + ok "namespace $NAME absent" + fi +fi + +# ------------------------------------------------------------ 3. container package +# The admin token has no package scope; the registry push token (same user) does. +deleted=0 +while :; do + api GET "/packages/$FORGEJO_OWNER?type=container&q=$NAME&limit=50" '' registry + [[ $API_CODE == 200 ]] || die "listing packages failed ($API_CODE): $API_BODY" + mapfile -t versions < <(jq -r --arg n "$NAME" '.[] | select(.name == $n) | .version' <<<"$API_BODY") + ((${#versions[@]})) || break + for v in "${versions[@]}"; do + api_write DELETE "/packages/$FORGEJO_OWNER/container/$NAME/$(jq -rn --arg v "$v" '$v | @uri')" '' registry + [[ $API_CODE =~ ^20[04]$ || $API_CODE == 404 ]] || die "delete package $NAME/$v failed ($API_CODE): $API_BODY" + deleted=$((deleted + 1)) + done + is_dry && break +done +ok "container package $NAME: $deleted version(s) deleted" + +# ------------------------------------------------------------ 4. repo +if repo_exists "$NAME"; then + [[ $(jq -r .template <<<"$API_BODY") != true ]] || die "refusing to delete template repo $NAME" + api_write DELETE "/repos/$FORGEJO_OWNER/$NAME" + [[ $API_CODE == 204 || $API_CODE == 200 ]] || die "delete repo failed ($API_CODE): $API_BODY" + ok "repo $FORGEJO_OWNER/$NAME deleted" +else + ok "repo $FORGEJO_OWNER/$NAME absent" +fi + +# ------------------------------------------------------------ 5. local clone +if [[ -d $APP_DIR ]]; then + origin=$(git -C "$APP_DIR" remote get-url origin 2>/dev/null || true) + [[ $origin == "$FORGEJO_URL/$FORGEJO_OWNER/$NAME.git" ]] || + die "$APP_DIR origin is '$origin', not this service's repo; not deleting it" + run rm -rf -- "$APP_DIR" + ok "removed $APP_DIR" +fi + +ok "$NAME deleted in $(elapsed)s" diff --git a/bin/new-service b/bin/new-service new file mode 100755 index 0000000..2cdb0f4 --- /dev/null +++ b/bin/new-service @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# new-service: one command from nothing to a running, observed service. +# new-service orders --kind backend --with postgres,redis --wait +# new-service orders-web --kind frontend --api orders --wait +# Idempotent: every step checks before acting, so re-running after a failure +# continues where it stopped. See README.md. +set -euo pipefail +# shellcheck source=../lib/common.sh +source "$(dirname "$(readlink -f "$0")")/../lib/common.sh" + +usage() { + cat <<'EOF' +usage: new-service NAME --kind backend|frontend [options] + + --kind backend|frontend template to generate from (required) + --with LIST backend only: comma list of postgres,redis + (runtime-config is always included) + --api BACKEND frontend only: route /api to BACKEND and deploy + into its namespace + --wait after deploy, wait until Healthy and the health + endpoint answers; print elapsed time + --timeout SECONDS per-wait timeout (default 900) + --dry-run print every API call, git push and kubectl + mutation instead of executing it + -h, --help this text +EOF +} + +NAME='' KIND='' WITH='' API_APP='' WAIT=0 TIMEOUT=900 +while (($#)); do + case $1 in + --kind) KIND=${2:?--kind needs a value}; shift 2 ;; + --with) WITH=${2:?--with needs a value}; shift 2 ;; + --api) API_APP=${2:?--api needs a value}; shift 2 ;; + --wait) WAIT=1; shift ;; + --timeout) TIMEOUT=${2:?--timeout needs a value}; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h | --help) usage; exit 0 ;; + -*) usage >&2; die "unknown option: $1" ;; + *) [[ -z $NAME ]] || die "unexpected argument: $1"; NAME=$1; shift ;; + esac +done + +# ------------------------------------------------------------ 1. validate +[[ -n $NAME ]] || { usage >&2; die "NAME is required"; } +validate_name "$NAME" +[[ $TIMEOUT =~ ^[0-9]+$ ]] || die "--timeout must be a number of seconds" +WITH_PG=0 WITH_REDIS=0 +case $KIND in + backend) + [[ -z $API_APP ]] || die "--api is for --kind frontend" + IFS=, read -ra _with <<<"$WITH" + for w in "${_with[@]}"; do + case $w in + postgres) WITH_PG=1 ;; + redis) WITH_REDIS=1 ;; + runtime-config | '') ;; # always on + *) die "--with: unknown component '$w' (postgres, redis)" ;; + esac + done + TEMPLATE_REPO=service-template-express + NS=$NAME + HEALTH_PATH=/readyz + ;; + frontend) + [[ -z $WITH ]] || die "--with is for --kind backend" + TEMPLATE_REPO=service-template-react + if [[ -n $API_APP ]]; then + validate_name "$API_APP" + [[ $API_APP != "$NAME" ]] || die "--api must name a different service" + NS=$API_APP + else + NS=$NAME + fi + HEALTH_PATH=/healthz + ;; + '') usage >&2; die "--kind is required" ;; + *) die "--kind must be backend or frontend" ;; +esac + +is_dry && log "DRY RUN: nothing below is executed except read-only checks" +load_config +gitops_sync + +if [[ -n $API_APP ]]; then + [[ -f $GITOPS_DIR/apps/$API_APP.yaml && -d $GITOPS_DIR/manifests/$API_APP ]] || + die "--api $API_APP: no such service in gitops (create it first)" + [[ $(app_namespace "$API_APP") == "$API_APP" ]] || + die "--api $API_APP: not a backend (its namespace is '$(app_namespace "$API_APP")')" +fi +if [[ -f $GITOPS_DIR/apps/$NAME.yaml && $(app_namespace "$NAME") != "$NS" ]]; then + die "gitops apps/$NAME.yaml exists with namespace '$(app_namespace "$NAME")', expected '$NS'" +fi +ok "validated $NAME (kind=$KIND ns=$NS postgres=$WITH_PG redis=$WITH_REDIS api=${API_APP:--})" + +# ------------------------------------------------------------ 2. repo +if repo_exists "$NAME"; then + ok "repo $FORGEJO_OWNER/$NAME exists" + [[ $(jq -r .private <<<"$API_BODY") == false ]] || die "repo $FORGEJO_OWNER/$NAME is private; the cluster pulls images anonymously" +else + log "generating $FORGEJO_OWNER/$NAME from $TEMPLATE_REPO" + body=$(jq -nc --arg o "$FORGEJO_OWNER" --arg n "$NAME" '{owner:$o, name:$n, git_content:true, private:false}') + api_write POST "/repos/$FORGEJO_OWNER/$TEMPLATE_REPO/generate" "$body" + [[ $API_CODE == 201 || $API_CODE == 200 ]] || die "generate failed ($API_CODE): $API_BODY" + ok "repo created: $FORGEJO_URL/$FORGEJO_OWNER/$NAME" +fi + +# ------------------------------------------------------------ 3. Actions secrets +set_secret() { + local body + body=$(jq -nc --arg d "$2" '{data:$d}') + api_write PUT "/repos/$FORGEJO_OWNER/$NAME/actions/secrets/$1" "$body" admin 1 + [[ $API_CODE =~ ^20[014]$ ]] || die "set secret $1 failed ($API_CODE): $API_BODY" +} +set_secret REGISTRY_USER "$FORGEJO_OWNER" +set_secret REGISTRY_TOKEN "$REGISTRY_TOKEN" +set_secret GITOPS_TOKEN "$GITOPS_TOKEN" +ok "Actions secrets REGISTRY_USER, REGISTRY_TOKEN, GITOPS_TOKEN set" + +# ------------------------------------------------------------ 4. namespace + unleash token +if kubectl get namespace "$NS" >/dev/null 2>&1; then + ok "namespace $NS exists" +else + run kubectl create namespace "$NS" >/dev/null + ok "namespace $NS created" +fi +if kubectl get secret -n "$NS" unleash-client-token >/dev/null 2>&1; then + ok "secret $NS/unleash-client-token exists" +else + # Copy the base64 values straight across; tokens are never decoded or put in argv. + manifest=$(kubectl get secret -n unleash unleash-init-tokens -o json | jq --arg ns "$NS" '{ + apiVersion: "v1", kind: "Secret", type: "Opaque", + metadata: {name: "unleash-client-token", namespace: $ns, + labels: {"app.kubernetes.io/managed-by": "platform-new-service"}}, + data: {token: .data.INIT_CLIENT_API_TOKENS, frontendToken: .data.INIT_FRONTEND_API_TOKENS}}') + [[ $(jq -r '.data.token // empty' <<<"$manifest") ]] || die "could not read unleash/unleash-init-tokens" + if is_dry; then + dry "kubectl create -f - (Secret $NS/unleash-client-token from unleash/unleash-init-tokens)" + else + kubectl create -f - <<<"$manifest" >/dev/null + fi + ok "secret $NS/unleash-client-token created" +fi + +# ------------------------------------------------------------ 5. gitops manifests +# apps/.yaml is written only after CI's first image exists (step 7), so +# ArgoCD never syncs `newTag: latest` before there is an image to pull (the +# migrate-Job ImagePullBackOff caveat from Plan 04). +render_manifests() { + local out=$1 t=$GITOPS_DIR/templates/$KIND + cp -r "$t/." "$out/" + rm -f "$out/README.md" "$out/application.yaml.tmpl" + if [[ $KIND == backend ]]; then + local extra=() + if ((WITH_PG)); then + cp "$GITOPS_DIR/templates/postgres/cluster.yaml" "$out/postgres.yaml" + extra+=(postgres.yaml) + else + rm "$out/migrate-job.yaml" + sed -i '/^ - migrate-job.yaml$/d' "$out/kustomization.yaml" + sed -i '/- name: DATABASE_URL$/,/secretKeyRef:/d' "$out/deployment.yaml" + fi + if ((WITH_REDIS)); then + cp "$GITOPS_DIR/templates/redis/redis.yaml" "$out/redis.yaml" + extra+=(redis.yaml) + else + sed -i '/- name: REDIS_URL$/,+1d' "$out/deployment.yaml" + fi + # Replace the "Added by the render recipe" comment with the actual list. + local add='' f + for f in "${extra[@]}"; do add+=" - $f\n"; done + sed -i "s|^ # Added by the render recipe.*$|${add%\\n}|" "$out/kustomization.yaml" + sed -i '/^$/d' "$out/kustomization.yaml" + sed -i "s/__APP__/$NAME/g" "$out"/*.yaml + else + if [[ -z $API_APP ]]; then + rm "$out/ingress-api.yaml" "$out/middleware.yaml" + sed -i '/ingress-api.yaml$/d; /middleware.yaml$/d; /# \/api -> backend/d' "$out/kustomization.yaml" + fi + sed -i "s/__APP__/$NAME/g; s/__API__/$API_APP/g" "$out"/*.yaml + fi + ! grep -rn '__[A-Z]*__' "$out" || die "unrendered placeholder left in manifests" + kustomize_build "$out" >/dev/null || die "kustomize build of rendered manifests failed" +} + +if [[ -d $GITOPS_DIR/manifests/$NAME ]]; then + ok "gitops manifests/$NAME exists" +else + stage=$(tmpdir) + render_manifests "$stage" + ok "rendered + linted manifests ($(cd "$stage" && echo *.yaml))" + if is_dry; then + dry "write $GITOPS_DIR/manifests/$NAME/{$(cd "$stage" && printf "%s," *.yaml | sed "s/,$//")}" + sed 's/^/ /' "$stage/kustomization.yaml" >&2 + else + cp -r "$stage" "$GITOPS_DIR/manifests/$NAME" + chmod 755 "$GITOPS_DIR/manifests/$NAME" + fi + gitops_commit_push "feat($NAME): scaffold $KIND manifests" "manifests/$NAME" +fi + +# ------------------------------------------------------------ 6. clone + kick CI +APP_DIR=$CODE_DIR/$NAME +REPO_URL=$FORGEJO_URL/$FORGEJO_OWNER/$NAME.git +deployed_tag() { # tag CI has written to gitops origin/main ('' if none yet) + git_auth -C "$GITOPS_DIR" fetch -q origin main 2>/dev/null || return 0 + git -C "$GITOPS_DIR" show "origin/main:manifests/$NAME/kustomization.yaml" 2>/dev/null | + awk '/newTag:/{print $2}' | grep -vx latest || true +} + +if [[ -d $APP_DIR/.git ]]; then + ok "local clone $APP_DIR exists" +elif is_dry; then + dry "git clone $REPO_URL $APP_DIR" +else + git_auth clone -q "$REPO_URL" "$APP_DIR" + ok "cloned to $APP_DIR" +fi + +# A CI task is live/finished for the kick sha? +ci_state() { # prints: none | running | success | failure + api GET "/repos/$FORGEJO_OWNER/$NAME/actions/tasks?limit=20" + [[ $API_CODE == 200 ]] || { echo none; return; } + jq -r --arg sha "$1" '[.workflow_runs[] | select(.head_sha == $sha) | .status] as $s + | if ($s | length) == 0 then "none" + elif any($s[]; . == "failure" or . == "cancelled") then "failure" + elif any($s[]; . == "running" or . == "waiting" or . == "blocked") then "running" + elif any($s[]; . == "success") and ($s | length) >= 2 then "success" + else "running" end' <<<"$API_BODY" +} + +TAG=$(deployed_tag) +if [[ -n $TAG ]]; then + ok "CI already deployed $NAME:$TAG" +elif is_dry; then + dry "git -C $APP_DIR commit --allow-empty -m 'chore: kick ci' && git push origin main" + dry "poll gitops origin/main manifests/$NAME/kustomization.yaml until newTag != latest" +else + git_auth -C "$APP_DIR" pull -q --ff-only origin main + KICK_SHA=$(git -C "$APP_DIR" log -1 --format=%H --grep='^chore: kick ci$' origin/main || true) + if [[ -n $KICK_SHA && $(ci_state "$KICK_SHA") != failure ]]; then + ok "CI already kicked (${KICK_SHA:0:8})" + else + [[ -n $KICK_SHA ]] && warn "previous CI run for ${KICK_SHA:0:8} failed; kicking again" + git -C "$APP_DIR" -c user.name="$FORGEJO_OWNER" -c user.email="$FORGEJO_OWNER@forgejo.local" \ + commit -q --allow-empty -m 'chore: kick ci' + git_auth -C "$APP_DIR" push -q origin main + KICK_SHA=$(git -C "$APP_DIR" rev-parse HEAD) + ok "pushed 'chore: kick ci' (${KICK_SHA:0:8}); CI: $FORGEJO_URL/$FORGEJO_OWNER/$NAME/actions" + fi + + # ---------------------------------------------------------- 7. wait for first image + ci_done() { + TAG=$(deployed_tag) + [[ -n $TAG ]] && return 0 + [[ $(ci_state "$KICK_SHA") != failure ]] || + die "CI failed for ${KICK_SHA:0:8}: $FORGEJO_URL/$FORGEJO_OWNER/$NAME/actions (re-run new-service to kick again)" + return 1 + } + wait_until "$TIMEOUT" 10 "CI to build $NAME and bump its tag in gitops" ci_done + ok "CI deployed $NAME:$TAG at $(elapsed)s" +fi + +# ------------------------------------------------------------ 8. register with ArgoCD +if [[ -f $GITOPS_DIR/apps/$NAME.yaml ]]; then + ok "gitops apps/$NAME.yaml exists" +else + is_dry || git_auth -C "$GITOPS_DIR" pull -q --ff-only origin main + if is_dry; then + dry "write $GITOPS_DIR/apps/$NAME.yaml (namespace $NS)" + else + sed "s/__APP__/$NAME/g; s/__NS__/$NS/g" "$GITOPS_DIR/templates/$KIND/application.yaml.tmpl" \ + >"$GITOPS_DIR/apps/$NAME.yaml" + fi + gitops_commit_push "feat($NAME): register ArgoCD application" "apps/$NAME.yaml" +fi +# Don't wait for ArgoCD's 3-min poll. +argocd_refresh root + +# ------------------------------------------------------------ 9. wait for healthy +HOST=$NAME.$INGRESS_DOMAIN +if ((WAIT)) && ! is_dry; then + app_ok() { + local s + s=$(kubectl get application -n argocd "$NAME" -o json 2>/dev/null | + jq -r '"\(.status.sync.status)/\(.status.health.status)"') || return 1 + [[ $s == Synced/Healthy ]] + } + wait_until "$TIMEOUT" 10 "ArgoCD app $NAME Synced/Healthy" app_ok + http_ok() { + local p + for p in "$@"; do + [[ $(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ + --resolve "$HOST:80:$INGRESS_IP" "http://$HOST$p") == 200 ]] || return 1 + done + } + paths=("$HEALTH_PATH") + [[ -n $API_APP ]] && paths+=(/api/readyz) + wait_until 180 5 "http://$HOST${paths[*]} -> 200" http_ok "${paths[@]}" + for p in "${paths[@]}"; do + printf ' GET %s -> %s\n' "$p" "$(curl -s --max-time 5 --resolve "$HOST:80:$INGRESS_IP" "http://$HOST$p")" >&2 + done +fi + +print_urls "$NAME" "$KIND" +if is_dry; then + log "dry run complete" +elif ((WAIT)); then + ok "$NAME is live: $(elapsed)s from invocation" +else + ok "$NAME registered; ArgoCD deploys it shortly (re-run with --wait to block until healthy)" +fi diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..e510362 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,230 @@ +# 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":""}' + 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 <.$INGRESS_DOMAIN +INGRESS_IP=192.168.1.83 # health checks use curl --resolve, no DNS needed