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>
This commit is contained in:
nholtware 2026-11-05 12:03:55 -06:00
commit 253b6033c3
7 changed files with 809 additions and 0 deletions

152
bin/delete-service Executable file
View file

@ -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 <<EOF
This permanently deletes, for service '$NAME':
- gitops apps/$NAME.yaml and manifests/$NAME/ (ArgoCD prunes the workloads)
- $([[ $OWN_NS == 1 ]] && echo "namespace $NAME, including its Postgres data (PVCs)" || echo "nothing else in shared namespace $NS (left in place)")
- Forgejo repo $FORGEJO_OWNER/$NAME and its container package versions
- local clone $APP_DIR
EOF
if ((!YES)) && ! is_dry; then
read -r -p "Type the service name to confirm: " answer
[[ $answer == "$NAME" ]] || die "aborted"
fi
# ------------------------------------------------------------ 1. ArgoCD app + gitops
# The resources finalizer makes ArgoCD delete the app's workloads when the
# root app prunes the Application (older apps were created without it).
if kubectl get application -n argocd "$NAME" >/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"

312
bin/new-service Executable file
View file

@ -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/<name>.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