From 6a0e6c1c0cc6a33768ae467699b6a8d0226b1bef Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 18:54:45 +0000 Subject: [PATCH] Bring F3 up to its release branch, with both sides' store tests (part 11 of 14) Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/template_test.go | 323 ++++++++++++++++++ internal/catalogue/terminal_settings_test.go | 14 +- .../grafana-ace.json | 35 ++ .../grafana-unlayered.json | 35 ++ internal/catalogue/vault_seat_test.go | 31 ++ internal/catalogue/verbs.go | 22 ++ 6 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 internal/catalogue/template_test.go create mode 100644 internal/catalogue/testdata/influxdb-served-by-setting/grafana-ace.json create mode 100644 internal/catalogue/testdata/influxdb-served-by-setting/grafana-unlayered.json diff --git a/internal/catalogue/template_test.go b/internal/catalogue/template_test.go new file mode 100644 index 00000000..a439acb0 --- /dev/null +++ b/internal/catalogue/template_test.go @@ -0,0 +1,323 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// A file resource may name a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package +// T1, issue 654): the build packs files/ into an archive, and resolving the build reads each template's text back +// into content, so the mesh holds, composes and sends exactly what it would for the same text inline. + +// The two forms of one module: its text inline, and its text in files/. +const ( + inlineTwin = `{"module":"tidy","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","content":"` + mainText + `"}, + {"id":"extra","type":"file","path":"${dir:conf}/extra.conf","content":"` + extraText + `"}]}` + templatedTwin = `{"module":"tidy","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","template":"files/tidy.conf"}, + {"id":"extra","type":"file","path":"${dir:conf}/extra.conf","template":"files/deeper/extra.conf"}]}` + mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n` + extraText = `one\ntwo\n` +) + +// templateTexts is what a build reads back from the packed files/ archive: by path inside it. +func templateTexts(t *testing.T) map[string]string { + t.Helper() + var m struct{ Main, Extra string } + if err := json.Unmarshal([]byte(`{"Main":"`+mainText+`","Extra":"`+extraText+`"}`), &m); err != nil { + t.Fatal(err) + } + return map[string]string{"tidy.conf": m.Main, "deeper/extra.conf": m.Extra, "unused.conf": "not named"} +} + +func builtFiles(texts map[string]string) []Built { + return []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + "tidy/files/blobs/sha256:" + strings.Repeat("c", 64), + Digest: "sha256:" + strings.Repeat("c", 64), Texts: texts}} +} + +// held is a manifest as registration holds it: resolved with what was built, then read again strictly. +func held(t *testing.T, raw string, built []Built) Manifest { + t.Helper() + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatal(err) + } + resolved, err := m.Resolve(built) + if err != nil { + t.Fatal(err) + } + again, err := json.Marshal(resolved) + if err != nil { + t.Fatal(err) + } + out, err := ParseManifest(again) + if err != nil { + t.Fatal(err) + } + if problem := HeldTemplateProblem(out); problem != "" { + t.Fatal(problem) + } + return out +} + +func composed(t *testing.T, m Manifest) string { + t.Helper() + got, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := json.Marshal(mustDeclare(t, got)) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +func TestATemplatedModuleComposesByteIdenticallyToItsInlineTwin(t *testing.T) { + inline := composed(t, held(t, inlineTwin, builtFiles(nil))) + templated := composed(t, held(t, templatedTwin, builtFiles(templateTexts(t)))) + if inline != templated { + t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated) + } + if !strings.Contains(templated, "quoted = \\\"yes\\\"") || strings.Contains(templated, `"template"`) { + t.Fatalf("the composition does not carry the template's text as content: %s", templated) + } +} + +// What `module check`, the merge gate and `module add` judge: the text read from the tree beside the manifest. +func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t *testing.T) { + dir := t.TempDir() + for name, text := range templateTexts(t) { + full := filepath.Join(dir, "files", filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + filled, err := WithTemplatesFrom(m, dir) + if err != nil { + t.Fatal(err) + } + if filled.Templated() { + t.Fatal("a manifest read from its tree still names a template") + } + resolved, err := filled.Resolve(builtFiles(nil)) + if err != nil { + t.Fatal(err) + } + if got, want := composed(t, resolved), composed(t, held(t, inlineTwin, builtFiles(nil))); got != want { + t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got) + } +} + +func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) { + raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`, + `"template":"files/tidy.conf","content":"x"`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") { + t.Fatalf("a file with both a template and content was not refused by name: %v", err) + } +} + +func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) { + for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf", + "files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} { + raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") { + t.Errorf("the template %q was not refused by name: %v", bad, err) + } + } +} + +func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) { + raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`, + `{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") { + t.Fatalf("a directory naming a template was not refused: %v", err) + } +} + +func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) { + for _, build := range []string{ + ``, + `"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`, + `"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`, + } { + raw := strings.Replace(templatedTwin, + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "files/") { + t.Errorf("a templated module with the build %q was not refused: %v", build, err) + } + } +} + +func TestAMissingTemplateIsRefusedByName(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + // In the tree beside the manifest. + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, err = WithTemplatesFrom(m, dir) + if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") { + t.Fatalf("a template missing from the tree was not refused by name: %v", err) + } + // And in what the build read back from its archive. + texts := templateTexts(t) + delete(texts, "deeper/extra.conf") + _, err = m.Resolve(builtFiles(texts)) + if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") { + t.Fatalf("a template missing from the archive was not refused by name: %v", err) + } +} + +func TestATemplateThatIsASymlinkIsRefused(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files", "deeper"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "elsewhere"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "files", "deeper", "extra.conf")); err != nil { + t.Fatal(err) + } + if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "not a plain file") { + t.Fatalf("a template that is a symlink was not refused: %v", err) + } +} + +func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + if _, err := m.Resolve(builtFiles(nil)); err == nil || !strings.Contains(err.Error(), "files") { + t.Fatalf("a templated module resolved with no text read back from its archive: %v", err) + } +} + +func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + texts := templateTexts(t) + texts["tidy.conf"] = "\xff\xfe" + if _, err := m.Resolve(builtFiles(texts)); err == nil || !strings.Contains(err.Error(), "UTF-8") { + t.Fatalf("a template that is not UTF-8 was resolved: %v", err) + } +} + +// What registration refuses: a manifest to be held that still names a template — one resolved by a builder that +// predates templates would otherwise compose files with no text. +func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + if problem := HeldTemplateProblem(m); !strings.Contains(problem, "main") || !strings.Contains(problem, "files/tidy.conf") { + t.Fatalf("a manifest naming a template would be held: %q", problem) + } + if problem := HeldTemplateProblem(held(t, inlineTwin, builtFiles(nil))); problem != "" { + t.Fatalf("an inline manifest is refused: %s", problem) + } +} + +// A symlinked files/, or a symlinked directory under it, would read a file outside the tree: refused by name, +// as is any symlink or special file under files/ that no template names, which the build's packing refuses. +func TestASymlinkAnywhereUnderFilesIsRefusedByName(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(outside, "deeper"), 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"tidy.conf", "deeper/extra.conf", "secret"} { + if err := os.WriteFile(filepath.Join(outside, filepath.FromSlash(name)), []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + } + write := func(t *testing.T, dir, name string) { + t.Helper() + full := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("inside"), 0o644); err != nil { + t.Fatal(err) + } + } + link := func(t *testing.T, target, at string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, at); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct { + name string + make func(t *testing.T, dir string) + names string + }{ + {"files/ itself", func(t *testing.T, dir string) { + link(t, outside, filepath.Join(dir, "files")) + }, "files/ is a symlink"}, + {"a directory under files/", func(t *testing.T, dir string) { + write(t, dir, "files/tidy.conf") + link(t, filepath.Join(outside, "deeper"), filepath.Join(dir, "files", "deeper")) + }, "files/deeper is a symlink"}, + {"a file no template names", func(t *testing.T, dir string) { + write(t, dir, "files/tidy.conf") + write(t, dir, "files/deeper/extra.conf") + link(t, filepath.Join(outside, "secret"), filepath.Join(dir, "files", "unnamed")) + }, "files/unnamed is a symlink"}, + } { + dir := t.TempDir() + c.make(t, dir) + _, err := WithTemplatesFrom(m, dir) + if err == nil || !strings.Contains(err.Error(), c.names) { + t.Errorf("a symlinked %s was not refused by name: %v", c.name, err) + } + if err != nil && strings.Contains(err.Error(), "outside") { + t.Errorf("a symlinked %s was read: %v", c.name, err) + } + } +} diff --git a/internal/catalogue/terminal_settings_test.go b/internal/catalogue/terminal_settings_test.go index ac767cfd..d822a896 100644 --- a/internal/catalogue/terminal_settings_test.go +++ b/internal/catalogue/terminal_settings_test.go @@ -13,22 +13,22 @@ func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) { for _, path := range []string{"/", "/etc", "/etc/", "/etc/sudoers.d", "/usr/bin", "/root", "/var", "/var/lib", "/home", "/home/", "/run", "/run/user/1000", "/var/lib/mesh/x", "/var/lib/mesh-host", "/var/lib/mesh-host/identity", "/srv/../etc", "//etc", "/proc/1", "/sys", "/dev", "/boot/efi", "/bin", "/sbin", "/lib", "/lib64"} { - layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}} + layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})} if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") { t.Errorf("a place at %s: %v", path, err) } - layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}} + layers = []Layer{LayerOf("laptop", map[string]any{AccessesSetting: map[string]any{"media": path}})} if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") { t.Errorf("an access at %s: %v", path, err) } } for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/services/media/movies", "/var/lib/notes/data", "/home/restic/repo", "/var/lib/mesh-store"} { - layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}} + layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})} if got, err := Places(m, layers); err != nil || got["data"].Path != path { t.Errorf("a place at %s: %v %v", path, got, err) } - layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}} + layers = []Layer{LayerOf("laptop", map[string]any{AccessesSetting: map[string]any{"media": path}})} if got, err := AccessPlaces(m, layers); err != nil || got["media"] != path { t.Errorf("an access at %s: %v %v", path, got, err) } @@ -111,17 +111,17 @@ func TestTheRuntimesDataAndAnyHomesSSHAreTheMachinesOwn(t *testing.T) { for _, path := range []string{"/var/spool", "/var/spool/cron", "/var/lib/docker", "/var/lib/docker/volumes", "/var/lib/containers/storage", "/var/lib/containerd", "/var/lib/containerd/io.containerd.snapshotter.v1", "/opt", "/opt/app", "/home/alice/.ssh", "/home/alice/.ssh/keys", "/srv/backup/.ssh", "/root/.ssh"} { - layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}} + layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})} if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") { t.Errorf("a place at %s: %v", path, err) } - layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}} + layers = []Layer{LayerOf("laptop", map[string]any{AccessesSetting: map[string]any{"media": path}})} if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") { t.Errorf("an access at %s: %v", path, err) } } for _, path := range []string{"/var/lib/dockerish", "/home/alice/ssh", "/home/alice/.sshd-notes", "/storage/media"} { - layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}} + layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})} if _, err := Places(m, layers); err != nil { t.Errorf("a place at %s: %v", path, err) } diff --git a/internal/catalogue/testdata/influxdb-served-by-setting/grafana-ace.json b/internal/catalogue/testdata/influxdb-served-by-setting/grafana-ace.json new file mode 100644 index 00000000..d9e60b8a --- /dev/null +++ b/internal/catalogue/testdata/influxdb-served-by-setting/grafana-ace.json @@ -0,0 +1,35 @@ +{ + "grafana.bound-influxdb-api": { + "content": "{\n \"as\": \"mesh_workstation_grafana\",\n \"at\": \"workstation.internal\",\n \"binding\": 1,\n \"from\": \"workstation\",\n \"generated\": \"by the mesh — do not edit; replaced whenever this changes. The credential is not here: it is sealed, in the file this module's manifest names under `secrets`\",\n \"provision\": \"influxdb-api\",\n \"serves\": {\n \"bucket\": \"made-up-bucket\",\n \"org\": \"made-up-org\",\n \"port\": 8086,\n \"scheme\": \"http\"\n }\n}\n", + "id": "grafana.bound-influxdb-api", + "mode": "0644", + "path": "/var/lib/grafana/influxdb.json", + "type": "file" + }, + "grafana.influxdb-datasource": { + "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: http://workstation.internal:8086\n user: mesh_workstation_grafana\n isDefault: false\n editable: false\n jsonData:\n dbName: made-up-bucket\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "id": "grafana.influxdb-datasource", + "mode": "0644", + "path": "/var/lib/grafana/influxdb-datasources/mesh-influxdb.yaml", + "type": "file" + }, + "grafana.influxdb-datasources": { + "id": "grafana.influxdb-datasources", + "mode": "0755", + "owner": "472:0", + "path": "/var/lib/grafana/influxdb-datasources", + "type": "directory" + }, + "grafana.mesh-state": { + "id": "grafana.mesh-state", + "mode": "0700", + "path": "/var/lib/mesh/grafana", + "type": "directory" + }, + "grafana.state": { + "id": "grafana.state", + "mode": "0700", + "path": "/var/lib/grafana", + "type": "directory" + } +} diff --git a/internal/catalogue/testdata/influxdb-served-by-setting/grafana-unlayered.json b/internal/catalogue/testdata/influxdb-served-by-setting/grafana-unlayered.json new file mode 100644 index 00000000..a0ee7989 --- /dev/null +++ b/internal/catalogue/testdata/influxdb-served-by-setting/grafana-unlayered.json @@ -0,0 +1,35 @@ +{ + "grafana.bound-influxdb-api": { + "content": "{\n \"as\": \"mesh_workstation_grafana\",\n \"at\": \"workstation.internal\",\n \"binding\": 1,\n \"from\": \"workstation\",\n \"generated\": \"by the mesh — do not edit; replaced whenever this changes. The credential is not here: it is sealed, in the file this module's manifest names under `secrets`\",\n \"provision\": \"influxdb-api\",\n \"serves\": {\n \"bucket\": \"default\",\n \"org\": \"mesh\",\n \"port\": 8086,\n \"scheme\": \"http\"\n }\n}\n", + "id": "grafana.bound-influxdb-api", + "mode": "0644", + "path": "/var/lib/grafana/influxdb.json", + "type": "file" + }, + "grafana.influxdb-datasource": { + "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: http://workstation.internal:8086\n user: mesh_workstation_grafana\n isDefault: false\n editable: false\n jsonData:\n dbName: default\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "id": "grafana.influxdb-datasource", + "mode": "0644", + "path": "/var/lib/grafana/influxdb-datasources/mesh-influxdb.yaml", + "type": "file" + }, + "grafana.influxdb-datasources": { + "id": "grafana.influxdb-datasources", + "mode": "0755", + "owner": "472:0", + "path": "/var/lib/grafana/influxdb-datasources", + "type": "directory" + }, + "grafana.mesh-state": { + "id": "grafana.mesh-state", + "mode": "0700", + "path": "/var/lib/mesh/grafana", + "type": "directory" + }, + "grafana.state": { + "id": "grafana.state", + "mode": "0700", + "path": "/var/lib/grafana", + "type": "directory" + } +} diff --git a/internal/catalogue/vault_seat_test.go b/internal/catalogue/vault_seat_test.go index 0f8827c7..92a31b1b 100644 --- a/internal/catalogue/vault_seat_test.go +++ b/internal/catalogue/vault_seat_test.go @@ -23,3 +23,34 @@ func TestTheVaultsSeatDeliversSecret(t *testing.T) { t.Fatal("a provider of secret that does not claim the seat was allowed to hold it") } } + +// The vault answers `get` on request and reply (novox/hq ADR 0349 §11, issue 674): the seat promises the +// verb, so a holder serving it is accepted — and, optional while the vault that runs is the TypeScript one, +// so is a holder that does not yet. +func TestTheVaultsSeatPromisesGet(t *testing.T) { + seat, _ := SeatNamed("mesh-vault") + var get *Verb + for i := range seat.Serves { + if seat.Serves[i].Name == "get" { + get = &seat.Serves[i] + } + } + if get == nil { + t.Fatal("the vault's seat does not promise get") + } + for _, arg := range []string{"name", "public_key"} { + if props, _ := get.Input["properties"].(map[string]any); props[arg] == nil { + t.Errorf("get takes no %s", arg) + } + } + serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, Serves: []string{"get"}}}} + if err := CanHold(serving, seat); err != nil { + t.Fatalf("the vault serving get was refused: %v", err) + } + notYet := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}} + if err := CanHold(notYet, seat); err != nil { + t.Fatalf("the vault that does not serve get yet was refused: %v", err) + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 673f62f4..a186e558 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -362,6 +362,28 @@ var ControllerVerbs = []Verb{ "register": "\"true\" to register what it builds", "older": "\"true\", with register: even though a newer build of the module is registered", }, []string{"id"}, "register", "older")}, + // novox/hq issue 686: a builder ran a branch's store tests for the first time in its pull request's merge check. + {Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the build " + + "seat merges it into the repository's trunk and runs the repository's own merge-check.sh on the merge, in the " + + "toolchain containers and against the throwaway store and bus a pull request's merge check gets. Answers at " + + "once with an id; asked again with that id it waits up to 20 s and answers pass, fail or error, how long it " + + "took and the lines naming what failed — or that it is still waiting or running, to ask again. Sets no " + + "status on the forge, registers and builds nothing, runs no merge gate, counts as a check in the build queue " + + "(after deliveries and after every pull request's check, however long it waits) and is bounded as one. One " + + "try of a commit at a time — a second ask answers the first's id — and at most four waiting or running in " + + "all: a fifth is refused. Only for a repository of the mesh; the ref must be on the forge.", + Input: schema(map[string]string{ + "repository": "the repository on the forge, owner/name; with ref", + "ref": "the branch or commit to try, as pushed to the forge", + "base": "the branch it is merged into (the repository's default branch when none is named). The " + + "merge-check.sh that runs is that branch's, never the ref's own: the same trust as a pull request's base", + "tests": "only the tests this `go test -run` pattern names, where the repository's merge-check.sh reads " + + "MESH_CHECK_TESTS; a script that does not runs whole", + "store-only": "\"true\": only the repository's store tests, where its merge-check.sh reads " + + "MESH_CHECK_STORE_ONLY", + "id": "instead of a repository: the id a try answered, to read what came of it", + }, nil, "store-only"), + Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, // novox/hq ADR 0350 §5: asked by the delivery's owner when the forge says a pull request closed unmerged. {Name: "forget-checks", Description: "Let go of every build a pull request's merge checks made for its merge in " + "the artifact store's scratch namespace (novox/hq ADR 0350 §5): asked when it closed unmerged; a merged one's go " +