carlos / platform Public

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>
Paul Campbell ede3ba44eb18cddadd7d1afa4946fac52cd50b3e parent bf2f8b6
13 files changed, +1321 −19
  • e2e_test.go +265 −0
  • infra/flagship/.gitignore +3 −0
  • infra/flagship/.terraform.lock.hcl +36 −0
  • infra/flagship/mail.tf +0 −9
  • infra/flagship/main.tf +4 −0
  • infra/tito/.gitignore +3 −0
  • infra/tito/.terraform.lock.hcl +39 −0
  • internal/edge/edge_test.go +8 −0
  • internal/edge/static.go +331 −10
  • internal/edge/static_test.go +235 −0
  • internal/vet/vet.go +227 −0
  • internal/vet/vet_test.go +129 −0
  • testdata/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)
+ }
+}