Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
31 changes: 31 additions & 0 deletions examples/expose-app.yaml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 9 additions & 2 deletions plugins/cloudflare/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}

Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions plugins/cloudflare/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
33 changes: 33 additions & 0 deletions plugins/cloudflare/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
...
}
`
192 changes: 192 additions & 0 deletions plugins/cloudflare/tunnelroute.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading