feat: static serving class, carlos vet, end-to-end smoke; flagship mail applied
Static: deterministic PackDir, sha-verified extraction, atomic version swap, 60s pointer re-check. Vet: manifest+artifact+boot checks. E2e: ship/promote/rollback through a live edge. Infra: dnsimple provider consolidated, lock files committed, plan.out ignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 files changed,
+1321
−19
e2e_test.go+265 −0infra/flagship/.gitignore+3 −0infra/flagship/.terraform.lock.hcl+36 −0infra/flagship/mail.tf+0 −9infra/flagship/main.tf+4 −0infra/tito/.gitignore+3 −0infra/tito/.terraform.lock.hcl+39 −0internal/edge/edge_test.go+8 −0internal/edge/static.go+331 −10internal/edge/static_test.go+235 −0internal/vet/vet.go+227 −0internal/vet/vet_test.go+129 −0testdata/echoapp/main.go+41 −0
diff --git a/e2e_test.go b/e2e_test.go| new file mode 100644 |
| index 000000000..ffad5a956 |
| --- /dev/null |
| +++ b/e2e_test.go |
| @@ -0,0 +1,265 @@ |
| +// End-to-end smoke of the release/serve/rollback loop, AWS-free: a DirStore |
| +// deployment bucket, a plain-HTTP edge on a TCP port, a real compiled tenant |
| +// (testdata/echoapp) behind a TCP-addr route, and a static site behind a |
| +// channel-tracked static route. Host-side adoption (release.Pass, symlink |
| +// repointing) is internal/host territory and is deliberately NOT exercised |
| +// here — the loop this test proves is: |
| +// |
| +// 1. the edge routes to a live backend by Host header, |
| +// 2. a shipped+promoted static tree is served through the edge, |
| +// 3. promote advances the served content, rollback returns the pointer to |
| +// the previous version, and the static handler swaps back on re-check. |
| +package main_test |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/vet" |
| +) |
| + |
| +// e2eGet GETs the edge with an explicit Host header, the way a real client |
| +// reaches a routed hostname. |
| +func e2eGet(t *testing.T, base, host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", base+path, nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s%s (host %s): %v", base, path, host, err) |
| + } |
| + defer resp.Body.Close() |
| + b, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + t.Fatalf("read body: %v", err) |
| + } |
| + return resp.StatusCode, string(b) |
| +} |
| + |
| +func e2eShipSite(t *testing.T, store release.Store, version string, files map[string]string) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + for name, body := range files { |
| + p := filepath.Join(dir, filepath.FromSlash(name)) |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatalf("mkdir: %v", err) |
| + } |
| + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { |
| + t.Fatalf("write %s: %v", name, err) |
| + } |
| + } |
| + tarball, err := edge.PackDir(dir) |
| + if err != nil { |
| + t.Fatalf("PackDir: %v", err) |
| + } |
| + m := release.Manifest{ |
| + Schema: 1, App: "site", Version: version, Kind: "static", |
| + Artifacts: map[string]release.Artifact{"site.tar.gz": {}}, |
| + } |
| + if err := release.Ship(context.Background(), store, m, map[string][]byte{"site.tar.gz": tarball}); err != nil { |
| + t.Fatalf("Ship site %s: %v", version, err) |
| + } |
| +} |
| + |
| +func e2eShipBinary(t *testing.T, store release.Store, version string, bin []byte) { |
| + t.Helper() |
| + m := release.Manifest{ |
| + Schema: 1, App: "echoapp", Version: version, Kind: "binary", |
| + Artifacts: map[string]release.Artifact{"echoapp": {}}, |
| + } |
| + if err := release.Ship(context.Background(), store, m, map[string][]byte{"echoapp": bin}); err != nil { |
| + t.Fatalf("Ship echoapp %s: %v", version, err) |
| + } |
| +} |
| + |
| +func TestEndToEndSmoke(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found; skipping e2e smoke") |
| + } |
| + ctx := context.Background() |
| + tmp := t.TempDir() |
| + |
| + // --- build the tenant --------------------------------------------------- |
| + appBin := filepath.Join(tmp, "echoapp") |
| + build := exec.Command(goBin, "build", "-o", appBin, "./testdata/echoapp") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build echoapp: %v\n%s", err, out) |
| + } |
| + binBytes, err := os.ReadFile(appBin) |
| + if err != nil { |
| + t.Fatalf("read echoapp: %v", err) |
| + } |
| + |
| + // --- release plane: DirStore + signing keys ----------------------------- |
| + store := release.NewDirStore(filepath.Join(tmp, "store")) |
| + seedB64, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + priv, err := release.ParseSigningKey(seedB64, "e2e") |
| + if err != nil { |
| + t.Fatalf("ParseSigningKey: %v", err) |
| + } |
| + var pub ed25519.PublicKey |
| + if pub, err = release.ParsePublicKey(pubB64, "e2e"); err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + |
| + // Ship + promote the binary app and the static site to canary/x. |
| + e2eShipBinary(t, store, "v1", binBytes) |
| + if err := release.Promote(ctx, store, priv, "echoapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote echoapp v1: %v", err) |
| + } |
| + e2eShipSite(t, store, "v1", map[string]string{"index.html": "site v1", "docs/page.html": "docs v1"}) |
| + e2eShipSite(t, store, "v2", map[string]string{"index.html": "site v2"}) |
| + if err := release.Promote(ctx, store, priv, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote site v1: %v", err) |
| + } |
| + |
| + // The shipped binary release passes vet (boot check self-skips where the |
| + // sandbox forbids unix listeners). |
| + if probs := vet.Check(ctx, store, pub, "echoapp", "v1", true); len(probs) != 0 { |
| + t.Fatalf("vet.Check echoapp v1 = %v, want clean", probs) |
| + } |
| + |
| + // --- backend process on a TCP port (NOT a unix socket: sandbox) --------- |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatalf("pick port: %v", err) |
| + } |
| + backendAddr := ln.Addr().String() |
| + ln.Close() |
| + app := exec.Command(appBin, "-addr", backendAddr) |
| + app.Env = append(os.Environ(), "BUILD=v1") |
| + if err := app.Start(); err != nil { |
| + t.Fatalf("start echoapp: %v", err) |
| + } |
| + t.Cleanup(func() { |
| + _ = app.Process.Kill() |
| + _, _ = app.Process.Wait() |
| + }) |
| + waitUp := func() { |
| + deadline := time.Now().Add(10 * time.Second) |
| + for time.Now().Before(deadline) { |
| + resp, err := http.Get("http://" + backendAddr + "/healthz") |
| + if err == nil { |
| + resp.Body.Close() |
| + if resp.StatusCode == 200 { |
| + return |
| + } |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + t.Fatalf("echoapp never became healthy on %s", backendAddr) |
| + } |
| + waitUp() |
| + |
| + // --- registry + edge (dev shape: plain HTTP on a TCP port) -------------- |
| + reg, err := registry.Open(filepath.Join(tmp, "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "app.local", Kind: "service", Addr: backendAddr, App: "echoapp", Channel: "canary/x", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert app route: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "site.local", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert site route: %v", err) |
| + } |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("edge.New: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(tmp, "static-cache")) |
| + e.SetStaticRecheck(0) // re-check the channel pointer on every request |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("edge.Reload: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + // (1) The edge routes by Host header to the live backend. |
| + if code, body := e2eGet(t, srv.URL, "app.local", "/"); code != 200 || !strings.Contains(body, "BUILD=v1") { |
| + t.Fatalf("GET app.local / = %d %q, want 200 containing BUILD=v1", code, body) |
| + } |
| + if code, body := e2eGet(t, srv.URL, "app.local", "/healthz"); code != 200 || body != "ok" { |
| + t.Fatalf("GET app.local /healthz = %d %q, want 200 ok", code, body) |
| + } |
| + if code, _ := e2eGet(t, srv.URL, "nobody.local", "/"); code != http.StatusNotFound { |
| + t.Fatalf("GET unknown host = %d, want 404", code) |
| + } |
| + |
| + // (2) The static site is served from the shipped+promoted tree. |
| + if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v1" { |
| + t.Fatalf("GET site.local / = %d %q, want 200 %q", code, body, "site v1") |
| + } |
| + if code, body := e2eGet(t, srv.URL, "site.local", "/docs/page.html"); code != 200 || body != "docs v1" { |
| + t.Fatalf("GET site.local /docs/page.html = %d %q, want 200 %q", code, body, "docs v1") |
| + } |
| + if code, _ := e2eGet(t, srv.URL, "site.local", "/missing"); code != http.StatusNotFound { |
| + t.Fatalf("GET site.local /missing = %d, want 404", code) |
| + } |
| + |
| + // (3) Promote advances the served site; rollback swaps it back. |
| + if err := release.Promote(ctx, store, priv, "site", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote site v2: %v", err) |
| + } |
| + if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v2" { |
| + t.Fatalf("after promote, GET site.local / = %d %q, want 200 %q", code, body, "site v2") |
| + } |
| + if err := release.Rollback(ctx, store, priv, "site", "canary/x"); err != nil { |
| + t.Fatalf("Rollback site: %v", err) |
| + } |
| + cp, err := release.Channel(ctx, store, pub, "site", "canary/x") |
| + if err != nil { |
| + t.Fatalf("Channel site after rollback: %v", err) |
| + } |
| + if cp.Version != "v1" || len(cp.History) == 0 || cp.History[0] != "v2" { |
| + t.Fatalf("rolled-back pointer = %s (history %v), want v1 with v2 first in history", cp.Version, cp.History) |
| + } |
| + if code, body := e2eGet(t, srv.URL, "site.local", "/"); code != 200 || body != "site v1" { |
| + t.Fatalf("after rollback, GET site.local / = %d %q, want 200 %q", code, body, "site v1") |
| + } |
| + |
| + // Binary channel pointer flow: ship v2 (same bytes — a rebuild), promote, |
| + // then roll back; the pointer must return to v1 with v2 as the rollback |
| + // target. (Applying the pointer on a box is host/agent territory.) |
| + e2eShipBinary(t, store, "v2", binBytes) |
| + if err := release.Promote(ctx, store, priv, "echoapp", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote echoapp v2: %v", err) |
| + } |
| + cp, err = release.Channel(ctx, store, pub, "echoapp", "canary/x") |
| + if err != nil { |
| + t.Fatalf("Channel echoapp: %v", err) |
| + } |
| + if cp.Version != "v2" || len(cp.History) == 0 || cp.History[0] != "v1" { |
| + t.Fatalf("promoted pointer = %s (history %v), want v2 over v1", cp.Version, cp.History) |
| + } |
| + if err := release.Rollback(ctx, store, priv, "echoapp", "canary/x"); err != nil { |
| + t.Fatalf("Rollback echoapp: %v", err) |
| + } |
| + cp, err = release.Channel(ctx, store, pub, "echoapp", "canary/x") |
| + if err != nil { |
| + t.Fatalf("Channel echoapp after rollback: %v", err) |
| + } |
| + if cp.Version != "v1" || len(cp.History) == 0 || cp.History[0] != "v2" { |
| + t.Fatalf("rolled-back pointer = %s (history %v), want v1 with v2 first in history", cp.Version, cp.History) |
| + } |
| +} |
diff --git a/infra/flagship/.gitignore b/infra/flagship/.gitignore| new file mode 100644 |
| index 000000000..58401f092 |
| --- /dev/null |
| +++ b/infra/flagship/.gitignore |
| @@ -0,0 +1,3 @@ |
| +*.tfstate* |
| +.terraform/ |
| +plan.out |
diff --git a/infra/flagship/.terraform.lock.hcl b/infra/flagship/.terraform.lock.hcl| index ab67445ee..b47796b99 100644 |
| --- a/infra/flagship/.terraform.lock.hcl |
| +++ b/infra/flagship/.terraform.lock.hcl |
| @@ -1,6 +1,42 @@ |
| # This file is maintained automatically by "tofu init". |
| # Manual edits may be lost in future updates. |
| +provider "registry.opentofu.org/dnsimple/dnsimple" { |
| + version = "1.10.0" |
| + constraints = "~> 1.5" |
| + hashes = [ |
| + "h1:EVDDjVKBij3ivkK6+tyS9jWxSExxVvOoGtMS8/M43f8=", |
| + "h1:HK5nqc5bZFdth/BcNc9GqUfXAYsDn8Ev/tgfmB0jMYI=", |
| + "h1:Hhbv3mnLhgY9NuvjzUqDo0PQKNTfStULx+m+S3wHKQg=", |
| + "h1:IHnPrj3/ayQ7PmKNkaq2nzoT87F+hnoSp5c/xdsPjnY=", |
| + "h1:O13KzReeaEXMiUe6qx2HDgHVVhBan2FnSiq41BMtSgo=", |
| + "h1:RN2gojQH/bA1zTRUOGdGH8w03uIa2agezDZyMY1VhV8=", |
| + "h1:UOCA/dFp2ORSVi/WlCCZrjjfXPUy9vpgk4PEBv6h4w0=", |
| + "h1:YyaCa5mDiLhM8lddBZyjKo5iM9lfunk0uA1X0uOSvoo=", |
| + "h1:ZNsEnMVMw39wrFufI7N5ypjnl92awiUlZ97z92MUPrs=", |
| + "h1:cQvKfEDJeGr3a0wWbGLoS+x0Orx8+9mWwSNAYfnqljM=", |
| + "h1:oV3AqgcHjXhMmoYmdHAerNEDRCkVq9QKvMnsyodsnSc=", |
| + "h1:r9RpaCOvjJjEZ6S3v3KPB6eNqf/STR9dYc94ippxhGk=", |
| + "h1:rIdsl1pgd2fqiSyB7lyn6gRh8RPq3MfCA5bt7q4NTWc=", |
| + "h1:zROoguvUNCxV46qrJGwbH7vEoUwxQJIfZrxb20teJa0=", |
| + "zh:025dbad287c9a9ca61b84fc4a14d3f595dbbc1139d5cf034597922264b756f6f", |
| + "zh:0c2fa8513004732f3aae6a408eb68ff9c2ad119f8bcefe491f0420ad0f4fe7d5", |
| + "zh:1ebb4cebfc29dfd8dd7f4a03f57aebe118a9dda15caf17eace06a16b40507317", |
| + "zh:5585c10af512519f006491bec5e63504e3682f61af4ab4c4807721de7764bf0f", |
| + "zh:571ead8072d43334d8ac169c86da21c9b325439508344898988f925f89ea7593", |
| + "zh:5bdd4d83e0831f0094f06c9d56aed7e1388967e9f4f4144d69c5c37d3d170e45", |
| + "zh:67982e17e937d71c1b2c83d740cd37a28c9c6ad16d325dddb91327303d6e96ff", |
| + "zh:7de0afaaed2668c27c7189180ceda3bc73e17b21a150ecec8eb3979e95875323", |
| + "zh:88911647fc4f8eaac3699b72ca7a0bee8f33521725b839b20235138f5072bfb7", |
| + "zh:8ad2a7f599864602e3126a9c31ec28b8c277090e98f80cd0d5e29eddb1bebae1", |
| + "zh:ac47ee0fd0837b57945fe961656f1f8e3c20e31178c49aafa7836b93aa5966a6", |
| + "zh:acdc1a9a5553d8d0e7aa887acfa0cf440f9c428dabc0c46b61d1b3eae6443e09", |
| + "zh:b3e3d236b72b5d975098f29e42a8eff071064de99cd81b27e264e5641aed7365", |
| + "zh:d21daf975282bdddc7c66580d0fde5395a0ed67fdfe3c3c87aec952a6604ba24", |
| + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", |
| + ] |
| +} |
| + |
| provider "registry.opentofu.org/hashicorp/aws" { |
| version = "6.57.1" |
| constraints = "~> 6.0" |
diff --git a/infra/flagship/mail.tf b/infra/flagship/mail.tf| index 60d92bec4..312ffda60 100644 |
| --- a/infra/flagship/mail.tf |
| +++ b/infra/flagship/mail.tf |
| @@ -8,15 +8,6 @@ |
| # tokens; the old account's DKIM records coexist in DNSimple until the |
| # old account winds down. |
| -terraform { |
| - required_providers { |
| - dnsimple = { |
| - source = "dnsimple/dnsimple" |
| - version = "~> 1.5" |
| - } |
| - } |
| -} |
| - |
| # Reads DNSIMPLE_TOKEN from the environment; never written to a file here. |
| provider "dnsimple" { |
| account = "74881" |
diff --git a/infra/flagship/main.tf b/infra/flagship/main.tf| index 46f74c278..47c21424e 100644 |
| --- a/infra/flagship/main.tf |
| +++ b/infra/flagship/main.tf |
| @@ -18,6 +18,10 @@ terraform { |
| source = "hashicorp/aws" |
| version = "~> 6.0" |
| } |
| + dnsimple = { |
| + source = "dnsimple/dnsimple" |
| + version = "~> 1.5" |
| + } |
| } |
| } |
diff --git a/infra/tito/.gitignore b/infra/tito/.gitignore| new file mode 100644 |
| index 000000000..951fb8e3a |
| --- /dev/null |
| +++ b/infra/tito/.gitignore |
| @@ -0,0 +1,3 @@ |
| +plan.out |
| +*.tfstate* |
| +.terraform/ |
diff --git a/infra/tito/.terraform.lock.hcl b/infra/tito/.terraform.lock.hcl| new file mode 100644 |
| index 000000000..ab67445ee |
| --- /dev/null |
| +++ b/infra/tito/.terraform.lock.hcl |
| @@ -0,0 +1,39 @@ |
| +# This file is maintained automatically by "tofu init". |
| +# Manual edits may be lost in future updates. |
| + |
| +provider "registry.opentofu.org/hashicorp/aws" { |
| + version = "6.57.1" |
| + constraints = "~> 6.0" |
| + hashes = [ |
| + "h1:8wbrCS3R5lunN2HMVfQ+oMB3uFVAO7gfUbItdZa+tdA=", |
| + "h1:C4XEcAWUKXExKCF6NhvMxkSyZ/JqNWjtwtwwEqQtl70=", |
| + "h1:Fah+p3BD+/E7wdgazQFp4fsjuvTh5/Q7fQFyMsv9juM=", |
| + "h1:S6eLURiAh6L00nr+9s2zwuhHQOU04rEG60Z9xO1ORRM=", |
| + "h1:UdllVJ4482tLgz5q8se3SPzpPoXuFXwvxiykpgdipWs=", |
| + "h1:ZAkIIdmQryKnldenUI2fqThlAciNz2UYKVTZ5F/57ME=", |
| + "h1:a/i6TVZX4jSNSvHIWWTqLIlaugg7SYfQMZNVtzDWlKM=", |
| + "h1:gGtYen1GfQvynKT8lHguY+cvmxNww+TOqCaapWoNW1w=", |
| + "h1:iFr4+F/b94MEadHCiaNm6o6TapyvLT6TrwmbPyDQs9E=", |
| + "h1:k0OzZI0aBgeEhCUcoK00/6C3hBK59ETo7g5Jq62wRqQ=", |
| + "h1:nlvu+qO8eMFdnMptYSUK1c5dOXoK6r3HG87jzE7EYvQ=", |
| + "h1:rVP8Z9tc37nwsW+NLSvEQyUBoDG/zsMtPSMeHhBrR3k=", |
| + "h1:rxnmHWTUE2Aou/otNocOI4Rvcm69LQsoNYssj0s7hIo=", |
| + "h1:va0Q0eDSt7xyvkSX7GbXmAO/QUqugZb/05gMdcQlR0I=", |
| + "h1:yXF1EzUTMepZv4SeQnBNUvjOj2HKEKSl+jVndIKksGU=", |
| + "zh:053910cce06bba506fb7a3dcecdcd719280d7df8b8a7cce06ae3e2dd20b1cc0d", |
| + "zh:14feabeefef742b528c978a78f6896240a7ca3464a4714a5b1e1b2d448db4fd5", |
| + "zh:22dd549e3c479b2ff2121e00a005aa3b6b43a166e727ef3208a529a59feb1608", |
| + "zh:3ca5eab63ac77da335d42ebe0af8569c5c617dc5b239750b111f5ba9f2f889cd", |
| + "zh:3d686f9fca9752850958a353971cd7eb1f5a48e55c2c56fcd96d18cc6e54f8ae", |
| + "zh:512e46d35abfec0ba85fc0f2da7dc39fece9106f02165e95096b6549f920453a", |
| + "zh:5fe83903b4bef5b7318de4b61e0ff9c88aa8ac42b7abeae83d1599cb054b0908", |
| + "zh:61ec8a18a7270e28f43c0f810c82a147af7a5de622eb1f243c3a773e234b2172", |
| + "zh:6428cd62e615ccaaa3a26a22f68a84f80a0d497a04f0405648119ef69e709ff9", |
| + "zh:776e57bb2c94716f452eea5803c90b97d304c9f343e6746780a255d8217b95f9", |
| + "zh:99bacf79ce92568d086ed62b287d9b77fa0c4e86a6c25ad398bec04ea0e44897", |
| + "zh:b4361d4ad5074df3e83dc0e40452b63f231ae5959b4007763739d3a02caffe29", |
| + "zh:d96c0f8603a1f8ab61c633a7a707922313f6bdfa8d17fa21d9ccf103fade6af7", |
| + "zh:edfd955636489371e5ad1f40fc77a023d7eb7d09140342b151f0ea15451ef41b", |
| + "zh:eeb03674754bd059fafa62b58cf4015c2c07bc25477def3c64eef487d583a63c", |
| + ] |
| +} |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 65bbb5a73..43189162f 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -1,11 +1,14 @@ |
| package edge |
| import ( |
| + "errors" |
| "io" |
| "net" |
| "net/http" |
| "net/http/httptest" |
| "path/filepath" |
| + "strings" |
| + "syscall" |
| "testing" |
| "time" |
| @@ -152,6 +155,11 @@ func TestUnixSocketUpstream(t *testing.T) { |
| sock := filepath.Join(t.TempDir(), "u.sock") |
| ln, err := net.Listen("unix", sock) |
| if err != nil { |
| + // Sandboxed environments block unix-socket listeners (EPERM); that is |
| + // an environment limit, not an edge bug. |
| + if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("unix-socket listeners not permitted in this environment: %v", err) |
| + } |
| t.Fatalf("listen unix: %v", err) |
| } |
| seenHost := new(string) |
diff --git a/internal/edge/static.go b/internal/edge/static.go| index 7a6d63340..29abc854c 100644 |
| --- a/internal/edge/static.go |
| +++ b/internal/edge/static.go |
| @@ -1,20 +1,341 @@ |
| +// The static serving class (routes with Kind=="static"): channel-tracked |
| +// file trees served at the edge. A static route names an app + release |
| +// channel; the release for that channel carries ONE artifact, site.tar.gz |
| +// (a gzipped tar of the site tree, produced by PackDir). The handler |
| +// resolves the signed channel pointer (cached, re-checked every 60s), |
| +// extracts the sha256-verified tar into <cacheDir>/<app>/<version>/, and |
| +// serves it with http.FileServer semantics — index.html for directories, |
| +// clean 404s. A pointer advance atomically swaps the served version (and the |
| +// `current` symlink) without dropping a request. |
| package edge |
| -// The static serving class (routes with Kind=="static"): channel-tracked file |
| -// trees served at the edge. This file is a stub — Task A8 replaces it with |
| -// the real handler (resolves the route's release-channel pointer, extracts |
| -// the sha256-verified site tar into a cache dir, serves with http.FileServer |
| -// semantics). Until then a static route answers 404, so the route can exist |
| -// (and its cert can be minted) before the serving class lands. |
| - |
| import ( |
| + "archive/tar" |
| + "bytes" |
| + "compress/gzip" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "io" |
| + "io/fs" |
| + "log" |
| "net/http" |
| + "os" |
| + "path" |
| + "path/filepath" |
| + "sort" |
| + "sync" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| -func staticHandler(rt registry.Route) http.Handler { |
| - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - http.Error(w, "static serving not yet implemented", http.StatusNotFound) |
| +// siteArtifactFile is the one artifact a static release carries: the gzipped |
| +// tar of the site tree (`carlos ship -kind static` packs it via PackDir). |
| +const siteArtifactFile = "site.tar.gz" |
| + |
| +// staticRecheck is how long a resolved channel pointer is trusted before the |
| +// handler re-reads it from the store. Tests drop it to 0 (re-check every |
| +// request) via the staticSite field or Edge.SetStaticRecheck. |
| +const staticRecheck = 60 * time.Second |
| + |
| +// PackDir packs a site tree into the gzipped tar the static handler expects. |
| +// The output is deterministic — sorted paths, zeroed times/uid/gid, fixed |
| +// 0644 modes — so re-shipping an unchanged tree produces byte-identical |
| +// artifacts (and Ship's immutable-put treats it as an idempotent no-op). |
| +// Only regular files are packed: directories are implied by file paths, and |
| +// anything else (symlinks, devices) in a site tree is a mistake, not content. |
| +func PackDir(dir string) ([]byte, error) { |
| + var names []string |
| + err := filepath.WalkDir(dir, func(p string, de fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if de.IsDir() { |
| + return nil |
| + } |
| + if !de.Type().IsRegular() { |
| + return fmt.Errorf("pack %s: %s is not a regular file", dir, p) |
| + } |
| + rel, err := filepath.Rel(dir, p) |
| + if err != nil { |
| + return err |
| + } |
| + names = append(names, filepath.ToSlash(rel)) |
| + return nil |
| }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sort.Strings(names) |
| + var buf bytes.Buffer |
| + gz := gzip.NewWriter(&buf) |
| + tw := tar.NewWriter(gz) |
| + for _, name := range names { |
| + b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := tw.WriteHeader(&tar.Header{ |
| + Typeflag: tar.TypeReg, |
| + Name: name, |
| + Mode: 0o644, |
| + Size: int64(len(b)), |
| + ModTime: time.Unix(0, 0), // zeroed: determinism over provenance |
| + }); err != nil { |
| + return nil, err |
| + } |
| + if _, err := tw.Write(b); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if err := tw.Close(); err != nil { |
| + return nil, err |
| + } |
| + if err := gz.Close(); err != nil { |
| + return nil, err |
| + } |
| + return buf.Bytes(), nil |
| +} |
| + |
| +// staticSite serves one static route. All mutable state (resolved version, |
| +// last pointer check) sits behind mu; requests serve from an immutable |
| +// per-version directory, so a swap never races a response in flight. |
| +type staticSite struct { |
| + app, channel string |
| + store release.Store |
| + pub ed25519.PublicKey |
| + cacheDir string |
| + // recheck is how long a resolved pointer is trusted; 0 = every request. |
| + recheck time.Duration |
| + |
| + mu sync.Mutex |
| + version string // currently served version; "" until first resolve |
| + checkedAt time.Time // when the pointer was last read |
| +} |
| + |
| +// newStaticHandler builds the serving handler for one static route. The |
| +// route's empty Channel defaults to "stable", mirroring `carlos add`. |
| +func newStaticHandler(rt registry.Route, store release.Store, pub ed25519.PublicKey, cacheDir string) http.Handler { |
| + ch := rt.Channel |
| + if ch == "" { |
| + ch = "stable" |
| + } |
| + return &staticSite{ |
| + app: rt.App, |
| + channel: ch, |
| + store: store, |
| + pub: pub, |
| + cacheDir: cacheDir, |
| + recheck: staticRecheck, |
| + } |
| +} |
| + |
| +func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + dir, err := s.currentDir(r.Context()) |
| + if err != nil { |
| + log.Printf("edge: static %s/%s: %v", s.app, s.channel, err) |
| + http.Error(w, "static site unavailable", http.StatusBadGateway) |
| + return |
| + } |
| + s.serveTree(dir, w, r) |
| +} |
| + |
| +// dir is where version's tree lives once extracted. |
| +func (s *staticSite) dir(version string) string { |
| + return filepath.Join(s.cacheDir, s.app, version) |
| +} |
| + |
| +// currentDir returns the directory to serve this request from, re-resolving |
| +// the channel pointer when the cache window has lapsed. A resolution or |
| +// staging failure NEVER takes down a site that was already serving: the |
| +// cached version keeps answering and the pointer is retried next window. |
| +// Only a handler that has never resolved (nothing safe to serve) errors out. |
| +func (s *staticSite) currentDir(ctx context.Context) (string, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + if s.version != "" && s.recheck > 0 && time.Since(s.checkedAt) < s.recheck { |
| + return s.dir(s.version), nil |
| + } |
| + cp, err := release.Channel(ctx, s.store, s.pub, s.app, s.channel) |
| + if err != nil { |
| + return s.stale(fmt.Errorf("resolve channel: %w", err)) |
| + } |
| + if cp.Version != s.version { |
| + if err := s.ensureExtracted(ctx, cp.Version); err != nil { |
| + return s.stale(fmt.Errorf("stage %s: %w", cp.Version, err)) |
| + } |
| + if err := s.pointCurrent(cp.Version); err != nil { |
| + return s.stale(fmt.Errorf("swap current -> %s: %w", cp.Version, err)) |
| + } |
| + s.version = cp.Version |
| + } |
| + s.checkedAt = time.Now() |
| + return s.dir(s.version), nil |
| +} |
| + |
| +// stale is the degraded path: keep serving the version we already verified |
| +// and extracted, or fail if there has never been one. |
| +func (s *staticSite) stale(err error) (string, error) { |
| + if s.version == "" { |
| + return "", err |
| + } |
| + log.Printf("edge: static %s/%s: %v — still serving cached %s", s.app, s.channel, err, s.version) |
| + s.checkedAt = time.Now() // back off: retry next recheck window, not next request |
| + return s.dir(s.version), nil |
| +} |
| + |
| +// ensureExtracted makes <cacheDir>/<app>/<version>/ exist and hold the |
| +// release's verified site tree. The tar's sha256 is checked against the |
| +// manifest BEFORE extraction — tampered artifact bytes never touch the |
| +// serving path — and extraction goes to a temp dir renamed into place, so |
| +// the version dir's existence means "complete and verified". app and version |
| +// are path-safe by construction: both came out of a signature-verified |
| +// pointer whose fields passed ValidApp/ValidVersion. |
| +func (s *staticSite) ensureExtracted(ctx context.Context, version string) error { |
| + dst := s.dir(version) |
| + if _, err := os.Stat(dst); err == nil { |
| + return nil // already staged by an earlier check (or a sibling handler) |
| + } |
| + m, err := release.GetManifest(ctx, s.store, s.app, version) |
| + if err != nil { |
| + return err |
| + } |
| + if m.Kind != "static" { |
| + return fmt.Errorf("release %s/%s is kind %q, not static", s.app, version, m.Kind) |
| + } |
| + var art *release.Artifact |
| + for _, a := range m.Artifacts { |
| + if a.File == siteArtifactFile { |
| + art = &a |
| + break |
| + } |
| + } |
| + if art == nil { |
| + return fmt.Errorf("release %s/%s has no %s artifact", s.app, version, siteArtifactFile) |
| + } |
| + data, _, err := s.store.Get(ctx, release.ArtifactKey(s.app, version, art.File)) |
| + if err != nil { |
| + return err |
| + } |
| + sum := sha256.Sum256(data) |
| + if hex.EncodeToString(sum[:]) != art.SHA256 { |
| + return fmt.Errorf("%s sha256 mismatch (manifest %s, stored %s) — refusing to extract", |
| + siteArtifactFile, art.SHA256, hex.EncodeToString(sum[:])) |
| + } |
| + if int64(len(data)) != art.Size { |
| + return fmt.Errorf("%s size mismatch (manifest %d, stored %d)", siteArtifactFile, art.Size, len(data)) |
| + } |
| + appDir := filepath.Join(s.cacheDir, s.app) |
| + if err := os.MkdirAll(appDir, 0o755); err != nil { |
| + return err |
| + } |
| + tmp, err := os.MkdirTemp(appDir, ".extract-") |
| + if err != nil { |
| + return err |
| + } |
| + if err := extractSiteTar(data, tmp); err != nil { |
| + os.RemoveAll(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, dst); err != nil { |
| + os.RemoveAll(tmp) |
| + if _, serr := os.Stat(dst); serr == nil { |
| + return nil // lost the staging race; the winner's tree is identical |
| + } |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// pointCurrent atomically swaps the app's `current` symlink to version's dir |
| +// (temp symlink + rename — the same swap versions.go's PointLive uses), so |
| +// anything looking at the cache dir sees the version flip as one atomic step. |
| +func (s *staticSite) pointCurrent(version string) error { |
| + link := filepath.Join(s.cacheDir, s.app, "current") |
| + tmp := link + ".tmp" |
| + _ = os.Remove(tmp) // a leftover from a crashed swap |
| + if err := os.Symlink(version, tmp); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(tmp, link); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// extractSiteTar unpacks a verified site tar into dst, rejecting anything |
| +// that would write outside it: absolute names, ".." traversal, and non-file |
| +// entry types (a symlink in a site tar could alias the extraction outside |
| +// the cache — PackDir never emits one, so none is ever legitimate). |
| +func extractSiteTar(data []byte, dst string) error { |
| + gz, err := gzip.NewReader(bytes.NewReader(data)) |
| + if err != nil { |
| + return fmt.Errorf("site tar: %v", err) |
| + } |
| + defer gz.Close() |
| + tr := tar.NewReader(gz) |
| + for { |
| + hdr, err := tr.Next() |
| + if err == io.EOF { |
| + return nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("site tar: %v", err) |
| + } |
| + name := filepath.FromSlash(path.Clean(hdr.Name)) |
| + if !filepath.IsLocal(name) { |
| + return fmt.Errorf("site tar: entry %q escapes the extraction dir", hdr.Name) |
| + } |
| + switch hdr.Typeflag { |
| + case tar.TypeDir: |
| + if err := os.MkdirAll(filepath.Join(dst, name), 0o755); err != nil { |
| + return err |
| + } |
| + case tar.TypeReg: |
| + p := filepath.Join(dst, name) |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + return err |
| + } |
| + f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := io.Copy(f, tr); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + return err |
| + } |
| + case tar.TypeXGlobalHeader: |
| + // pax global header: metadata, nothing to write |
| + default: |
| + return fmt.Errorf("site tar: entry %q has unsupported type %d", hdr.Name, hdr.Typeflag) |
| + } |
| + } |
| +} |
| + |
| +// serveTree answers one request from an extracted version dir: |
| +// http.FileServer semantics with index.html for directories and a clean 404 |
| +// (never a directory listing) when neither the file nor an index exists. |
| +func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Request) { |
| + // Clean rooted at "/" so no request path can climb out of the tree. |
| + p := path.Clean("/" + r.URL.Path) |
| + fp := filepath.Join(dir, filepath.FromSlash(p)) |
| + fi, err := os.Stat(fp) |
| + if err == nil && fi.IsDir() { |
| + fp = filepath.Join(fp, "index.html") |
| + fi, err = os.Stat(fp) |
| + } |
| + if err != nil || fi.IsDir() { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + // ServeFile brings Content-Type, ranges, If-Modified-Since for free. |
| + http.ServeFile(w, r, fp) |
| } |
diff --git a/internal/edge/static_test.go b/internal/edge/static_test.go| new file mode 100644 |
| index 000000000..330308763 |
| --- /dev/null |
| +++ b/internal/edge/static_test.go |
| @@ -0,0 +1,235 @@ |
| +package edge |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// getPath GETs base+path with an explicit Host header (getHost, but with a |
| +// request path — static trees are about more URLs than "/"). |
| +func getPath(t *testing.T, base, host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", base+path, nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s%s (host %s): %v", base, path, host, err) |
| + } |
| + defer resp.Body.Close() |
| + b, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + t.Fatalf("read body: %v", err) |
| + } |
| + return resp.StatusCode, string(b) |
| +} |
| + |
| +func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| + t.Helper() |
| + seedB64, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + priv, err := release.ParseSigningKey(seedB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse signing key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + return priv, pub |
| +} |
| + |
| +// writeTree materialises files (rel path -> body) under a fresh temp dir. |
| +func writeTree(t *testing.T, files map[string]string) string { |
| + t.Helper() |
| + dir := t.TempDir() |
| + for name, body := range files { |
| + p := filepath.Join(dir, filepath.FromSlash(name)) |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatalf("mkdir for %s: %v", name, err) |
| + } |
| + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { |
| + t.Fatalf("write %s: %v", name, err) |
| + } |
| + } |
| + return dir |
| +} |
| + |
| +// shipSite packs a tree and ships it as a static release of app@version. |
| +func shipSite(t *testing.T, store release.Store, app, version string, files map[string]string) { |
| + t.Helper() |
| + tarball, err := PackDir(writeTree(t, files)) |
| + if err != nil { |
| + t.Fatalf("PackDir: %v", err) |
| + } |
| + m := release.Manifest{ |
| + Schema: 1, App: app, Version: version, Kind: "static", |
| + Artifacts: map[string]release.Artifact{siteArtifactFile: {}}, |
| + } |
| + if err := release.Ship(context.Background(), store, m, map[string][]byte{siteArtifactFile: tarball}); err != nil { |
| + t.Fatalf("Ship %s %s: %v", app, version, err) |
| + } |
| +} |
| + |
| +// staticFixture is a shipped+promoted site behind a fresh static handler |
| +// whose pointer cache is disabled (recheck 0: every request re-resolves). |
| +func staticFixture(t *testing.T) (release.Store, string, ed25519.PrivateKey, *httptest.Server) { |
| + t.Helper() |
| + storeRoot := filepath.Join(t.TempDir(), "store") |
| + store := release.NewDirStore(storeRoot) |
| + priv, pub := testKeys(t) |
| + |
| + shipSite(t, store, "site", "v1", map[string]string{ |
| + "index.html": "home v1", |
| + "docs/page.html": "docs v1", |
| + "style.css": "body{}", |
| + }) |
| + if err := release.Promote(context.Background(), store, priv, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote v1: %v", err) |
| + } |
| + |
| + rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"} |
| + h := newStaticHandler(rt, store, pub, filepath.Join(t.TempDir(), "cache")) |
| + h.(*staticSite).recheck = 0 // re-check the pointer on every request |
| + srv := httptest.NewServer(h) |
| + t.Cleanup(srv.Close) |
| + return store, storeRoot, priv, srv |
| +} |
| + |
| +func TestServesShippedTree(t *testing.T) { |
| + _, _, _, srv := staticFixture(t) |
| + |
| + for path, want := range map[string]string{ |
| + "/": "home v1", // directory -> index.html fallback |
| + "/docs/page.html": "docs v1", |
| + "/style.css": "body{}", |
| + } { |
| + code, body := getPath(t, srv.URL, "site.example.com", path) |
| + if code != http.StatusOK || body != want { |
| + t.Fatalf("GET %s = %d %q, want 200 %q", path, code, body, want) |
| + } |
| + } |
| + if code, _ := getPath(t, srv.URL, "site.example.com", "/missing.html"); code != http.StatusNotFound { |
| + t.Fatalf("GET /missing.html = %d, want 404", code) |
| + } |
| + // A directory with no index.html is a clean 404, never a listing. |
| + if code, body := getPath(t, srv.URL, "site.example.com", "/docs/"); code != http.StatusNotFound { |
| + t.Fatalf("GET /docs/ = %d %q, want 404 (no index.html, no listing)", code, body) |
| + } |
| +} |
| + |
| +func TestChannelAdvanceSwapsContent(t *testing.T) { |
| + store, _, priv, srv := staticFixture(t) |
| + |
| + if code, body := getPath(t, srv.URL, "site.example.com", "/"); code != 200 || body != "home v1" { |
| + t.Fatalf("pre-advance GET / = %d %q, want 200 %q", code, body, "home v1") |
| + } |
| + |
| + shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"}) |
| + if err := release.Promote(context.Background(), store, priv, "site", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + |
| + // recheck is 0, so the very next request re-reads the pointer and swaps. |
| + if code, body := getPath(t, srv.URL, "site.example.com", "/"); code != 200 || body != "home v2" { |
| + t.Fatalf("post-advance GET / = %d %q, want 200 %q", code, body, "home v2") |
| + } |
| + // v2's tree has no docs/ — the old version's files must not bleed through. |
| + if code, _ := getPath(t, srv.URL, "site.example.com", "/docs/page.html"); code != http.StatusNotFound { |
| + t.Fatalf("GET /docs/page.html after advance = %d, want 404", code) |
| + } |
| +} |
| + |
| +func TestTamperedTarRejected(t *testing.T) { |
| + _, storeRoot, _, srv := staticFixture(t) |
| + |
| + // Corrupt the stored artifact bytes underneath the store (an attacker or |
| + // bit-rot on the bucket): flip one byte in the middle of the tar. |
| + artPath := filepath.Join(storeRoot, filepath.FromSlash(release.ArtifactKey("site", "v1", siteArtifactFile))) |
| + raw, err := os.ReadFile(artPath) |
| + if err != nil { |
| + t.Fatalf("read stored artifact: %v", err) |
| + } |
| + raw[len(raw)/2] ^= 0xff |
| + if err := os.WriteFile(artPath, raw, 0o644); err != nil { |
| + t.Fatalf("corrupt stored artifact: %v", err) |
| + } |
| + |
| + code, body := getPath(t, srv.URL, "site.example.com", "/") |
| + if code < 500 || code > 599 { |
| + t.Fatalf("GET / over tampered tar = %d %q, want 5xx", code, body) |
| + } |
| + if bytes.Contains([]byte(body), []byte("home v1")) { |
| + t.Fatalf("tampered tar was served: %q", body) |
| + } |
| +} |
| + |
| +func TestPackDirDeterministic(t *testing.T) { |
| + files := map[string]string{ |
| + "index.html": "same tree", |
| + "b/two.txt": "2", |
| + "a/one.txt": "1", |
| + } |
| + // Two independent checkouts of the same tree must pack byte-identically, |
| + // so re-shipping an unchanged site is Ship's idempotent no-op. |
| + p1, err := PackDir(writeTree(t, files)) |
| + if err != nil { |
| + t.Fatalf("PackDir 1: %v", err) |
| + } |
| + p2, err := PackDir(writeTree(t, files)) |
| + if err != nil { |
| + t.Fatalf("PackDir 2: %v", err) |
| + } |
| + if !bytes.Equal(p1, p2) { |
| + t.Fatalf("PackDir is not deterministic: %d vs %d bytes differ", len(p1), len(p2)) |
| + } |
| +} |
| + |
| +func TestEdgeServesStaticRouteWhenEnabled(t *testing.T) { |
| + storeRoot := filepath.Join(t.TempDir(), "store") |
| + store := release.NewDirStore(storeRoot) |
| + priv, pub := testKeys(t) |
| + shipSite(t, store, "site", "v1", map[string]string{"index.html": "via the edge"}) |
| + if err := release.Promote(context.Background(), store, priv, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + // Before EnableStatic the route exists but answers 404 (cert-first shape). |
| + if code, _ := getHost(t, srv.URL, "site.example.com"); code != http.StatusNotFound { |
| + t.Fatalf("pre-enable GET = %d, want 404", code) |
| + } |
| + |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + code, body := getHost(t, srv.URL, "site.example.com") |
| + if code != 200 || body != "via the edge" { |
| + t.Fatalf("post-enable GET = %d %q, want 200 %q", code, body, "via the edge") |
| + } |
| +} |
diff --git a/internal/vet/vet.go b/internal/vet/vet.go| new file mode 100644 |
| index 000000000..164cda2f1 |
| --- /dev/null |
| +++ b/internal/vet/vet.go |
| @@ -0,0 +1,227 @@ |
| +// Package vet checks a shipped release against the platform contract before |
| +// anyone promotes it: the manifest must exist and be valid, every artifact's |
| +// stored bytes must match their manifested sha256+size, and — for binary |
| +// releases, on request — the binary must actually boot. |
| +// |
| +// The app contract vet's boot check enforces (the same contract the |
| +// activator's exec backend and `carlos agent` rely on): a binary release |
| +// must accept a `-socket <path>` flag, listen for HTTP on that unix socket, |
| +// and answer GET /healthz with 200 within 10 seconds of exec. |
| +package vet |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// bootTimeout is how long a booted binary has to answer /healthz. A var so |
| +// the failure-path test doesn't sit through the full production window. |
| +var bootTimeout = 10 * time.Second |
| + |
| +// Check vets app@version in s and returns the list of problems found (empty |
| +// = clean). pub is the deployment's release public key — unused today |
| +// because release manifests are content-addressed rather than signed (only |
| +// channel pointers are), but part of the signature so pointer-level checks |
| +// can land without touching every caller. With boot set and kind "binary", |
| +// the platform-matching artifact is extracted to a temp dir, exec'd with |
| +// `-socket <tmp>`, and polled for a 200 on GET /healthz for up to 10s; when |
| +// the environment can't listen on unix sockets (sandboxes), the boot check |
| +// is skipped with a log line rather than reported as a failure. |
| +func Check(ctx context.Context, s release.Store, pub ed25519.PublicKey, app, version string, boot bool) []string { |
| + _ = pub // reserved: manifests are content-addressed, nothing to verify yet |
| + m, err := release.GetManifest(ctx, s, app, version) |
| + if err != nil { |
| + return []string{fmt.Sprintf("manifest: %v", err)} |
| + } |
| + |
| + // Deterministic problem order: artifacts by map key. |
| + keys := make([]string, 0, len(m.Artifacts)) |
| + for k := range m.Artifacts { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + |
| + var problems []string |
| + var bootBytes []byte // verified bytes of the artifact this platform runs |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + for _, k := range keys { |
| + a := m.Artifacts[k] |
| + b, _, err := s.Get(ctx, release.ArtifactKey(app, version, a.File)) |
| + if err != nil { |
| + problems = append(problems, fmt.Sprintf("artifact %s (%s): %v", k, a.File, err)) |
| + continue |
| + } |
| + sum := sha256.Sum256(b) |
| + if got := hex.EncodeToString(sum[:]); got != a.SHA256 { |
| + problems = append(problems, fmt.Sprintf("artifact %s (%s): sha256 mismatch (manifest %s, stored %s)", k, a.File, a.SHA256, got)) |
| + continue |
| + } |
| + if int64(len(b)) != a.Size { |
| + problems = append(problems, fmt.Sprintf("artifact %s (%s): size mismatch (manifest %d, stored %d)", k, a.File, a.Size, len(b))) |
| + continue |
| + } |
| + if k == platform || len(m.Artifacts) == 1 { |
| + bootBytes = b |
| + } |
| + } |
| + |
| + if boot && m.Kind == "binary" && len(problems) == 0 { |
| + switch { |
| + case !canListenUnix(): |
| + log.Printf("vet: unix sockets unavailable in this environment — skipping boot check") |
| + case bootBytes == nil: |
| + problems = append(problems, fmt.Sprintf("boot: no artifact for this platform (%s)", platform)) |
| + default: |
| + if err := bootHealthz(ctx, bootBytes); err != nil { |
| + problems = append(problems, fmt.Sprintf("boot: %v", err)) |
| + } |
| + } |
| + } |
| + return problems |
| +} |
| + |
| +// canListenUnix probes whether this environment permits unix-socket |
| +// listeners (sandboxes commonly block them with EPERM). |
| +func canListenUnix() bool { |
| + dir, err := os.MkdirTemp("", "carlos-vet-probe-") |
| + if err != nil { |
| + return false |
| + } |
| + defer os.RemoveAll(dir) |
| + ln, err := net.Listen("unix", filepath.Join(dir, "probe.sock")) |
| + if err != nil { |
| + return false |
| + } |
| + ln.Close() |
| + return true |
| +} |
| + |
| +// bootHealthz writes bin to a temp dir, execs it with `-socket <tmp>` (the |
| +// app contract), and polls GET /healthz over that socket until 200 or |
| +// bootTimeout. The child is killed on the way out no matter what. |
| +func bootHealthz(ctx context.Context, bin []byte) error { |
| + dir, err := os.MkdirTemp("", "carlos-vet-") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.RemoveAll(dir) |
| + exe := filepath.Join(dir, "app") |
| + if err := os.WriteFile(exe, bin, 0o755); err != nil { |
| + return err |
| + } |
| + sock := filepath.Join(dir, "app.sock") |
| + |
| + ctx, cancel := context.WithTimeout(ctx, bootTimeout+2*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, exe, "-socket", sock) |
| + var out bytes.Buffer |
| + cmd.Stdout, cmd.Stderr = &out, &out |
| + if err := cmd.Start(); err != nil { |
| + return fmt.Errorf("exec: %v", err) |
| + } |
| + defer func() { |
| + _ = cmd.Process.Kill() |
| + _ = cmd.Wait() |
| + }() |
| + |
| + client := &http.Client{ |
| + Timeout: 2 * time.Second, |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", sock) |
| + }, |
| + }, |
| + } |
| + deadline := time.Now().Add(bootTimeout) |
| + var last error |
| + for time.Now().Before(deadline) { |
| + resp, err := client.Get("http://vet/healthz") |
| + if err == nil { |
| + _, _ = io.Copy(io.Discard, resp.Body) |
| + resp.Body.Close() |
| + if resp.StatusCode == http.StatusOK { |
| + return nil |
| + } |
| + last = fmt.Errorf("/healthz answered %d", resp.StatusCode) |
| + } else { |
| + last = err |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-time.After(150 * time.Millisecond): |
| + } |
| + } |
| + msg := strings.TrimSpace(out.String()) |
| + if msg != "" { |
| + return fmt.Errorf("no 200 from /healthz within %s: %v (app output: %s)", bootTimeout, last, msg) |
| + } |
| + return fmt.Errorf("no 200 from /healthz within %s: %v", bootTimeout, last) |
| +} |
| + |
| +// Run is the `carlos vet` verb: vet a shipped release against the store the |
| +// environment names. |
| +func Run(args []string) { |
| + fs := flag.NewFlagSet("vet", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + ver := fs.String("version", "", "shipped version to vet") |
| + boot := fs.Bool("boot", false, "also boot the binary with -socket and poll /healthz (binary kind)") |
| + _ = fs.Parse(args) |
| + if *app == "" || *ver == "" { |
| + log.Fatal("usage: carlos vet -app <a> -version <v> [-boot]") |
| + } |
| + s := openStore() |
| + var pub ed25519.PublicKey |
| + if b := os.Getenv("CARLOS_RELEASE_PUBKEY"); b != "" { |
| + p, err := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + pub = p |
| + } |
| + problems := Check(context.Background(), s, pub, *app, *ver, *boot) |
| + if len(problems) == 0 { |
| + fmt.Printf("vet: %s %s OK\n", *app, *ver) |
| + return |
| + } |
| + for _, p := range problems { |
| + fmt.Fprintf(os.Stderr, "vet: %s %s: %s\n", *app, *ver, p) |
| + } |
| + os.Exit(1) |
| +} |
| + |
| +// openStore duplicates the CLI's ten-line env->store helper (an internal |
| +// package can't import package main): S3 when CARLOS_DEPLOYMENT_BUCKET is |
| +// set, else a directory store at CARLOS_DEPLOYMENT_DIR. |
| +func openStore() release.Store { |
| + if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| + s, err := release.NewS3Store(context.Background(), b) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return s |
| + } |
| + if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| + return release.NewDirStore(d) |
| + } |
| + log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)") |
| + return nil |
| +} |
diff --git a/internal/vet/vet_test.go b/internal/vet/vet_test.go| new file mode 100644 |
| index 000000000..f595ce62a |
| --- /dev/null |
| +++ b/internal/vet/vet_test.go |
| @@ -0,0 +1,129 @@ |
| +package vet |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func testPub(t *testing.T) ed25519.PublicKey { |
| + t.Helper() |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + return pub |
| +} |
| + |
| +// shipBinary ships bytes as a one-artifact binary release keyed for this |
| +// platform (what Check's boot path selects). |
| +func shipBinary(t *testing.T, store release.Store, app, version string, bin []byte) { |
| + t.Helper() |
| + key := runtime.GOOS + "-" + runtime.GOARCH |
| + m := release.Manifest{ |
| + Schema: 1, App: app, Version: version, Kind: "binary", |
| + Artifacts: map[string]release.Artifact{key: {File: app}}, |
| + } |
| + if err := release.Ship(context.Background(), store, m, map[string][]byte{key: bin}); err != nil { |
| + t.Fatalf("Ship %s %s: %v", app, version, err) |
| + } |
| +} |
| + |
| +func TestCheckPassesGoodRelease(t *testing.T) { |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + shipBinary(t, store, "echoapp", "v1", []byte("not a real binary, hashes fine")) |
| + if probs := Check(context.Background(), store, testPub(t), "echoapp", "v1", false); len(probs) != 0 { |
| + t.Fatalf("Check = %v, want no problems", probs) |
| + } |
| +} |
| + |
| +func TestCheckReportsMissingManifest(t *testing.T) { |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + probs := Check(context.Background(), store, testPub(t), "ghost", "v9", false) |
| + if len(probs) != 1 || !strings.Contains(probs[0], "manifest") { |
| + t.Fatalf("Check = %v, want one manifest problem", probs) |
| + } |
| +} |
| + |
| +func TestCheckReportsTamperedArtifact(t *testing.T) { |
| + root := filepath.Join(t.TempDir(), "store") |
| + store := release.NewDirStore(root) |
| + shipBinary(t, store, "echoapp", "v1", []byte("original artifact bytes")) |
| + |
| + // Corrupt the stored bytes underneath the manifest's sha256. |
| + artPath := filepath.Join(root, filepath.FromSlash(release.ArtifactKey("echoapp", "v1", "echoapp"))) |
| + if err := os.WriteFile(artPath, []byte("EVIL artifact bytes!!!!"), 0o644); err != nil { |
| + t.Fatalf("corrupt artifact: %v", err) |
| + } |
| + |
| + probs := Check(context.Background(), store, testPub(t), "echoapp", "v1", false) |
| + if len(probs) != 1 || !strings.Contains(probs[0], "sha256 mismatch") { |
| + t.Fatalf("Check = %v, want one sha256-mismatch problem", probs) |
| + } |
| +} |
| + |
| +// buildEchoapp compiles testdata/echoapp (the module root's test tenant) or |
| +// skips when no Go toolchain is around. |
| +func buildEchoapp(t *testing.T) []byte { |
| + t.Helper() |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found; skipping boot check test") |
| + } |
| + out := filepath.Join(t.TempDir(), "echoapp") |
| + cmd := exec.Command(goBin, "build", "-o", out, "./testdata/echoapp") |
| + cmd.Dir = filepath.Join("..", "..") // module root, from internal/vet |
| + cmd.Env = os.Environ() |
| + if msg, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("build echoapp: %v\n%s", err, msg) |
| + } |
| + bin, err := os.ReadFile(out) |
| + if err != nil { |
| + t.Fatalf("read built echoapp: %v", err) |
| + } |
| + return bin |
| +} |
| + |
| +func TestCheckBootHealthz(t *testing.T) { |
| + if !canListenUnix() { |
| + t.Skip("unix-socket listeners not permitted in this environment (sandbox)") |
| + } |
| + bin := buildEchoapp(t) |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + shipBinary(t, store, "echoapp", "v1", bin) |
| + if probs := Check(context.Background(), store, testPub(t), "echoapp", "v1", true); len(probs) != 0 { |
| + t.Fatalf("Check with boot = %v, want no problems", probs) |
| + } |
| +} |
| + |
| +func TestCheckBootFlagsBinaryThatNeverServes(t *testing.T) { |
| + if !canListenUnix() { |
| + t.Skip("unix-socket listeners not permitted in this environment (sandbox)") |
| + } |
| + falseBin, err := os.ReadFile("/bin/false") |
| + if err != nil { |
| + t.Skipf("no /bin/false to misbehave with: %v", err) |
| + } |
| + old := bootTimeout |
| + bootTimeout = 1 * time.Second // don't sit through the production window |
| + defer func() { bootTimeout = old }() |
| + |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + shipBinary(t, store, "deadapp", "v1", falseBin) |
| + probs := Check(context.Background(), store, testPub(t), "deadapp", "v1", true) |
| + if len(probs) != 1 || !strings.Contains(probs[0], "boot:") { |
| + t.Fatalf("Check = %v, want one boot problem", probs) |
| + } |
| +} |
diff --git a/testdata/echoapp/main.go b/testdata/echoapp/main.go| new file mode 100644 |
| index 000000000..d96d78e20 |
| --- /dev/null |
| +++ b/testdata/echoapp/main.go |
| @@ -0,0 +1,41 @@ |
| +// echoapp is the e2e/vet test tenant: a minimal app honoring the platform |
| +// contract — it accepts -socket (unix) or -addr (TCP), answers GET /healthz |
| +// with "ok", and echoes its BUILD env everywhere else. |
| +package main |
| + |
| +import ( |
| + "flag" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "os" |
| +) |
| + |
| +func main() { |
| + socket := flag.String("socket", "", "unix socket to listen on") |
| + addr := flag.String("addr", "", "TCP host:port to listen on") |
| + flag.Parse() |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { |
| + fmt.Fprint(w, "ok") |
| + }) |
| + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { |
| + fmt.Fprintf(w, "echoapp BUILD=%s", os.Getenv("BUILD")) |
| + }) |
| + |
| + network, address := "tcp", *addr |
| + if *socket != "" { |
| + network, address = "unix", *socket |
| + os.Remove(*socket) |
| + } |
| + ln, err := net.Listen(network, address) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + if err := http.Serve(ln, mux); err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| +} |