Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c255d56b71 |
@@ -35,8 +35,6 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
/checks/paths/paths
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-delivery-gate/mesh-delivery-gate
|
||||
modules/mesh-config/cmd/mesh-config/mesh-config
|
||||
checks/placement/placement
|
||||
|
||||
# Built by their modules' build step (issue 1577), never committed.
|
||||
modules/forticlient/cmd/forticlient-tools/forticlient-tools
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module checks/placement
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36
|
||||
@@ -1,30 +0,0 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026171759-a431746c0e8c h1:zaSfYTJd03b0pF29QTZ6G5ajfjOXRXjHIpOiGu/niHA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026171759-a431746c0e8c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172611-5b1e990eb4bb h1:Mo+XdKBGFhUbH1XLSyMdkqIL/JhMIUAucjT+ipZoTfg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026172611-5b1e990eb4bb/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026173336-9789c8379a74 h1:+LrIwBeG0eCN6xOS9jhzL6ag0ipXHZ1vfCFEJqtT1eI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026173336-9789c8379a74/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6 h1:sIagBHevPfpISBRK2r5EemnhHEGubdfEx/l3K6ujomo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4 h1:rKDfp+IHnv903jcCujf5IdTrWd3mnudpNCfKaKvqp3w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181846-91413de33516 h1:xbbss6kQFrAiyy8UEvqh2Ddi2KP+O9oTUSh7GkqmCYU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026181846-91413de33516/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520 h1:twiziR88X6ttKmw5JTd3aW6xb5bMZZ3Bl7WS2QOP3PE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18 h1:HWNPfnT9YT9O6xHPLy2y6Q+LYT8XRjvLnZw9O61NETk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36 h1:aBkmFUq/zAVdR3PzfgjjC9AxSymNl3eVC7UEZv9tZ0Q=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
@@ -1,80 +0,0 @@
|
||||
// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every
|
||||
// `placement/<node>.json` reads as a placement of the node its name says, and the files together are refused, every
|
||||
// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not
|
||||
// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or
|
||||
// more than one hub (mesh-sdk delivery.CheckPlacements).
|
||||
//
|
||||
// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names
|
||||
// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A
|
||||
// catalogue with no placement directory has none to check.
|
||||
func Check(root string) ([]string, error) {
|
||||
dir := filepath.Join(root, delivery.PlacementDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var found []string
|
||||
placements := map[string]delivery.Placement{}
|
||||
for _, e := range entries {
|
||||
rel := delivery.PlacementDir + "/" + e.Name()
|
||||
node, ok := delivery.PlacementFileNode(rel)
|
||||
if e.IsDir() || !ok {
|
||||
found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only <node>.json, one per node", rel))
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil {
|
||||
found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err))
|
||||
continue
|
||||
}
|
||||
if p.Revision != "" {
|
||||
found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel))
|
||||
}
|
||||
placements[node] = p
|
||||
}
|
||||
all := func(string) bool { return true }
|
||||
found = append(found, delivery.CheckPlacements(placements, all, all)...)
|
||||
sort.Strings(found)
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: placement <catalogue root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
found, err := Check(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every
|
||||
// rule, and a tree that breaks them is refused with every finding named at once.
|
||||
func TestPlacementFiles(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
t.Errorf("%s (novox/hq ADR 0549 §1)", f)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
write := func(name, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if found, err := Check(root); err != nil || len(found) != 0 {
|
||||
t.Fatalf("a catalogue with no placement directory: %v, %v", found, err)
|
||||
}
|
||||
write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`)
|
||||
write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`)
|
||||
write("novox.json", `{"v":1,"node":"shanks","modules":[]}`)
|
||||
write("bad.json", `{"v":1,"node":"bad","modules":{}}`)
|
||||
write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`)
|
||||
write("notes.md", "x")
|
||||
found, err = Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1",
|
||||
"ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision",
|
||||
"notes.md is not a placement file"} {
|
||||
hit := false
|
||||
for _, f := range found {
|
||||
hit = hit || strings.Contains(f, want)
|
||||
}
|
||||
if !hit {
|
||||
t.Errorf("no finding says %q; found %v", want, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
+3
-19
@@ -540,7 +540,7 @@ export class GiteaClient {
|
||||
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
|
||||
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
||||
* see, since a directory left out would be taken for one without a module. Reads only. */
|
||||
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, listed?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; listed?: Record<string, Record<string, string>> }> {
|
||||
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
|
||||
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
||||
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
||||
const entries: any[] = [];
|
||||
@@ -567,23 +567,7 @@ export class GiteaClient {
|
||||
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
||||
trees[dir] = tree;
|
||||
}
|
||||
// **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name
|
||||
// to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a
|
||||
// merge. A directory the commit lacks lists nothing (the catalogue before its first placement file).
|
||||
let files: Record<string, Record<string, string>> | undefined;
|
||||
if (listed !== undefined) {
|
||||
files = {};
|
||||
for (const dir of listed) {
|
||||
files[dir] = {};
|
||||
const prefix = dir === "" ? "" : `${dir}/`;
|
||||
for (const e of entries) {
|
||||
if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue;
|
||||
const name = e.path.slice(prefix.length);
|
||||
if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files };
|
||||
if (paths === undefined) return { root, trees };
|
||||
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
|
||||
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
|
||||
// naming both, and nothing is answered in part.
|
||||
@@ -598,7 +582,7 @@ export class GiteaClient {
|
||||
named[dir][p] = id;
|
||||
}
|
||||
}
|
||||
return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
|
||||
return { root, trees, paths: named };
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
|
||||
@@ -183,15 +183,6 @@ test("named paths: each asked path's git object id at the commit, a file's or a
|
||||
});
|
||||
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
|
||||
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
|
||||
// Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none.
|
||||
const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]);
|
||||
assert.deepEqual(listed.listed, {
|
||||
"modules/a": { "main.go": h("6"), "module.json": h("5") },
|
||||
"modules/b": {},
|
||||
"": { "README.md": h("1"), "module.json": h("2") },
|
||||
placement: {},
|
||||
});
|
||||
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -218,12 +209,7 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
|
||||
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
|
||||
try {
|
||||
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
|
||||
assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments");
|
||||
const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any);
|
||||
assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } });
|
||||
for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) {
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad));
|
||||
}
|
||||
assert.ok("paths" in tool.input, "the tool names the argument");
|
||||
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
|
||||
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
|
||||
const before = asked.length;
|
||||
|
||||
@@ -76,17 +76,6 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
|
||||
* the empty name); anything else is refused before the forge is asked. Absent is absent. */
|
||||
function listedDirs(raw: unknown): string[] | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
|
||||
for (const d of raw as string[]) {
|
||||
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
|
||||
}
|
||||
return raw as string[];
|
||||
}
|
||||
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin));
|
||||
}
|
||||
@@ -561,20 +550,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_trees",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit, its full 40-hex sha" },
|
||||
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
|
||||
listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
||||
const sha = String(args.sha ?? "").trim();
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
|
||||
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed));
|
||||
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,11 +56,15 @@ type TestResult struct {
|
||||
Failed []Failure `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// Failure is a check or script that failed; Output is the first lines it wrote.
|
||||
// Failure is a check or script that failed, or one failing test of a test script.
|
||||
type Failure struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
// File is the failing test's location (file:line:column) when node --test named it, Cause the first line of its
|
||||
// error, and Output its own output, Cause first, up to 4096 bytes (issue 1901), as mesh-build-go's.
|
||||
File string `json:"file,omitempty"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Module) testTool() StepTool {
|
||||
@@ -128,11 +132,19 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
var check, run time.Duration
|
||||
var failed []Failure
|
||||
// a command that could not start is not a verdict: the step fails. One that ran and said no is.
|
||||
ran := func(name string, r forgekey.Ran) error {
|
||||
ran := func(name string, r forgekey.Ran, tests bool) error {
|
||||
if r.Err != "" {
|
||||
return fmt.Errorf("%s could not be run: status %d %s: %s", name, r.Status, r.Err, firstLine(r.Stderr))
|
||||
}
|
||||
if r.Status != 0 {
|
||||
if r.Status != 0 && tests {
|
||||
// the test script ran under node's TAP reporter: each failing test is a failure of its own, never a passing line
|
||||
for _, f := range tapFailures(where, r.Stdout+"\n"+r.Stderr) {
|
||||
if f.Test == "" {
|
||||
f.Test = name
|
||||
}
|
||||
failed = append(failed, f)
|
||||
}
|
||||
} else if r.Status != 0 {
|
||||
failed = append(failed, Failure{Package: where, Test: name, Output: lines(r.Stdout + "\n" + r.Stderr)})
|
||||
}
|
||||
return nil
|
||||
@@ -148,17 +160,17 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
start := m.now()
|
||||
r := m.runIn(ctx, root, false, dir, nil, "node", line...)
|
||||
check = m.now().Sub(start)
|
||||
if err := ran(name, r); err != nil {
|
||||
if err := ran(name, r, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Ran = append(res.Ran, name)
|
||||
}
|
||||
if hasScript && len(failed) == 0 {
|
||||
start := m.now()
|
||||
env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm")}
|
||||
env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "NODE_OPTIONS=" + TestReporterOption, "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm")}
|
||||
r := m.runIn(ctx, root, false, dir, env, "npm", scriptArgs(script)...)
|
||||
run = m.now().Sub(start)
|
||||
if err := ran(script, r); err != nil {
|
||||
if err := ran(script, r, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Ran = append(res.Ran, script)
|
||||
@@ -166,7 +178,7 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
res.Failed = failed
|
||||
var why []string
|
||||
for _, f := range failed {
|
||||
why = append(why, fmt.Sprintf("%s failed in %s: %s", f.Test, f.Package, firstLine(f.Output)))
|
||||
why = append(why, failureWhy(f))
|
||||
}
|
||||
if level == "unit" {
|
||||
res.Elapsed, res.Bound = (check + run).Seconds(), UnitBound.Seconds()
|
||||
@@ -280,3 +292,16 @@ func typeScriptFiles(dir string) ([]string, error) {
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// failureWhy is the words of one failure: the check or test that failed, where, and its cause (the first line of its output
|
||||
// when it has none).
|
||||
func failureWhy(f Failure) string {
|
||||
if f.Cause == "" {
|
||||
return fmt.Sprintf("%s failed in %s: %s", f.Test, f.Package, firstLine(f.Output))
|
||||
}
|
||||
file := ""
|
||||
if f.File != "" {
|
||||
file = " (" + f.File + ")"
|
||||
}
|
||||
return fmt.Sprintf("test %q failed in %s%s: %s", f.Test, f.Package, file, firstLine(f.Cause))
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestAFailingScriptIsAVerdictAndACommandThatCannotStartIsAFailedStep(t *test
|
||||
f.answer["npm"] = forgekey.Ran{Status: 1, Stdout: "not ok 1 - adds\n"}
|
||||
out, err := m.test(context.Background(), testArgs("unit"))
|
||||
r, _ := out.(*TestResult)
|
||||
if err != nil || r == nil || r.Passed || len(r.Failed) != 1 || r.Failed[0].Test != "test" || !strings.Contains(r.Failed[0].Output, "not ok 1") {
|
||||
if err != nil || r == nil || r.Passed || len(r.Failed) != 1 || r.Failed[0].Test != "adds" || !strings.Contains(r.Failed[0].Output, "not ok 1") {
|
||||
t.Errorf("a failing script = %+v, %v, want passed false and its output", r, err)
|
||||
}
|
||||
f.answer["npm"] = forgekey.Ran{Status: 127, Err: "ENOENT"}
|
||||
@@ -199,3 +199,38 @@ func TestTheTestScriptFindsTscAndEsbuildOnItsPath(t *testing.T) {
|
||||
t.Errorf("npm ran with PATH %q, want %s first", path, bin)
|
||||
}
|
||||
}
|
||||
|
||||
// 1901: a failing node --test run is reported test by test, from node's TAP reporter, in the output captured in
|
||||
// testdata/node-test-tap: each failing test with its name, file and cause, never a passing line of an earlier file.
|
||||
func TestFailingNodeTestsAreReportedEachWithTheirCause(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
file, test, location, cause, inOutput string
|
||||
n int
|
||||
}{
|
||||
{"two-files", "fails in the second file", "/work/two-files/test/b.test.mjs:3:1", "one plus one is not three", "2 !== 3", 1},
|
||||
{"suite", "fails inside", "/work/suite/test/s.test.mjs:5:3", "Expected values to be strictly deep-equal:", "operator: 'deepStrictEqual'", 1},
|
||||
{"syntax", "test/b.test.mjs", "/work/syntax/test/b.test.mjs:1:1", "", "SyntaxError: Unexpected end of input", 1},
|
||||
} {
|
||||
out, err := os.ReadFile(filepath.Join("testdata", "node-test-tap", c.file+".tap"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := tapFailures("p", string(out))
|
||||
if len(got) != c.n {
|
||||
t.Fatalf("%s: %d failures %+v, want %d", c.file, len(got), got, c.n)
|
||||
}
|
||||
f := got[0]
|
||||
if c.file == "syntax" {
|
||||
c.cause = "SyntaxError: Unexpected end of input" // node names the file, the error is in the lines before it
|
||||
}
|
||||
if f.Test != c.test || f.File != c.location || !strings.HasPrefix(f.Cause, c.cause) ||
|
||||
!strings.Contains(f.Output, c.inOutput) || len(f.Output) > 4096+len(f.Cause)+64 {
|
||||
t.Errorf("%s: failure %+v, want test %q at %q, cause %q, output with %q", c.file, f, c.test, c.location, c.cause, c.inOutput)
|
||||
}
|
||||
for _, l := range strings.Split(f.Output, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "ok ") || strings.Contains(l, "passes in the first file") {
|
||||
t.Errorf("%s: output keeps the passing line %q", c.file, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ansi = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
// TestReporterOption is what the test script runs under: node's TAP reporter, which writes each failing test as a
|
||||
// "not ok" line with a YAML block (its error, its location, its stack), whatever the package's own script prints
|
||||
// otherwise (issue 1901: the default output of a run over several files kept the first file's passing lines).
|
||||
const TestReporterOption = "--test-reporter=tap"
|
||||
|
||||
const outputBytes = 4096
|
||||
|
||||
// tapFailures are the failing tests of a `node --test` run read from its TAP output, each with its name, its file
|
||||
// (the test's location), its Cause (the first line of its error) and its own block up to outputBytes, Cause first.
|
||||
// A suite that failed only because a test inside it did (failureType subtestsFailed) is not reported twice. When the
|
||||
// output holds no failing test (node itself failed: a syntax error, a file that could not load, a crash) the failure
|
||||
// is the last lines of the output without its passing lines, and its Cause the first line that names an error.
|
||||
func tapFailures(pkg, out string) []Failure {
|
||||
var failed []Failure
|
||||
all := strings.Split(ansi.ReplaceAllString(strings.ReplaceAll(out, "\r\n", "\n"), ""), "\n")
|
||||
prev := 0 // the line after the last test block: what the run wrote since is the next test's own
|
||||
for i := 0; i < len(all); i++ {
|
||||
name, ok := notOk(all[i])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
indent := len(all[i]) - len(strings.TrimLeft(all[i], " "))
|
||||
block := []string{strings.TrimSpace(all[i])}
|
||||
fields := map[string]string{}
|
||||
var key string
|
||||
inYAML := false
|
||||
j := i + 1
|
||||
for ; j < len(all); j++ {
|
||||
t := strings.TrimSpace(all[j])
|
||||
if !inYAML {
|
||||
if t == "---" {
|
||||
inYAML = true
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if t == "..." {
|
||||
j++
|
||||
break
|
||||
}
|
||||
block = append(block, strings.TrimRight(all[j], " \t"))
|
||||
if k, v, found := yamlKey(all[j], indent); found {
|
||||
key = k
|
||||
if v != "|-" && v != "|" {
|
||||
fields[k] = strings.Trim(v, "'")
|
||||
}
|
||||
} else if key != "" && fields[key] == "" && t != "" {
|
||||
fields[key] = t // the first line of a block scalar
|
||||
}
|
||||
}
|
||||
from := prev
|
||||
i, prev = j-1, j
|
||||
if fields["failureType"] == "subtestsFailed" {
|
||||
continue
|
||||
}
|
||||
cause := fields["error"]
|
||||
if cause == "" {
|
||||
cause = fields["failureType"]
|
||||
}
|
||||
if cause == "test failed" || cause == "" {
|
||||
// a file that did not load (a syntax error, a crash): node says only "test failed" and writes the error
|
||||
// as comment lines before it
|
||||
var own []string
|
||||
for _, l := range all[from:i] {
|
||||
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") {
|
||||
own = append(own, strings.TrimPrefix(t, "# "))
|
||||
}
|
||||
}
|
||||
if len(own) > 0 {
|
||||
cause = own[0]
|
||||
for _, l := range own {
|
||||
if strings.Contains(l, "Error") {
|
||||
cause = l
|
||||
break
|
||||
}
|
||||
}
|
||||
block = append(own, block...)
|
||||
}
|
||||
}
|
||||
failed = append(failed, Failure{Package: pkg, Test: name, File: fields["location"], Cause: cause, Output: clipBlock(cause, block)})
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return failed
|
||||
}
|
||||
var rest []string
|
||||
for _, l := range all {
|
||||
t := strings.TrimSpace(l)
|
||||
if t == "" || strings.HasPrefix(t, "ok ") || strings.HasPrefix(t, "✔") || strings.HasPrefix(t, "# Subtest") || strings.HasPrefix(t, "TAP version") {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, t)
|
||||
}
|
||||
cause := ""
|
||||
for _, l := range rest {
|
||||
if strings.Contains(l, "Error") || strings.HasPrefix(l, "not ok") {
|
||||
cause = l
|
||||
break
|
||||
}
|
||||
}
|
||||
return []Failure{{Package: pkg, Cause: cause, Output: clipBlock(cause, rest)}}
|
||||
}
|
||||
|
||||
// notOk is the test name of a "not ok N - name" line, indented or not.
|
||||
func notOk(l string) (string, bool) {
|
||||
t := strings.TrimSpace(l)
|
||||
rest, ok := strings.CutPrefix(t, "not ok ")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if _, after, found := strings.Cut(rest, " - "); found {
|
||||
rest = after
|
||||
}
|
||||
return strings.TrimSpace(rest), true
|
||||
}
|
||||
|
||||
// yamlKey is "key: value" of a field of a TAP block, whose lines sit two spaces deeper than their "not ok" line; a
|
||||
// deeper line is part of a block scalar (an error's lines, a stack).
|
||||
func yamlKey(l string, indent int) (key, val string, ok bool) {
|
||||
t := strings.TrimLeft(l, " ")
|
||||
if len(l)-len(t) != indent+2 {
|
||||
return "", "", false
|
||||
}
|
||||
k, v, found := strings.Cut(t, ": ")
|
||||
if !found {
|
||||
if k, found = strings.CutSuffix(t, ":"); !found {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
if k == "" || strings.ContainsAny(k, " ()/") {
|
||||
return "", "", false
|
||||
}
|
||||
return k, strings.TrimSpace(v), true
|
||||
}
|
||||
|
||||
// clipBlock is a failing test's block, Cause first, kept to its last lines within outputBytes.
|
||||
func clipBlock(cause string, lines []string) string {
|
||||
size, from := 0, len(lines)
|
||||
for from > 0 {
|
||||
n := len(lines[from-1]) + 1
|
||||
if size+n > outputBytes && from < len(lines) {
|
||||
break
|
||||
}
|
||||
size += n
|
||||
from--
|
||||
}
|
||||
var kept []string
|
||||
if cause = strings.TrimSpace(cause); cause != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != cause) {
|
||||
kept = append(kept, cause)
|
||||
}
|
||||
if from > 0 {
|
||||
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
||||
}
|
||||
kept = append(kept, lines[from:]...)
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# node --test TAP output, captured (issue 1901)
|
||||
|
||||
Real output of `NODE_OPTIONS=--test-reporter=tap node --test 'test/*.test.mjs'` (node v23.9.0, the Node the build
|
||||
module runs with) in each project folder beside it, stdout and stderr together; every run exits 1.
|
||||
|
||||
- `two-files`: a.test.mjs passes, b.test.mjs has one failing and one passing test (`two-files.tap`).
|
||||
- `suite`: a describe whose second test fails, and prints a line first (`suite.tap`); the suite itself is reported
|
||||
`not ok` too, with failureType subtestsFailed.
|
||||
- `syntax`: a.test.mjs passes, b.test.mjs has a syntax error, so node reports the file `not ok` (`syntax.tap`).
|
||||
|
||||
Captured by running the command in each folder. The only change to the captures: the absolute path of the capturing
|
||||
checkout is replaced by `/work/`. To capture again, run the command in the folder and redirect `2>&1` to the file.
|
||||
@@ -0,0 +1,62 @@
|
||||
TAP version 13
|
||||
# a line the test printed
|
||||
# Subtest: a suite
|
||||
# Subtest: passes inside
|
||||
ok 1 - passes inside
|
||||
---
|
||||
duration_ms: 0.786439
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: fails inside
|
||||
not ok 2 - fails inside
|
||||
---
|
||||
duration_ms: 4.551994
|
||||
type: 'test'
|
||||
location: '/work/suite/test/s.test.mjs:5:3'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
[32m+ actual[39m [31m- expected[39m
|
||||
|
||||
[39m {
|
||||
[32m+[39m a: 1
|
||||
[31m-[39m a: 2
|
||||
[39m }
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
a: 2
|
||||
actual:
|
||||
a: 1
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///work/suite/test/s.test.mjs:5:77)
|
||||
Test.runInAsyncScope (node:async_hooks:214:14)
|
||||
Test.run (node:internal/test_runner/test:1047:25)
|
||||
Suite.processPendingSubtests (node:internal/test_runner/test:744:18)
|
||||
Test.postRun (node:internal/test_runner/test:1173:19)
|
||||
Test.run (node:internal/test_runner/test:1101:12)
|
||||
async Promise.all (index 0)
|
||||
async Suite.run (node:internal/test_runner/test:1442:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:297:3)
|
||||
...
|
||||
1..2
|
||||
not ok 1 - a suite
|
||||
---
|
||||
duration_ms: 6.475177
|
||||
type: 'suite'
|
||||
location: '/work/suite/test/s.test.mjs:3:1'
|
||||
failureType: 'subtestsFailed'
|
||||
error: '1 subtest failed'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..1
|
||||
# tests 2
|
||||
# suites 1
|
||||
# pass 1
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 69.244721
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"name":"p","type":"module"}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
describe('a suite', () => {
|
||||
it('passes inside', () => {});
|
||||
it('fails inside', () => { console.log('a line the test printed'); assert.deepEqual({ a: 1 }, { a: 2 }); });
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
TAP version 13
|
||||
# Subtest: passes
|
||||
ok 1 - passes
|
||||
---
|
||||
duration_ms: 0.929406
|
||||
type: 'test'
|
||||
...
|
||||
# file:///work/syntax/test/b.test.mjs:3
|
||||
# SyntaxError: Unexpected end of input
|
||||
# [90m at compileSourceTextModule (node:internal/modules/esm/utils:344:16)[39m
|
||||
# [90m at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:106:18)[39m
|
||||
# [90m at \#translate (node:internal/modules/esm/loader:490:12)[39m
|
||||
# [90m at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:537:27)[39m
|
||||
# [90m at async ModuleJob._link (node:internal/modules/esm/module_job:116:19)[39m
|
||||
# Node.js v23.9.0
|
||||
# Subtest: test/b.test.mjs
|
||||
not ok 2 - test/b.test.mjs
|
||||
---
|
||||
duration_ms: 37.927068
|
||||
type: 'test'
|
||||
location: '/work/syntax/test/b.test.mjs:1:1'
|
||||
failureType: 'testCodeFailure'
|
||||
exitCode: 1
|
||||
signal: ~
|
||||
error: 'test failed'
|
||||
code: 'ERR_TEST_FAILURE'
|
||||
...
|
||||
1..2
|
||||
# tests 2
|
||||
# suites 0
|
||||
# pass 1
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 68.264065
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"name":"p","type":"module"}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import test from 'node:test';
|
||||
test('passes', () => {});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import test from "node:test";
|
||||
test("never loads", () => {;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
TAP version 13
|
||||
# Subtest: passes in the first file
|
||||
ok 1 - passes in the first file
|
||||
---
|
||||
duration_ms: 0.596042
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: fails in the second file
|
||||
not ok 2 - fails in the second file
|
||||
---
|
||||
duration_ms: 1.962872
|
||||
type: 'test'
|
||||
location: '/work/two-files/test/b.test.mjs:3:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
one plus one is not three
|
||||
|
||||
2 !== 3
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 3
|
||||
actual: 2
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///work/two-files/test/b.test.mjs:3:49)
|
||||
Test.runInAsyncScope (node:async_hooks:214:14)
|
||||
Test.run (node:internal/test_runner/test:1047:25)
|
||||
Test.start (node:internal/test_runner/test:944:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:297:17)
|
||||
...
|
||||
# Subtest: also passes
|
||||
ok 3 - also passes
|
||||
---
|
||||
duration_ms: 0.148378
|
||||
type: 'test'
|
||||
...
|
||||
1..3
|
||||
# tests 3
|
||||
# suites 0
|
||||
# pass 2
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 69.115323
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"name":"p","type":"module"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import test from 'node:test';
|
||||
test('passes in the first file', () => {});
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
test('fails in the second file', () => { assert.equal(1 + 1, 3, 'one plus one is not three'); });
|
||||
test('also passes', () => {});
|
||||
@@ -115,7 +115,7 @@ func resolving(c *Config) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
|
||||
for _, read := range []string{AssignmentsRead, DeclaredConfigRead, PredefinedRead} {
|
||||
go func(read string) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
time.Sleep(wait)
|
||||
|
||||
@@ -21,16 +21,4 @@ func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
|
||||
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
|
||||
}
|
||||
|
||||
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
|
||||
// while the two sources stand; it goes when neither names it.
|
||||
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
|
||||
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
|
||||
}
|
||||
expect(t, m, "ace", "10", SourceDefault)
|
||||
_ = m.Delete(PlacementRead, "placement.ace")
|
||||
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("a module no longer placed moved %+v", r.Moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ const (
|
||||
PredefinedState = "predefined"
|
||||
ProposalsState = "proposals"
|
||||
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
// PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each
|
||||
// node's key `placement.<node>` with the modules placed there.
|
||||
PlacementRead = "mesh-delivery-indexer.placement"
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
DeclaredConfigRead = "mesh-controller.declared-config"
|
||||
PredefinedRead = "mesh-controller.predefined"
|
||||
|
||||
|
||||
@@ -27,38 +27,6 @@ func (c *Config) sources() (Sources, error) {
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the assignments cannot be read: %w", err)
|
||||
}
|
||||
// And every module the catalogue's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
|
||||
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
|
||||
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
|
||||
standing := map[string]bool{}
|
||||
for _, a := range s.Assignments {
|
||||
standing[a.Node+"."+a.Module] = true
|
||||
}
|
||||
if err := c.each(PlacementRead, func(key string, raw []byte) error {
|
||||
node, ok := strings.CutPrefix(key, "placement.")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
|
||||
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
|
||||
return nil
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if m.Module != "" && !standing[node+"."+m.Module] {
|
||||
standing[node+"."+m.Module] = true
|
||||
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
|
||||
}
|
||||
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
|
||||
m, err := ParseModuleDeclarations(raw)
|
||||
if err != nil {
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-delivery-indexer.placement",
|
||||
"mesh-controller.declared-config",
|
||||
"mesh-controller.predefined"
|
||||
],
|
||||
|
||||
@@ -5,8 +5,6 @@ package main
|
||||
// key (or a first one) raises `secret-changed` for the machine's core secrets, the same key says nothing.
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -102,17 +100,14 @@ authorization {
|
||||
users = [
|
||||
{ user: "novox.mesh-vault", password: "pw", permissions: {
|
||||
publish: { deny: [">"] }
|
||||
subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*", "mesh.seat.mesh-vault.call.own.*"] }
|
||||
subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*"] }
|
||||
allow_responses: true } }
|
||||
{ user: "node.ace", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace", "mesh.seat.mesh-vault.call.own.node_ace"] }
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace"] }
|
||||
subscribe: { allow: ["_INBOX.node.ace.>"] } } }
|
||||
{ user: "node.g14", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14", "mesh.seat.mesh-vault.call.own.node_g14"] }
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14"] }
|
||||
subscribe: { allow: ["_INBOX.node.g14.>"] } } }
|
||||
{ user: "controller", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.own.controller"] }
|
||||
subscribe: { allow: ["_INBOX.controller.>"] } } }
|
||||
]
|
||||
}
|
||||
`
|
||||
@@ -140,48 +135,39 @@ func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) {
|
||||
t.Cleanup(s.Shutdown)
|
||||
|
||||
v := newVault(t, nil)
|
||||
v.Askers = Declared{Read: assignments{"ace.web": `{"node":"ace","module":"web"}`}.read, Store: v.Store}
|
||||
tools := map[string]toolDef{"node-key": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key"),
|
||||
"own": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".own")}
|
||||
tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key")
|
||||
vaultConn := connectAs(t, s.ClientURL(), "novox.mesh-vault")
|
||||
serve := func(m *nats.Msg) {
|
||||
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.node-key.*", func(m *nats.Msg) {
|
||||
parts := strings.Split(m.Subject, ".")
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
answer := busAnswer{}
|
||||
if got, err := tools[parts[len(parts)-2]].runAs(untoken(parts[len(parts)-1]), args); err != nil {
|
||||
if got, err := tool.runAs(untoken(parts[len(parts)-1]), args); err != nil {
|
||||
answer.Error = err.Error()
|
||||
} else {
|
||||
answer.Result, _ = json.Marshal(got)
|
||||
}
|
||||
raw, _ := json.Marshal(answer)
|
||||
_ = m.Respond(raw)
|
||||
}
|
||||
for _, verb := range []string{"node-key", "own"} {
|
||||
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call."+verb+".*", serve); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vaultConn.Flush()
|
||||
|
||||
call := func(user, subject string, args any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
say := func(user, subject, key string) (string, error) {
|
||||
raw, _ := json.Marshal(map[string]string{"key": key})
|
||||
msg, err := connectAs(t, s.ClientURL(), user).Request(subject, raw, 3*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
var a busAnswer
|
||||
_ = json.Unmarshal(msg.Data, &a)
|
||||
if a.Error != "" {
|
||||
return nil, errString(a.Error)
|
||||
return a.Error, errString(a.Error)
|
||||
}
|
||||
return a.Result, nil
|
||||
return "", nil
|
||||
}
|
||||
say := func(user, subject, key string) (string, error) {
|
||||
_, err := call(user, subject, map[string]string{"key": key})
|
||||
return "", err
|
||||
}
|
||||
aceKey, acePriv := aSealingKey(t)
|
||||
aceKey, _ := aSealingKey(t)
|
||||
g14Key, _ := aSealingKey(t)
|
||||
if _, err := say("node.ace", "mesh.seat.mesh-vault.call.node-key.node_ace", aceKey); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -202,32 +188,4 @@ func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) {
|
||||
if got, held, _ := v.Store.NodeKey("g14"); !held || got != g14Key {
|
||||
t.Errorf("g14's key is %q, %v", got, held)
|
||||
}
|
||||
|
||||
// Issue 1126 (ADR 0547 §2, ADR 0550 §5): `own` on the same bus. node.ace asks its own and gets its module's secret
|
||||
// sealed to the key it stated, opening with its key alone; g14 cannot ask in ace's name, and asking in its own gets
|
||||
// none of ace's; the controller is refused, as no node-engine.
|
||||
if _, err := moveIn(t, v, "node.ace", MoveOf{Node: "ace", Module: "web", Name: "password", Origin: "made", Generation: "g-1",
|
||||
SealingKey: aceKey}, "ace-web-pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := call("node.ace", "mesh.seat.mesh-vault.call.own.node_ace", map[string]any{})
|
||||
var own OwnAnswer
|
||||
if err != nil || json.Unmarshal(raw, &own) != nil || own.Node != "ace" || len(own.Own) != 1 {
|
||||
t.Fatalf("ace's own over the bus: %s, %v", raw, err)
|
||||
}
|
||||
if value, err := seal.Open(own.Own[0].Sealed, acePriv); err != nil || string(value) != "ace-web-pw" {
|
||||
t.Errorf("ace's secret does not open with ace's key: %q, %v", value, err)
|
||||
}
|
||||
if strings.Contains(string(raw), "ace-web-pw") {
|
||||
t.Error("ace's secret crossed the bus in the clear")
|
||||
}
|
||||
if _, err := call("node.g14", "mesh.seat.mesh-vault.call.own.node_ace", map[string]any{}); err == nil {
|
||||
t.Error("node.g14 asked ace's own in ace's name")
|
||||
}
|
||||
if raw, err := call("node.g14", "mesh.seat.mesh-vault.call.own.node_g14", map[string]any{}); err != nil || strings.Contains(string(raw), "web") {
|
||||
t.Errorf("g14 asking its own was answered %s, %v", raw, err)
|
||||
}
|
||||
if _, err := call("controller", "mesh.seat.mesh-vault.call.own.controller", map[string]any{}); err == nil || !strings.Contains(err.Error(), "not a node-engine") {
|
||||
t.Errorf("the controller asking own: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
package main
|
||||
|
||||
// A machine's node-engine composes its own modules (novox/hq ADR 0547 §2, issue 1126), and asks the vault for their own
|
||||
// secrets sealed to its own key (ADR 0349 §11, ADR 0550 §5): the verb `own`, answered to the node-engine of a machine
|
||||
// alone, about that machine alone (the machine is the caller's, as the bus proved it, never an argument). Each value is
|
||||
// sealed to the sealing key the machine's engine stated (`node-key`), so what crosses the bus opens on that machine only,
|
||||
// and the composition carries it sealed (no composition carries a secret in the clear). A module handed to the
|
||||
// provisioner is left out: its client fills its secrets from `get` (ADR 0349 §17).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// NodeOwnSecret is one own secret of a module on the asking machine, sealed to the machine's key.
|
||||
type NodeOwnSecret struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Origin string `json:"origin"`
|
||||
Generation int `json:"generation"`
|
||||
Sealed seal.Box `json:"sealed"`
|
||||
}
|
||||
|
||||
// OwnAnswer is what `own` answers: the machine, the sealing key every value is sealed to (raw X25519, base64, as its
|
||||
// engine stated it), each own secret of its modules, and each pair credential it asked for that the vault holds.
|
||||
type OwnAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Key string `json:"key"`
|
||||
Own []NodeOwnSecret `json:"own"`
|
||||
Credentials []NodeCredential `json:"credentials,omitempty"`
|
||||
Missing []CredentialAsked `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialAsked names one pair credential by its ends, as a node composing its own asks for it (mesh-sdk
|
||||
// compose.CredentialKey): Role is `consumer` when the asking machine consumes it, `provider` when it grants it.
|
||||
type CredentialAsked struct {
|
||||
Role string `json:"role"`
|
||||
Provision string `json:"provision"`
|
||||
Consumer string `json:"consumer"`
|
||||
ConsumerModule string `json:"consumer_module"`
|
||||
Provider string `json:"provider"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// NodeCredential is one pair credential answered, sealed to the asking machine's key, with the name the vault keeps it
|
||||
// under (`pair/...` per pair, `provision/<provider>/<provision>/<node>/<module>` issued for one holder, or the provision's
|
||||
// one shared value `provision/<provider>/<provision>`).
|
||||
type NodeCredential struct {
|
||||
CredentialAsked
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
Sealed seal.Box `json:"sealed"`
|
||||
}
|
||||
|
||||
// credentialNames are the names a pair credential may be kept under, the most particular first.
|
||||
func credentialNames(c CredentialAsked) []string {
|
||||
pair := "pair/" + c.Provider + "/" + c.Provision + "/" + c.Consumer + "/" + c.ConsumerModule
|
||||
if c.Local != "" {
|
||||
pair += "/" + c.Local
|
||||
}
|
||||
return []string{pair, "provision/" + c.Provider + "/" + c.Provision + "/" + c.Consumer + "/" + c.ConsumerModule,
|
||||
"provision/" + c.Provider + "/" + c.Provision}
|
||||
}
|
||||
|
||||
// credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module
|
||||
// stands on, a provider's to the providing machine, and nothing to a machine that is neither.
|
||||
func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
|
||||
for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} {
|
||||
if !namePart.MatchString(part) {
|
||||
return fmt.Sprintf("%q is not a name the mesh writes", part)
|
||||
}
|
||||
}
|
||||
if c.Local != "" && !namePart.MatchString(c.Local) {
|
||||
return fmt.Sprintf("%q is not a name the mesh writes", c.Local)
|
||||
}
|
||||
switch c.Role {
|
||||
case "consumer":
|
||||
if c.Consumer != node {
|
||||
return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer)
|
||||
}
|
||||
// The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses).
|
||||
read, why := v.assignmentReader(node)
|
||||
if why != "" {
|
||||
return why
|
||||
}
|
||||
raw, err := read(node + "." + c.ConsumerModule)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
|
||||
}
|
||||
if raw == nil {
|
||||
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
|
||||
}
|
||||
return ""
|
||||
case "provider":
|
||||
if c.Provider != node {
|
||||
return fmt.Sprintf("%s asks as the provider %s, and is not that machine", node, c.Provider)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("the role %q is not consumer or provider", c.Role)
|
||||
}
|
||||
|
||||
// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them,
|
||||
// and without them it refuses rather than hands (a rule that cannot be read is no rule).
|
||||
func (v *Vault) assignmentReader(node string) (AssignmentReader, string) {
|
||||
d, ok := v.Askers.(Declared)
|
||||
if !ok || d.Read == nil {
|
||||
return nil, "the vault cannot read the assignments, so it cannot tell what " + node + "'s modules are; nothing is handed"
|
||||
}
|
||||
return standing(d.Read), ""
|
||||
}
|
||||
|
||||
// sharedRefusal says why node may not have the provision's one shared value `provision/<provider>/<provision>` for c,
|
||||
// or "": the vault's record of who holds it names the consumer's module among its consumers, or the machine as its
|
||||
// provider. Without that record nothing is handed: the shared value is not one any machine may have by naming a
|
||||
// provider (ADR 0550 §5).
|
||||
func (v *Vault) sharedRefusal(node string, c CredentialAsked) (string, error) {
|
||||
name := "provision/" + c.Provider + "/" + c.Provision
|
||||
sh, ok, err := v.Store.ReadShared(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Sprintf("the vault keeps no record of who holds %s", name), nil
|
||||
}
|
||||
switch c.Role {
|
||||
case "consumer":
|
||||
want := consumerOf(c.Consumer, c.ConsumerModule, c.Local)
|
||||
for _, held := range sh.Consumers {
|
||||
if held == want {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s is held by %s, not by %s", name, strings.Join(sh.Consumers, ", "), want), nil
|
||||
case "provider":
|
||||
if sh.Provider == node {
|
||||
return "", nil
|
||||
}
|
||||
return fmt.Sprintf("%s is provided from %s, not %s", name, sh.Provider, node), nil
|
||||
}
|
||||
return fmt.Sprintf("the role %q is not consumer or provider", c.Role), nil
|
||||
}
|
||||
|
||||
// composeOrigin is the vault's origin of a value as the composition reads it: made by the mesh, or given (accepted at a
|
||||
// terminal, typed at a desk, issued by a provider's software).
|
||||
func composeOrigin(origin string) string {
|
||||
if origin == "" || origin == OriginMade {
|
||||
return OriginMade
|
||||
}
|
||||
return OriginAccepted
|
||||
}
|
||||
|
||||
// OwnOfNode answers the node-engine of a machine the own secrets of its modules, each sealed to the machine's stated key.
|
||||
func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, error) {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
if !ok {
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
caller = "an unproven caller"
|
||||
}
|
||||
return OwnAnswer{}, fmt.Errorf("own answers a machine's node-engine alone, about its own machine; %s is not a node-engine", caller)
|
||||
}
|
||||
key, had, err := v.Store.NodeKey(node)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if !had {
|
||||
return OwnAnswer{}, fmt.Errorf("the vault holds no secret named own/%s yet: %s's node-engine has stated no sealing key "+
|
||||
"(node-key), so there is no key of its own to seal to", node, node)
|
||||
}
|
||||
to, err := publicPEM(key)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err)
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
out := OwnAnswer{Node: node, Key: key}
|
||||
for _, r := range all {
|
||||
parts := strings.Split(r.Name, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
|
||||
continue
|
||||
}
|
||||
handed, err := v.handed(node, parts[2])
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if handed {
|
||||
continue
|
||||
}
|
||||
value, err := seal.Open(r.AtRest, v.Own.PrivateKey)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", r.Name, err)
|
||||
}
|
||||
sealed, err := seal.Seal(value, to)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if err := v.recordHanding(Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}); err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", r.Name, err)
|
||||
}
|
||||
out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin),
|
||||
Generation: r.Generation, Sealed: sealed})
|
||||
}
|
||||
sort.Slice(out.Own, func(i, j int) bool {
|
||||
if out.Own[i].Module != out.Own[j].Module {
|
||||
return out.Own[i].Module < out.Own[j].Module
|
||||
}
|
||||
return out.Own[i].Name < out.Own[j].Name
|
||||
})
|
||||
for _, c := range asked {
|
||||
if why := v.credentialRefusal(node, c); why != "" {
|
||||
return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller,
|
||||
c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why)
|
||||
}
|
||||
found := false
|
||||
names := credentialNames(c)
|
||||
for i, name := range names {
|
||||
r, held, err := v.Store.Read(name)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if !held {
|
||||
continue
|
||||
}
|
||||
if i == len(names)-1 {
|
||||
// The provision's one shared value: handed only to a machine the record of its holders names.
|
||||
why, err := v.sharedRefusal(node, c)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if why != "" {
|
||||
v.log("%s is not handed %s: %s", node, name, why)
|
||||
continue
|
||||
}
|
||||
}
|
||||
value, err := seal.Open(r.AtRest, v.Own.PrivateKey)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
|
||||
}
|
||||
sealed, err := seal.Seal(value, to)
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"}); err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
|
||||
}
|
||||
out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed})
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
out.Missing = append(out.Missing, c)
|
||||
}
|
||||
}
|
||||
v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key",
|
||||
node, len(out.Own), len(out.Credentials), len(out.Missing))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed.
|
||||
// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner
|
||||
// holds.
|
||||
func (v *Vault) handed(node, module string) (bool, error) {
|
||||
read, why := v.assignmentReader(node)
|
||||
if why != "" {
|
||||
return false, errors.New(why)
|
||||
}
|
||||
raw, err := read(node + "." + module)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+
|
||||
"provisioner is not known: %w", node, module, err)
|
||||
}
|
||||
if raw == nil {
|
||||
return false, nil
|
||||
}
|
||||
var a plannedSecrets
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err)
|
||||
}
|
||||
return a.Provisioning != nil, nil
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// Issue 1126 (ADR 0547 §2, ADR 0550 §5): `own` answers a machine's node-engine the own secrets of its modules, sealed to
|
||||
// the key that machine's engine stated, about that machine alone; another caller, another machine's secrets and a
|
||||
// machine with no stated key get nothing.
|
||||
func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) {
|
||||
plans := assignments{"anchor.web": `{"node":"anchor","module":"web"}`, "laptop.web": `{"node":"laptop","module":"web"}`}
|
||||
v := newVault(t, nil)
|
||||
v.Askers = Declared{Read: plans.read, Store: v.Store}
|
||||
anchorPub, anchorPriv := aSealingKey(t)
|
||||
laptopPub, _ := aSealingKey(t)
|
||||
for _, m := range []struct {
|
||||
caller string
|
||||
of MoveOf
|
||||
value string
|
||||
}{
|
||||
{"node.anchor", MoveOf{Node: "anchor", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: anchorPub}, "anchor-web-pw"},
|
||||
{"node.anchor", MoveOf{Node: "anchor", Module: "web", Name: "api-key", Origin: "accepted", Generation: "g-1", SealingKey: anchorPub}, "anchor-web-key"},
|
||||
{"node.laptop", MoveOf{Node: "laptop", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: laptopPub}, "laptop-web-pw"},
|
||||
} {
|
||||
if _, err := moveIn(t, v, m.caller, m.of, m.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := v.OwnOfNode("node.anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Node != "anchor" || got.Key != anchorPub || len(got.Own) != 2 {
|
||||
t.Fatalf("anchor was answered %+v", got)
|
||||
}
|
||||
want := map[string][2]string{"api-key": {"accepted", "anchor-web-key"}, "password": {"made", "anchor-web-pw"}}
|
||||
for _, s := range got.Own {
|
||||
w, ok := want[s.Name]
|
||||
if !ok || s.Module != "web" || s.Origin != w[0] || s.Generation < 1 {
|
||||
t.Errorf("anchor's %s: %+v", s.Name, s)
|
||||
continue
|
||||
}
|
||||
value, err := seal.Open(s.Sealed, anchorPriv)
|
||||
if err != nil || string(value) != w[1] {
|
||||
t.Errorf("anchor's %s does not open to its value with anchor's key: %q, %v", s.Name, value, err)
|
||||
}
|
||||
}
|
||||
for caller, says := range map[string]string{
|
||||
"controller": "is not a node-engine",
|
||||
"anchor.web": "is not a node-engine",
|
||||
"": "an unproven caller",
|
||||
"node.unknown": "no secret named own/unknown yet",
|
||||
} {
|
||||
if _, err := v.OwnOfNode(caller); err == nil || !strings.Contains(err.Error(), says) {
|
||||
t.Errorf("%q: %v, want a refusal saying %q", caller, err, says)
|
||||
}
|
||||
}
|
||||
if laptop, err := v.OwnOfNode("node.laptop"); err != nil || len(laptop.Own) != 1 || laptop.Own[0].Name != "password" {
|
||||
t.Errorf("laptop was answered %+v, %v; want its one secret alone", laptop, err)
|
||||
}
|
||||
|
||||
// A pair credential: laptop's web consumes anchor's postgres. Each end is answered the one value sealed to its own key;
|
||||
// a credential the vault holds none of is named missing; a machine at neither end is refused.
|
||||
if _, err := moveIn(t, v, "node.laptop", MoveOf{Provider: "anchor", Provision: "postgres", Consumer: "laptop",
|
||||
ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: laptopPub}, "pg-pair"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair := CredentialAsked{Provision: "postgres", Consumer: "laptop", ConsumerModule: "web", Provider: "anchor"}
|
||||
asConsumer, asProvider := pair, pair
|
||||
asConsumer.Role, asProvider.Role = "consumer", "provider"
|
||||
none := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "laptop", ConsumerModule: "web", Provider: "anchor"}
|
||||
laptop, err := v.OwnOfNode("node.laptop", asConsumer, none)
|
||||
if err != nil || len(laptop.Credentials) != 1 || len(laptop.Missing) != 1 || laptop.Missing[0] != none {
|
||||
t.Fatalf("laptop as consumer: %+v, %v", laptop, err)
|
||||
}
|
||||
if !strings.HasPrefix(laptop.Credentials[0].Name, "pair/anchor/postgres/laptop/web") {
|
||||
t.Errorf("laptop's credential is kept as %s", laptop.Credentials[0].Name)
|
||||
}
|
||||
anchor, err := v.OwnOfNode("node.anchor", asProvider)
|
||||
if err != nil || len(anchor.Credentials) != 1 {
|
||||
t.Fatalf("anchor as provider: %+v, %v", anchor, err)
|
||||
}
|
||||
if value, err := seal.Open(anchor.Credentials[0].Sealed, anchorPriv); err != nil || string(value) != "pg-pair" {
|
||||
t.Errorf("anchor's copy does not open to the value with its key: %q, %v", value, err)
|
||||
}
|
||||
for who, c := range map[string]CredentialAsked{"node.anchor": asConsumer, "node.laptop": asProvider} {
|
||||
if _, err := v.OwnOfNode(who, c); err == nil || !strings.Contains(err.Error(), "is not that machine") {
|
||||
t.Errorf("%s asking %+v: %v", who, c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review of L2 (C1, C2): `own` fails closed. A provision's one shared value is handed only to a consumer the record
|
||||
// of its holders names, never to a machine that names its provider; assignments that cannot be read, or a vault
|
||||
// that reads none, refuse rather than hand.
|
||||
if _, err := v.keep("provision/anchor/redis", "shared-redis", "test", "", OriginMade); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.Store.WriteShared(Shared{Name: "provision/anchor/redis", Provider: "anchor", Provision: "redis",
|
||||
Consumers: []string{"anchor/web"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := v.OwnOfNode("node.laptop", none); err != nil || len(got.Credentials) != 0 || len(got.Missing) != 1 {
|
||||
t.Errorf("laptop, whose web the shared value's record does not name, got %+v, %v", got, err)
|
||||
}
|
||||
anchorRedis := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"}
|
||||
if got, err := v.OwnOfNode("node.anchor", anchorRedis); err != nil || len(got.Credentials) != 1 {
|
||||
t.Errorf("anchor's web, a holder the record names, got %+v, %v", got, err)
|
||||
}
|
||||
v.Askers = Declared{Read: func(string) ([]byte, error) { return nil, errors.New("the bucket did not answer") }, Store: v.Store}
|
||||
if _, err := v.OwnOfNode("node.anchor"); err == nil || !strings.Contains(err.Error(), "could not be read") {
|
||||
t.Errorf("own with the assignments unread: %v", err)
|
||||
}
|
||||
v.Askers = nil
|
||||
if _, err := v.OwnOfNode("node.anchor", anchorRedis); err == nil || !strings.Contains(err.Error(), "cannot read the assignments") {
|
||||
t.Errorf("own with no reader of the assignments: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -251,34 +251,6 @@ func definitions(env *Env) []toolDef {
|
||||
// clause 9, adopt.go): a public half, never the private one.
|
||||
return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil
|
||||
}},
|
||||
{Name: Seat + ".own",
|
||||
Description: "The own secrets of this machine's modules, each sealed to the machine's sealing key (novox/hq ADR 0547 §2, " +
|
||||
"ADR 0550 §5, issue 1126): from the node-engine of the machine alone, about its own machine (the bus proves which), " +
|
||||
"for the compositions it makes itself. Answers the machine, the key each value is sealed to, and per module and " +
|
||||
"name the origin, the generation and the sealed value; a module handed to the provisioner is left out. With credentials, " +
|
||||
"also each pair credential the machine consumes or grants, sealed the same way. Never a value in the clear.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"credentials": map[string]any{"type": "array", "description": "the pair credentials the machine's composition " +
|
||||
"reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}: answered " +
|
||||
"sealed to the machine's key when the machine is that consumer's or that provider, and named missing when " +
|
||||
"the vault holds none", "items": map[string]any{"type": "object"}}}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var asked []CredentialAsked
|
||||
if raw, ok := a["credentials"]; ok && raw != nil {
|
||||
b, err := json.Marshal(raw)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(b, &asked)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credentials is a list of {role, provision, consumer, consumer_module, provider, local}: %w", err)
|
||||
}
|
||||
}
|
||||
return v.OwnOfNode(caller, asked...)
|
||||
}},
|
||||
{Name: Seat + ".move-asks",
|
||||
Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " +
|
||||
"(design 57's V3): from the controller alone, at most 500 at a time. No value is in any of them.",
|
||||
|
||||
@@ -177,8 +177,7 @@
|
||||
"rotate",
|
||||
"move-in",
|
||||
"node-key",
|
||||
"move-asks",
|
||||
"own"
|
||||
"move-asks"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user