Skip to content

feat(cloudflare): TunnelRoute — per-app tunnel ingress (G1)#157

Merged
astrojerms merged 1 commit into
mainfrom
feat/g1-tunnelroute
Jul 21, 2026
Merged

feat(cloudflare): TunnelRoute — per-app tunnel ingress (G1)#157
astrojerms merged 1 commit into
mainfrom
feat/g1-tunnelroute

Conversation

@astrojerms

Copy link
Copy Markdown
Owner

G1 — expose-app (model C: TunnelRoute)

You asked what G1 was deciding; given the ratified homelab-PaaS scope (K5), I built the real primitive rather than docs. A Cloudflare Tunnel's ingress is one ordered list, so managing it from N per-app resources would clobber (last-writer-wins). TunnelRoute solves that: each app contributes ONE host-scoped rule to a named Tunnel via a read-modify-write keyed by hostname.

kind: TunnelRoute
spec: { tunnel: home, hostname: chat.example.com, service: https://traefik...:443 }
---
kind: DNSRecord              # the other half of the pair
spec:
  type: CNAME
  name: chat.example.com
  content: { $ref: {kind: Tunnel, name: home, field: status.cnameTarget} }   # #139
  • mergeIngressRule upserts this app's rule (keyed by hostname) and keeps a single trailing catch-all; removeIngressRule (delete) pulls just its rule. So many apps share one tunnel, each owning its own resource — no clobbering. Both are pure/deterministic.
  • Get probes the live tunnel config for its hostname (Ready / NotFound).
  • #TunnelRoute schema; dispatch wired in Apply/Get/Delete/Handshake. examples/expose-app.yaml shows the per-app pair.

Why model C

Model A (docs) is still "two places, shared edits"; model B (one composite owning the tunnel) clobbers on the shared ingress list. C makes "expose an app" a self-contained per-app unit — the right PaaS primitive.

Caveat

Documented: routes to the same tunnel should apply serially (the shared-config RMW is last-write-wins). Not yet validated against a real Cloudflare account (needs a token) — unit-tested against the fake CF API.

Tests

mergeIngressRule/removeIngressRule (upsert-by-hostname, catch-all invariant), and end-to-end aggregation (two routes coexist on one tunnel, deleting one leaves the other, Get Ready→NotFound).

Local CI: build, go test ./... (+-race on cloudflare), gofmt, vet, staticcheck, golangci-lint (0 issues), modernize — all green. (Stray build binary removed before commit.)

Add a TunnelRoute kind so "expose one app publicly" is a self-contained
per-app unit instead of hand-editing a shared Tunnel's ingress list.

A Tunnel's ingress is a single ordered list, so managing it from N per-app
resources would clobber (last-writer-wins). TunnelRoute contributes ONE
host-scoped rule ({tunnel, hostname, service, path?}) via a read-modify-
write keyed by hostname: mergeIngressRule upserts this app's rule and keeps
a single trailing catch-all; removeIngressRule (on delete) pulls just its
rule. So many apps share one tunnel, each owning its own resource. Get
probes the live tunnel config for its hostname.

Pair with the app's CNAME DNSRecord ($ref the Tunnel's status.cnameTarget,
#139); examples/expose-app.yaml shows the pair.

Decided model C (a real primitive) per the K5 homelab-PaaS scope over a
documented-pattern or a clobber-prone composite. #TunnelRoute schema;
dispatch wired in Apply/Get/Delete/Handshake.

Concurrency caveat (documented): routes to the SAME tunnel should apply
serially — the shared-config RMW is last-write-wins.

Tests (fake CF API): mergeIngressRule/removeIngressRule (upsert-by-hostname,
catch-all invariant), and end-to-end aggregation (two routes coexist on one
tunnel, delete leaves the other, get Ready then NotFound). Not yet validated
against a real Cloudflare account.
@astrojerms
astrojerms merged commit 9e92fb0 into main Jul 21, 2026
6 checks passed
@astrojerms
astrojerms deleted the feat/g1-tunnelroute branch July 21, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant