diff --git a/ROADMAP.md b/ROADMAP.md index 6177c4f..7c47a35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -196,11 +196,22 @@ prerequisite (Longhorn); the rest are convenience + robustness. true`. ### G. Public exposure -- [ ] **G1 — expose-app convenience.** A composite (or documented pattern) - that wires an in-cluster service to a public hostname in ONE place: the - Cloudflare Tunnel ingress rule + the CNAME `DNSRecord` (`$ref` - cnameTarget — already shipped #139). Removes the per-app two-resource - dance for blogs, sites, Jellyfin, Open WebUI, Authentik, etc. +- [x] **G1 — expose-app convenience (TunnelRoute).** Decided **model C** (per + the K5 homelab-PaaS scope — a real primitive, not just docs). A Tunnel's + ingress is one ordered list, so managing it from N per-app resources would + clobber (last-writer-wins). New **`TunnelRoute`** kind in the cloudflare + provider contributes ONE host-scoped rule (`{tunnel, hostname, service, + path?}`) to a named Tunnel via a **read-modify-write keyed by hostname** + (`mergeIngressRule`/`removeIngressRule` — pure, keep a single trailing + catch-all), so many apps share one tunnel, each owning its own resource; + delete pulls just its rule. Get probes the live config for its hostname. + Pair with the app's CNAME `DNSRecord` (`$ref` the Tunnel's cnameTarget, + #139) — so "expose an app" is a self-contained per-app unit instead of + hand-editing the shared Tunnel. `#TunnelRoute` schema; unit-tested against + the fake CF API (aggregation: two routes coexist, delete leaves the other; + upsert-by-hostname; catch-all invariant). Concurrency caveat (documented): + routes to the *same* tunnel should apply serially (shared-config RMW). + Not yet validated against a real Cloudflare account (needs a token). ### H. Integrations (optional — deploying the app works without these) - [x] **H1 — Infisical `$secret` provider.** `secrets.InfisicalProvider` diff --git a/examples/expose-app.yaml b/examples/expose-app.yaml new file mode 100644 index 0000000..ec00aa3 --- /dev/null +++ b/examples/expose-app.yaml @@ -0,0 +1,31 @@ +# Expose one app publicly (G1). Per app, a self-contained pair: +# 1. a TunnelRoute — contributes this app's ingress rule to the shared Tunnel +# (merged by hostname, so many apps coexist without clobbering the list). +# 2. a CNAME DNSRecord — points the public hostname at the Tunnel, resolving +# the target from the Tunnel's status.cnameTarget via $ref (no hand-copying). +# +# The Tunnel itself (kind: Tunnel, name: home) is defined once elsewhere (see +# examples/homelab/03-tunnel.yaml). Add a pair like this for each new app. +apiVersion: cloudflare.openctl.io/v1 +kind: TunnelRoute +metadata: + name: chat-route +spec: + tunnel: home + hostname: chat.example.com + service: https://traefik.traefik.svc.cluster.local:443 +--- +apiVersion: cloudflare.openctl.io/v1 +kind: DNSRecord +metadata: + name: chat-dns +spec: + type: CNAME + name: chat.example.com + proxied: true + content: + $ref: + apiVersion: cloudflare.openctl.io/v1 + kind: Tunnel + name: home + field: status.cnameTarget diff --git a/plugins/cloudflare/provider.go b/plugins/cloudflare/provider.go index 9a398d2..a58f6ee 100644 --- a/plugins/cloudflare/provider.go +++ b/plugins/cloudflare/provider.go @@ -49,6 +49,7 @@ func (p *provider) Handshake(context.Context) (*pluginproto.HandshakeResult, err Kinds: []pluginproto.KindInfo{ {Kind: kindDNSRecord, Schema: dnsRecordSchema}, {Kind: kindTunnel, Schema: tunnelSchema, Actions: []string{actionGetToken}}, + {Kind: kindTunnelRoute, Schema: tunnelRouteSchema}, }, }, nil } @@ -76,8 +77,10 @@ func (p *provider) Apply(ctx context.Context, req pluginproto.ApplyParams) (*plu return p.applyDNSRecord(ctx, m, req.State) case kindTunnel: return p.applyTunnel(ctx, m, req.State) + case kindTunnelRoute: + return p.applyTunnelRoute(ctx, m, req.State) default: - return nil, pluginproto.Unsupported("cloudflare handles DNSRecord and Tunnel, not " + m.Kind) + return nil, pluginproto.Unsupported("cloudflare handles DNSRecord, Tunnel and TunnelRoute, not " + m.Kind) } } @@ -87,8 +90,10 @@ func (p *provider) Get(ctx context.Context, req pluginproto.GetParams) (*pluginp return p.getDNSRecord(ctx, req.Name, req.State) case kindTunnel: return p.getTunnel(ctx, req.Name, req.State) + case kindTunnelRoute: + return p.getTunnelRoute(ctx, req.Name, req.State) default: - return nil, pluginproto.Unsupported("cloudflare handles DNSRecord and Tunnel, not " + req.Kind) + return nil, pluginproto.Unsupported("cloudflare handles DNSRecord, Tunnel and TunnelRoute, not " + req.Kind) } } @@ -109,6 +114,8 @@ func (p *provider) Delete(ctx context.Context, req pluginproto.DeleteParams) err return p.deleteDNSRecord(ctx, req.State) case kindTunnel: return p.deleteTunnel(ctx, req.State) + case kindTunnelRoute: + return p.deleteTunnelRoute(ctx, req.State) default: return nil } diff --git a/plugins/cloudflare/provider_test.go b/plugins/cloudflare/provider_test.go index 7b7e445..037f769 100644 --- a/plugins/cloudflare/provider_test.go +++ b/plugins/cloudflare/provider_test.go @@ -148,6 +148,8 @@ func (f *fakeCF) handleTunnel(w http.ResponseWriter, r *http.Request, parts []st _ = json.NewDecoder(r.Body).Decode(&body) f.configs[parts[3]] = body["config"] f.ok(w, body) + case len(parts) == 5 && parts[4] == "configurations" && r.Method == "GET": + f.ok(w, map[string]any{"config": f.configs[parts[3]]}) case len(parts) == 5 && parts[4] == "token" && r.Method == "GET": f.ok(w, "run-token-"+parts[3]) case len(parts) == 5 && parts[4] == "connections" && r.Method == "DELETE": diff --git a/plugins/cloudflare/schema.go b/plugins/cloudflare/schema.go index 7c26ae6..8681bcd 100644 --- a/plugins/cloudflare/schema.go +++ b/plugins/cloudflare/schema.go @@ -89,3 +89,36 @@ const tunnelSchema = ` ... } ` + +// tunnelRouteSchema is the CUE schema for the TunnelRoute kind (G1): one app's +// ingress rule contributed to a named Tunnel. Many TunnelRoutes share a Tunnel +// without clobbering (the provider merges by hostname). Pair with a CNAME +// DNSRecord that $refs the Tunnel's status.cnameTarget. +const tunnelRouteSchema = ` +#TunnelRoute: { + apiVersion: "cloudflare.openctl.io/v1" + kind: "TunnelRoute" + metadata: { + name: string + ... + } + spec: { + // accountId is the Cloudflare account. Optional when the provider config + // sets defaults.accountId. + accountId?: string + // tunnel is the metadata.name of the Tunnel this route attaches to. The + // Tunnel must already exist (order this route after it). + tunnel: string + // hostname is the public name routed to the service (e.g. + // "chat.example.com"). Unique per tunnel — re-applying the same hostname + // updates its rule in place. + hostname: string + // service is the local origin the hostname maps to, e.g. + // "https://traefik.traefik.svc.cluster.local:443". + service: string + // path optionally scopes the rule to a URL path. + path?: string + } + ... +} +` diff --git a/plugins/cloudflare/tunnelroute.go b/plugins/cloudflare/tunnelroute.go new file mode 100644 index 0000000..d82c67a --- /dev/null +++ b/plugins/cloudflare/tunnelroute.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + + "github.com/openctl/openctl/pkg/pluginproto" + "github.com/openctl/openctl/pkg/protocol" +) + +// kindTunnelRoute is the "expose one app" primitive (G1). A Tunnel's ingress is +// a single ordered list, so managing it from N per-app resources would clobber +// (last-writer-wins). TunnelRoute instead contributes ONE host-scoped rule to a +// named Tunnel via a read-modify-write keyed by hostname, so many apps coexist +// on one tunnel — each owns its own resource. Pair it with a CNAME DNSRecord +// that $refs the Tunnel's status.cnameTarget to finish the public wiring. +const kindTunnelRoute = "TunnelRoute" + +// tunnelRouteState records what a route owns so Delete can pull exactly its +// rule (and Get can probe for it) without re-reading the spec. +type tunnelRouteState struct { + AccountID string `json:"accountId"` + TunnelID string `json:"tunnelId"` + TunnelName string `json:"tunnelName"` + Hostname string `json:"hostname"` +} + +// cfTunnelConfig is a tunnel's ingress configuration as Cloudflare returns it +// from GET .../configurations. +type cfTunnelConfig struct { + Config struct { + Ingress []map[string]any `json:"ingress"` + } `json:"config"` +} + +// mergeIngressRule upserts a host-scoped rule into a tunnel's ingress list, +// keyed by hostname, and guarantees exactly one catch-all (http_status:404) as +// the final rule (Cloudflare rejects a config whose last rule is host-scoped). +// Rules for OTHER hostnames are preserved, so many TunnelRoutes coexist on one +// tunnel without clobbering each other. Pure/deterministic. +func mergeIngressRule(existing []map[string]any, hostname, service, path string) []map[string]any { + out := make([]map[string]any, 0, len(existing)+2) + for _, r := range existing { + h, _ := r["hostname"].(string) + if h == "" || h == hostname { + continue // drop the catch-all and any prior rule for this hostname + } + out = append(out, r) + } + rule := map[string]any{"hostname": hostname, "service": service} + if path != "" { + rule["path"] = path + } + out = append(out, rule) + out = append(out, map[string]any{"service": "http_status:404"}) + return out +} + +// removeIngressRule drops the rule for hostname (and any catch-all) and +// re-appends a single trailing catch-all. Idempotent — removing an absent +// hostname just re-normalizes the catch-all. +func removeIngressRule(existing []map[string]any, hostname string) []map[string]any { + out := make([]map[string]any, 0, len(existing)+1) + for _, r := range existing { + h, _ := r["hostname"].(string) + if h == "" || h == hostname { + continue + } + out = append(out, r) + } + out = append(out, map[string]any{"service": "http_status:404"}) + return out +} + +func (p *provider) readTunnelIngress(ctx context.Context, acct, tunnelID string) ([]map[string]any, error) { + var cfg cfTunnelConfig + err := p.client.do(ctx, "GET", "/accounts/"+acct+"/cfd_tunnel/"+tunnelID+"/configurations", nil, nil, &cfg) + if err != nil { + if errors.Is(err, errNotFound) { + return nil, nil // no configuration pushed yet + } + return nil, err + } + return cfg.Config.Ingress, nil +} + +func (p *provider) writeTunnelIngress(ctx context.Context, acct, tunnelID string, ingress []map[string]any) error { + body := map[string]any{"config": map[string]any{"ingress": ingress}} + return p.client.do(ctx, "PUT", "/accounts/"+acct+"/cfd_tunnel/"+tunnelID+"/configurations", nil, body, nil) +} + +// applyTunnelRoute upserts this route's ingress rule into the named tunnel. +// +// Concurrency note: this is a read-modify-write on the tunnel's shared config. +// Applies of the same resource are serialized, and routes are normally ordered +// after the Tunnel via a $ref; two routes to the SAME tunnel applied +// concurrently could race (last write wins). For a homelab that's acceptable; +// apply routes to one tunnel serially if it matters. +func (p *provider) applyTunnelRoute(ctx context.Context, m *protocol.Resource, _ json.RawMessage) (*pluginproto.ApplyResult, error) { + acct := p.tunnelAccount(m.Spec) + if acct == "" { + return nil, fmt.Errorf("no account for TunnelRoute %q (set spec.accountId or provider defaults.accountId)", m.Metadata.Name) + } + tunnelName := specString(m.Spec, "tunnel") + hostname := specString(m.Spec, "hostname") + service := specString(m.Spec, "service") + if tunnelName == "" || hostname == "" || service == "" { + return nil, fmt.Errorf("TunnelRoute %q requires spec.tunnel, spec.hostname and spec.service", m.Metadata.Name) + } + path := specString(m.Spec, "path") + + tunnelID, err := p.findTunnelID(ctx, acct, tunnelName) + if err != nil { + return nil, err + } + ingress, err := p.readTunnelIngress(ctx, acct, tunnelID) + if err != nil { + return nil, err + } + if err := p.writeTunnelIngress(ctx, acct, tunnelID, mergeIngressRule(ingress, hostname, service, path)); err != nil { + return nil, err + } + + newState, _ := json.Marshal(tunnelRouteState{AccountID: acct, TunnelID: tunnelID, TunnelName: tunnelName, Hostname: hostname}) + return &pluginproto.ApplyResult{Resource: tunnelRouteObserved(m, tunnelID), State: newState}, nil +} + +func (p *provider) getTunnelRoute(ctx context.Context, name string, state json.RawMessage) (*pluginproto.GetResult, error) { + var st tunnelRouteState + if len(state) > 0 { + _ = json.Unmarshal(state, &st) + } + if st.TunnelID == "" || st.AccountID == "" || st.Hostname == "" { + return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q has no prior state", name)) + } + ingress, err := p.readTunnelIngress(ctx, st.AccountID, st.TunnelID) + if err != nil { + if errors.Is(err, errNotFound) { + return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q: tunnel config gone", name)) + } + return nil, err + } + for _, r := range ingress { + if h, _ := r["hostname"].(string); h == st.Hostname { + res := &protocol.Resource{APIVersion: apiVersion, Kind: kindTunnelRoute, Status: map[string]any{ + "tunnelId": st.TunnelID, "hostname": st.Hostname, "phase": "Ready", + }} + res.Metadata.Name = name + return &pluginproto.GetResult{Resource: res, State: state}, nil + } + } + return nil, pluginproto.NotFound(fmt.Sprintf("TunnelRoute %q: no rule for %s in tunnel %s", name, st.Hostname, st.TunnelName)) +} + +func (p *provider) deleteTunnelRoute(ctx context.Context, state json.RawMessage) error { + var st tunnelRouteState + if len(state) > 0 { + _ = json.Unmarshal(state, &st) + } + if st.TunnelID == "" || st.AccountID == "" || st.Hostname == "" { + return nil + } + ingress, err := p.readTunnelIngress(ctx, st.AccountID, st.TunnelID) + if err != nil { + if errors.Is(err, errNotFound) { + return nil // tunnel/config already gone + } + return err + } + return p.writeTunnelIngress(ctx, st.AccountID, st.TunnelID, removeIngressRule(ingress, st.Hostname)) +} + +// tunnelRouteObserved echoes the desired spec plus a Ready status. +func tunnelRouteObserved(m *protocol.Resource, tunnelID string) *protocol.Resource { + spec := make(map[string]any, len(m.Spec)) + maps.Copy(spec, m.Spec) + r := &protocol.Resource{ + APIVersion: apiVersion, + Kind: kindTunnelRoute, + Spec: spec, + Status: map[string]any{ + "tunnelId": tunnelID, + "hostname": specString(m.Spec, "hostname"), + "phase": "Ready", + }, + } + r.Metadata.Name = m.Metadata.Name + return r +} diff --git a/plugins/cloudflare/tunnelroute_test.go b/plugins/cloudflare/tunnelroute_test.go new file mode 100644 index 0000000..a4becf8 --- /dev/null +++ b/plugins/cloudflare/tunnelroute_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + "github.com/openctl/openctl/pkg/pluginproto" + "github.com/openctl/openctl/pkg/protocol" +) + +// lastRuleIsCatchAll asserts the ingress ends with exactly one host-less +// catch-all and no earlier catch-all sneaks in. +func hostnames(ingress []map[string]any) []string { + var hs []string + for _, r := range ingress { + if h, _ := r["hostname"].(string); h != "" { + hs = append(hs, h) + } + } + return hs +} + +func catchAllLast(t *testing.T, ingress []map[string]any) { + t.Helper() + if len(ingress) == 0 { + t.Fatal("ingress is empty (needs at least a catch-all)") + } + last := ingress[len(ingress)-1] + if _, hasHost := last["hostname"]; hasHost { + t.Errorf("last rule must be the host-less catch-all, got %v", last) + } + for i, r := range ingress[:len(ingress)-1] { + if _, hasHost := r["hostname"]; !hasHost { + t.Errorf("non-final rule %d is a catch-all (only the last may be): %v", i, r) + } + } +} + +func TestMergeIngressRule(t *testing.T) { + // Into empty → [rule, catch-all]. + got := mergeIngressRule(nil, "a.example.com", "http://a:80", "") + if hs := hostnames(got); len(hs) != 1 || hs[0] != "a.example.com" { + t.Fatalf("hostnames = %v, want [a.example.com]", hs) + } + catchAllLast(t, got) + + // Add a second hostname → both preserved. + got = mergeIngressRule(got, "b.example.com", "http://b:80", "/api") + if hs := hostnames(got); len(hs) != 2 { + t.Fatalf("hostnames = %v, want 2", hs) + } + catchAllLast(t, got) + + // Upsert an existing hostname → replaced in place, not duplicated. + got = mergeIngressRule(got, "a.example.com", "http://a-new:80", "") + hs := hostnames(got) + countA := 0 + for _, h := range hs { + if h == "a.example.com" { + countA++ + } + } + if countA != 1 { + t.Errorf("a.example.com appears %d times, want 1 (upsert)", countA) + } + for _, r := range got { + if r["hostname"] == "a.example.com" && r["service"] != "http://a-new:80" { + t.Errorf("a.example.com service not updated: %v", r) + } + } + catchAllLast(t, got) +} + +func TestRemoveIngressRule(t *testing.T) { + in := []map[string]any{ + {"hostname": "a.example.com", "service": "http://a:80"}, + {"hostname": "b.example.com", "service": "http://b:80"}, + {"service": "http_status:404"}, + } + got := removeIngressRule(in, "a.example.com") + if hs := hostnames(got); len(hs) != 1 || hs[0] != "b.example.com" { + t.Fatalf("after remove, hostnames = %v, want [b.example.com]", hs) + } + catchAllLast(t, got) + + // Removing the last host rule leaves just a catch-all (valid minimal config). + got = removeIngressRule(got, "b.example.com") + if hs := hostnames(got); len(hs) != 0 { + t.Errorf("expected no host rules, got %v", hs) + } + catchAllLast(t, got) +} + +func applyRoute(t *testing.T, p *provider, name, tunnel, hostname, service string) { + t.Helper() + m := &protocol.Resource{ + APIVersion: apiVersion, Kind: kindTunnelRoute, + Metadata: protocol.ResourceMetadata{Name: name}, + Spec: map[string]any{"tunnel": tunnel, "hostname": hostname, "service": service}, + } + if _, err := p.Apply(context.Background(), pluginproto.ApplyParams{Manifest: m}); err != nil { + t.Fatalf("apply route %s: %v", name, err) + } +} + +// TestTunnelRouteAggregation is the core G1 guarantee: multiple TunnelRoutes +// contribute to one shared Tunnel's ingress without clobbering each other, and +// deleting one leaves the others intact. +func TestTunnelRouteAggregation(t *testing.T) { + f := newFakeCF(t) + p := configuredProvider(t, f, map[string]string{"accountId": "acct-1"}) + ctx := context.Background() + + // Create the tunnel the routes attach to. + tun := &protocol.Resource{ + APIVersion: apiVersion, Kind: kindTunnel, + Metadata: protocol.ResourceMetadata{Name: "home"}, + Spec: map[string]any{}, + } + tunRes, err := p.Apply(ctx, pluginproto.ApplyParams{Manifest: tun}) + if err != nil { + t.Fatalf("apply tunnel: %v", err) + } + tunnelID, _ := tunRes.Resource.Status["id"].(string) + if tunnelID == "" { + t.Fatal("tunnel got no id") + } + + // Two apps expose themselves via their own TunnelRoute. + applyRoute(t, p, "chat", "home", "chat.example.com", "https://traefik:443") + applyRoute(t, p, "blog", "home", "blog.example.com", "https://traefik:443") + + // The tunnel's ingress (as pushed to Cloudflare) carries both, catch-all last. + cfg, err := p.readTunnelIngress(ctx, "acct-1", tunnelID) + if err != nil { + t.Fatalf("read ingress: %v", err) + } + hs := hostnames(cfg) + if len(hs) != 2 { + t.Fatalf("ingress hostnames = %v, want chat + blog", hs) + } + catchAllLast(t, cfg) + + // Get reports Ready for a live route. + gr, err := p.getTunnelRoute(ctx, "chat", mustRouteState(t, "acct-1", tunnelID, "home", "chat.example.com")) + if err != nil { + t.Fatalf("get chat route: %v", err) + } + if gr.Resource.Status["phase"] != "Ready" { + t.Errorf("chat route phase = %v, want Ready", gr.Resource.Status["phase"]) + } + + // Deleting chat leaves blog intact. + if err := p.deleteTunnelRoute(ctx, mustRouteState(t, "acct-1", tunnelID, "home", "chat.example.com")); err != nil { + t.Fatalf("delete chat: %v", err) + } + cfg, _ = p.readTunnelIngress(ctx, "acct-1", tunnelID) + if hs := hostnames(cfg); len(hs) != 1 || hs[0] != "blog.example.com" { + t.Fatalf("after deleting chat, hostnames = %v, want [blog.example.com]", hs) + } + catchAllLast(t, cfg) + + // Get on the deleted route → NotFound. + if _, err := p.getTunnelRoute(ctx, "chat", mustRouteState(t, "acct-1", tunnelID, "home", "chat.example.com")); err == nil { + t.Error("expected NotFound for the deleted chat route") + } +} + +func mustRouteState(t *testing.T, acct, tunnelID, tunnelName, hostname string) []byte { + t.Helper() + b, err := json.Marshal(tunnelRouteState{AccountID: acct, TunnelID: tunnelID, TunnelName: tunnelName, Hostname: hostname}) + if err != nil { + t.Fatal(err) + } + return b +}