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:
parent
4b6f898089
commit
e094cdbdd9
4 changed files with 110 additions and 6 deletions
|
|
@ -100,6 +100,9 @@ if ! is_dry; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 1b. public DNS
|
||||||
|
cf_cname_delete "$NAME"
|
||||||
|
|
||||||
# ------------------------------------------------------------ 2. namespace
|
# ------------------------------------------------------------ 2. namespace
|
||||||
if ((OWN_NS)); then
|
if ((OWN_NS)); then
|
||||||
if kubectl get namespace "$NAME" >/dev/null 2>&1; then
|
if kubectl get namespace "$NAME" >/dev/null 2>&1; then
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ usage: new-service NAME --kind backend|frontend [options]
|
||||||
(runtime-config is always included)
|
(runtime-config is always included)
|
||||||
--api BACKEND frontend only: route /api to BACKEND and deploy
|
--api BACKEND frontend only: route /api to BACKEND and deploy
|
||||||
into its namespace
|
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
|
--wait after deploy, wait until Healthy and the health
|
||||||
endpoint answers; print elapsed time
|
endpoint answers; print elapsed time
|
||||||
--timeout SECONDS per-wait timeout (default 900)
|
--timeout SECONDS per-wait timeout (default 900)
|
||||||
|
|
@ -26,12 +29,13 @@ usage: new-service NAME --kind backend|frontend [options]
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
NAME='' KIND='' WITH='' API_APP='' WAIT=0 TIMEOUT=900
|
NAME='' KIND='' WITH='' API_APP='' WAIT=0 TIMEOUT=900 PUBLIC=0
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case $1 in
|
case $1 in
|
||||||
--kind) KIND=${2:?--kind needs a value}; shift 2 ;;
|
--kind) KIND=${2:?--kind needs a value}; shift 2 ;;
|
||||||
--with) WITH=${2:?--with needs a value}; shift 2 ;;
|
--with) WITH=${2:?--with needs a value}; shift 2 ;;
|
||||||
--api) API_APP=${2:?--api needs a value}; shift 2 ;;
|
--api) API_APP=${2:?--api needs a value}; shift 2 ;;
|
||||||
|
--public) PUBLIC=1; shift ;;
|
||||||
--wait) WAIT=1; shift ;;
|
--wait) WAIT=1; shift ;;
|
||||||
--timeout) TIMEOUT=${2:?--timeout needs a value}; shift 2 ;;
|
--timeout) TIMEOUT=${2:?--timeout needs a value}; shift 2 ;;
|
||||||
--dry-run) DRY_RUN=1; shift ;;
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
|
@ -81,6 +85,7 @@ esac
|
||||||
is_dry && log "DRY RUN: nothing below is executed except read-only checks"
|
is_dry && log "DRY RUN: nothing below is executed except read-only checks"
|
||||||
load_config
|
load_config
|
||||||
gitops_sync
|
gitops_sync
|
||||||
|
((!PUBLIC)) || cf_require
|
||||||
|
|
||||||
if [[ -n $API_APP ]]; then
|
if [[ -n $API_APP ]]; then
|
||||||
[[ -f $GITOPS_DIR/apps/$API_APP.yaml && -d $GITOPS_DIR/manifests/$API_APP ]] ||
|
[[ -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
|
# 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
|
# ArgoCD never syncs `newTag: latest` before there is an image to pull (the
|
||||||
# migrate-Job ImagePullBackOff caveat from Plan 04).
|
# 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() {
|
render_manifests() {
|
||||||
local out=$1 t=$GITOPS_DIR/templates/$KIND
|
local out=$1 t=$GITOPS_DIR/templates/$KIND
|
||||||
cp -r "$t/." "$out/"
|
cp -r "$t/." "$out/"
|
||||||
|
|
@ -171,15 +185,20 @@ render_manifests() {
|
||||||
for f in "${extra[@]}"; do add+=" - $f\n"; done
|
for f in "${extra[@]}"; do add+=" - $f\n"; done
|
||||||
sed -i "s|^ # Added by the render recipe.*$|${add%\\n}|" "$out/kustomization.yaml"
|
sed -i "s|^ # Added by the render recipe.*$|${add%\\n}|" "$out/kustomization.yaml"
|
||||||
sed -i '/^$/d' "$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
|
else
|
||||||
if [[ -z $API_APP ]]; then
|
if [[ -z $API_APP ]]; then
|
||||||
rm "$out/ingress-api.yaml" "$out/middleware.yaml"
|
rm "$out/ingress-api.yaml" "$out/middleware.yaml"
|
||||||
sed -i '/ingress-api.yaml$/d; /middleware.yaml$/d; /# \/api -> backend/d' "$out/kustomization.yaml"
|
sed -i '/ingress-api.yaml$/d; /middleware.yaml$/d; /# \/api -> backend/d' "$out/kustomization.yaml"
|
||||||
fi
|
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
|
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"
|
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.
|
# Don't wait for ArgoCD's 3-min poll.
|
||||||
argocd_refresh root
|
argocd_refresh root
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 8b. public DNS
|
||||||
|
if ((PUBLIC)); then
|
||||||
|
cf_cname_ensure "$NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
# ------------------------------------------------------------ 9. wait for healthy
|
# ------------------------------------------------------------ 9. wait for healthy
|
||||||
HOST=$NAME.$INGRESS_DOMAIN
|
HOST=$NAME.$INGRESS_DOMAIN
|
||||||
if ((WAIT)) && ! is_dry; then
|
if ((WAIT)) && ! is_dry; then
|
||||||
|
|
@ -302,7 +326,7 @@ if ((WAIT)) && ! is_dry; then
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_urls "$NAME" "$KIND"
|
print_urls "$NAME" "$KIND" "$PUBLIC"
|
||||||
if is_dry; then
|
if is_dry; then
|
||||||
log "dry run complete"
|
log "dry run complete"
|
||||||
elif ((WAIT)); then
|
elif ((WAIT)); then
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@ load_config() {
|
||||||
for v in curl jq git kubectl; do need_cmd "$v"; done
|
for v in curl jq git kubectl; do need_cmd "$v"; done
|
||||||
|
|
||||||
export KUBECONFIG
|
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")
|
FORGEJO_TOKEN=$(tr -d '\n' <"$FORGEJO_ADMIN_TOKEN_FILE")
|
||||||
REGISTRY_TOKEN=$(tr -d '\n' <"$REGISTRY_PUSH_TOKEN_FILE")
|
REGISTRY_TOKEN=$(tr -d '\n' <"$REGISTRY_PUSH_TOKEN_FILE")
|
||||||
GITOPS_TOKEN=$(tr -d '\n' <"$GITOPS_TOKEN_FILE")
|
GITOPS_TOKEN=$(tr -d '\n' <"$GITOPS_TOKEN_FILE")
|
||||||
|
|
@ -126,6 +129,73 @@ api_write() {
|
||||||
api "$method" "$path" "$body" "$as"
|
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() {
|
repo_exists() {
|
||||||
api GET "/repos/$FORGEJO_OWNER/$1"
|
api GET "/repos/$FORGEJO_OWNER/$1"
|
||||||
[[ $API_CODE == 200 ]]
|
[[ $API_CODE == 200 ]]
|
||||||
|
|
@ -217,13 +287,14 @@ validate_name() {
|
||||||
|
|
||||||
# Print the URLs a human wants after a deploy.
|
# Print the URLs a human wants after a deploy.
|
||||||
print_urls() {
|
print_urls() {
|
||||||
local name=$1 kind=$2
|
local name=$1 kind=$2 public=${3:-0}
|
||||||
local host="$name.$INGRESS_DOMAIN"
|
local host="$name.$INGRESS_DOMAIN"
|
||||||
local kql
|
local kql
|
||||||
kql=$(jq -rn --arg s "$name" '"service:\"\($s)\"" | @uri')
|
kql=$(jq -rn --arg s "$name" '"service:\"\($s)\"" | @uri')
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
service : https://$host/
|
service : https://$host/
|
||||||
|
$([[ $public == 1 ]] && echo " public : https://$name.$PUBLIC_DOMAIN/")
|
||||||
health : https://$host/$([[ $kind == backend ]] && echo readyz || echo healthz)
|
health : https://$host/$([[ $kind == backend ]] && echo readyz || echo healthz)
|
||||||
repo : $FORGEJO_URL/$FORGEJO_OWNER/$name
|
repo : $FORGEJO_URL/$FORGEJO_OWNER/$name
|
||||||
argocd : https://argocd.$INGRESS_DOMAIN/applications/argocd/$name
|
argocd : https://argocd.$INGRESS_DOMAIN/applications/argocd/$name
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,9 @@ KUBECONFIG=$HOME/.kube/homelab.yaml
|
||||||
|
|
||||||
INGRESS_DOMAIN=lab.holtlabs.xyz # services at https://<name>.$INGRESS_DOMAIN (renders __DOMAIN__)
|
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)
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue