feat(new-service): --public opt-in via Cloudflare Tunnel; delete-service removes the CNAME

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-11 12:45:47 -06:00
parent 4b6f898089
commit e094cdbdd9
4 changed files with 110 additions and 6 deletions

View file

@ -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

View file

@ -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/<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).
# 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

View file

@ -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 -> <tunnel>.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 <<EOF
service : https://$host/
$([[ $public == 1 ]] && echo " public : https://$name.$PUBLIC_DOMAIN/")
health : https://$host/$([[ $kind == backend ]] && echo readyz || echo healthz)
repo : $FORGEJO_URL/$FORGEJO_OWNER/$name
argocd : https://argocd.$INGRESS_DOMAIN/applications/argocd/$name

View file

@ -14,3 +14,9 @@ KUBECONFIG=$HOME/.kube/homelab.yaml
INGRESS_DOMAIN=lab.holtlabs.xyz # services at https://<name>.$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://<name>.$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 <id>.cfargotunnel.com
CLOUDFLARE_TOKEN_FILE=$HOMELAB_SECRETS/cloudflare-dns-token # Plan 11 token (Zone DNS edit)