| 1 | // Package vet checks a shipped release against the platform contract before |
| 2 | // anyone promotes it: the manifest must exist and be valid, every artifact's |
| 3 | // stored bytes must match their manifested sha256+size, and — for binary |
| 4 | // releases, on request — the binary must actually boot. |
| 5 | // |
| 6 | // The app contract vet's boot check enforces (the same contract the |
| 7 | // activator's exec backend and `carlos agent` rely on): a binary release |
| 8 | // must accept a `-socket <path>` flag, listen for HTTP on that unix socket, |
| 9 | // and answer GET /healthz with 200 within 10 seconds of exec. |
| 10 | package vet |
| 11 | |
| 12 | import ( |
| 13 | "bytes" |
| 14 | "context" |
| 15 | "crypto/ed25519" |
| 16 | "crypto/sha256" |
| 17 | "encoding/hex" |
| 18 | "flag" |
| 19 | "fmt" |
| 20 | "io" |
| 21 | "log" |
| 22 | "net" |
| 23 | "net/http" |
| 24 | "os" |
| 25 | "os/exec" |
| 26 | "path/filepath" |
| 27 | "runtime" |
| 28 | "sort" |
| 29 | "strings" |
| 30 | "time" |
| 31 | |
| 32 | "github.com/carlosframework/platform/internal/cliflag" |
| 33 | "github.com/carlosframework/platform/internal/release" |
| 34 | ) |
| 35 | |
| 36 | // bootTimeout is how long a booted binary has to answer /healthz. A var so |
| 37 | // the failure-path test doesn't sit through the full production window. |
| 38 | var bootTimeout = 10 * time.Second |
| 39 | |
| 40 | // Check vets app@version in s and returns the list of problems found (empty |
| 41 | // = clean). pub is the deployment's release public key — unused today |
| 42 | // because release manifests are content-addressed rather than signed (only |
| 43 | // channel pointers are), but part of the signature so pointer-level checks |
| 44 | // can land without touching every caller. With boot set and kind "binary", |
| 45 | // the platform-matching artifact is extracted to a temp dir, exec'd with |
| 46 | // `-socket <tmp>`, and polled for a 200 on GET /healthz for up to 10s; when |
| 47 | // the environment can't listen on unix sockets (sandboxes), the boot check |
| 48 | // is skipped with a log line rather than reported as a failure. |
| 49 | func Check(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app, version string, boot bool) []string { |
| 50 | _ = pub // reserved: manifests are content-addressed, nothing to verify yet |
| 51 | m, err := release.GetManifest(ctx, s, acct, app, version) |
| 52 | if err != nil { |
| 53 | return []string{fmt.Sprintf("manifest: %v", err)} |
| 54 | } |
| 55 | |
| 56 | // Deterministic problem order: artifacts by map key. |
| 57 | keys := make([]string, 0, len(m.Artifacts)) |
| 58 | for k := range m.Artifacts { |
| 59 | keys = append(keys, k) |
| 60 | } |
| 61 | sort.Strings(keys) |
| 62 | |
| 63 | var problems []string |
| 64 | var bootBytes []byte // verified bytes of the artifact this platform runs |
| 65 | platform := runtime.GOOS + "-" + runtime.GOARCH |
| 66 | for _, k := range keys { |
| 67 | a := m.Artifacts[k] |
| 68 | b, _, err := release.GetQualified(ctx, s, release.ArtifactKey(acct, app, version, a.File)) |
| 69 | if err != nil { |
| 70 | problems = append(problems, fmt.Sprintf("artifact %s (%s): %v", k, a.File, err)) |
| 71 | continue |
| 72 | } |
| 73 | sum := sha256.Sum256(b) |
| 74 | if got := hex.EncodeToString(sum[:]); got != a.SHA256 { |
| 75 | problems = append(problems, fmt.Sprintf("artifact %s (%s): sha256 mismatch (manifest %s, stored %s)", k, a.File, a.SHA256, got)) |
| 76 | continue |
| 77 | } |
| 78 | if int64(len(b)) != a.Size { |
| 79 | problems = append(problems, fmt.Sprintf("artifact %s (%s): size mismatch (manifest %d, stored %d)", k, a.File, a.Size, len(b))) |
| 80 | continue |
| 81 | } |
| 82 | if k == platform || len(m.Artifacts) == 1 { |
| 83 | bootBytes = b |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | if boot && m.Kind == "binary" && len(problems) == 0 { |
| 88 | switch { |
| 89 | case !canListenUnix(): |
| 90 | log.Printf("vet: unix sockets unavailable in this environment — skipping boot check") |
| 91 | case bootBytes == nil: |
| 92 | problems = append(problems, fmt.Sprintf("boot: no artifact for this platform (%s)", platform)) |
| 93 | default: |
| 94 | if err := bootHealthz(ctx, bootBytes); err != nil { |
| 95 | problems = append(problems, fmt.Sprintf("boot: %v", err)) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | return problems |
| 100 | } |
| 101 | |
| 102 | // canListenUnix probes whether this environment permits unix-socket |
| 103 | // listeners (sandboxes commonly block them with EPERM). |
| 104 | func canListenUnix() bool { |
| 105 | dir, err := os.MkdirTemp("", "carlos-vet-probe-") |
| 106 | if err != nil { |
| 107 | return false |
| 108 | } |
| 109 | defer os.RemoveAll(dir) |
| 110 | ln, err := net.Listen("unix", filepath.Join(dir, "probe.sock")) |
| 111 | if err != nil { |
| 112 | return false |
| 113 | } |
| 114 | ln.Close() |
| 115 | return true |
| 116 | } |
| 117 | |
| 118 | // bootHealthz writes bin to a temp dir, execs it with `-socket <tmp>` (the |
| 119 | // app contract), and polls GET /healthz over that socket until 200 or |
| 120 | // bootTimeout. The child is killed on the way out no matter what. |
| 121 | func bootHealthz(ctx context.Context, bin []byte) error { |
| 122 | dir, err := os.MkdirTemp("", "carlos-vet-") |
| 123 | if err != nil { |
| 124 | return err |
| 125 | } |
| 126 | defer os.RemoveAll(dir) |
| 127 | exe := filepath.Join(dir, "app") |
| 128 | if err := os.WriteFile(exe, bin, 0o755); err != nil { |
| 129 | return err |
| 130 | } |
| 131 | sock := filepath.Join(dir, "app.sock") |
| 132 | |
| 133 | ctx, cancel := context.WithTimeout(ctx, bootTimeout+2*time.Second) |
| 134 | defer cancel() |
| 135 | cmd := exec.CommandContext(ctx, exe, "-socket", sock) |
| 136 | var out bytes.Buffer |
| 137 | cmd.Stdout, cmd.Stderr = &out, &out |
| 138 | if err := cmd.Start(); err != nil { |
| 139 | return fmt.Errorf("exec: %v", err) |
| 140 | } |
| 141 | defer func() { |
| 142 | _ = cmd.Process.Kill() |
| 143 | _ = cmd.Wait() |
| 144 | }() |
| 145 | |
| 146 | client := &http.Client{ |
| 147 | Timeout: 2 * time.Second, |
| 148 | Transport: &http.Transport{ |
| 149 | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| 150 | return (&net.Dialer{}).DialContext(ctx, "unix", sock) |
| 151 | }, |
| 152 | }, |
| 153 | } |
| 154 | deadline := time.Now().Add(bootTimeout) |
| 155 | var last error |
| 156 | for time.Now().Before(deadline) { |
| 157 | resp, err := client.Get("http://vet/healthz") |
| 158 | if err == nil { |
| 159 | _, _ = io.Copy(io.Discard, resp.Body) |
| 160 | resp.Body.Close() |
| 161 | if resp.StatusCode == http.StatusOK { |
| 162 | return nil |
| 163 | } |
| 164 | last = fmt.Errorf("/healthz answered %d", resp.StatusCode) |
| 165 | } else { |
| 166 | last = err |
| 167 | } |
| 168 | select { |
| 169 | case <-ctx.Done(): |
| 170 | return ctx.Err() |
| 171 | case <-time.After(150 * time.Millisecond): |
| 172 | } |
| 173 | } |
| 174 | msg := strings.TrimSpace(out.String()) |
| 175 | if msg != "" { |
| 176 | return fmt.Errorf("no 200 from /healthz within %s: %v (app output: %s)", bootTimeout, last, msg) |
| 177 | } |
| 178 | return fmt.Errorf("no 200 from /healthz within %s: %v", bootTimeout, last) |
| 179 | } |
| 180 | |
| 181 | const vetUsage = "usage: carlos vet --app <a> --version <v> [--account <sqid>] [--boot]" |
| 182 | |
| 183 | // Run is the `carlos vet` verb: vet a shipped release against the store the |
| 184 | // environment names. |
| 185 | func Run(args []string) { |
| 186 | fs := flag.NewFlagSet("vet", flag.ExitOnError) |
| 187 | app := cliflag.String(fs, "app", "", "app name") |
| 188 | // Sqid only, deliberately: vet has no -registry flag to resolve a name |
| 189 | // against, and guessing an account would vet the wrong app's bytes. |
| 190 | acct := fs.String("account", release.OpsAccount, "account sqid the app lives under") |
| 191 | ver := cliflag.String(fs, "version", "", "shipped version to vet") |
| 192 | boot := fs.Bool("boot", false, "also boot the binary with -socket and poll /healthz (binary kind)") |
| 193 | fs.Usage = cliflag.Usage(fs, vetUsage) |
| 194 | _ = fs.Parse(args) |
| 195 | if *app == "" || *ver == "" { |
| 196 | log.Fatal(vetUsage) |
| 197 | } |
| 198 | if !release.ValidAccount(*acct) { |
| 199 | log.Fatalf("vet: invalid account sqid %q", *acct) |
| 200 | } |
| 201 | s := openStore() |
| 202 | var pub ed25519.PublicKey |
| 203 | if b := os.Getenv("CARLOS_RELEASE_PUBKEY"); b != "" { |
| 204 | p, err := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY") |
| 205 | if err != nil { |
| 206 | log.Fatal(err) |
| 207 | } |
| 208 | pub = p |
| 209 | } |
| 210 | problems := Check(context.Background(), s, pub, *acct, *app, *ver, *boot) |
| 211 | if len(problems) == 0 { |
| 212 | fmt.Printf("vet: %s %s OK\n", *app, *ver) |
| 213 | return |
| 214 | } |
| 215 | for _, p := range problems { |
| 216 | fmt.Fprintf(os.Stderr, "vet: %s %s: %s\n", *app, *ver, p) |
| 217 | } |
| 218 | os.Exit(1) |
| 219 | } |
| 220 | |
| 221 | // openStore duplicates the CLI's ten-line env->store helper (an internal |
| 222 | // package can't import package main): S3 when CARLOS_DEPLOYMENT_BUCKET is |
| 223 | // set, else a directory store at CARLOS_DEPLOYMENT_DIR. |
| 224 | func openStore() release.Store { |
| 225 | if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| 226 | s, err := release.NewDeploymentS3Store(context.Background(), b) |
| 227 | if err != nil { |
| 228 | log.Fatal(err) |
| 229 | } |
| 230 | return s |
| 231 | } |
| 232 | if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| 233 | ds := release.NewDirStore(d) |
| 234 | ds.EnableChangeIndex() |
| 235 | return ds |
| 236 | } |
| 237 | log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)") |
| 238 | return nil |
| 239 | } |
| 240 | |