// 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 ` 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/cliflag" "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 `, 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, acct, app, version string, boot bool) []string { _ = pub // reserved: manifests are content-addressed, nothing to verify yet m, err := release.GetManifest(ctx, s, acct, 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 := release.GetQualified(ctx, s, release.ArtifactKey(acct, 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 ` (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) } const vetUsage = "usage: carlos vet --app --version [--account ] [--boot]" // 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 := cliflag.String(fs, "app", "", "app name") // Sqid only, deliberately: vet has no -registry flag to resolve a name // against, and guessing an account would vet the wrong app's bytes. acct := fs.String("account", release.OpsAccount, "account sqid the app lives under") ver := cliflag.String(fs, "version", "", "shipped version to vet") boot := fs.Bool("boot", false, "also boot the binary with -socket and poll /healthz (binary kind)") fs.Usage = cliflag.Usage(fs, vetUsage) _ = fs.Parse(args) if *app == "" || *ver == "" { log.Fatal(vetUsage) } if !release.ValidAccount(*acct) { log.Fatalf("vet: invalid account sqid %q", *acct) } 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, *acct, *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.NewDeploymentS3Store(context.Background(), b) if err != nil { log.Fatal(err) } return s } if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { ds := release.NewDirStore(d) ds.EnableChangeIndex() return ds } log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)") return nil }