Skip to content

Commit 9e92fb0

Browse files
authored
feat(cloudflare): TunnelRoute — per-app tunnel ingress (G1) (#157)
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.
1 parent 6979639 commit 9e92fb0

7 files changed

Lines changed: 460 additions & 7 deletions

File tree

ROADMAP.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,22 @@ prerequisite (Longhorn); the rest are convenience + robustness.
196196
true`.
197197

198198
### G. Public exposure
199-
- [ ] **G1 — expose-app convenience.** A composite (or documented pattern)
200-
that wires an in-cluster service to a public hostname in ONE place: the
201-
Cloudflare Tunnel ingress rule + the CNAME `DNSRecord` (`$ref`
202-
cnameTarget — already shipped #139). Removes the per-app two-resource
203-
dance for blogs, sites, Jellyfin, Open WebUI, Authentik, etc.
199+
- [x] **G1 — expose-app convenience (TunnelRoute).** Decided **model C** (per
200+
the K5 homelab-PaaS scope — a real primitive, not just docs). A Tunnel's
201+
ingress is one ordered list, so managing it from N per-app resources would
202+
clobber (last-writer-wins). New **`TunnelRoute`** kind in the cloudflare
203+
provider contributes ONE host-scoped rule (`{tunnel, hostname, service,
204+
path?}`) to a named Tunnel via a **read-modify-write keyed by hostname**
205+
(`mergeIngressRule`/`removeIngressRule` — pure, keep a single trailing
206+
catch-all), so many apps share one tunnel, each owning its own resource;
207+
delete pulls just its rule. Get probes the live config for its hostname.
208+
Pair with the app's CNAME `DNSRecord` (`$ref` the Tunnel's cnameTarget,
209+
#139) — so "expose an app" is a self-contained per-app unit instead of
210+
hand-editing the shared Tunnel. `#TunnelRoute` schema; unit-tested against
211+
the fake CF API (aggregation: two routes coexist, delete leaves the other;
212+
upsert-by-hostname; catch-all invariant). Concurrency caveat (documented):
213+
routes to the *same* tunnel should apply serially (shared-config RMW).
214+
Not yet validated against a real Cloudflare account (needs a token).
204215

205216
### H. Integrations (optional — deploying the app works without these)
206217
- [x] **H1 — Infisical `$secret` provider.** `secrets.InfisicalProvider`

examples/expose-app.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Expose one app publicly (G1). Per app, a self-contained pair:
2+
# 1. a TunnelRoute — contributes this app's ingress rule to the shared Tunnel
3+
# (merged by hostname, so many apps coexist without clobbering the list).
4+
# 2. a CNAME DNSRecord — points the public hostname at the Tunnel, resolving
5+
# the target from the Tunnel's status.cnameTarget via $ref (no hand-copying).
6+
#
7+
# The Tunnel itself (kind: Tunnel, name: home) is defined once elsewhere (see
8+
# examples/homelab/03-tunnel.yaml). Add a pair like this for each new app.
9+
apiVersion: cloudflare.openctl.io/v1
10+
kind: TunnelRoute
11+
metadata:
12+
name: chat-route
13+
spec:
14+
tunnel: home
15+
hostname: chat.example.com
16+
service: https://traefik.traefik.svc.cluster.local:443
17+
---
18+
apiVersion: cloudflare.openctl.io/v1
19+
kind: DNSRecord
20+
metadata:
21+
name: chat-dns
22+
spec:
23+
type: CNAME
24+
name: chat.example.com
25+
proxied: true
26+
content:
27+
$ref:
28+
apiVersion: cloudflare.openctl.io/v1
29+
kind: Tunnel
30+
name: home
31+
field: status.cnameTarget

plugins/cloudflare/provider.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func (p *provider) Handshake(context.Context) (*pluginproto.HandshakeResult, err
4949
Kinds: []pluginproto.KindInfo{
5050
{Kind: kindDNSRecord, Schema: dnsRecordSchema},
5151
{Kind: kindTunnel, Schema: tunnelSchema, Actions: []string{actionGetToken}},
52+
{Kind: kindTunnelRoute, Schema: tunnelRouteSchema},
5253
},
5354
}, nil
5455
}
@@ -76,8 +77,10 @@ func (p *provider) Apply(ctx context.Context, req pluginproto.ApplyParams) (*plu
7677
return p.applyDNSRecord(ctx, m, req.State)
7778
case kindTunnel:
7879
return p.applyTunnel(ctx, m, req.State)
80+
case kindTunnelRoute:
81+
return p.applyTunnelRoute(ctx, m, req.State)
7982
default:
80-
return nil, pluginproto.Unsupported("cloudflare handles DNSRecord and Tunnel, not " + m.Kind)
83+
return nil, pluginproto.Unsupported("cloudflare handles DNSRecord, Tunnel and TunnelRoute, not " + m.Kind)
8184
}
8285
}
8386

