From e094cdbdd967f98f73e0fa6329e51fb87877ec95 Mon Sep 17 00:00:00 2001 From: nholtware Date: Wed, 11 Nov 2026 12:45:47 -0600 Subject: [PATCH] feat(new-service): --public opt-in via Cloudflare Tunnel; delete-service removes the CNAME Co-Authored-By: Claude Sonnet 5.5 --- bin/delete-service | 3 ++ bin/new-service | 34 ++++++++++++++++++--- lib/common.sh | 73 +++++++++++++++++++++++++++++++++++++++++++- platform.env.example | 6 ++++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/bin/delete-service b/bin/delete-service index f6a3122..1dddacd 100755 --- a/bin/delete-service +++ b/bin/delete-service @@ -100,6 +100,9 @@ if ! is_dry; then fi fi +# ------------------------------------------------------------ 1b. public DNS +cf_cname_delete "$NAME" + # ------------------------------------------------------------ 2. namespace if ((OWN_NS)); then if kubectl get namespace "$NAME" >/dev/null 2>&1; then diff --git a/bin/new-service b/bin/new-service index 7f30bde..6288ec7 100755 --- a/bin/new-service +++ b/bin/new-service @@ -17,6 +17,9 @@ usage: new-service NAME --kind backend|frontend [options] (runtime-config is always included) --api BACKEND frontend only: route /api to BACKEND and deploy into its namespace + --public also serve https://NAME.$PUBLIC_DOMAIN through the + Cloudflare Tunnel (adds the DNS CNAME); default is + LAN-only. No Cloudflare Access on generated services. --wait after deploy, wait until Healthy and the health endpoint answers; print elapsed time --timeout SECONDS per-wait timeout (default 900) @@ -26,12 +29,13 @@ usage: new-service NAME --kind backend|frontend [options] EOF } -NAME='' KIND='' WITH='' API_APP='' WAIT=0 TIMEOUT=900 +NAME='' KIND='' WITH='' API_APP='' WAIT=0 TIMEOUT=900 PUBLIC=0 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 ;; + --public) PUBLIC=1; shift ;; --wait) WAIT=1; shift ;; --timeout) TIMEOUT=${2:?--timeout needs a value}; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; @@ -81,6 +85,7 @@ esac is_dry && log "DRY RUN: nothing below is executed except read-only checks" load_config gitops_sync +((!PUBLIC)) || cf_require if [[ -n $API_APP ]]; then [[ -f $GITOPS_DIR/apps/$API_APP.yaml && -d $GITOPS_DIR/manifests/$API_APP ]] || @@ -146,6 +151,15 @@ fi # 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). +# public_block DIR: keep (markers only) or strip the `# __PUBLIC_BEGIN__..END__` host rule. +public_block() { + if ((PUBLIC)); then + sed -i '/# __PUBLIC_\(BEGIN\|END\)__/d' "$1"/*.yaml + else + sed -i '/# __PUBLIC_BEGIN__/,/# __PUBLIC_END__/d' "$1"/*.yaml + fi +} + render_manifests() { local out=$1 t=$GITOPS_DIR/templates/$KIND cp -r "$t/." "$out/" @@ -171,15 +185,20 @@ render_manifests() { 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; s/__DOMAIN__/$INGRESS_DOMAIN/g" "$out"/*.yaml + public_block "$out" + sed -i "s/__APP__/$NAME/g; s/__DOMAIN__/$INGRESS_DOMAIN/g; s/__PUBLIC_DOMAIN__/$PUBLIC_DOMAIN/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; s/__DOMAIN__/$INGRESS_DOMAIN/g" "$out"/*.yaml + public_block "$out" + # One Unleash URL serves both hosts; public services point browsers at the public name. + local ud=$INGRESS_DOMAIN + ((PUBLIC)) && ud=$PUBLIC_DOMAIN + sed -i "s/__APP__/$NAME/g; s/__API__/$API_APP/g; s/__DOMAIN__/$INGRESS_DOMAIN/g; s/__PUBLIC_DOMAIN__/$PUBLIC_DOMAIN/g; s/__UNLEASH_DOMAIN__/$ud/g" "$out"/*.yaml fi - ! grep -rn '__[A-Z]*__' "$out" || die "unrendered placeholder left in manifests" + ! grep -rn '__[A-Z_]*__' "$out" || die "unrendered placeholder left in manifests" kustomize_build "$out" >/dev/null || die "kustomize build of rendered manifests failed" } @@ -277,6 +296,11 @@ fi # Don't wait for ArgoCD's 3-min poll. argocd_refresh root +# ------------------------------------------------------------ 8b. public DNS +if ((PUBLIC)); then + cf_cname_ensure "$NAME" +fi + # ------------------------------------------------------------ 9. wait for healthy HOST=$NAME.$INGRESS_DOMAIN if ((WAIT)) && ! is_dry; then @@ -302,7 +326,7 @@ if ((WAIT)) && ! is_dry; then done fi -print_urls "$NAME" "$KIND" +print_urls "$NAME" "$KIND" "$PUBLIC" if is_dry; then log "dry run complete" elif ((WAIT)); then diff --git a/lib/common.sh b/lib/common.sh index e09c006..f720845 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -80,6 +80,9 @@ load_config() { for v in curl jq git kubectl; do need_cmd "$v"; done export KUBECONFIG + # Public access is optional config; only --public / delete-service use it. + PUBLIC_DOMAIN=${PUBLIC_DOMAIN:-} CF_ZONE_ID=${CF_ZONE_ID:-} CF_TUNNEL_ID=${CF_TUNNEL_ID:-} + CLOUDFLARE_TOKEN_FILE=${CLOUDFLARE_TOKEN_FILE:-} 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") @@ -126,6 +129,73 @@ api_write() { api "$method" "$path" "$body" "$as" } +# ---------------------------------------------------------------- Cloudflare (public DNS) +# cf_require: the Plan 13 settings must be present before touching DNS. +cf_require() { + local v + for v in PUBLIC_DOMAIN CF_ZONE_ID CF_TUNNEL_ID CLOUDFLARE_TOKEN_FILE; do + [[ -n ${!v:-} ]] || die "platform.env: $v is not set (needed for public access, see platform.env.example)" + done + [[ -r $CLOUDFLARE_TOKEN_FILE ]] || die "platform.env: CLOUDFLARE_TOKEN_FILE=$CLOUDFLARE_TOKEN_FILE is not readable" +} + +# cf_api METHOD PATH [JSON]: sets CF_BODY; dies if Cloudflare reports failure. +# The token goes through a header file so it never appears in argv. +CF_BODY='' +cf_api() { + local method=$1 path=$2 body=${3:-} hdr args + hdr=$(tmpfile) + chmod 600 "$hdr" + printf 'Authorization: Bearer %s\n' "$(tr -d '\n' <"$CLOUDFLARE_TOKEN_FILE")" >"$hdr" + args=(-sS -X "$method" -H "@$hdr" -H 'Content-Type: application/json') + [[ -n $body ]] && args+=(--data-binary "$body") + CF_BODY=$(curl "${args[@]}" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID$path") || die "curl failed: Cloudflare $method $path" + [[ $(jq -r .success <<<"$CF_BODY") == true ]] || die "Cloudflare $method $path failed: $(jq -c .errors <<<"$CF_BODY")" +} + +# cf_record_id NAME: id of the DNS record for NAME.$PUBLIC_DOMAIN, or ''. +cf_record_id() { + cf_api GET "/dns_records?name=$1.$PUBLIC_DOMAIN&type=CNAME" + jq -r '.result[0].id // empty' <<<"$CF_BODY" +} + +# cf_cname_ensure NAME: proxied CNAME NAME -> .cfargotunnel.com. +cf_cname_ensure() { + local name=$1 id body + cf_require + body=$(jq -nc --arg n "$name" --arg t "$CF_TUNNEL_ID.cfargotunnel.com" \ + '{type:"CNAME", name:$n, content:$t, proxied:true, comment:"platform new-service --public"}') + if is_dry; then + dry "Cloudflare: ensure proxied CNAME $name.$PUBLIC_DOMAIN -> $CF_TUNNEL_ID.cfargotunnel.com ($body)" + return 0 + fi + id=$(cf_record_id "$name") + if [[ -n $id ]]; then + ok "DNS $name.$PUBLIC_DOMAIN exists" + else + cf_api POST /dns_records "$body" + ok "DNS $name.$PUBLIC_DOMAIN -> tunnel created" + fi +} + +# cf_cname_delete NAME: remove NAME.$PUBLIC_DOMAIN if it is one of ours (points at the tunnel). +cf_cname_delete() { + local name=$1 id + [[ -n $PUBLIC_DOMAIN && -n $CF_ZONE_ID && -n $CF_TUNNEL_ID && -r $CLOUDFLARE_TOKEN_FILE ]] || return 0 + if is_dry; then + dry "Cloudflare: delete CNAME $name.$PUBLIC_DOMAIN if it points at $CF_TUNNEL_ID.cfargotunnel.com" + return 0 + fi + cf_api GET "/dns_records?name=$name.$PUBLIC_DOMAIN&type=CNAME" + id=$(jq -r --arg t "$CF_TUNNEL_ID.cfargotunnel.com" '.result[] | select(.content == $t) | .id' <<<"$CF_BODY" | head -1) + if [[ -n $id ]]; then + cf_api DELETE "/dns_records/$id" + ok "DNS $name.$PUBLIC_DOMAIN deleted" + else + ok "no tunnel DNS record for $name.$PUBLIC_DOMAIN" + fi +} + repo_exists() { api GET "/repos/$FORGEJO_OWNER/$1" [[ $API_CODE == 200 ]] @@ -217,13 +287,14 @@ validate_name() { # Print the URLs a human wants after a deploy. print_urls() { - local name=$1 kind=$2 + local name=$1 kind=$2 public=${3:-0} local host="$name.$INGRESS_DOMAIN" local kql kql=$(jq -rn --arg s "$name" '"service:\"\($s)\"" | @uri') cat <.$INGRESS_DOMAIN (renders __DOMAIN__) INGRESS_IP=192.168.1.83 # health checks use curl --resolve, no DNS needed (TLS still verified) + +# Public access (Plan 13): `new-service --public` adds https://.$PUBLIC_DOMAIN via the Cloudflare Tunnel. +PUBLIC_DOMAIN=holtlabs.xyz +CF_ZONE_ID=f85fa3d7556872a69c4b24cc0c16a3a4 # zone holtlabs.xyz +CF_TUNNEL_ID=a7032422-b137-456a-ba9b-424dc99b4b46 # tunnel platform-homelab; CNAME target .cfargotunnel.com +CLOUDFLARE_TOKEN_FILE=$HOMELAB_SECRETS/cloudflare-dns-token # Plan 11 token (Zone DNS edit)