#!/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