From f2844b49ddc3855adf8267b4d3d7d3ba986885c1 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 25 Oct 2026 16:07:10 +0100 Subject: [PATCH] feat(gate): warm a revision up through its public hosts once it serves The tend loop requests every member's paths through each public hostname its Canary hands the gate, the first round an Application serves a new revision, and records what it met in the release record and GET /releases. Part of #12. --- README.md | 20 ++- deploy/delivery.project.yml | 8 ++ internal/gate/export_test.go | 9 ++ internal/gate/gate.go | 9 +- internal/gate/kube.go | 10 +- internal/gate/kube_test.go | 10 +- internal/gate/releases.go | 5 +- internal/gate/tend.go | 25 +++- internal/gate/warmup.go | 148 +++++++++++++++++++++ internal/gate/warmup_test.go | 247 +++++++++++++++++++++++++++++++++++ 10 files changed, 478 insertions(+), 13 deletions(-) create mode 100644 internal/gate/export_test.go create mode 100644 internal/gate/warmup.go create mode 100644 internal/gate/warmup_test.go diff --git a/README.md b/README.md index b48c41d..62f1f06 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ Flagger, `503` a question the gate could not answer. Flagger records a refusal's Flagger's edge to the gate is the only one declared for its webhooks. - **It serves what it holds, read-only, at `GET /releases`.** For every gated Application, ordered by namespace and Application: its members and its migration, from the release-gate - inputs, and its record (`serving`, `pinned`, `since`). An Application whose inputs or record do - not read says so in `unreadable` and keeps the rest from failing with it. A thousand + inputs, and its record (`serving`, `pinned`, `since`, `warmup`). An Application whose inputs or + record do not read says so in `unreadable` and keeps the rest from failing with it. A thousand Applications at most, with `truncated` set beyond that. This is how the estate dashboard reads both without any ConfigMap access of its own (JorisJonkers-dev/estate-dashboard#15): it declares an edge to the gate's `http` surface, which its derived policy then admits. Reading it starts @@ -170,6 +170,22 @@ gated Application itself (`internal/gate/tend.go`): each ConfigMap the render wr one it cannot answer from. No rendered object says what serves once a new render is applied, so this is the gate's one piece of state, and it survives a restart. When it first sees a revision applied and not yet serving, it notes that and when. +- **It warms up a revision once it serves.** The first round an Application serves a new + revision, the gate requests every member's paths once more through each public hostname its + Canary hands it (`hostnames`: the hosts of its `anonymous` and `authenticated` routes), so the + request crosses Cloudflare and Traefik to the new primary and warms the caches nearest the + cluster. Flagger asks nothing after promotion, which is why this loop does it + (JorisJonkers-dev/delivery#12). + - Each request is `https` on port 443, through no proxy, with no redirect followed. The + hostname is resolved as an absolute name, IPv4 only, since the derived internet egress admits + no IPv6, and the gate connects only to an address that is public. It decides on the address, + not the name: loopback, private, link-local, the tailnet's `100.64.0.0/10` and the reserved + blocks are refused. + - A `5xx`, or no answer within 10 seconds, stops the warmup. The record keeps what it met in + `warmup` (`revision`, and `failed` with the first failing request). A `401`, a `404` or a + redirect passes. A revision is warmed once and never again, and nothing is rolled back: the + Reporter fails the release's Rollout on it. + - An Application no public host reaches is recorded as warmed without a request. - **It starts a migration only while its proof holds.** The up Job, `-`, is unsuspended when the record's serving revision is the inputs' `testedAgainst` and no primary's digest has moved since. A release proven against nothing starts only where nothing is recorded diff --git a/deploy/delivery.project.yml b/deploy/delivery.project.yml index 00371c7..de9a1c0 100644 --- a/deploy/delivery.project.yml +++ b/deploy/delivery.project.yml @@ -93,6 +93,14 @@ applications: placement: memory: 64Mi cpu: 25m + # The warmup after promotion crosses the internet to the edge, at a public address only; + # its checks during analysis reach each member by the member label instead. + egress: + to: internet + reason: >- + after promotion the gate requests a released Application's paths through each of its + public hostnames, so the request crosses the edge to the new primary and warms the + caches nearest the cluster api: reason: >- the Release Gate answers Flagger from what the cluster holds, so it reads each gated diff --git a/internal/gate/export_test.go b/internal/gate/export_test.go new file mode 100644 index 0000000..5c5f624 --- /dev/null +++ b/internal/gate/export_test.go @@ -0,0 +1,9 @@ +package gate + +// What the tests reach that is not the package's API. +var ( + PublicDialer = publicDialer + Public = public + ErrNoPublicAddress = errNoPublicAddress + ErrNotHTTPS = errNotHTTPS +) diff --git a/internal/gate/gate.go b/internal/gate/gate.go index cf3c62b..dd3956e 100644 --- a/internal/gate/gate.go +++ b/internal/gate/gate.go @@ -98,6 +98,9 @@ type Canary struct { // every Route to the member, and the path its readiness is asked on. Paths []string Readiness string + // Hostnames are the public hosts the Canary's webhooks hand the gate to warm through, once + // the Application serves the revision. + Hostnames []string // Transitioned is when Flagger last moved the member to another phase. Transitioned time.Time } @@ -165,6 +168,7 @@ type SyncObject struct { type Gate struct { cluster Cluster traffic Traffic + public Traffic mu sync.Mutex // askedAt is the revision each member last asked a question at, by namespace and Process. @@ -182,9 +186,12 @@ type Option func(*Gate) // WithTraffic has the Gate send its own requests through traffic rather than HTTPTraffic. func WithTraffic(traffic Traffic) Option { return func(g *Gate) { g.traffic = traffic } } +// WithPublicTraffic has the Gate warm up through public rather than PublicTraffic. +func WithPublicTraffic(public Traffic) Option { return func(g *Gate) { g.public = public } } + // New returns a Gate reading cluster. func New(cluster Cluster, options ...Option) *Gate { - g := &Gate{cluster: cluster, traffic: HTTPTraffic(), askedAt: map[string]string{}, served: served{now: time.Now}} + g := &Gate{cluster: cluster, traffic: HTTPTraffic(), public: PublicTraffic(), askedAt: map[string]string{}, served: served{now: time.Now}} for _, option := range options { option(g) } diff --git a/internal/gate/kube.go b/internal/gate/kube.go index 634079a..699ae44 100644 --- a/internal/gate/kube.go +++ b/internal/gate/kube.go @@ -133,23 +133,27 @@ func (k Kube) Canary(ctx context.Context, namespace, process string) (Canary, er } webhooks, _, _ := unstructured.NestedSlice(object.Object, "spec", "analysis", "webhooks") var read Canary - var paths string + var paths, hostnames string for i, webhook := range webhooks { fields, _ := webhook.(map[string]any) application, _, _ := unstructured.NestedString(fields, "metadata", "application") revision, _, _ := unstructured.NestedString(fields, "metadata", "revision") routes, _, _ := unstructured.NestedString(fields, "metadata", "paths") readiness, _, _ := unstructured.NestedString(fields, "metadata", "readiness") + hosts, _, _ := unstructured.NestedString(fields, "metadata", "hostnames") if i == 0 { - read.Application, read.Revision, paths, read.Readiness = application, revision, routes, readiness + read.Application, read.Revision, paths, read.Readiness, hostnames = application, revision, routes, readiness, hosts } - if application != read.Application || revision != read.Revision || routes != paths || readiness != read.Readiness { + if application != read.Application || revision != read.Revision || routes != paths || readiness != read.Readiness || hosts != hostnames { return Canary{}, errNotTheRenders } } if paths != "" { read.Paths = strings.Fields(paths) } + if hostnames != "" { + read.Hostnames = strings.Fields(hostnames) + } port, _, _ := unstructured.NestedInt64(object.Object, "spec", "service", "port") read.Port = int(port) if read.Application == "" || read.Revision == "" { diff --git a/internal/gate/kube_test.go b/internal/gate/kube_test.go index 1150c39..c820387 100644 --- a/internal/gate/kube_test.go +++ b/internal/gate/kube_test.go @@ -162,6 +162,9 @@ func TestACanaryIsReadWithWhatItsWebhooksCarryAndWhatFlaggerRecorded(t *testing. api := canary("auth-api", handing(rendered(revision), "/api /api/v1/users/register", "/api/actuator/health/readiness"), map[string]any{"phase": "WaitingPromotion", "lastAppliedSpec": "a2", "lastPromotedSpec": "a1", "iterations": int64(4)}) api.Object["spec"].(map[string]any)["service"] = map[string]any{"port": int64(8080)} + for _, w := range api.Object["spec"].(map[string]any)["analysis"].(map[string]any)["webhooks"].([]any) { + w.(map[string]any)["metadata"].(map[string]any)["hostnames"] = "auth.jorisjonkers.dev id.jorisjonkers.dev" + } k := kube(nil, api, canary("auth-ui", rendered(revision), nil), @@ -169,6 +172,10 @@ func TestACanaryIsReadWithWhatItsWebhooksCarryAndWhatFlaggerRecorded(t *testing. handing([]any{webhook("may-start", "auth", revision)}, "/api", "")[0], handing([]any{webhook("checks", "auth", revision)}, "/", "")[0], }, nil), + canary("two-host-sets", []any{ + map[string]any{"name": "may-start", "metadata": map[string]any{"application": "auth", "revision": revision, "hostnames": "a.example"}}, + map[string]any{"name": "checks", "metadata": map[string]any{"application": "auth", "revision": revision}}, + }, nil), canary("two-readiness-paths", []any{ handing([]any{webhook("may-start", "auth", revision)}, "/", "/ready")[0], handing([]any{webhook("checks", "auth", revision)}, "/", "/")[0], @@ -186,6 +193,7 @@ func TestACanaryIsReadWithWhatItsWebhooksCarryAndWhatFlaggerRecorded(t *testing. want := gate.Canary{ Application: "auth", Revision: revision, Phase: "WaitingPromotion", Applied: "a2", Promoted: "a1", Iterations: 4, Port: 8080, Paths: []string{"/api", "/api/v1/users/register"}, Readiness: "/api/actuator/health/readiness", + Hostnames: []string{"auth.jorisjonkers.dev", "id.jorisjonkers.dev"}, } if got, err := k.Canary(t.Context(), ns, "auth-api"); err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("canary = %+v, %v", got, err) @@ -193,7 +201,7 @@ func TestACanaryIsReadWithWhatItsWebhooksCarryAndWhatFlaggerRecorded(t *testing. if got, err := k.Canary(t.Context(), ns, "auth-ui"); err != nil || !reflect.DeepEqual(got, gate.Canary{Application: "auth", Revision: revision}) { t.Fatalf("a Canary Flagger has not touched = %+v, %v", got, err) } - for _, name := range []string{"two-path-sets", "two-readiness-paths", "two-revisions", "two-applications", "revision-on-one", "no-revision", "no-application", "not-webhooks", "no-webhooks", "absent"} { + for _, name := range []string{"two-path-sets", "two-host-sets", "two-readiness-paths", "two-revisions", "two-applications", "revision-on-one", "no-revision", "no-application", "not-webhooks", "no-webhooks", "absent"} { if got, err := k.Canary(t.Context(), ns, name); err == nil { t.Fatalf("%s: read as %+v, want an error", name, got) } diff --git a/internal/gate/releases.go b/internal/gate/releases.go index 9aaf2f8..e61ed13 100644 --- a/internal/gate/releases.go +++ b/internal/gate/releases.go @@ -32,6 +32,9 @@ type Release struct { Serving string `json:"serving,omitempty"` Pinned string `json:"pinned,omitempty"` Since *time.Time `json:"since,omitempty"` + // Warmup is what the warmup of the serving revision met, once the gate has warmed it: the + // Reporter fails the Rollout on it. + Warmup *Warmup `json:"warmup,omitempty"` // Unreadable says why the gate could not read the Application's inputs or record. The // fields that depend on what it could not read are then empty. Unreadable string `json:"unreadable,omitempty"` @@ -122,7 +125,7 @@ func (g *Gate) release(ctx context.Context, a Gated) (Release, error) { case err != nil: return Release{}, fmt.Errorf("gate: read the release record of %s/%s: %w", a.Namespace, a.Application, err) } - release.Serving, release.Pinned = record.Serving, record.Pinned + release.Serving, release.Pinned, release.Warmup = record.Serving, record.Pinned, record.Warmup if !record.Since.IsZero() { since := record.Since.UTC() release.Since = &since diff --git a/internal/gate/tend.go b/internal/gate/tend.go index 47724ac..cbb79a3 100644 --- a/internal/gate/tend.go +++ b/internal/gate/tend.go @@ -47,6 +47,8 @@ type Record struct { // it first saw it: the start of that release as far as the gate can tell. Pinned string `json:"pinned,omitempty"` Since time.Time `json:"since,omitzero"` + // Warmup is what the warmup of the serving revision met: absent until the gate has warmed it. + Warmup *Warmup `json:"warmup,omitempty"` } // ErrNoRecord is what Cluster.Record returns where the gate has recorded nothing yet. @@ -253,10 +255,7 @@ func (g *Gate) tend(ctx context.Context, a Gated, now time.Time) (*Held, *Refuse return nil, nil, err } if serving { - if err := g.record(ctx, r, Record{Serving: r.pinned, Promoted: r.promoted()}); err != nil { - return nil, nil, err - } - return nil, nil, g.sweep(ctx, r) + return nil, nil, g.serving(ctx, r) } // A release of the pinned revision is under way, or held. if r, err = g.note(ctx, r, now); err != nil { @@ -284,6 +283,20 @@ func (g *Gate) tend(ctx context.Context, a Gated, now time.Time) (*Held, *Refuse return held, r.refusal(condition), nil } +// serving records that the Application serves the pinned revision, warms that revision up the +// first round it serves, and sweeps what older revisions synced. A revision is warmed once and +// never again: a warmup that failed fails the Rollout and rolls nothing back. +func (g *Gate) serving(ctx context.Context, r release) error { + next := Record{Serving: r.pinned, Promoted: r.promoted(), Warmup: r.record.Warmup} + if next.Warmup == nil || next.Warmup.Revision != r.pinned { + next.Warmup = g.warm(ctx, r) + } + if err := g.record(ctx, r, next); err != nil { + return err + } + return g.sweep(ctx, r) +} + // sweep deletes the Application's sync objects of every revision but the one it now serves. Every // primary runs that revision, so none reads what they sync any more // (deploy-kit spec/v1/55-delivery.md#a-removed-grant-outlives-its-release). @@ -329,7 +342,9 @@ func (r release) refusal(condition string) *Refused { // record writes the Application's record where it differs from what the cluster holds. func (g *Gate) record(ctx context.Context, r release, next Record) error { - if next.Serving == r.record.Serving && next.Pinned == r.record.Pinned && next.Since.Equal(r.record.Since) && maps.Equal(next.Promoted, r.record.Promoted) { + sameWarmup := (next.Warmup == nil && r.record.Warmup == nil) || + (next.Warmup != nil && r.record.Warmup != nil && *next.Warmup == *r.record.Warmup) + if next.Serving == r.record.Serving && next.Pinned == r.record.Pinned && next.Since.Equal(r.record.Since) && maps.Equal(next.Promoted, r.record.Promoted) && sameWarmup { return nil } if err := g.cluster.SetRecord(ctx, r.Namespace, r.Application, next); err != nil { diff --git a/internal/gate/warmup.go b/internal/gate/warmup.go new file mode 100644 index 0000000..813e6a3 --- /dev/null +++ b/internal/gate/warmup.go @@ -0,0 +1,148 @@ +package gate + +import ( + "context" + "errors" + "fmt" + "maps" + "net" + "net/http" + "net/netip" + "regexp" + "slices" + "time" +) + +// The warmup after promotion (deploy-kit spec/v1/55-delivery.md#the-release-gate): once an +// Application serves a new revision, the gate requests the same paths once more through every +// public hostname its members' Canaries hand it, so the request crosses the edge to the new +// primary. A 5xx there fails the release's Rollout and rolls nothing back. The tend loop starts +// it, since Flagger asks no question after promotion. + +// Warmup is what the warmup of a serving revision met. +type Warmup struct { + // Revision is the revision warmed: the one the record says serves. + Revision string `json:"revision"` + // Failed says what the first failing request met; empty where none failed. + Failed string `json:"failed,omitempty"` +} + +// warmupTimeout bounds one warmup request, across the edge and a cache that may be cold. +const warmupTimeout = 10 * time.Second + +// hostname is a host the gate warms through: a lower-case DNS name of two labels or more. The +// render's schema guarantees it; the gate checks again, and the address it connects to too. +var hostname = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$`) //nolint:gochecknoglobals // a compiled constant + +// warm requests every target of every member through each of its hostnames, in member order, +// and stops at the first that fails. +func (g *Gate) warm(ctx context.Context, r release) *Warmup { + warmup := &Warmup{Revision: r.pinned} + for _, process := range slices.Sorted(maps.Keys(r.members)) { + canary := r.members[process] + targets := targetsOf(canary) + for _, host := range canary.Hostnames { + for _, path := range targets { + if !hostname.MatchString(host) || !requestable.MatchString(path) { + warmup.Failed = fmt.Sprintf("the Canary of %s hands a host or path the gate does not request", process) + return warmup + } + url := "https://" + host + path + status, _, err := g.public.Get(ctx, url) + switch { + case err != nil: + warmup.Failed = url + " was not answered" + return warmup + case status >= http.StatusInternalServerError: + warmup.Failed = fmt.Sprintf("%s answered %d", url, status) + return warmup + } + } + } + } + return warmup +} + +// PublicTraffic is Traffic across the internet to the edge: `https` on the default port only, no +// proxy, no redirect followed, each hostname resolved as an absolute name and connected to only at +// a public address. +func PublicTraffic() Traffic { + dialer := &net.Dialer{Timeout: 5 * time.Second} + lookup := func(ctx context.Context, host string) ([]netip.Addr, error) { + // IPv4 only: the derived internet egress admits no IPv6 address. + return net.DefaultResolver.LookupNetIP(ctx, "ip4", host) + } + transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // the standard library's own type + transport.Proxy = nil + transport.DialContext = publicDialer(lookup, dialer.DialContext) + return httpTraffic{client: &http.Client{ + Transport: transport, + Timeout: warmupTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }} +} + +var ( + errNotHTTPS = errors.New("gate: a warmup connects to port 443 only") + errNoPublicAddress = errors.New("gate: the hostname resolves to no public address") +) + +// publicDialer connects only to a public address on port 443. It resolves the hostname as an +// absolute name, so the cluster's search path never completes it into a Service, and decides on +// each address it would connect to, not on the name: a name that resolves somewhere internal is +// refused whatever it is called. +func publicDialer( + lookup func(ctx context.Context, host string) ([]netip.Addr, error), + dial func(ctx context.Context, network, address string) (net.Conn, error), +) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if port != "443" { + return nil, errNotHTTPS + } + addresses, err := lookup(ctx, host+".") + if err != nil { + return nil, err + } + failed := fmt.Errorf("%w: %s", errNoPublicAddress, host) + for _, a := range addresses { + if !public(a) { + continue + } + conn, err := dial(ctx, network, netip.AddrPortFrom(a.Unmap(), 443).String()) + if err == nil { + return conn, nil + } + failed = err + } + return nil, failed + } +} + +// notPublic are the ranges that are global unicast to the standard library and reach no public +// host: the carrier-grade NAT range the tailnet uses, and the IETF's special-purpose blocks. +var notPublic = []netip.Prefix{ //nolint:gochecknoglobals // a constant list + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("2001:db8::/32"), +} + +// public reports whether an address is one the warmup may connect to: global unicast, and in no +// private, shared or special-purpose range. Loopback, link-local, multicast and the cluster's own +// pod and service ranges, which are private, are all refused. +func public(a netip.Addr) bool { + a = a.Unmap() + if !a.IsGlobalUnicast() || a.IsPrivate() { + return false + } + for _, p := range notPublic { + if p.Contains(a) { + return false + } + } + return true +} diff --git a/internal/gate/warmup_test.go b/internal/gate/warmup_test.go new file mode 100644 index 0000000..ec0e6d2 --- /dev/null +++ b/internal/gate/warmup_test.go @@ -0,0 +1,247 @@ +package gate_test + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "github.com/JorisJonkers-dev/delivery/internal/gate" +) + +// edge is a fake public edge: what each URL answers, and every URL it was asked. +type edge struct { + mu sync.Mutex + answers map[string]answer + asked []string +} + +func (e *edge) Get(_ context.Context, url string) (int, time.Duration, error) { + e.mu.Lock() + defer e.mu.Unlock() + e.asked = append(e.asked, url) + a, held := e.answers[url] + if !held { + return http.StatusOK, 30 * time.Millisecond, nil + } + return a.status, a.took, a.err +} + +// promoted is auth just promoted at the revision under release, both members handing the gate +// their paths and auth's public host. +func promoted() *cluster { + c := auth() + api := of("Succeeded", "a2", "a2", 4) + api.Paths, api.Readiness, api.Hostnames = []string{"/api"}, "/ready", []string{"auth.jorisjonkers.dev"} + ui := of("Initialized", "u1", "u1", 0) + ui.Paths, ui.Readiness, ui.Hostnames = []string{"/"}, "/", []string{"auth.jorisjonkers.dev"} + c.canaries = map[string]gate.Canary{"auth-api": api, "auth-ui": ui} + c.record = &gate.Record{Serving: earlier, Promoted: map[string]string{"auth-api": "a1", "auth-ui": "u1"}, Pinned: revision, Since: first} + return c +} + +func warmed(t *testing.T, c *cluster, e *edge) *gate.Warmup { + t.Helper() + if err := gate.New(c, gate.WithPublicTraffic(e)).Tend(t.Context(), now, quiet); err != nil { + t.Fatal(err) + } + if len(c.written) == 0 { + t.Fatal("no record was written") + } + return c.written[len(c.written)-1].Warmup +} + +func TestOnceAnApplicationServesANewRevisionTheGateWarmsItThroughEveryPublicHostOnce(t *testing.T) { + c, e := promoted(), &edge{} + + got := warmed(t, c, e) + + want := []string{"https://auth.jorisjonkers.dev/api", "https://auth.jorisjonkers.dev/ready", "https://auth.jorisjonkers.dev/"} + if got == nil || *got != (gate.Warmup{Revision: revision}) || strings.Join(e.asked, " ") != strings.Join(want, " ") { + t.Fatalf("warmup = %+v, asked %v", got, e.asked) + } + if c.written[0].Serving != revision { + t.Fatalf("record = %+v", c.written[0]) + } + // The revision is warmed: the next round asks nothing and writes nothing. + if err := gate.New(c, gate.WithPublicTraffic(e)).Tend(t.Context(), now, quiet); err != nil { + t.Fatal(err) + } + if len(e.asked) != len(want) || len(c.written) != 1 { + t.Fatalf("a second round asked %v and wrote %+v", e.asked, c.written) + } +} + +func TestAWarmupFailsOnTheFirst5xxOrUnansweredRequestAndOnNothingElse(t *testing.T) { + cases := map[string]struct { + answers map[string]answer + failed string + asked int + }{ + "a 401, a 404 and a redirect pass": { + map[string]answer{ + "https://auth.jorisjonkers.dev/api": {status: http.StatusUnauthorized}, + "https://auth.jorisjonkers.dev/ready": {status: http.StatusNotFound}, + "https://auth.jorisjonkers.dev/": {status: http.StatusFound}, + }, + "", 3, + }, + "a 502 stops the warmup": { + map[string]answer{"https://auth.jorisjonkers.dev/api": {status: http.StatusBadGateway}}, + "https://auth.jorisjonkers.dev/api answered 502", 1, + }, + "no answer stops the warmup": { + map[string]answer{"https://auth.jorisjonkers.dev/ready": {err: errUnanswered}}, + "https://auth.jorisjonkers.dev/ready was not answered", 2, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c, e := promoted(), &edge{answers: tc.answers} + got := warmed(t, c, e) + if got == nil || got.Revision != revision || got.Failed != tc.failed || len(e.asked) != tc.asked { + t.Fatalf("warmup = %+v, asked %v", got, e.asked) + } + }) + } +} + +func TestTheGateWarmsNothingItShouldNot(t *testing.T) { + t.Run("an Application no public host reaches is warmed by asking nothing", func(t *testing.T) { + c, e := auth(), &edge{} + c.canaries = map[string]gate.Canary{"auth-api": of("Succeeded", "a2", "a2", 4), "auth-ui": of("Initialized", "u1", "u1", 0)} + c.record = &gate.Record{Serving: earlier, Pinned: revision, Since: first} + if got := warmed(t, c, e); got == nil || *got != (gate.Warmup{Revision: revision}) || len(e.asked) != 0 { + t.Fatalf("warmup = %+v, asked %v", got, e.asked) + } + }) + t.Run("a release still under way is not warmed", func(t *testing.T) { + c, e := promoted(), &edge{} + api := c.canaries["auth-api"] + api.Phase = "Progressing" + c.canaries["auth-api"] = api + if err := gate.New(c, gate.WithPublicTraffic(e)).Tend(t.Context(), now, quiet); err != nil { + t.Fatal(err) + } + if len(e.asked) != 0 { + t.Fatalf("asked %v", e.asked) + } + }) + for name, change := range map[string]func(*gate.Canary){ + "a host that is not lower case": func(c *gate.Canary) { c.Hostnames = []string{"Auth.jorisjonkers.dev"} }, + "a host with one label": func(c *gate.Canary) { c.Hostnames = []string{"auth"} }, + "a host with a port": func(c *gate.Canary) { c.Hostnames = []string{"auth.jorisjonkers.dev:8443"} }, + "a path with a query": func(c *gate.Canary) { c.Paths = []string{"/api?x"} }, + } { + t.Run(name+" fails the warmup and is not requested", func(t *testing.T) { + c, e := promoted(), &edge{} + api := c.canaries["auth-api"] + change(&api) + c.canaries["auth-api"] = api + got := warmed(t, c, e) + if got == nil || got.Failed != "the Canary of auth-api hands a host or path the gate does not request" || len(e.asked) != 0 { + t.Fatalf("warmup = %+v, asked %v", got, e.asked) + } + }) + } +} + +func TestTheReleasesSayWhatTheWarmupMet(t *testing.T) { + c := promoted() + c.record = &gate.Record{Serving: revision, Warmup: &gate.Warmup{Revision: revision, Failed: "https://auth.jorisjonkers.dev/ answered 503"}} + releases, err := gate.New(c).Releases(t.Context()) + if err != nil || len(releases.Releases) != 1 { + t.Fatalf("releases = %+v, %v", releases, err) + } + if w := releases.Releases[0].Warmup; w == nil || *w != *c.record.Warmup { + t.Fatalf("warmup = %+v", w) + } +} + +var errLookup = errors.New("no such host") + +func TestAWarmupConnectsOnlyToAPublicAddressOnPort443(t *testing.T) { + var looked []string + addresses := map[string][]string{ + "public.example.": {"10.42.0.7", "127.0.0.1", "203.0.113.9"}, + "internal.example.": {"10.43.0.10", "169.254.1.1", "100.100.1.1", "::1", "fd00::1"}, + "down.example.": {"203.0.113.10"}, + } + lookup := func(_ context.Context, host string) ([]netip.Addr, error) { + looked = append(looked, host) + listed, held := addresses[host] + if !held { + return nil, errLookup + } + out := make([]netip.Addr, 0, len(listed)) + for _, a := range listed { + out = append(out, netip.MustParseAddr(a)) + } + return out, nil + } + var dialed []string + errDown := errors.New("connection refused") + dial := func(_ context.Context, _, address string) (net.Conn, error) { + dialed = append(dialed, address) + if address == "203.0.113.10:443" { + return nil, errDown + } + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close(); _ = server.Close() }) + return client, nil + } + connect := gate.PublicDialer(lookup, dial) + + if conn, err := connect(t.Context(), "tcp", "public.example:443"); err != nil || conn == nil { + t.Fatalf("a public host = %v, %v", conn, err) + } + // Resolved as an absolute name, and connected to at its one public address. + if looked[0] != "public.example." || strings.Join(dialed, " ") != "203.0.113.9:443" { + t.Fatalf("looked up %v, dialed %v", looked, dialed) + } + refused := map[string]error{ + "internal.example:443": gate.ErrNoPublicAddress, + "public.example:80": gate.ErrNotHTTPS, + "missing.example:443": errLookup, + "down.example:443": errDown, + } + for address, want := range refused { + if conn, err := connect(t.Context(), "tcp", address); conn != nil || !errors.Is(err, want) { + t.Fatalf("%s = %v, %v, want %v", address, conn, err, want) + } + } + if _, err := connect(t.Context(), "tcp", "no port"); err == nil { + t.Fatal("an address with no port was dialed") + } + if strings.Join(dialed, " ") != "203.0.113.9:443 203.0.113.10:443" { + t.Fatalf("dialed %v", dialed) + } +} + +func TestOnlyAPublicAddressIsPublic(t *testing.T) { + for address, want := range map[string]bool{ + "203.0.113.9": true, "2606:4700::1": true, "::ffff:203.0.113.9": true, + "10.42.0.7": false, "172.16.0.1": false, "192.168.1.1": false, "127.0.0.1": false, + "169.254.1.1": false, "100.64.0.1": false, "100.127.255.254": false, "192.0.0.1": false, + "198.18.0.1": false, "0.0.0.0": false, "224.0.0.1": false, "::1": false, "fe80::1": false, + "fd00::1": false, "2001:db8::1": false, "::ffff:10.0.0.1": false, + } { + if got := gate.Public(netip.MustParseAddr(address)); got != want { + t.Errorf("%s: public = %v", address, got) + } + } +} + +func TestPublicTrafficRefusesAnythingButHTTPSToAPublicAddress(t *testing.T) { + traffic := gate.PublicTraffic() + for _, url := range []string{"https://localhost/", "http://203.0.113.9/"} { + if _, _, err := traffic.Get(t.Context(), url); err == nil { + t.Fatalf("%s was requested", url) + } + } +}