diff --git a/go/catalogue/files_test.go b/go/catalogue/files_test.go index dcbf535..92de928 100644 --- a/go/catalogue/files_test.go +++ b/go/catalogue/files_test.go @@ -111,11 +111,14 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { } } seats = compiled - // Issue 1126, C5: a module carried as it runs (compose.Carried) owns the paths it runs at, beside its resources'. - carried := Manifest{Module: "de-spiegel", Version: "1", RunsAt: []string{"/var/lib/de-spiegel/server.env"}} + // Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as + // the controller tells them, and not the paths its running resources are at (a directory the mesh placed). + carried := Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"}, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/de-spiegel"}}} w, err := Resolution{Node: "anchor", Modules: []Manifest{files, carried}}.filesWords(files, nil, Rendering{}) - if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) { - t.Errorf("the paths a carried module runs at are not owned: %v %v", w[FilesOwnedPaths], err) + if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) || + strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel":`) { + t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[FilesOwnedPaths], err) } if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" { t.Error("a module not holding node-files is told the owned paths") diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 2f0be1e..d91df7f 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -864,10 +864,11 @@ type Manifest struct { // that could only see its own ports would write a rule set that closed everything else. Filtering *Filtering `json:"filtering,omitempty"` - // RunsAt is, for a module composed from what runs of it (compose.Carried: folded into its node's own composition - // at a build of the old form, issue 1126, C5), the paths its running resources are at: paths it owns beside its - // resources' (declaredPaths), so the holder of node-files is told them. Never in a manifest's JSON. - RunsAt []string `json:"-"` + // OwnsPaths is, for a module composed from what runs of it (compose.Carried: folded into its node's own composition + // at a build of the old form, issue 1126, C5), the paths it owns: its manifest's (DeclaredPaths), as the controller + // tells the holder of node-files them, in place of its resources', which are what runs (a directory placed at a + // path its manifest leaves to the mesh, a binding). Nil for any other module. Never in a manifest's JSON. + OwnsPaths []string `json:"-"` // Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31). // Written into whichever node runs the module, the same way `listens` become that node's rules. diff --git a/go/catalogue/seat_paths.go b/go/catalogue/seat_paths.go index b80dd4d..a2a13af 100644 --- a/go/catalogue/seat_paths.go +++ b/go/catalogue/seat_paths.go @@ -123,6 +123,14 @@ func namesWithoutSteppingBack(s Seat, p string) bool { // as written would pass for a path nobody named. func declaredPaths(m Manifest) map[string]bool { out := map[string]bool{} + if m.OwnsPaths != nil { + // Carried: what its manifest declares, never what runs of it (issue 1126, seed-8: f1-circuits, snake and txt-game + // on ace owned their /var/lib paths on the node and none on the controller). + for _, p := range m.OwnsPaths { + out[p] = true + } + return out + } for _, r := range m.Resources { if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { out[path.Clean(p)] = true @@ -133,14 +141,15 @@ func declaredPaths(m Manifest) map[string]bool { out[path.Clean(f.Path)] = true } } - for _, p := range m.RunsAt { - if strings.HasPrefix(p, "/") { - out[path.Clean(p)] = true - } - } return out } +// DeclaredPaths is every machine path a module's manifest declares it owns (declaredPaths), sorted and never nil: what +// a carried module owns (Manifest.OwnsPaths). +func DeclaredPaths(m Manifest) []string { + return append([]string{}, sortedKeys(declaredPaths(m))...) +} + // declaresPath says whether a module declares one path, as a resource or as a file the mesh renders. func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] } diff --git a/go/compose/composed.go b/go/compose/composed.go index f6b9bb0..f89dec1 100644 --- a/go/compose/composed.go +++ b/go/compose/composed.go @@ -263,6 +263,10 @@ func NamesBuild(m catalogue.Manifest) bool { // and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a // node's answer is composed so; a folded one the node runs is Carried. func Stub(m catalogue.Manifest) catalogue.Manifest { + // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). + if m.OwnsPaths == nil { + m.OwnsPaths = catalogue.DeclaredPaths(m) + } m.Build = nil var dirs []map[string]any for _, r := range m.Resources { @@ -276,19 +280,21 @@ func Stub(m catalogue.Manifest) catalogue.Manifest { // Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126, // C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their -// module's word taken off their ids, with the manifest's directories it does not run; and the path of every resource it -// runs (catalogue.Manifest.RunsAt). So what other modules read of it is what runs: the machine ports its containers -// publish (the filter's rules, the routes reaching it: MachineSide) and the paths it owns (the holder of node-files is -// told them). Its other resources are not taken, since the composition makes some of them again from its manifest (its -// secrets' files) and one id twice is refused; none of them is composed into what is applied either way (compositions -// leaves a carried module's out, and carries the ones that run). What it provides, requires, listens on and grants is -// its manifest's. A module whose resources runs holds none of is its Stub, as before. +// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it +// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The +// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes +// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and +// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the +// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them +// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that +// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none +// of is its Stub, as before. func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { if runs == nil { return Stub(m) } var taken []map[string]any - var at []string + runsAny := false ids := map[string]bool{} for _, raw := range runs.Resources { var r map[string]any @@ -303,9 +309,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal if owner != module { continue } - if p, ok := r["path"].(string); ok && p != "" { - at = append(at, p) - } + runsAny = true if typ := r["type"]; typ != "container" && typ != "directory" { continue } @@ -314,7 +318,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal ids[r["id"].(string)] = true taken = append(taken, r) } - if len(at) == 0 && len(taken) == 0 { + if !runsAny { return Stub(m) } stub := Stub(m) @@ -324,7 +328,6 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal } } stub.Resources = taken - stub.RunsAt = at return stub } diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index d39da77..b1a463c 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "reflect" "slices" "strings" @@ -240,17 +241,39 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") { t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules) } + // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue + // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and + // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' + // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is + // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). for _, c := range running { - if c.Module == "" && !slices.Contains(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).RunsAt, "/var/lib/spiegel/server.env") { - t.Errorf("spiegel carried does not own the path its running file is at") + if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { + t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) } } + f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") + if err != nil { + t.Fatal(err) + } + f1, err := catalogue.ParseManifest(f1raw) + if err != nil { + t.Fatal(err) + } + f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ + json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), + json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} + if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { + t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) + } var k1, k2 any _ = json.Unmarshal(keptComposed, &k1) _ = json.Unmarshal(keptFile, &k2) if !reflect.DeepEqual(k1, k2) { t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) } + } // A module another node cannot run in this composition offers nothing in it: the refusal names it and why diff --git a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json new file mode 100644 index 0000000..8f2b612 --- /dev/null +++ b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json @@ -0,0 +1,82 @@ +{ + "module": "f1-circuits", + "slug": "f1", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "f1-circuits", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "cache", + "type": "directory", + "mode": "0700" + }, + { + "id": "site-env", + "type": "file", + "path": "${dir:state}/site.env", + "mode": "0644", + "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n" + }, + { + "id": "server", + "type": "container", + "name": "f1-circuits", + "artifact": "cdn", + "ports": [ + "8000" + ], + "env-file": [ + "${dir:state}/site.env" + ], + "volumes": [ + "${dir:cache}:/app/cache" + ], + "restart-on": [ + "site-env" + ] + } + ], + "build": { + "on": [ + { + "arg": "PYTHON_BASE", + "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" + } + ], + "artifacts": [ + { + "name": "cdn", + "kind": "image", + "from": "service/Dockerfile" + } + ] + } +}