From 26fd3dc66eb4d09fa56d3595b4d6466643981863 Mon Sep 17 00:00:00 2001 From: Jeremy Harrington Date: Tue, 21 Jul 2026 06:54:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(cloudflare):=20TunnelRoute=20=E2=80=94=20p?= =?UTF-8?q?er-app=20tunnel=20ingress=20(G1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ROADMAP.md | 21 ++- examples/expose-app.yaml | 31 ++++ plugins/cloudflare/provider.go | 11 +- plugins/cloudflare/provider_test.go | 2 + plugins/cloudflare/schema.go | 33 +++++ plugins/cloudflare/tunnelroute.go | 192 +++++++++++++++++++++++++ plugins/cloudflare/tunnelroute_test.go | 177 +++++++++++++++++++++++ 7 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 examples/expose-app.yaml create mode 100644 plugins/cloudflare/tunnelroute.go create mode 100644 plugins/cloudflare/tunnelroute_test.go 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 +}