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) + } + } +}