diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 5b969b2..60695c3 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -522,6 +522,8 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu recorded := inventory.Source{ Repository: result.Repository, Path: result.Path, Ref: result.Ref, BuiltFrom: result.Commit, Head: result.Commit, + // What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4). + Against: kept.Against, } if result.Source != nil && result.Source.Seat != "" { recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat diff --git a/cmd/mesh-controller/order_test.go b/cmd/mesh-controller/order_test.go index f3b0cd6..be2c4d0 100644 --- a/cmd/mesh-controller/order_test.go +++ b/cmd/mesh-controller/order_test.go @@ -1,6 +1,7 @@ package main import ( + "reflect" "strings" "testing" "time" @@ -191,7 +192,7 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) { t.Fatalf("the source records as %+v", from) } // A manifest with no provenance at all is legitimate: fixing something in a hurry. - if from, err := whereItComesFrom("", "", "", "", false); err != nil || from != (inventory.Source{}) { + if from, err := whereItComesFrom("", "", "", "", false); err != nil || !reflect.DeepEqual(from, inventory.Source{}) { t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err) } for _, c := range []struct { diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index ed6d251..b5be9e3 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -195,3 +195,57 @@ func toAny(in []string) []any { } return out } + +// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was +// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image +// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175). +const ( + RuntimeImageModule = "mesh-tools" + RuntimeImageArtifact = "runtime" +) + +// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools +// are served from a container built on the tool runtime's image — or nothing when it is not. Judged +// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood +// on when it is a built one, because a resolved manifest carries no build. The gate itself is +// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused. +// +// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container +// (a module's service may well be one); building on the runtime's image (a service written against +// the SDK may). All three is a container whose purpose is tools, which the runtime now serves. +func ToolContainerOnTheRuntime(m Manifest, against []string) string { + if len(m.Tools) == 0 { + return "" + } + container := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "container" { + container = true + } + } + if !container { + return "" + } + onTheRuntime := false + if m.Build != nil { + for _, on := range m.Build.On { + if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact { + onTheRuntime = true + } + } + } + for _, ref := range against { + path, kept := InArtifactStore(Recorded(ref)) + if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") { + onTheRuntime = true + } + } + if !onTheRuntime { + return "" + } + return fmt.Sprintf( + "%s declares tools and a container built on %s's %s image — a container whose purpose is "+ + "serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+ + "now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container", + m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule) +} diff --git a/internal/catalogue/runtime_gate_test.go b/internal/catalogue/runtime_gate_test.go new file mode 100644 index 0000000..d023a35 --- /dev/null +++ b/internal/catalogue/runtime_gate_test.go @@ -0,0 +1,88 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The packet-filter manifest as it was the day the runtime was decided (novox/hq ADR 0175): tools, +// served from a container built on the tool runtime's image, with NET_ADMIN so the container could +// reach the filter. The exact pattern to-be 38 WP4 moves it off, and the one the gate refuses. +const thePacketFilterAsItWas = `{ + "module": "nftables", + "version": "1", + "capabilities": ["firewall", "container-runtime"], + "claims": [{"name": "node-packet-filter", "scope": "node", "serves": ["rules", "reload", "remove"]}], + "filtering": {"into": "/etc/nftables.conf"}, + "resources": [ + {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, + {"id": "package", "type": "package", "package": "nftables"}, + {"id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-filter.service", + "content": "[Unit]\nDescription=The mesh's packet filter\n[Service]\nType=oneshot\nExecStart=nft -f /etc/nftables.conf\n", "mode": "0644"}, + {"id": "load", "type": "service", "unit": "mesh-filter.service", "state": "running", "boot": "enabled", + "restart-on": ["unit"], "reload-on": ["filtering"]}, + {"id": "runtime", "type": "container", "name": "mesh-nftables", "network": "host", + "capabilities": ["NET_ADMIN"], + "volumes": ["${dir:mesh-state}/broker:/run/secrets/broker:ro", "/etc/nftables.conf:/etc/nftables.conf:ro"], + "env": {"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_FILTER_FILE": "/etc/nftables.conf"}, + "artifact": "runtime"} + ], + "tools": ["firewall_rules"], + "own-secrets": {"broker": "${dir:mesh-state}/broker"}, + "build": { + "on": [ + {"arg": "BUILD_BASE", "module": "mesh-tools", "artifact": "build"}, + {"arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime"} + ], + "artifacts": [{"name": "runtime", "kind": "image", "from": "Dockerfile"}] + } +}` + +func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) { + m, err := ParseManifest([]byte(thePacketFilterAsItWas)) + if err != nil { + t.Fatal(err) + } + // From the repository: the manifest says what it builds on. + why := ToolContainerOnTheRuntime(m, nil) + if why == "" { + t.Fatal("the packet filter's tool container was not recognised from its build") + } + for _, word := range []string{"nftables", "mesh-tools", "runtime", "ADR 0175", "bundle"} { + if !strings.Contains(why, word) { + t.Errorf("the refusal does not say %q: %s", word, why) + } + } + + // Built: the manifest carries no build, and what it stood on says the same. + built, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactImage, + Reference: ArtifactStoreScheme + "nftables/runtime@" + digest}}) + if err != nil { + t.Fatal(err) + } + stoodOn := []string{"anchor.internal:5100/mesh-tools/build@" + digest, "anchor.internal:5100/mesh-tools/runtime@" + digest} + if ToolContainerOnTheRuntime(built, stoodOn) == "" { + t.Error("the packet filter's tool container was not recognised from what its build stood on") + } + if ToolContainerOnTheRuntime(built, nil) != "" { + t.Error("a built manifest with no record of its base was judged to be on the runtime") + } + + // Each of the three alone is an ordinary module. + bundle := m + bundle.Resources = m.Resources[:len(m.Resources)-1] + if ToolContainerOnTheRuntime(bundle, nil) != "" { + t.Error("a module with tools and no container is the pattern the runtime serves, and was refused") + } + service := m + service.Tools = nil + if ToolContainerOnTheRuntime(service, nil) != "" { + t.Error("a service built against the SDK, declaring no tools, was refused") + } + elsewhere := m + elsewhere.Build = &Build{On: []BuildsOn{{Arg: "NODE_BASE", Image: "node@" + digest}}, + Artifacts: m.Build.Artifacts} + if ToolContainerOnTheRuntime(elsewhere, nil) != "" { + t.Error("a tool container on a public base was refused as though it were on the runtime's") + } +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index b339509..0c1def5 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -42,6 +42,11 @@ type Source struct { // Seen is when the source was last looked at — by a build, by hand, or by the forge saying it // moved. What a late report of an older move is judged against. Seen time.Time + // Against is every artifact the build this manifest came from stood on, as recorded. Part of a + // module's provenance like the commit is, and what tells a built manifest's base when the manifest + // itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over + // by hand, which carries its `build.on` itself. + Against []string } // Current reports whether what the mesh holds is what the source last had. @@ -61,6 +66,22 @@ func (s Source) Current() bool { // gains a requirement, a claim, a resource. What matters is that the change is visible the next // time a node is resolved, which it is. func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, from Source) error { + // **Once the node's tool runtime is in the catalogue, the pattern it retires is refused** + // (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the + // runtime's image. Refused at registration, by name, because this is the mechanism that keeps + // the old pattern from returning by habit — a rebuild of an unmoved module stops here with the + // record that says why. Before the runtime exists the pattern is accepted as it always was. + if m.Module != catalogue.RuntimeModule { + if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" { + runtime, err := i.hasModule(ctx, catalogue.RuntimeModule) + if err != nil { + return err + } + if runtime { + return fmt.Errorf("%s is not registered: %s", m.Module, why) + } + } + } raw, err := json.Marshal(m) if err != nil { return err @@ -89,6 +110,16 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr return err } +// hasModule is whether the catalogue holds a module of that name. +func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { + var one int + err := i.store.Pool().QueryRow(ctx, `select 1 from module where name = $1`, name).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return err == nil, err +} + // SourceMoved records that a module's source has a newer commit than the mesh has built. // // This is the whole of noticing. Nothing here builds anything — it writes down that the two diff --git a/internal/inventory/catalogue_test.go b/internal/inventory/catalogue_test.go index 9c0f974..7940ccb 100644 --- a/internal/inventory/catalogue_test.go +++ b/internal/inventory/catalogue_test.go @@ -685,3 +685,32 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) { t.Fatalf("a hand-registered manifest erased where the module comes from: %+v", got) } } + +// Once the node's tool runtime is in the catalogue, a module serving its tools from a container +// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175, +// to-be 38 WP2.4). Before, it is accepted as it always was — so a mesh converts in the order the +// design says and nothing is refused before there is anything to move to. +func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) { + inv := fresh(t) + filter := catalogue.Manifest{Module: "nftables", Version: "1", Tools: []string{"firewall_rules"}, + Resources: []map[string]any{{"id": "runtime", "type": "container", "name": "mesh-nftables"}}} + stoodOn := []string{catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("d", 64)} + + if err := inv.RegisterModule(t.Context(), filter, Source{Repository: "/r", Against: stoodOn}); err != nil { + t.Fatalf("before the runtime exists the old pattern is accepted: %v", err) + } + runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1"} + if err := inv.RegisterModule(t.Context(), runtime, Source{Repository: "/r"}); err != nil { + t.Fatal(err) + } + err := inv.RegisterModule(t.Context(), filter, Source{Repository: "/r", Against: stoodOn}) + if err == nil || !strings.Contains(err.Error(), "ADR 0175") { + t.Fatalf("the old pattern was registered beside the runtime: %v", err) + } + // A module that moved its tools to a bundle registers. + moved := filter + moved.Resources = nil + if err := inv.RegisterModule(t.Context(), moved, Source{Repository: "/r", Against: stoodOn}); err != nil { + t.Fatalf("a module whose tools are a bundle was refused: %v", err) + } +}