@@ -87,8 +90,10 @@ func (p *provider) Get(ctx context.Context, req pluginproto.GetParams) (*pluginp
8790
return p.getDNSRecord(ctx, req.Name, req.State)
8891
case kindTunnel:
8992
return p.getTunnel(ctx, req.Name, req.State)
93+
case kindTunnelRoute:
94+
return p.getTunnelRoute(ctx, req.Name, req.State)
9095
default:
91-
return nil, pluginproto.Unsupported("cloudflare handles DNSRecord and Tunnel, not " + req.Kind)
96+
return nil, pluginproto.Unsupported("cloudflare handles DNSRecord, Tunnel and TunnelRoute, not " + req.Kind)
9297
}
9398
}
9499

@@ -109,6 +114,8 @@ func (p *provider) Delete(ctx context.Context, req pluginproto.DeleteParams) err
109114
return p.deleteDNSRecord(ctx, req.State)
110115
case kindTunnel:
111116
return p.deleteTunnel(ctx, req.State)
117+
case kindTunnelRoute:
118+
return p.deleteTunnelRoute(ctx, req.State)
112119
default:
113120
return nil
114121
}

plugins/cloudflare/provider_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ func (f *fakeCF) handleTunnel(w http.ResponseWriter, r *http.Request, parts []st
148148
_ = json.NewDecoder(r.Body).Decode(&body)
149149
f.configs[parts[3]] = body["config"]
150150
f.ok(w, body)
151+
case len(parts) == 5 && parts[4] == "configurations" && r.Method == "GET":
152+
f.ok(w, map[string]any{"config": f.configs[parts[3]]})
151153
case len(parts) == 5 && parts[4] == "token" && r.Method == "GET":
152154
f.ok(w, "run-token-"+parts[3])
153155
case len(parts) == 5 && parts[4] == "connections" && r.Method == "DELETE":

plugins/cloudflare/schema.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,36 @@ const tunnelSchema = `
8989
...
9090
}
9191
`
92+
93+
// tunnelRouteSchema is the CUE schema for the TunnelRoute kind (G1): one app's
94+
// ingress rule contributed to a named Tunnel. Many TunnelRoutes share a Tunnel
95+
// without clobbering (the provider merges by hostname). Pair with a CNAME
96+
// DNSRecord that $refs the Tunnel's status.cnameTarget.
97+
const tunnelRouteSchema = `
98+
#TunnelRoute: {
99+
apiVersion: "cloudflare.openctl.io/v1"
100+
kind: "TunnelRoute"
101+
metadata: {
102+
name: string
103+
...
104+
}
105+
spec: {
106+
// accountId is the Cloudflare account. Optional when the provider config
107+
// sets defaults.accountId.
108+
accountId?: string
109+
// tunnel is the metadata.name of the Tunnel this route attaches to. The
110+
// Tunnel must already exist (order this route after it).
111+
tunnel: string
112+
// hostname is the public name routed to the service (e.g.
113+
// "chat.example.com"). Unique per tunnel — re-applying the same hostname
114+
// updates its rule in place.
115+
hostname: string
116+
// service is the local origin the hostname maps to, e.g.
117+
// "https://traefik.traefik.svc.cluster.local:443".
118+
service: string
119+
// path optionally scopes the rule to a URL path.
120+
path?: string
121+
}
122+
...
123+
}
124+
`

plugins/cloudflare/tunnelroute.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"maps"
9+
10+
"github.com/openctl/openctl/pkg/pluginproto"
11+
"github.com/openctl/openctl/pkg/protocol"
12+
)
13+
14+
// kindTunnelRoute is the "expose one app" primitive (G1). A Tunnel's ingress is
15+
// a single ordered list, so managing it from N per-app resources would clobber
16+
// (last-writer-wins). TunnelRoute instead contributes ONE host-scoped rule to a
17+
// named Tunnel via a read-modify-write keyed by hostname, so many apps coexist
18+
// on one tunnel — each owns its own resource. Pair it with a CNAME DNSRecord
19+
// that $refs the Tunnel's status.cnameTarget to finish the public wiring.
20+
const kindTunnelRoute = "TunnelRoute"
21+
22+
// tunnelRouteState records what a route owns so Delete can pull exactly its
23+
// rule (and Get can probe for it) without re-reading the spec.
24+
type tunnelRouteState struct {
25+
AccountID string `json:"accountId"`
26+
TunnelID string `json:"tunnelId"`
27+
TunnelName string `json:"tunnelName"`
28+
Hostname string `json:"hostname"`
29+
}
30+
31+
// cfTunnelConfig is a tunnel's ingress configuration as Cloudflare returns it
32+
// from GET .../configurations.
33+
type cfTunnelConfig struct {
34+
Config struct {
35+
Ingress []map[string]any `json:"ingress"`
36+
} `json:"config"`
37+
}
38+
39+
// mergeIngressRule upserts a host-scoped rule into a tunnel's ingress list,
40+
// keyed by hostname, and guarantees exactly one catch-all (http_status:404) as
41+
// the final rule (Cloudflare rejects a config whose last rule is host-scoped).
42+
// Rules for OTHER hostnames are preserved, so many TunnelRoutes coexist on one
43+
// tunnel without clobbering each other. Pure/deterministic.
44+
func mergeIngressRule(existing []map[string]any, hostname, service, path string) []map[string]any {
45+
out := make([]map[string]any, 0, len(existing)+2)
46+
for _, r := range existing {
47+
h, _ := r["hostname"].(string)
48+
if h == "" || h == hostname {
49+
continue // drop the catch-all and any prior rule for this hostname
50+
}
51+
out = append(out, r)
52+
}
53+
rule := map[string]any{"hostname": hostname, "service": service}
54+
if path != "" {
55+
rule["path"] = path
56+
}
57+
out = append(out, rule)
58+
out = append(out, map[string]any{"service": "http_status:404"})
59+
return out
60+
}
61+
62+
// removeIngressRule drops the rule for hostname (and any catch-all) and
63+
// re-appends a single trailing catch-all. Idempotent — removing an absent
64+
// hostname just re-normalizes the catch-all.
65+
func removeIngressRule(existing []map[string]any, hostname string) []map[string]any {
66+
out := make([]map[string]any, 0, len(existing)+1)
67+
for _, r := range existing {
68+
h, _ := r["hostname"].(string)
69+
if h == "" || h == hostname {
70+
continue
71+
}
72+
out = append(out, r)
73+
}
74+
out = append(out, map[string]any{"service": "http_status:404"})
75+
return out
76+
}
77+
78+
func (p *provider) readTunnelIngress(ctx context.Context, acct, tunnelID string) ([]map[string]any, error) {
79+
var cfg cfTunnelConfig
80+
err := p.client.do(ctx, "GET", "/accounts/"+acct+"/cfd_tunnel/"+tunnelID+"/configurations", nil, nil, &cfg)
81+
if err != nil {
82+
if errors.Is(err, errNotFound) {
83+
return nil, nil // no configuration pushed yet
84+
}
85+
return nil, err
86+
}
87+
return cfg.Config.Ingress, nil
88+
}
89+
90+
func (p *provider) writeTunnelIngress(ctx context.Context, acct, tunnelID string, ingress []map[string]any) error {
91+
body := map[string]any{"config": map[string]any{"ingress": ingress}}
92+
return p.client.do(ctx, "PUT", "/accounts/"+acct+"/cfd_tunnel/"+tunnelID+"/configurations", nil, body, nil)
93+
}
94+
95+
// applyTunnelRoute upserts this route's ingress rule into the named tunnel.
96+
//
97+
// Concurrency note: this is a read-modify-write on the tunnel's shared config.
98+
// Applies of the same resource are serialized, and routes are normally ordered
99+
// after the Tunnel via a $ref; two routes to the SAME tunnel applied
100+
// concurrently could race (last write wins). For a homelab that's acceptable;
101+
// apply routes to one tunnel serially if it matters.
102+
func (p *provider) applyTunnelRoute(ctx context.Context, m *protocol.Resource, _ json.RawMessage) (*pluginproto.ApplyResult, error) {
103+
acct := p.tunnelAccount(m.Spec)
104+
if acct == "" {
105+
return nil, fmt.Errorf("no account for TunnelRoute %q (set spec.accountId or provider defaults.accountId)", m.Metadata.Name)
106+
}
107+
tunnelName := specString(m.Spec, "tunnel")
108+
hostname := specString(m.Spec, "hostname")
109+
service := specString(m.Spec, "service")
110+
if tunnelName == "" || hostname == "" || service == "" {
111+
return nil, fmt.Errorf("TunnelRoute %q requires spec.tunnel, spec.hostname and spec.service", m.Metadata.Name)
112+
}
113+
path := specString(m.Spec, "path")
114+
115+
tunnelID, err := p.findTunnelID(ctx, acct, tunnelName)
116+
if err != nil {
117+
return nil, err
118+
}
119+
ingress, err := p.readTunnelIngress(ctx, acct, tunnelID)
120+
if err != nil {
121+
return nil, err
122+
}
123+
if err := p.writeTunnelIngress(ctx, acct, tunnelID, mergeIngressRule(ingress, hostname, service, path)); err != nil {
124+
return nil, err
125+
}
126+
127+
newState, _ := json.Marshal(tunnelRouteState{AccountID: acct, TunnelID: tunnelID, TunnelName: tunnelName, Hostname: hostname})
128+
return &pluginproto.ApplyResult{Resource: tunnelRouteObserved(m, tunnelID), State: newState}, nil
129+
}
130+
131+
func (p *provider) getTunnelRoute(ctx context.Context, name string, state json.RawMessage) (*pluginproto.GetResult, error) {
132+
var st tunnelRouteState
133+
if len(state) > 0 {
134+
_ = json.Unmarshal(state, &st)
135+
}
136+
if st.TunnelID == "" || st.AccountID == "" || st.Hostname == "" {
137+
return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q has no prior state", name))
138+
}
139+
ingress, err := p.readTunnelIngress(ctx, st.AccountID, st.TunnelID)
140+
if err != nil {
141+
if errors.Is(err, errNotFound) {
142+
return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q: tunnel config gone", name))
143+
}
144+
return nil, err
145+
}
146+
for _, r := range ingress {
147+
if h, _ := r["hostname"].(string); h == st.Hostname {
148+
res := &protocol.Resource{APIVersion: apiVersion, Kind: kindTunnelRoute, Status: map[string]any{
149+
"tunnelId": st.TunnelID, "hostname": st.Hostname, "phase": "Ready",
150+
}}
151+
res.Metadata.Name = name
152+
return &pluginproto.GetResult{Resource: res, State: state}, nil
153+
}
154+
}
155+
return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q: no rule for %s in tunnel %s", name, st.Hostname, st.TunnelName))
156+
}
157+
158+
func (p *provider) deleteTunnelRoute(ctx context.Context, state json.RawMessage) error {
159+
var st tunnelRouteState
160+
if len(state) > 0 {
161+
_ = json.Unmarshal(state, &st)
162+
}
163+
if st.TunnelID == "" || st.AccountID == "" || st.Hostname == "" {
164+
return nil
165+
}
166+
ingress, err := p.readTunnelIngress(ctx, st.AccountID, st.TunnelID)
167+
if err != nil {
168+
if errors.Is(err, errNotFound) {
169+
return nil // tunnel/config already gone
170+
}
171+
return err
172+
}
173+
return p.writeTunnelIngress(ctx, st.AccountID, st.TunnelID, removeIngressRule(ingress, st.Hostname))
174+
}
175+
176+
// tunnelRouteObserved echoes the desired spec plus a Ready status.
177+
func tunnelRouteObserved(m *protocol.Resource, tunnelID string) *protocol.Resource {
178+
spec := make(map[string]any, len(m.Spec))
179+
maps.Copy(spec, m.Spec)
180+
r := &protocol.Resource{
181+
APIVersion: apiVersion,
182+
Kind: kindTunnelRoute,
183+
Spec: spec,
184+
Status: map[string]any{
185+
"tunnelId": tunnelID,
186+
"hostname": specString(m.Spec, "hostname"),
187+
"phase": "Ready",
188+
},
189+
}
190+
r.Metadata.Name = m.Metadata.Name
191+
return r
192+
}

0 commit comments

Comments
 (0)