From c3804b012fe108a5d59a1a0f081ba2fd400e5810 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 11:18:30 +0200 Subject: [PATCH] Add the builder part: its manifest, its credential and its bus connection (issue 1607, AC1) --- .../cmd/mesh-delivery-builder/main.go | 118 ++++++++++++ modules/mesh-delivery-builder/go.mod | 22 +++ modules/mesh-delivery-builder/go.sum | 27 +++ .../mesh-delivery-builder/manifest_test.go | 168 ++++++++++++++++++ modules/mesh-delivery-builder/module.json | 75 ++++++++ 5 files changed, 410 insertions(+) create mode 100644 modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go create mode 100644 modules/mesh-delivery-builder/go.mod create mode 100644 modules/mesh-delivery-builder/go.sum create mode 100644 modules/mesh-delivery-builder/manifest_test.go create mode 100644 modules/mesh-delivery-builder/module.json diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go new file mode 100644 index 0000000..c023e2e --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -0,0 +1,118 @@ +// Command mesh-delivery-builder is delivery's builder (novox/hq ADR 0453 clause 4, ADR 0478, issue 1607): it builds every +// new-form fingerprint the indexer writes, dark, into its own build keys (builds..), through the build +// modules' fetch and build tools. Nothing reads its keys to deliver until release R4e. It serves no request subject and +// holds no timer: every reaction is a watch or a bus event (ADR 0437 §8). +package main + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Getenv, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "mesh-delivery-builder:", err) + os.Exit(1) + } +} + +// credential is the broker file the controller issues to the module: JSON of the bus address, the certificate +// fingerprint to pin, the principal and its password (as the indexer reads it; issue 1242 moves this into the SDK). +type credential struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` +} + +func readCredential(path string) (credential, error) { + raw, err := os.ReadFile(path) + if err != nil { + return credential{}, err + } + var c credential + if err := json.Unmarshal(raw, &c); err != nil { + return credential{}, fmt.Errorf("the broker credential is not one: %w", err) + } + if c.URL == "" || c.User == "" { + return credential{}, errors.New("the broker credential names no bus address or user") + } + return c, nil +} + +// pinned accepts exactly the certificate with this SHA-256 and no other. +func pinned(want string) *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter + MinVersion: tls.VersionTLS12, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return errors.New("the bus presented no certificate") + } + sum := sha256.Sum256(rawCerts[0]) + if got := "sha256:" + hex.EncodeToString(sum[:]); got != want { + return fmt.Errorf("the bus presented a certificate this mesh does not know (%.23s…)", got) + } + return nil + }, + } +} + +// run is the program: it returns when ctx ends or the bus cannot be used. +func run(ctx context.Context, getenv func(string) string, logOut io.Writer) error { + logger := log.New(logOut, "mesh-delivery-builder: ", log.LstdFlags) + path := strings.TrimSpace(getenv("MESH_BROKER_FILE")) + if path == "" { + return errors.New("MESH_BROKER_FILE is not set, so there is no credential to hear the bus with") + } + cred, err := readCredential(path) + if err != nil { + return err + } + opts := []nats.Option{ + nats.Name(cred.Node + "." + cred.Module), + nats.UserInfo(cred.User, cred.Password), + nats.CustomInboxPrefix("_INBOX." + cred.User), + nats.MaxReconnects(-1), + nats.RetryOnFailedConnect(true), + } + if strings.TrimSpace(cred.Fingerprint) != "" { + opts = append(opts, nats.Secure(pinned(cred.Fingerprint))) + } + conn, err := nats.Connect(cred.URL, opts...) + if err != nil { + return fmt.Errorf("connecting to the bus: %w", err) + } + defer conn.Close() + js, err := jetstream.New(conn) + if err != nil { + return err + } + if _, err := js.KeyValue(ctx, delivery.BuilderBuildsBucket); err != nil { + return fmt.Errorf("the bucket %s: %w", delivery.BuilderBuildsBucket, err) + } + logger.Printf("started on %s", cred.Node) + <-ctx.Done() + return nil +} diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod new file mode 100644 index 0000000..956a222 --- /dev/null +++ b/modules/mesh-delivery-builder/go.mod @@ -0,0 +1,22 @@ +module mesh-delivery-builder + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020182814-4024909a2960 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum new file mode 100644 index 0000000..0e67d23 --- /dev/null +++ b/modules/mesh-delivery-builder/go.sum @@ -0,0 +1,27 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020182814-4024909a2960 h1:FCId3IuRScG03X7Gx9vfKfGNZH9jWnfx9RChfJ3eWfo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020182814-4024909a2960/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go new file mode 100644 index 0000000..5d224e7 --- /dev/null +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -0,0 +1,168 @@ +package manifest + +import ( + "bytes" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" +) + +// The controller's manifest reader is mesh-controller internal/catalogue (Manifest, manifest.go: Consumes :521, +// Invokes :590, Streams :613, Reads :659; StreamDeclaration, streams.go), an internal package of another repository. +// These tests decode the manifest strictly into the fields those types name (DisallowUnknownFields) and hold the +// rules of issue 1607 (ADR 0453 §4, ADR 0478). The merge gate's module check proves the controller takes the manifest. + +type state struct { + Name string `json:"name"` + History int `json:"history,omitempty"` +} + +type artifact struct { + Name string `json:"name"` + Kind string `json:"kind"` + Language string `json:"language"` + System string `json:"system"` + From string `json:"from"` + Binary string `json:"binary"` +} + +type manifest struct { + Module string `json:"module"` + Version string `json:"version"` + Slug string `json:"slug"` + Consumes []string `json:"consumes"` + State []state `json:"state"` + Reads []string `json:"reads"` + Invokes []string `json:"invokes"` + OwnSecrets map[string]string `json:"own-secrets"` + SecretsOwner string `json:"secrets-owner"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []artifact `json:"artifacts"` + } `json:"build"` +} + +func load(t *testing.T) (manifest, map[string]json.RawMessage) { + t.Helper() + raw, err := os.ReadFile("module.json") + if err != nil { + t.Fatal(err) + } + var m manifest + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + t.Fatalf("the controller's reader refuses a key this manifest has: %v", err) + } + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + t.Fatal(err) + } + return m, top +} + +func sorted(s []string) []string { + s = slices.Clone(s) + slices.Sort(s) + return s +} + +func TestManifestDeclarations(t *testing.T) { // 1607/AC1 + m, top := load(t) + if m.Module != "mesh-delivery-builder" { + t.Errorf("module = %q", m.Module) + } + if want := []state{{Name: "builds", History: 5}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory + t.Errorf("state = %+v, want %+v", m.State, want) + } + if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-node.steps", "mesh-build-node.toolchains", + "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules"}; !reflect.DeepEqual(sorted(m.Reads), want) { + t.Errorf("reads = %v, want %v", m.Reads, want) + } + if want := []string{"mesh-build-go.build_go_build", "mesh-build-go.build_go_fetch", "mesh-build-node.build_node_build", "mesh-build-node.build_node_fetch"}; !reflect.DeepEqual(sorted(m.Invokes), want) { + t.Errorf("invokes = %v, want only the build modules' fetch and build", m.Invokes) + } + // The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts. + if want := []string{"mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported"}; !reflect.DeepEqual(sorted(m.Consumes), want) { + t.Errorf("consumes = %v, want %v", m.Consumes, want) + } + if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" { + t.Errorf("own-secrets = %v, want exactly broker", m.OwnSecrets) + } + if m.SecretsOwner != "mesh-delivery-builder" { + t.Errorf("secrets-owner = %q", m.SecretsOwner) + } + for _, k := range []string{"streams", "logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats", "bus"} { + if _, ok := top[k]; ok { + t.Errorf("the manifest declares %q: the builder has none", k) + } + } +} + +func TestManifestProcess(t *testing.T) { // 1607/AC1 + m, _ := load(t) + var procs []map[string]any + for _, r := range m.Resources { + if r["type"] == "process" { + procs = append(procs, r) + } + if r["type"] == "tool" || r["type"] == "seat" { + t.Errorf("resource %v: the builder serves no tool and holds no seat", r) + } + } + if len(procs) != 1 { + t.Fatalf("%d process resources, want one", len(procs)) + } + p := procs[0] + if p["artifact"] != "program" || !reflect.DeepEqual(p["run"], []any{"./mesh-delivery-builder"}) { + t.Errorf("process = %v", p) + } + env, _ := p["env"].(map[string]any) + if len(env) != 1 || env["MESH_BROKER_FILE"] != "${dir:state}/broker" { + t.Errorf("env = %v", p["env"]) + } + if _, bus := p["bus"]; bus { + t.Errorf("the process declares a bus: it runs on its own broker credential") + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("artifacts = %+v", m.Build.Artifacts) + } + a := m.Build.Artifacts[0] + if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-builder" || !strings.HasSuffix(a.From, a.Binary) || a.Kind != "bundle" || a.Language != "go" { + t.Errorf("artifact = %+v", a) + } +} + +func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1607/AC6 (ADR 0437 §8: no timer, a wait is a context's bound) + forbidden := []string{"Subscribe(", "QueueSubscribe", "ChanSubscribe", "time.NewTicker", "time.Tick", "time.NewTimer", "time.AfterFunc", "time.After(", "time.Sleep"} + var files int + for _, root := range []string{"cmd", "internal"} { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + files++ + src, err := os.ReadFile(path) + if err != nil { + return err + } + for _, f := range forbidden { + if strings.Contains(string(src), f) { + t.Errorf("%s holds %s: the builder serves no request subject and holds no timer", path, f) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } + if files < 1 { + t.Fatalf("read %d source files, want the program's", files) + } +} diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json new file mode 100644 index 0000000..9d27205 --- /dev/null +++ b/modules/mesh-delivery-builder/module.json @@ -0,0 +1,75 @@ +{ + "module": "mesh-delivery-builder", + "version": "1", + "slug": "builder", + "consumes": [ + "mesh-build-go.toolchain.reported", + "mesh-build-node.toolchain.reported" + ], + "state": [ + { "name": "builds", "history": 5 } + ], + "reads": [ + "mesh-build-go.steps", + "mesh-build-go.toolchains", + "mesh-build-node.steps", + "mesh-build-node.toolchains", + "mesh-delivery-indexer.manifests", + "mesh-delivery-indexer.modules" + ], + "invokes": [ + "mesh-build-go.build_go_build", + "mesh-build-go.build_go_fetch", + "mesh-build-node.build_node_build", + "mesh-build-node.build_node_fetch" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-builder", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-builder", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-builder" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-builder", + "place": ".", + "unknown": "program" + }, + { + "id": "builder", + "type": "process", + "name": "mesh-delivery-builder", + "artifact": "program", + "run": [ + "./mesh-delivery-builder" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-builder", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-builder", + "binary": "mesh-delivery-builder" + } + ] + } +}