Merge feat/modules-name-no-node into bulk-b
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
// CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// Three names hold a node name and are not one, so they are blanked before a line is read: the forge's host
|
||||
// (git.novox.be) and organisation path (novox/<repo>), the hardware model (asus-zephyrus-g14, ROG Zephyrus G14)
|
||||
// and the software's own name (Novox Mesh, novox-mesh).
|
||||
// and the software's own name and npm scope (Novox Mesh, novox-mesh, @novox/mesh-sdk).
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
@@ -33,8 +33,10 @@ var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w
|
||||
|
||||
// ownNames are the hardware model's and the software's own names, blanked before a line is read: the model
|
||||
// in any separator and case (asus-zephyrus-g14, ASUS_ZEPHYRUS_G14, ROG Zephyrus G14), the software as two
|
||||
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined.
|
||||
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])`)
|
||||
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined;
|
||||
// and the software's npm scope (@novox/mesh-sdk, @novox:registry=), its package path as the Go module path is,
|
||||
// where no word stands before the @, so a session's who@node (fix-1@novox) is still a node name.
|
||||
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])|(?:^|[^\w.-])@novox(?:$|[^\w-])`)
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
@@ -173,12 +175,24 @@ func hits(line string, names []string) []string {
|
||||
line = ownNames.ReplaceAllStringFunc(line, blank)
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
if rx.MatchString(line) {
|
||||
if matcher(n).MatchString(line) {
|
||||
out = append(out, strings.ToLower(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchers holds each name's matcher, compiled once: the check reads every line of every module.
|
||||
var matchers = map[string]*regexp.Regexp{}
|
||||
|
||||
// matcher is the matcher of one name: as a word, or as a part of a CamelCase identifier.
|
||||
func matcher(n string) *regexp.Regexp {
|
||||
if rx, ok := matchers[n]; ok {
|
||||
return rx
|
||||
}
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
matchers[n] = rx
|
||||
return rx
|
||||
}
|
||||
|
||||
@@ -169,3 +169,32 @@ func TestANodeNameBesideTheHardwareOrTheSoftwareIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC2: the software's npm scope (@novox/mesh-sdk, @novox:registry=) is its package path, as the Go module
|
||||
// path is; a session's who@node naming a node is still a node name.
|
||||
func TestTheSoftwaresNpmScopeIsNotANodeName(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)}",
|
||||
"const url = `${asked}@novox%2fmesh-sdk`;",
|
||||
"# The @novox scope resolves from the mesh's own package registry",
|
||||
`"dependencies": { "@novox/mesh-sdk": "^0.1.0" }`,
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"by fix-1@novox", "jochen@novox:"} {
|
||||
if got := scan("f", s, names); len(got) != 1 {
|
||||
t.Errorf("%q gave %v, want one finding", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1838/AC1: reading every module, the check compiles its matchers once, not once a line: a line read costs a
|
||||
// few allocations, not a regexp compiled per name (the whole catalogue took 41 s with one per line).
|
||||
func TestALineIsReadWithoutCompilingAMatcher(t *testing.T) {
|
||||
line := "a line of code that names nothing at all, with git.novox.be/novox/mesh-sdk in it"
|
||||
if n := testing.AllocsPerRun(100, func() { hits(line, names) }); n > 20 {
|
||||
t.Errorf("a line costs %.0f allocations, want at most 20", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ through an injected runner and a fake root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
| | node-b | node-d |
|
||||
|---|---|---|
|
||||
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
|
||||
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
|
||||
@@ -62,7 +62,7 @@ The workstations are already in the state this module describes.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
|
||||
Nothing is required on either machine. On node-d, log out and in once, or run `blueman_restart`, and
|
||||
the applet runs from its one start. `blueman_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
@@ -160,7 +160,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
|
||||
"path": str("an absolute path of a file on the machine node names"),
|
||||
"node": str("the machine path is on, such as g14"),
|
||||
"node": str("the machine path is on, such as node-b"),
|
||||
"url": str("an http or https address of a document or a page"),
|
||||
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
|
||||
"copies": num("1 to 99 (default 1)"),
|
||||
|
||||
@@ -813,7 +813,7 @@ func (m *MemoryCarrier) unwritten(key, project, path string, err error) {
|
||||
}
|
||||
|
||||
// OlderBeside is where the older of two versions too large to share one file is kept: beside the file, named for
|
||||
// the node it was changed on ("MEMORY.md" from shanks: "MEMORY.older-from-shanks.md").
|
||||
// the node it was changed on ("MEMORY.md" from node-d: "MEMORY.older-from-node-d.md").
|
||||
func OlderBeside(path, node string) string {
|
||||
ext := filepath.Ext(path)
|
||||
return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext
|
||||
@@ -900,7 +900,7 @@ func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uin
|
||||
}
|
||||
// Only noted here, never applied: the runtime hands the watch's values over one at a time and gives the whole
|
||||
// current set ten seconds, so a watch whose handler reads the state, waits for a look or writes a file never
|
||||
// starts on a node with a few hundred memory files (issue 367, seen on shanks on 2026-10-13). Drain applies
|
||||
// starts on a node with a few hundred memory files (issue 367, seen on node-d on 2026-10-13). Drain applies
|
||||
// what was noted, the newest revision of each key.
|
||||
m.queueMu.Lock()
|
||||
if m.queue == nil {
|
||||
|
||||
@@ -117,7 +117,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
||||
Input: map[string]any{
|
||||
"scope": str("mesh (every node) or node (the nodes given, or this one)"),
|
||||
"nodes": str("for scope node: a comma-separated list of nodes; absent is this node"),
|
||||
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"),
|
||||
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-node-b"),
|
||||
"text": str("the section's text, markdown, as every session will read it"),
|
||||
"remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"},
|
||||
},
|
||||
@@ -173,7 +173,7 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
||||
}, "required": []string{"label", "means"}}},
|
||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@node-b"),
|
||||
"expires_minutes": map[string]any{"type": "integer",
|
||||
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
|
||||
},
|
||||
|
||||
@@ -351,7 +351,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
by = who
|
||||
}
|
||||
if by == "" {
|
||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
|
||||
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@node-b")
|
||||
}
|
||||
life := in.Expires
|
||||
if life == 0 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
MESH_LAB_FORGE=${config:forge}
|
||||
MESH_LAB_FORGE_OWNER=${config:forge-owner}
|
||||
MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json
|
||||
MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
"description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"forge-owner": {
|
||||
"type": "string",
|
||||
"description": "The owner on the forge the mesh's repositories and npm packages are under, such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
|
||||
@@ -60,8 +60,8 @@ test("no run starts while the base image is rebuilt", () => {
|
||||
});
|
||||
|
||||
test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => {
|
||||
const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials");
|
||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/);
|
||||
const s = baseScript("/work/base-x", "https://forge.invalid", "mesh-org", "/work/.base-x.git-credentials");
|
||||
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/mesh-org\/mesh-lab.git'/);
|
||||
assert.match(s, /src\/cli.ts base build/);
|
||||
assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// The forge's owner differs between meshes (novox/hq ADR 0507 §9, issue 1838): the lab takes it as a config value
|
||||
// the operator gives, with no default, and no file of the lab names it.
|
||||
// 1838/AC4
|
||||
|
||||
const here = join(import.meta.dirname, "..");
|
||||
|
||||
test("the forge's owner is a required config value with no default, and reaches the lab's env-file", () => {
|
||||
const manifest = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { config: Record<string, Record<string, unknown>> };
|
||||
const owner = manifest.config["forge-owner"];
|
||||
assert.ok(owner, "the manifest declares no forge-owner");
|
||||
assert.equal(owner.required, true);
|
||||
assert.ok(!("default" in owner), `forge-owner has the default ${JSON.stringify(owner.default)}`);
|
||||
assert.match(readFileSync(join(here, "files", "lab.env"), "utf8"), /^MESH_LAB_FORGE_OWNER=\$\{config:forge-owner\}$/m);
|
||||
});
|
||||
|
||||
test("no file of the lab's code names an owner on the forge", () => {
|
||||
for (const f of readdirSync(join(here, "tools")).filter((f) => f.endsWith(".ts"))) {
|
||||
const src = readFileSync(join(here, "tools", f), "utf8");
|
||||
assert.doesNotMatch(src, /\}\/[a-z][\w.-]*\/\$\{repo\}|\}\/[a-z][\w.-]*\/mesh-lab\.git|\/api\/packages\/[a-z][\w.-]*\/npm\//, f);
|
||||
}
|
||||
});
|
||||
@@ -92,8 +92,8 @@ test("a run fails at its setup, naming the checkout, when nothing of it can be i
|
||||
// checkouts are.
|
||||
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
|
||||
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
|
||||
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
|
||||
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
|
||||
const s = script("/work", "run-x", "http://forge", "mesh-org", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
|
||||
assert.match(s, /mesh-org\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
|
||||
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
|
||||
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
||||
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
||||
@@ -113,22 +113,22 @@ function aBinding(as: string, secret: string): { binding: string; secret: string
|
||||
}
|
||||
|
||||
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
|
||||
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
|
||||
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
|
||||
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000", b.binding, b.secret),
|
||||
"http://lab-node-d:s3cret-of-forty-characters@node-c.internal:20000");
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000/", b.binding, ""), undefined);
|
||||
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
|
||||
assert.equal(forgeCredential("http://node-c.internal:20000", "/nowhere", b.secret), undefined);
|
||||
});
|
||||
|
||||
test("a run clones through git's credential store, and its script carries no secret", () => {
|
||||
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
|
||||
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
|
||||
const credential = forgeCredential("http://node-c.internal:20000", b.binding, b.secret);
|
||||
const path = credentialPath("/work", "run-x");
|
||||
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
|
||||
const s = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, path);
|
||||
assert.ok(credential);
|
||||
assert.doesNotMatch(s, /s3cret/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/node-c\.internal:20000\/mesh-org\/mesh-tools-go\.git'/);
|
||||
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
|
||||
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
|
||||
assert.equal(credentialArgs(undefined), "");
|
||||
@@ -139,10 +139,10 @@ test("a run clones through git's credential store, and its script carries no sec
|
||||
test("git presents the stored credential to the forge, and only when one is given", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
|
||||
const store = join(dir, "git-credentials");
|
||||
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
writeFileSync(store, "http://lab-node-d:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
assert.match(fill.stdout, /username=lab-shanks/);
|
||||
assert.match(fill.stdout, /username=lab-node-d/);
|
||||
assert.match(fill.stdout, /password=pw/);
|
||||
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
@@ -168,7 +168,7 @@ test("a stopped script removes the credential, whatever ends it", () => {
|
||||
});
|
||||
|
||||
test("the script removes the credential as soon as its clones are done", () => {
|
||||
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const s = script("/work", "run-x", "http://forge", "mesh-org", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||
const lastClone = s.lastIndexOf("clone --quiet");
|
||||
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
|
||||
const building = s.indexOf("npm ci");
|
||||
@@ -252,9 +252,9 @@ test("the lab's tools bundle names its bound credential and binding in its env",
|
||||
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
|
||||
});
|
||||
|
||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
|
||||
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on node-d): npm 12 refused
|
||||
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
|
||||
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||
// address (http://node-c.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
|
||||
// A run names the registry by the address the registry names itself by.
|
||||
import { npmRegistry } from "../tools/runs.ts";
|
||||
@@ -270,54 +270,54 @@ function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fe
|
||||
|
||||
const packument = (tarball: string) => new Response(JSON.stringify({
|
||||
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
|
||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/mesh-org/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
|
||||
test("a run names the package registry by the address the registry gives its tarballs", async () => {
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", "mesh-org", r.get, "https://forge.example.org/api/packages/mesh-org/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/mesh-org/npm/@novox%2fmesh-sdk"]);
|
||||
// The forge's own scheme is taken too, with its port.
|
||||
const plain = await npmRegistry("http://forge.example.org:3000",
|
||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
|
||||
const plain = await npmRegistry("http://forge.example.org:3000", "mesh-org",
|
||||
aRegistry(() => packument("http://forge.example.org:3000/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/mesh-org/npm/" });
|
||||
});
|
||||
|
||||
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
|
||||
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
|
||||
// it (review of mesh-catalog #275, ADR 0346).
|
||||
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
for (const [answer, why] of [
|
||||
[() => new Response("not found", { status: 404 }), /it answered 404/],
|
||||
[() => new Response("{}", { status: 200 }), /no tarball/],
|
||||
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/mesh-org\/npm\//],
|
||||
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://node-c.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://node-c.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://node-c.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://node-c.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://node-c.internal${sdk}`), /at ftp:\/\/node-c\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/mesh-org/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://node-c.internal/api/packages/mesh-org/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@node-c.internal${sdk}`), /user or password/],
|
||||
] as [() => Response, RegExp][]) {
|
||||
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
|
||||
const got = await npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(answer).get);
|
||||
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", String(why));
|
||||
assert.match(got.problem ?? "", /did not give an address the lab takes/);
|
||||
assert.match(got.problem ?? "", why);
|
||||
}
|
||||
});
|
||||
|
||||
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
|
||||
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
|
||||
"https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
|
||||
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
|
||||
const given = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, undefined,
|
||||
"https://forge.example.org/api/packages/mesh-org/npm/");
|
||||
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/mesh-org\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||
assert.doesNotMatch(given, /@novox:registry=http:\/\/node-c\.internal/);
|
||||
const none = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/node-c\.internal:20000\/api\/packages\/mesh-org\/npm\/'/);
|
||||
});
|
||||
|
||||
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
|
||||
@@ -325,20 +325,20 @@ test("a run's npm configuration names the registry it was given, and the forge's
|
||||
import { publicRootOf } from "../tools/runs.ts";
|
||||
|
||||
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/mesh-org/npm/";
|
||||
const at = (tarball: string, publicRoot?: string) =>
|
||||
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
|
||||
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
|
||||
const got = await at(tarball, root);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
|
||||
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", tarball);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
|
||||
}
|
||||
// Without a public root, the forge's public name is not trusted on its own word.
|
||||
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
|
||||
// And the forge's own address only at its own port.
|
||||
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
assert.match((await at(`http://node-c.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
|
||||
@@ -346,39 +346,39 @@ test("the public root is read from the binding, and only an https root at the np
|
||||
const binding = (root: unknown) => {
|
||||
const f = join(dir, `b-${Math.random()}.json`);
|
||||
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
|
||||
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
"npm-path": "/api/packages/mesh-org/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
return f;
|
||||
};
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/mesh-org/npm/"), "mesh-org"),
|
||||
{ root: "https://git.example.org/api/packages/mesh-org/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/mesh-org/npm/"), "mesh-org"),
|
||||
{ root: "https://git.example.org:8443/api/packages/mesh-org/npm/" });
|
||||
for (const [root, why] of [
|
||||
[undefined, /serves no public-root/],
|
||||
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["http://git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
|
||||
["https://git.example.org/api/packages/mesh-org/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/mesh-org/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/mesh-org/npm/", /not a URL|is not https/],
|
||||
] as [unknown, RegExp][]) {
|
||||
const got = publicRootOf(binding(root));
|
||||
const got = publicRootOf(binding(root), "mesh-org");
|
||||
assert.equal(got.root, undefined, String(root));
|
||||
assert.match(got.problem ?? "", why, String(root));
|
||||
}
|
||||
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
|
||||
assert.match(publicRootOf(join(dir, "none.json"), "mesh-org").problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined, "mesh-org").problem ?? "", /no npm-package-registry binding/);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
|
||||
// is "null", as is a `file:` forge's, and the two must not match.
|
||||
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
|
||||
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
|
||||
const got = await npmRegistry("file:///srv/forge", "mesh-org", aRegistry(() => packument("file:///api/packages/mesh-org/npm/x.tgz")).get);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
|
||||
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
|
||||
const root = "https://git.example.org/api/packages/mesh-org/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://node-c.internal", "mesh-org", aRegistry(() =>
|
||||
packument("ftp://node-c.internal/api/packages/mesh-org/npm/x.tgz")).get, root);
|
||||
assert.match(viaRoot.problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
@@ -401,7 +401,7 @@ test("every file takes its text from a template in files/", () => {
|
||||
});
|
||||
|
||||
// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0,
|
||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it.
|
||||
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on node-d wrote it.
|
||||
const oldLog = [
|
||||
"STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.",
|
||||
"\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:",
|
||||
|
||||
+23
-13
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
checkDir, repositoryURL, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
|
||||
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
|
||||
} from "./runs.js";
|
||||
@@ -19,13 +19,17 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||
// The owner on the forge the mesh's repositories and packages are under, the operator's too (novox/hq ADR 0507
|
||||
// §9), read the same way.
|
||||
const ownerOf = (): string => (env.MESH_LAB_FORGE_OWNER ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_OWNER")).trim();
|
||||
const noOwner = "the lab's forge owner is not set: config for lab, {\"forge-owner\": \"<owner>\"}";
|
||||
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
||||
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
|
||||
const bindingOf = (): string | undefined =>
|
||||
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
|
||||
const registryOf = async (forge: string) => {
|
||||
const root = publicRootOf(bindingOf());
|
||||
const registry = await npmRegistry(forge, fetch, root.root);
|
||||
const registryOf = async (forge: string, owner: string) => {
|
||||
const root = publicRootOf(bindingOf(), owner);
|
||||
const registry = await npmRegistry(forge, owner, fetch, root.root);
|
||||
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
|
||||
};
|
||||
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
||||
@@ -39,6 +43,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async () => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { ok: false, output: noOwner };
|
||||
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
|
||||
// directory of its own either way, and removes it when it is done.
|
||||
const leftovers = clearChecks(work);
|
||||
@@ -46,13 +52,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
let check: ReturnType<typeof spawnSync>;
|
||||
let problems: string[];
|
||||
try {
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", repositoryURL(forge, owner, "mesh-lab"), dir], { encoding: "utf8" });
|
||||
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
|
||||
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
||||
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
|
||||
problems = setupProblems(forge, owner, `${dir}/setup`, credentialOf(forge));
|
||||
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
|
||||
const registry = await registryOf(forge);
|
||||
const registry = await registryOf(forge, owner);
|
||||
if (registry.problem) problems.push(registry.problem);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -91,6 +97,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
run: async (args) => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { started: false, reason: noOwner };
|
||||
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
||||
let refs: Record<string, string> = {};
|
||||
@@ -105,8 +113,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
||||
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
||||
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
|
||||
(registry) => start(work, forge, tests, refs, credentialOf(forge), registry));
|
||||
const launched = await launchRun(work, { registry: () => registryOf(forge, owner), base: () => readBaseImage() },
|
||||
(registry) => start(work, forge, owner, tests, refs, credentialOf(forge), registry));
|
||||
if (!launched.started) return launched;
|
||||
return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry,
|
||||
...(launched.problem ? { problem: launched.problem } : {}) };
|
||||
@@ -134,10 +142,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (args.rebuild !== true) return reading;
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const owner = ownerOf();
|
||||
if (!owner) return { ...reading, started: false, reason: noOwner };
|
||||
// Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with
|
||||
// nothing awaited between (review 256).
|
||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before },
|
||||
(registry) => startBaseBuild(work, forge, credentialOf(forge), registry));
|
||||
const launched = await launchRebuild(work, { registry: () => registryOf(forge, owner), base: () => before },
|
||||
(registry) => startBaseBuild(work, forge, owner, credentialOf(forge), registry));
|
||||
if (launched.started === false) return { ...reading, started: false, reason: launched.reason };
|
||||
return { ...reading, started: true, build: launched.job };
|
||||
},
|
||||
@@ -174,7 +184,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
||||
* would go through.
|
||||
*/
|
||||
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
|
||||
export function setupProblems(forge: string, owner: string, dir: string, credential?: string): string[] {
|
||||
const problems: string[] = [];
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
|
||||
@@ -186,7 +196,7 @@ export function setupProblems(forge: string, dir: string, credential?: string):
|
||||
});
|
||||
try {
|
||||
for (const repo of REPOSITORIES) {
|
||||
const url = `${forge}/novox/${repo}.git`;
|
||||
const url = repositoryURL(forge, owner, repo);
|
||||
const remote = git(["ls-remote", "--heads", url, "main"]);
|
||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||
const said = remote.stderr.trim().split("\n")[0] ?? "";
|
||||
|
||||
+22
-13
@@ -60,8 +60,11 @@ export function installStep(root: string, build: boolean): string {
|
||||
fi`;
|
||||
}
|
||||
|
||||
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
|
||||
export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
/**
|
||||
* Where the forge serves the mesh's npm packages, under its own address: gitea's npm registry for the owner the
|
||||
* mesh's repositories are under, the operator's config value (novox/hq ADR 0507 §9).
|
||||
*/
|
||||
export const npmPath = (owner: string): string => `/api/packages/${encodeURIComponent(owner)}/npm/`;
|
||||
|
||||
/**
|
||||
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
|
||||
@@ -70,7 +73,8 @@ export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
|
||||
* machine with no public domain serves none (ADR 0346 §3).
|
||||
*/
|
||||
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
|
||||
export function publicRootOf(bindingFile: string | undefined, owner: string): { root?: string; problem?: string } {
|
||||
const NPM_PATH = npmPath(owner);
|
||||
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
|
||||
let said: unknown;
|
||||
try {
|
||||
@@ -112,8 +116,9 @@ export function publicRootOf(bindingFile: string | undefined): { root?: string;
|
||||
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
|
||||
* forge's own address and says why, so a run still starts and lab_check names it.
|
||||
*/
|
||||
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
export async function npmRegistry(forge: string, owner: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
Promise<{ registry: string; problem?: string }> {
|
||||
const NPM_PATH = npmPath(owner);
|
||||
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
|
||||
const url = `${asked}@novox%2fmesh-sdk`;
|
||||
const fallback = (why: string) => ({
|
||||
@@ -453,6 +458,10 @@ export function running(work: string): RunStatus | undefined {
|
||||
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
|
||||
}
|
||||
|
||||
/** A repository's clone address on the forge: under the owner the mesh's repositories are under (ADR 0507 §9). */
|
||||
export const repositoryURL = (forge: string, owner: string, repo: string): string =>
|
||||
`${forge}/${encodeURIComponent(owner)}/${repo}.git`;
|
||||
|
||||
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
/**
|
||||
@@ -461,8 +470,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
||||
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
||||
*/
|
||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
export function script(work: string, id: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry = `${forge}${npmPath(owner)}`): string {
|
||||
const dir = runDir(work, id);
|
||||
const setState = (state: string) =>
|
||||
`node -e ${shellQuote(
|
||||
@@ -471,7 +480,7 @@ export function script(work: string, id: string, forge: string, tests: string[],
|
||||
const clones = REPOSITORIES.map((repo) => {
|
||||
const ref = refs[repo] ?? "main";
|
||||
return [
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(repositoryURL(forge, owner, repo))} ${shellQuote(join(dir, repo))}`,
|
||||
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
@@ -515,7 +524,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
|
||||
}
|
||||
|
||||
/** start begins a run and returns at once with its status. */
|
||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||
export function start(work: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
|
||||
credential?: string, registry?: string): RunStatus {
|
||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = runDir(work, id);
|
||||
@@ -525,7 +534,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
|
||||
// What a crashed or rebooted run left behind goes before this run writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, owner, tests, refs, credentials, registry), { mode: 0o700 });
|
||||
|
||||
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
||||
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
||||
@@ -715,13 +724,13 @@ export function baseBuildLog(work: string, id: string, lines = 40): string {
|
||||
* baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it,
|
||||
* then its `base build`, which replaces the image and reads back that it works before publishing it.
|
||||
*/
|
||||
export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||
export function baseScript(dir: string, forge: string, owner: string, credential?: string, registry = `${forge}${npmPath(owner)}`): string {
|
||||
const lab = join(dir, "mesh-lab");
|
||||
return `set -euo pipefail
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
${credentialGuard(credential)}
|
||||
cd ${shellQuote(dir)}
|
||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)}
|
||||
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(repositoryURL(forge, owner, "mesh-lab"))} ${shellQuote(lab)}
|
||||
${credential ? `rm -f ${shellQuote(credential)}` : ""}
|
||||
echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)"
|
||||
printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||
@@ -733,7 +742,7 @@ node --experimental-strip-types src/cli.ts base build
|
||||
}
|
||||
|
||||
/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */
|
||||
export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild {
|
||||
export function startBaseBuild(work: string, forge: string, owner: string, credential?: string, registry?: string): BaseBuild {
|
||||
const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const dir = join(work, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -742,7 +751,7 @@ export function startBaseBuild(work: string, forge: string, credential?: string,
|
||||
// What a crashed or rebooted run or rebuild left behind goes before this one writes its own.
|
||||
cleanCredentials(work);
|
||||
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 });
|
||||
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, owner, credentials, registry), { mode: 0o700 });
|
||||
const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$?
|
||||
node -e ${shellQuote(
|
||||
`const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||
|
||||
@@ -99,8 +99,8 @@ On the laptop:
|
||||
|
||||
1. `systemctl --user disable --now mem-guard.service`, then delete
|
||||
`~/.config/systemd/user/mem-guard.service` and `~/scripts/mem-guard.sh`.
|
||||
2. `/etc/systemd/oomd.conf.d/g14-oomd.conf`: the same values as the module's drop-in.
|
||||
3. `/etc/sysctl.d/90-g14-zram.conf`: the same key as the module's file.
|
||||
2. `/etc/systemd/oomd.conf.d/node-b-oomd.conf`: the same values as the module's drop-in.
|
||||
3. `/etc/sysctl.d/90-node-b-zram.conf`: the same key as the module's file.
|
||||
|
||||
The desktop had none of these.
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import "testing"
|
||||
|
||||
// TestSendKeyNamesThisMachine proves Send asks its own machine's node-notifier, not the bare seat: a bare
|
||||
// "seat:node-notifier.send" is answered by whichever machine's node-notifier takes it first, so notices sent
|
||||
// from g14 and shanks were lost to "503 no responders" or another machine's desktop (novox/hq issue 865).
|
||||
// from node-b and node-d were lost to "503 no responders" or another machine's desktop (novox/hq issue 865).
|
||||
// desk-channel names the machine it asks the same way ("seat:node-notifier.send@"+machine,
|
||||
// modules/desk-channel/cmd/desk-channel/main.go); sendKey must read this machine's own name the same way the
|
||||
// SDK's own Emit does, from MESH_NODE, which the runtime sets.
|
||||
func TestSendKeyNamesThisMachine(t *testing.T) {
|
||||
t.Setenv("MESH_NODE", "g14")
|
||||
t.Setenv("MESH_NODE", "node-b")
|
||||
|
||||
if got, want := sendKey(), "seat:node-notifier.send@g14"; got != want {
|
||||
if got, want := sendKey(), "seat:node-notifier.send@node-b"; got != want {
|
||||
t.Fatalf("sendKey() = %q, want %q (it must name this machine, as desk-channel does)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ func (f *scriptedSecrets) Get(context.Context, string) (string, int, error) {
|
||||
// that holds the value later fills the file with no change of the plan.
|
||||
func TestARefusedSecretIsAskedAgainAfterRefusedForAndFilledWhenTheVaultHasIt(t *testing.T) {
|
||||
now := time.Date(2026, 10, 16, 16, 20, 0, 0, time.UTC)
|
||||
fake := &scriptedSecrets{errs: []error{errors.New("the vault refused it: the assignment g14.claude-code could not be read")}}
|
||||
c := &Client{Node: "g14", Secrets: fake, Now: func() time.Time { return now }}
|
||||
fake := &scriptedSecrets{errs: []error{errors.New("the vault refused it: the assignment node-b.claude-code could not be read")}}
|
||||
c := &Client{Node: "node-b", Secrets: fake, Now: func() time.Time { return now }}
|
||||
s := &state{secrets: map[string]heldSecret{}}
|
||||
n := Need{Kind: "secret", Name: "gitea-read"}
|
||||
name := "provision/novox/gitea-read/g14/claude-code"
|
||||
name := "provision/node-c/gitea-read/node-b/claude-code"
|
||||
|
||||
if _, _, fail := c.vaultValue(context.Background(), s, n, name); fail == "" {
|
||||
t.Fatal("a refusal did not fail the need")
|
||||
@@ -50,7 +50,7 @@ func TestANotYetSecretIsAskedAgainOnEveryPass(t *testing.T) {
|
||||
now := time.Date(2026, 10, 16, 16, 20, 0, 0, time.UTC)
|
||||
notYet := fmt.Errorf("%w: the vault holds no secret named x yet", errNotYet)
|
||||
fake := &scriptedSecrets{errs: []error{notYet, notYet}}
|
||||
c := &Client{Node: "g14", Secrets: fake, Now: func() time.Time { return now }}
|
||||
c := &Client{Node: "node-b", Secrets: fake, Now: func() time.Time { return now }}
|
||||
s := &state{secrets: map[string]heldSecret{}}
|
||||
n := Need{Kind: "secret", Name: "gitea-read"}
|
||||
for i := 0; i < 2; i++ {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// the module does not declare, a module not placed on the machine, a secret and a malformed ask are refused, each
|
||||
// with its reason.
|
||||
|
||||
// toolRunner is g14's tool runner on the bus, asking as its account may.
|
||||
// toolRunner is node-b's tool runner on the bus, asking as its account may.
|
||||
type toolRunner struct {
|
||||
t *testing.T
|
||||
nc *nats.Conn
|
||||
@@ -121,7 +121,7 @@ func TestNatsAModulesCodeReadsItsConfigAndIsToldOfAChange(t *testing.T) {
|
||||
// Taken off the machine: said, and no longer answered.
|
||||
b.del(t, AssignmentsBucket, node+".claude-code")
|
||||
until(t, heard, `claude-code {"kind":"config"}`)
|
||||
if a := runner.askConfig("claude-code", "managed_settings"); !strings.Contains(a.Error, "not placed on g14") {
|
||||
if a := runner.askConfig("claude-code", "managed_settings"); !strings.Contains(a.Error, "not placed on node-b") {
|
||||
t.Fatalf("a module taken off the machine was answered %+v", a)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestNatsWhatAModulesCodeMayNotReadIsRefusedWithTheReason(t *testing.T) {
|
||||
|
||||
for _, c := range []struct{ ask, refused string }{
|
||||
{`{"module":"claude-code","kind":"config","key":"no_such_key"}`, "declares no config value no_such_key"},
|
||||
{`{"module":"dunst","kind":"config","key":"font-size"}`, "dunst is not placed on g14"},
|
||||
{`{"module":"dunst","kind":"config","key":"font-size"}`, "dunst is not placed on node-b"},
|
||||
{`{"module":"claude-code","kind":"secret","key":"gitea-read"}`, "not answered on the tool runner's word"},
|
||||
{`{"module":"claude-code","kind":"bound","key":"mcp-endpoint:url"}`, "not answered yet"},
|
||||
{`{"module":"claude-code","kind":"setting","key":"role"}`, "not a kind the client answers"},
|
||||
|
||||
@@ -28,8 +28,8 @@ func giteaBoundExpected(internalToken, password string) string {
|
||||
return "GITEA__security__INTERNAL_TOKEN=" + internalToken + "\n" +
|
||||
"GITEA__database__DB_TYPE=postgres\n" +
|
||||
"GITEA__database__HOST=10.7.0.1:5432\n" +
|
||||
"GITEA__database__NAME=mesh_g14_gitea\n" +
|
||||
"GITEA__database__USER=mesh_g14_gitea\n" +
|
||||
"GITEA__database__NAME=mesh_node-b_gitea\n" +
|
||||
"GITEA__database__USER=mesh_node-b_gitea\n" +
|
||||
"GITEA__database__PASSWD=" + password + "\n" +
|
||||
"GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
|
||||
}
|
||||
@@ -39,15 +39,15 @@ func giteaRun(t *testing.T, entry string, extra string) (*engine, *aBus, *report
|
||||
b := startBus(t)
|
||||
registryOf679(t, b)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/gitea/internal-token", "tok-never-on-the-bus", false)
|
||||
vault.keep("pair/novox/postgres-database/g14/gitea", "pw-never-on-the-bus", false)
|
||||
vault.keep("own/node-b/gitea/internal-token", "tok-never-on-the-bus", false)
|
||||
vault.keep("pair/node-c/postgres-database/node-b/gitea", "pw-never-on-the-bus", false)
|
||||
heard := b.listen(t)
|
||||
e := startEngine(t, map[string]string{giteaEnvPath: "owned"})
|
||||
tpl, _ := templates(t, map[string]string{"server.env": giteaTemplate(t) + extra})
|
||||
b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "env", Path: giteaEnvPath,
|
||||
Template: "server.env", OwnerAtRuntime: "owned"}},
|
||||
Secrets: []PlannedSecret{{Name: "internal-token"}},
|
||||
Provisions: []PlannedProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
|
||||
Provisions: []PlannedProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "node-c",
|
||||
ProviderModule: "postgres", CredentialForm: "pair"}}})
|
||||
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "postgres_bound"})
|
||||
if err != nil {
|
||||
@@ -60,19 +60,19 @@ func giteaRun(t *testing.T, entry string, extra string) (*engine, *aBus, *report
|
||||
}
|
||||
|
||||
func TestNatsAHandedGiteasServerEnvIsFilledAsTheControllerComposedIt(t *testing.T) {
|
||||
e, _, _ := giteaRun(t, `{"provider":"novox","provider-module":"postgres","provision":"postgres-database",`+
|
||||
`"consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+
|
||||
`"values":{"at":"10.7.0.1","from":"novox","port":5432},"vault-name":"pair/novox/postgres-database/g14/gitea"}`,
|
||||
e, _, _ := giteaRun(t, `{"provider":"node-c","provider-module":"postgres","provision":"postgres-database",`+
|
||||
`"consumer":"node-b","module":"gitea","as":"mesh_node-b_gitea","user":"mesh_node-b_gitea",`+
|
||||
`"values":{"at":"10.7.0.1","from":"node-c","port":5432},"vault-name":"pair/node-c/postgres-database/node-b/gitea"}`,
|
||||
"FROM=${bound:postgres-database:from}\n")
|
||||
eventually(t, "server.env is filled to the controller's text", func() bool {
|
||||
return e.read(t, giteaEnvPath) == giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus")+"FROM=novox\n"
|
||||
return e.read(t, giteaEnvPath) == giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus")+"FROM=node-c\n"
|
||||
})
|
||||
}
|
||||
|
||||
// An entry without `at`, or without `from`, fills nothing: the module fails naming the key, never a file with a hole.
|
||||
func TestNatsAnEntryWithoutAtOrFromLeavesGiteasServerEnvUnfilled(t *testing.T) {
|
||||
for key, values := range map[string]string{"at": `{"from":"novox","port":5432}`, "from": `{"at":"10.7.0.1","port":5432}`} {
|
||||
e, _, heard := giteaRun(t, `{"as":"mesh_g14_gitea","user":"mesh_g14_gitea","values":`+values+`}`,
|
||||
for key, values := range map[string]string{"at": `{"from":"node-c","port":5432}`, "from": `{"at":"10.7.0.1","port":5432}`} {
|
||||
e, _, heard := giteaRun(t, `{"as":"mesh_node-b_gitea","user":"mesh_node-b_gitea","values":`+values+`}`,
|
||||
"FROM=${bound:postgres-database:from}\n")
|
||||
eventually(t, "the module fails for the missing "+key, func() bool {
|
||||
r, _ := heard.last("gitea")
|
||||
@@ -99,24 +99,24 @@ func TestNatsAHandedGiteasOriginsAreFilledFromTheRouteProvidersEntry(t *testing.
|
||||
tpl, _ := templates(t, map[string]string{"origins": string(raw)})
|
||||
b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "origins", Path: originsPath,
|
||||
Template: "origins", OwnerAtRuntime: "owned"}},
|
||||
Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-adapter"}}})
|
||||
Provisions: []PlannedProvision{{Provision: "route", Provider: "node-c", ProviderModule: "route-adapter"}}})
|
||||
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-adapter_bound"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.kv["route-adapter_bound"] = kv
|
||||
b.put(t, "route-adapter_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-adapter",`+
|
||||
`"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+
|
||||
`"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`)
|
||||
b.put(t, "route-adapter_bound", node+".gitea.route", `{"provider":"node-c","provider-module":"route-adapter",`+
|
||||
`"provision":"route","consumer":"node-b","module":"gitea","as":"mesh_node-b_gitea",`+
|
||||
`"values":{"name":"git.example.org","internal-name":"git.node-c.internal","name-web":"git.example.org"}}`)
|
||||
aClient(t, b, e, t.TempDir())
|
||||
eventually(t, "origins is filled to the controller's text", func() bool {
|
||||
return e.read(t, originsPath) == "https://git.example.org\n"
|
||||
})
|
||||
}
|
||||
|
||||
// The same where route-proxy, not route-adapter, answers the route, as on novox (issue 792): filled by its machine's client from the route provider's entry, which
|
||||
// The same where route-proxy, not route-adapter, answers the route, as on node-c (issue 792): filled by its machine's client from the route provider's entry, which
|
||||
// carries the public name the controller composed for gitea, to the text the controller wrote for the binding file.
|
||||
func TestNatsAHandedGiteasOriginsAreFilledFromRouteProxysEntryOnNovox(t *testing.T) {
|
||||
func TestNatsAHandedGiteasOriginsAreFilledFromRouteProxysEntryOnNodeC(t *testing.T) {
|
||||
const originsPath = "/etc/gitea/origins"
|
||||
raw, err := os.ReadFile("../../../gitea/files/origins")
|
||||
if err != nil {
|
||||
@@ -129,15 +129,15 @@ func TestNatsAHandedGiteasOriginsAreFilledFromRouteProxysEntryOnNovox(t *testing
|
||||
tpl, _ := templates(t, map[string]string{"origins": string(raw)})
|
||||
b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "origins", Path: originsPath,
|
||||
Template: "origins", OwnerAtRuntime: "owned"}},
|
||||
Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}})
|
||||
Provisions: []PlannedProvision{{Provision: "route", Provider: "node-c", ProviderModule: "route-proxy"}}})
|
||||
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-proxy_bound"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.kv["route-proxy_bound"] = kv
|
||||
b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-proxy",`+
|
||||
`"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+
|
||||
`"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`)
|
||||
b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"node-c","provider-module":"route-proxy",`+
|
||||
`"provision":"route","consumer":"node-b","module":"gitea","as":"mesh_node-b_gitea",`+
|
||||
`"values":{"name":"git.example.org","internal-name":"git.node-c.internal","name-web":"git.example.org"}}`)
|
||||
aClient(t, b, e, t.TempDir())
|
||||
eventually(t, "origins is filled to the controller's text", func() bool {
|
||||
return e.read(t, originsPath) == "https://git.example.org\n"
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
// node-engine's own tests prove its writes; these prove the client asks for the right ones — and counting each
|
||||
// write that moved a file, which is what the node-engine reloads or restarts the program for.
|
||||
|
||||
const node = "g14"
|
||||
const node = "node-b"
|
||||
|
||||
// aBus is a server of this test alone with the client's buckets, the registry written as mesh-provisioner writes it.
|
||||
type aBus struct {
|
||||
@@ -343,7 +343,7 @@ func templates(t *testing.T, files map[string]string) (Templates, *httptest.Serv
|
||||
return Templates{Source: srv.URL + "/files.tar.gz", Digest: digestOf(body)}, srv
|
||||
}
|
||||
|
||||
// aClient runs a client on g14's account until the returned stop is called.
|
||||
// aClient runs a client on node-b's account until the returned stop is called.
|
||||
func aClient(t *testing.T, b *aBus, e *engine, state string) (*Client, func()) {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(b.url, nats.UserInfo("provisioner."+node, "x"),
|
||||
@@ -432,7 +432,7 @@ func TestNatsATemplateFillsFromEachOwnersKeys(t *testing.T) {
|
||||
// The needs, one per placeholder, under the node's and the module's name.
|
||||
eventually(t, "the needs are recorded", func() bool {
|
||||
got := b.keys(t, NeedsBucket, node+".dunst.")
|
||||
return len(got) == 2 && got[0] == "g14.dunst.config.font-size" && got[1] == "g14.dunst.machine.account-home"
|
||||
return len(got) == 2 && got[0] == "node-b.dunst.config.font-size" && got[1] == "node-b.dunst.machine.account-home"
|
||||
})
|
||||
kept, err := b.kv[AppliedBucket].Get(context.Background(), node+".dunst")
|
||||
if err != nil || !strings.Contains(string(kept.Value()), `"status":"applied"`) {
|
||||
@@ -573,8 +573,8 @@ func TestNatsWithNothingOverTheBusItAsksForNoWriteNoSecretAndNoReport(t *testing
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/app/app-token", "tok", false)
|
||||
vault.keep("own/g14/app/db-password", "pw", false)
|
||||
vault.keep("own/node-b/app/app-token", "tok", false)
|
||||
vault.keep("own/node-b/app/db-password", "pw", false)
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
b.config(t, "app", "font-size", "13")
|
||||
b.assign(t, "app", app(t))
|
||||
@@ -585,7 +585,7 @@ func TestNatsWithNothingOverTheBusItAsksForNoWriteNoSecretAndNoReport(t *testing
|
||||
return r.Status == StatusApplied
|
||||
})
|
||||
_, askedBefore := e.counts(appConf)
|
||||
vaultBefore := vault.times("own/g14/app/app-token") + vault.times("own/g14/app/db-password")
|
||||
vaultBefore := vault.times("own/node-b/app/app-token") + vault.times("own/node-b/app/db-password")
|
||||
_, reportsBefore := heard.last("app")
|
||||
|
||||
// Well over any former drift interval used in these tests (100ms, 200ms): nothing reaches the client.
|
||||
@@ -594,7 +594,7 @@ func TestNatsWithNothingOverTheBusItAsksForNoWriteNoSecretAndNoReport(t *testing
|
||||
if _, askedAfter := e.counts(appConf); askedAfter != askedBefore {
|
||||
t.Errorf("the node-engine was asked to write %s again, with nothing over the bus", appConf)
|
||||
}
|
||||
if n := vault.times("own/g14/app/app-token") + vault.times("own/g14/app/db-password"); n != vaultBefore {
|
||||
if n := vault.times("own/node-b/app/app-token") + vault.times("own/node-b/app/db-password"); n != vaultBefore {
|
||||
t.Error("the vault was asked again, with nothing over the bus")
|
||||
}
|
||||
if _, after := heard.last("app"); after != reportsBefore {
|
||||
@@ -661,7 +661,7 @@ func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) {
|
||||
e := startEngine(t, map[string]string{dunstrc: "owned", "/etc/app.conf": "owned"})
|
||||
aClient(t, b, e, t.TempDir())
|
||||
|
||||
b.put(t, AssignmentsBucket, node+".broken", `{"node":"g14","module":"broken","provisioning":{"templates":`+
|
||||
b.put(t, AssignmentsBucket, node+".broken", `{"node":"node-b","module":"broken","provisioning":{"templates":`+
|
||||
`{"source":"x","digest":"sha256:00"},"files":[],"surprise":1}}`)
|
||||
p := dunst(t)
|
||||
p.Templates.Digest = "sha256:" + strings.Repeat("0", 64)
|
||||
@@ -684,10 +684,10 @@ func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) {
|
||||
|
||||
func TestANeedsKeyHoldsAnyName(t *testing.T) {
|
||||
for n, want := range map[Need]string{
|
||||
{Kind: "config", Name: "font-size"}: "g14.dunst.config.font-size",
|
||||
{Kind: "seat", Name: "mesh-broker:4222"}: "g14.dunst.seat.mesh-broker=3A4222",
|
||||
{Kind: "config", Name: "a=b c"}: "g14.dunst.config.a=3Db=20c",
|
||||
{Kind: "machine", Name: "account-home"}: "g14.dunst.machine.account-home",
|
||||
{Kind: "config", Name: "font-size"}: "node-b.dunst.config.font-size",
|
||||
{Kind: "seat", Name: "mesh-broker:4222"}: "node-b.dunst.seat.mesh-broker=3A4222",
|
||||
{Kind: "config", Name: "a=b c"}: "node-b.dunst.config.a=3Db=20c",
|
||||
{Kind: "machine", Name: "account-home"}: "node-b.dunst.machine.account-home",
|
||||
} {
|
||||
if got := NeedKey(node, "dunst", n); got != want {
|
||||
t.Errorf("%v: %s, want %s", n, got, want)
|
||||
|
||||
@@ -51,8 +51,8 @@ func TestNatsASelectingPlaceholderTakesOnlyTheItemsCarryingItsAttributesInOrder(
|
||||
eventually(t, "the file holds each placeholder's items", func() bool { return e.read(t, barsConf) == want })
|
||||
eventually(t, "the named items are recorded under their module and name", func() bool {
|
||||
got := b.keys(t, CollectBucket, node+".blocks.")
|
||||
return len(got) == 4 && got[0] == "g14.blocks.another" && got[1] == "g14.blocks.power/battery" &&
|
||||
got[2] == "g14.blocks.power/clock" && got[3] == "g14.blocks.power/cpu"
|
||||
return len(got) == 4 && got[0] == "node-b.blocks.another" && got[1] == "node-b.blocks.power/battery" &&
|
||||
got[2] == "node-b.blocks.power/clock" && got[3] == "node-b.blocks.power/cpu"
|
||||
})
|
||||
|
||||
// An item no longer contributed goes, and only that one.
|
||||
@@ -91,13 +91,13 @@ func TestASelectorIsReadFromAPlaceholderOfAnyNamespace(t *testing.T) {
|
||||
|
||||
func TestAnItemsKeyHoldsItsName(t *testing.T) {
|
||||
key := CollectKey(node, "blocks", itemID("power", "battery"))
|
||||
if key != "g14.blocks.power/battery" {
|
||||
if key != "node-b.blocks.power/battery" {
|
||||
t.Fatal(key)
|
||||
}
|
||||
if c, id, ok := itemOfKey(node, key); !ok || c != "blocks" || id != "power/battery" {
|
||||
t.Errorf("reads back as %q %q %v", c, id, ok)
|
||||
}
|
||||
for _, bad := range []string{"g14.blocks.power/", "g14.blocks./x", "g14.blocks.a/b/c"} {
|
||||
for _, bad := range []string{"node-b.blocks.power/", "node-b.blocks./x", "node-b.blocks.a/b/c"} {
|
||||
if _, _, ok := itemOfKey(node, bad); ok {
|
||||
t.Errorf("%s read as an item's key", bad)
|
||||
}
|
||||
@@ -115,15 +115,15 @@ func TestAnItemsKeyHoldsItsName(t *testing.T) {
|
||||
|
||||
func TestAnItemOfAPlanReadsItsNameAndAttributesAndRefusesAnUnknownField(t *testing.T) {
|
||||
plan := func(item string) string {
|
||||
return `{"node":"g14","module":"power","provisioning":{"templates":{"source":"x","digest":"sha256:` +
|
||||
return `{"node":"node-b","module":"power","provisioning":{"templates":{"source":"x","digest":"sha256:` +
|
||||
strings.Repeat("0", 64) + `"},"files":[],"items":[` + item + `]}}`
|
||||
}
|
||||
_, p, err := planOf("g14", "g14.power", []byte(plan(
|
||||
_, p, err := planOf("node-b", "node-b.power", []byte(plan(
|
||||
`{"collection":"blocks","name":"battery","attributes":{"bar":"top"},"template":"battery"}`)))
|
||||
if err != nil || len(p.Items) != 1 || p.Items[0].Name != "battery" || p.Items[0].Attributes["bar"] != "top" {
|
||||
t.Fatalf("read %+v (%v)", p.Items, err)
|
||||
}
|
||||
if _, _, err := planOf("g14", "g14.power", []byte(plan(
|
||||
if _, _, err := planOf("node-b", "node-b.power", []byte(plan(
|
||||
`{"collection":"blocks","template":"battery","colour":"red"}`))); err == nil || !strings.Contains(err.Error(), "colour") {
|
||||
t.Errorf("an unknown item field was accepted: %v", err)
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ func TestNatsZshsFileHoldsEveryPlacedContributorsItemAndLosesOneWhoseContributor
|
||||
// Each item recorded under the node, the collection and its contributor; filled, the contributor's report says so.
|
||||
eventually(t, "the items are recorded", func() bool {
|
||||
got := b.keys(t, CollectBucket, node+".")
|
||||
return len(got) == 3 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli" &&
|
||||
got[1] == "g14.shell=3Azsh=3Anormal.powerlevel10k" && got[2] == "g14.zsh-widgets.fzf"
|
||||
return len(got) == 3 && got[0] == "node-b.shell=3Azsh=3Anormal.mesh-cli" &&
|
||||
got[1] == "node-b.shell=3Azsh=3Anormal.powerlevel10k" && got[2] == "node-b.zsh-widgets.fzf"
|
||||
})
|
||||
kept, err := b.kv[CollectBucket].Get(context.Background(), "g14.shell=3Azsh=3Anormal.powerlevel10k")
|
||||
kept, err := b.kv[CollectBucket].Get(context.Background(), "node-b.shell=3Azsh=3Anormal.powerlevel10k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestNatsZshsFileHoldsEveryPlacedContributorsItemAndLosesOneWhoseContributor
|
||||
// zsh records the collections it reads as its needs, like any value.
|
||||
eventually(t, "zsh's needs are recorded", func() bool {
|
||||
got := b.keys(t, NeedsBucket, node+".zsh.")
|
||||
return len(got) == 2 && got[0] == "g14.zsh.collected.zsh-widgets" && got[1] == "g14.zsh.shell.zsh=3Anormal"
|
||||
return len(got) == 2 && got[0] == "node-b.zsh.collected.zsh-widgets" && got[1] == "node-b.zsh.shell.zsh=3Anormal"
|
||||
})
|
||||
|
||||
// A contributor's own value changes: its item, and so zshrc, follow.
|
||||
@@ -136,9 +136,9 @@ func TestNatsACollectionNotPlannedAndAnItemNamingASecretFail(t *testing.T) {
|
||||
|
||||
func TestAnItemsKeyHoldsAnyCollectionAndReadsBack(t *testing.T) {
|
||||
for collection, want := range map[string]string{
|
||||
"zsh-widgets": "g14.zsh-widgets.fzf",
|
||||
"shell:zsh:normal": "g14.shell=3Azsh=3Anormal.fzf",
|
||||
"contribution:node-hotkeys:trigger": "g14.contribution=3Anode-hotkeys=3Atrigger.fzf",
|
||||
"zsh-widgets": "node-b.zsh-widgets.fzf",
|
||||
"shell:zsh:normal": "node-b.shell=3Azsh=3Anormal.fzf",
|
||||
"contribution:node-hotkeys:trigger": "node-b.contribution=3Anode-hotkeys=3Atrigger.fzf",
|
||||
} {
|
||||
key := CollectKey(node, collection, "fzf")
|
||||
if key != want {
|
||||
@@ -149,7 +149,7 @@ func TestAnItemsKeyHoldsAnyCollectionAndReadsBack(t *testing.T) {
|
||||
t.Errorf("%s reads back as %q %q %v", key, c, who, ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"g14.x", "ace.zsh-widgets.fzf", "g14.=ZZ.fzf", "g14.a.b.c", "g14.Upper.fzf"} {
|
||||
for _, bad := range []string{"node-b.x", "node-a.zsh-widgets.fzf", "node-b.=ZZ.fzf", "node-b.a.b.c", "node-b.Upper.fzf"} {
|
||||
if _, _, ok := itemOfKey(node, bad); ok {
|
||||
t.Errorf("%s read as an item's key", bad)
|
||||
}
|
||||
|
||||
@@ -93,25 +93,25 @@ func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake(
|
||||
heard := b.listen(t)
|
||||
wire := sniff(t, b)
|
||||
vault := startVault(t, b)
|
||||
vault.keep(pairOf("ace", "letta", ""), "letta-pw-never-on-the-bus", false)
|
||||
vault.keep(pairOf("shanks", "gitea", "cache"), "gitea-pw-never-on-the-bus", false)
|
||||
// Bound to this provider: ace's letta, and shanks's gitea under a local name. Not: a provision of another
|
||||
vault.keep(pairOf("node-a", "letta", ""), "letta-pw-never-on-the-bus", false)
|
||||
vault.keep(pairOf("node-d", "gitea", "cache"), "gitea-pw-never-on-the-bus", false)
|
||||
// Bound to this provider: node-a's letta, and node-d's gitea under a local name. Not: a provision of another
|
||||
// provider, another provision, and a credential its provider's software issued per holder (issue 757).
|
||||
b.bind(t, "ace", "letta", fromHere(""),
|
||||
PlannedProvision{Provision: "postgres-database", Local: "old", Provider: "ace", ProviderModule: "postgres"})
|
||||
b.bind(t, "shanks", "gitea", PlannedProvision{Provision: "redis", Provider: node, ProviderModule: "postgres"},
|
||||
b.bind(t, "node-a", "letta", fromHere(""),
|
||||
PlannedProvision{Provision: "postgres-database", Local: "old", Provider: "node-a", ProviderModule: "postgres"})
|
||||
b.bind(t, "node-d", "gitea", PlannedProvision{Provision: "redis", Provider: node, ProviderModule: "postgres"},
|
||||
fromHere("cache"),
|
||||
PlannedProvision{Name: "token", Provision: "forge-read", Provider: node, ProviderModule: "postgres"})
|
||||
// Nor a bind whose offer names another credential form than a login per consumer (issue 768).
|
||||
b.bind(t, "ace", "shared", PlannedProvision{Provision: "postgres-database", Provider: node,
|
||||
b.bind(t, "node-a", "shared", PlannedProvision{Provision: "postgres-database", Provider: node,
|
||||
ProviderModule: "postgres", CredentialForm: "shared"})
|
||||
e := startEngine(t, map[string]string{consumersFile: "owned"})
|
||||
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
|
||||
letta := listed{Consumer: "ace", Module: "letta", Credential: pairOf("ace", "letta", ""),
|
||||
letta := listed{Consumer: "node-a", Module: "letta", Credential: pairOf("node-a", "letta", ""),
|
||||
Value: "letta-pw-never-on-the-bus"}
|
||||
gitea := listed{Consumer: "shanks", Module: "gitea", Local: "cache", Credential: pairOf("shanks", "gitea", "cache"),
|
||||
gitea := listed{Consumer: "node-d", Module: "gitea", Local: "cache", Credential: pairOf("node-d", "gitea", "cache"),
|
||||
Value: "gitea-pw-never-on-the-bus"}
|
||||
eventually(t, "the file lists each bound consumer with its credential, in the plans' order", func() bool {
|
||||
return sameList(consumersIn(t, e), letta, gitea)
|
||||
@@ -134,21 +134,21 @@ func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake(
|
||||
eventually(t, "a consumer that binds is listed when the vault holds its credential, in its assignment's order",
|
||||
func() bool { return sameList(consumersIn(t, e), letta, photos, gitea) })
|
||||
// The vault remakes a credential.
|
||||
vault.keep(pairOf("ace", "letta", ""), "letta-pw-remade-never-on-the-bus", true)
|
||||
vault.keep(pairOf("node-a", "letta", ""), "letta-pw-remade-never-on-the-bus", true)
|
||||
letta.Value = "letta-pw-remade-never-on-the-bus"
|
||||
eventually(t, "a credential the vault remakes reaches the file", func() bool {
|
||||
return sameList(consumersIn(t, e), letta, photos, gitea)
|
||||
})
|
||||
// A consumer unbinds.
|
||||
b.del(t, AssignmentsBucket, "shanks.gitea")
|
||||
b.del(t, AssignmentsBucket, "node-d.gitea")
|
||||
eventually(t, "a consumer that unbinds leaves the file", func() bool {
|
||||
return sameList(consumersIn(t, e), letta, photos)
|
||||
})
|
||||
// Another provider's consumer's credential changing is no reason to ask.
|
||||
before := vault.times(pairOf("ace", "letta", ""))
|
||||
vault.keep("pair/ace/postgres-database/ace/letta/old", "elsewhere", true)
|
||||
before := vault.times(pairOf("node-a", "letta", ""))
|
||||
vault.keep("pair/node-a/postgres-database/node-a/letta/old", "elsewhere", true)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if vault.times("pair/ace/postgres-database/ace/letta/old") != 0 || vault.times(pairOf("ace", "letta", "")) != before {
|
||||
if vault.times("pair/node-a/postgres-database/node-a/letta/old") != 0 || vault.times(pairOf("node-a", "letta", "")) != before {
|
||||
t.Error("a change of another provider's consumer made the client ask")
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ func appPlan(t *testing.T) Plan {
|
||||
"PORT=${bound:postgres-database:port}\nUSER=${bound:postgres-database:user}\n"})
|
||||
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "db", Path: appBound, Template: "database.env",
|
||||
OwnerAtRuntime: "owned"}},
|
||||
Provisions: []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}
|
||||
Provisions: []PlannedProvision{{Provision: "postgres-database", Provider: "node-c", ProviderModule: "postgres"}}}
|
||||
}
|
||||
|
||||
func TestNatsAConsumersBoundValuesAreFilledFromItsProvidersEntry(t *testing.T) {
|
||||
@@ -216,24 +216,24 @@ func TestNatsAConsumersBoundValuesAreFilledFromItsProvidersEntry(t *testing.T) {
|
||||
}
|
||||
b.kv["postgres_bound"] = kv
|
||||
// The provider has not answered it yet: waited for.
|
||||
b.put(t, "postgres_bound", "ace.app.postgres-database", `{"values":{"host":"elsewhere"}}`)
|
||||
b.put(t, "postgres_bound", "node-a.app.postgres-database", `{"values":{"host":"elsewhere"}}`)
|
||||
b.assign(t, "app", appPlan(t)) // a change of its own is the only reason it looks again: there is no timer
|
||||
eventually(t, "the consumer waits for its own entry", func() bool {
|
||||
r, _ := heard.last("app")
|
||||
return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres on novox")
|
||||
return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres on node-c")
|
||||
})
|
||||
b.put(t, "postgres_bound", node+".app.postgres-database", `{"provider":"novox","provider-module":"postgres",`+
|
||||
`"provision":"postgres-database","consumer":"g14","module":"app","as":"mesh_g14_app","user":"mesh_g14_app",`+
|
||||
`"values":{"host":"novox.mesh","port":5432},"vault-name":"pair/novox/postgres-database/g14/app"}`)
|
||||
b.put(t, "postgres_bound", node+".app.postgres-database", `{"provider":"node-c","provider-module":"postgres",`+
|
||||
`"provision":"postgres-database","consumer":"node-b","module":"app","as":"mesh_node-b_app","user":"mesh_node-b_app",`+
|
||||
`"values":{"host":"node-c.mesh","port":5432},"vault-name":"pair/node-c/postgres-database/node-b/app"}`)
|
||||
eventually(t, "the file is filled from its provider's entry", func() bool {
|
||||
return e.read(t, appBound) == "HOST=novox.mesh\nPORT=5432\nUSER=mesh_g14_app\n"
|
||||
return e.read(t, appBound) == "HOST=node-c.mesh\nPORT=5432\nUSER=mesh_node-b_app\n"
|
||||
})
|
||||
b.put(t, "postgres_bound", node+".app.postgres-database", `{"user":"mesh_g14_app",`+
|
||||
`"values":{"host":"novox.lan","port":5432}}`)
|
||||
b.put(t, "postgres_bound", node+".app.postgres-database", `{"user":"mesh_node-b_app",`+
|
||||
`"values":{"host":"node-c.lan","port":5432}}`)
|
||||
eventually(t, "a changed entry refills the file", func() bool {
|
||||
return e.read(t, appBound) == "HOST=novox.lan\nPORT=5432\nUSER=mesh_g14_app\n"
|
||||
return e.read(t, appBound) == "HOST=node-c.lan\nPORT=5432\nUSER=mesh_node-b_app\n"
|
||||
})
|
||||
b.put(t, "postgres_bound", node+".app.postgres-database", `{"refused":"app on g14 keeps several holders"}`)
|
||||
b.put(t, "postgres_bound", node+".app.postgres-database", `{"refused":"app on node-b keeps several holders"}`)
|
||||
eventually(t, "an entry the provider refused fails the consumer with its reason", func() bool {
|
||||
r, _ := heard.last("app")
|
||||
return r.Status == StatusFailed && strings.Contains(r.Reason, "keeps several holders")
|
||||
@@ -246,17 +246,17 @@ func TestNatsAProviderListsAConsumerWhoseEntryNamesItsLoginFile(t *testing.T) {
|
||||
b := startBus(t)
|
||||
registryOf679(t, b)
|
||||
vault := startVault(t, b)
|
||||
vault.keep(pairOf("ace", "nextcloud", ""), "nc-pw-never-on-the-bus", false)
|
||||
vault.keep(pairOf("ace", "forge", ""), "forge-pw", false)
|
||||
b.bind(t, "ace", "nextcloud", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
|
||||
vault.keep(pairOf("node-a", "nextcloud", ""), "nc-pw-never-on-the-bus", false)
|
||||
vault.keep(pairOf("node-a", "forge", ""), "forge-pw", false)
|
||||
b.bind(t, "node-a", "nextcloud", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
|
||||
ProviderModule: "postgres", CredentialForm: "pair", Path: "/s/database.secret"})
|
||||
b.bind(t, "ace", "forge", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
|
||||
b.bind(t, "node-a", "forge", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
|
||||
ProviderModule: "postgres", CredentialForm: "issued", Path: "/s/forge.secret"})
|
||||
e := startEngine(t, map[string]string{consumersFile: "owned"})
|
||||
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
eventually(t, "the consumer with a login file is listed, the per-holder one is not", func() bool {
|
||||
return sameList(consumersIn(t, e), listed{Consumer: "ace", Module: "nextcloud",
|
||||
Credential: pairOf("ace", "nextcloud", ""), Value: "nc-pw-never-on-the-bus"})
|
||||
return sameList(consumersIn(t, e), listed{Consumer: "node-a", Module: "nextcloud",
|
||||
Credential: pairOf("node-a", "nextcloud", ""), Value: "nc-pw-never-on-the-bus"})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,13 +48,13 @@ func TestNatsAMovedGiteaAndClaudeCodeHaveTheirFilesFilledFromTheBus(t *testing.T
|
||||
origins = "/etc/gitea/origins"
|
||||
facts = "/var/lib/claude-code/state/facts.json"
|
||||
readCred = "/var/lib/claude-code/state/gitea-read.secret"
|
||||
credential = "https://agent-g14:gta-never-on-the-bus@git.example.org"
|
||||
credential = "https://agent-node-b:gta-never-on-the-bus@git.example.org"
|
||||
)
|
||||
b := startBus(t)
|
||||
registryOf679(t, b)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/gitea/internal-token", "tok-never-on-the-bus", false)
|
||||
vault.keep("pair/novox/postgres-database/g14/gitea", "pw-never-on-the-bus", false)
|
||||
vault.keep("own/node-b/gitea/internal-token", "tok-never-on-the-bus", false)
|
||||
vault.keep("pair/node-c/postgres-database/node-b/gitea", "pw-never-on-the-bus", false)
|
||||
vault.keep(forgeHolder, credential, false)
|
||||
e := startEngine(t, map[string]string{serverEnv: "owned", origins: "owned", facts: "owned", readCred: "owned"})
|
||||
|
||||
@@ -65,16 +65,16 @@ func TestNatsAMovedGiteaAndClaudeCodeHaveTheirFilesFilledFromTheBus(t *testing.T
|
||||
{ID: "origins", Path: origins, Template: "origins", OwnerAtRuntime: "owned"}},
|
||||
Secrets: []PlannedSecret{{Name: "internal-token"}},
|
||||
Provisions: []PlannedProvision{
|
||||
{Name: "postgres-database", Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres",
|
||||
{Name: "postgres-database", Provision: "postgres-database", Provider: "node-c", ProviderModule: "postgres",
|
||||
CredentialForm: "pair"},
|
||||
{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}})
|
||||
{Provision: "route", Provider: "node-c", ProviderModule: "route-proxy"}}})
|
||||
ccTpl, _ := templates(t, map[string]string{"facts.json": catalogFile(t, "claude-code/files/facts.json")})
|
||||
b.assign(t, "claude-code", Plan{Templates: ccTpl, Files: []PlannedFile{
|
||||
{ID: "facts", Path: facts, Template: "facts.json", OwnerAtRuntime: "owned"}},
|
||||
Placement: map[string]string{"machine:name": node},
|
||||
Provisions: []PlannedProvision{
|
||||
{Provision: "mcp-endpoint", Provider: node, ProviderModule: "node-tools"},
|
||||
{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: readCred}}})
|
||||
{Name: "gitea-read", Provision: "gitea-read", Provider: "node-c", Path: readCred}}})
|
||||
|
||||
for _, bucket := range []string{"postgres_bound", "route-proxy_bound", "node-tools_bound"} {
|
||||
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: bucket})
|
||||
@@ -83,21 +83,21 @@ func TestNatsAMovedGiteaAndClaudeCodeHaveTheirFilesFilledFromTheBus(t *testing.T
|
||||
}
|
||||
b.kv[bucket] = kv
|
||||
}
|
||||
b.put(t, "postgres_bound", node+".gitea.postgres-database", `{"provider":"novox","provider-module":"postgres",`+
|
||||
`"provision":"postgres-database","consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+
|
||||
`"values":{"at":"10.7.0.1","from":"novox","port":5432},"vault-name":"pair/novox/postgres-database/g14/gitea"}`)
|
||||
b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-proxy",`+
|
||||
`"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+
|
||||
`"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`)
|
||||
b.put(t, "node-tools_bound", node+".claude-code.mcp-endpoint", `{"provider":"g14","provider-module":"node-tools",`+
|
||||
`"provision":"mcp-endpoint","consumer":"g14","module":"claude-code","as":"mesh_g14_claude-code",`+
|
||||
b.put(t, "postgres_bound", node+".gitea.postgres-database", `{"provider":"node-c","provider-module":"postgres",`+
|
||||
`"provision":"postgres-database","consumer":"node-b","module":"gitea","as":"mesh_node-b_gitea","user":"mesh_node-b_gitea",`+
|
||||
`"values":{"at":"10.7.0.1","from":"node-c","port":5432},"vault-name":"pair/node-c/postgres-database/node-b/gitea"}`)
|
||||
b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"node-c","provider-module":"route-proxy",`+
|
||||
`"provision":"route","consumer":"node-b","module":"gitea","as":"mesh_node-b_gitea",`+
|
||||
`"values":{"name":"git.example.org","internal-name":"git.node-c.internal","name-web":"git.example.org"}}`)
|
||||
b.put(t, "node-tools_bound", node+".claude-code.mcp-endpoint", `{"provider":"node-b","provider-module":"node-tools",`+
|
||||
`"provision":"mcp-endpoint","consumer":"node-b","module":"claude-code","as":"mesh_node-b_claude-code",`+
|
||||
`"values":{"port":4270}}`)
|
||||
aClient(t, b, e, t.TempDir())
|
||||
|
||||
for path, want := range map[string]string{
|
||||
serverEnv: giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus"),
|
||||
origins: "https://git.example.org\n",
|
||||
facts: "{\n \"node\": \"g14\",\n \"console\": \"http://127.0.0.1:4270/mcp\"\n}\n",
|
||||
facts: "{\n \"node\": \"node-b\",\n \"console\": \"http://127.0.0.1:4270/mcp\"\n}\n",
|
||||
readCred: credential,
|
||||
} {
|
||||
eventually(t, path+" is filled to the controller's text", func() bool { return e.read(t, path) == want })
|
||||
|
||||
@@ -10,20 +10,20 @@ import (
|
||||
// client reads that strictly, before the controller writes it, and fills nothing from an entry with no credential;
|
||||
// the vault acts on it.
|
||||
func TestAPlanNamingItsProvidersIsRead(t *testing.T) {
|
||||
value := `{"node":"g14","module":"app","provisioning":{` +
|
||||
value := `{"node":"node-b","module":"app","provisioning":{` +
|
||||
`"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` +
|
||||
`"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"},` +
|
||||
`{"provision":"s3-bucket","local":"hot","provider":"ace","provider-module":"minio"},` +
|
||||
`{"name":"forge-read","provision":"forge-read","provider":"novox","provider-module":"forge",` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"node-c","provider-module":"postgres"},` +
|
||||
`{"provision":"s3-bucket","local":"hot","provider":"node-a","provider-module":"minio"},` +
|
||||
`{"name":"forge-read","provision":"forge-read","provider":"node-c","provider-module":"forge",` +
|
||||
`"path":"/var/lib/app/forge.secret"}]}}`
|
||||
_, p, err := planOf("g14", "g14.app", []byte(value))
|
||||
_, p, err := planOf("node-b", "node-b.app", []byte(value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"},
|
||||
{Provision: "s3-bucket", Local: "hot", Provider: "ace", ProviderModule: "minio"},
|
||||
{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
want := []PlannedProvision{{Provision: "postgres-database", Provider: "node-c", ProviderModule: "postgres"},
|
||||
{Provision: "s3-bucket", Local: "hot", Provider: "node-a", ProviderModule: "minio"},
|
||||
{Name: "forge-read", Provision: "forge-read", Provider: "node-c", ProviderModule: "forge",
|
||||
Path: "/var/lib/app/forge.secret"}}
|
||||
if len(p.Provisions) != len(want) {
|
||||
t.Fatalf("the plan's provisions are read as %+v, want %+v", p.Provisions, want)
|
||||
@@ -55,16 +55,16 @@ func TestAPlanNamingItsProvidersIsRead(t *testing.T) {
|
||||
// Each entry names the credential form of its provider's offer (issue 768), which the vault acts on: the client
|
||||
// reads it strictly, before the controller writes it, and refuses a form it does not know.
|
||||
func TestAPlanNamingEachProvidersCredentialFormIsRead(t *testing.T) {
|
||||
value := `{"node":"g14","module":"app","provisioning":{` +
|
||||
value := `{"node":"node-b","module":"app","provisioning":{` +
|
||||
`"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` +
|
||||
`"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"node-c","provider-module":"postgres",` +
|
||||
`"credential-form":"pair"},` +
|
||||
`{"provision":"sonarr-api","provider":"ace","provider-module":"sonarr","credential-form":"shared"},` +
|
||||
`{"name":"forge-read","provision":"forge-read","provider":"novox","provider-module":"forge",` +
|
||||
`{"provision":"sonarr-api","provider":"node-a","provider-module":"sonarr","credential-form":"shared"},` +
|
||||
`{"name":"forge-read","provision":"forge-read","provider":"node-c","provider-module":"forge",` +
|
||||
`"credential-form":"issued","path":"/var/lib/app/forge.secret"},` +
|
||||
`{"provision":"smtp-relay","provider":"ace","provider-module":"relay","credential-form":"given"}]}}`
|
||||
_, p, err := planOf("g14", "g14.app", []byte(value))
|
||||
`{"provision":"smtp-relay","provider":"node-a","provider-module":"relay","credential-form":"given"}]}}`
|
||||
_, p, err := planOf("node-b", "node-b.app", []byte(value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestAPlanNamingEachProvidersCredentialFormIsRead(t *testing.T) {
|
||||
t.Fatalf("the credential forms are read as %v", forms)
|
||||
}
|
||||
bad := strings.Replace(value, `"credential-form":"pair"`, `"credential-form":"one-for-all"`, 1)
|
||||
if _, _, err := planOf("g14", "g14.app", []byte(bad)); err == nil || !strings.Contains(err.Error(), "one-for-all") {
|
||||
if _, _, err := planOf("node-b", "node-b.app", []byte(bad)); err == nil || !strings.Contains(err.Error(), "one-for-all") {
|
||||
t.Fatalf("a credential form the client does not know was taken: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -84,12 +84,12 @@ func TestAPlanNamingEachProvidersCredentialFormIsRead(t *testing.T) {
|
||||
// A consumer's plan entry may carry what its module contributes to the provider (issue 776): the client reads it
|
||||
// strictly, keeps it on the entry, and still refuses any other field it does not know.
|
||||
func TestAPlanEntryCarryingItsContributionIsRead(t *testing.T) {
|
||||
value := `{"node":"g14","module":"app","provisioning":{` +
|
||||
value := `{"node":"node-b","module":"app","provisioning":{` +
|
||||
`"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` +
|
||||
`"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
|
||||
`"provisions":[{"provision":"postgres-database","provider":"node-c","provider-module":"postgres",` +
|
||||
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}`
|
||||
_, p, err := planOf("g14", "g14.app", []byte(value))
|
||||
_, p, err := planOf("node-b", "node-b.app", []byte(value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestAPlanEntryCarryingItsContributionIsRead(t *testing.T) {
|
||||
t.Fatalf("the plan's provisions are read as %+v, want the contribution %v kept", p.Provisions, want)
|
||||
}
|
||||
bad := strings.Replace(value, `"contributes":`, `"contribution":`, 1)
|
||||
if _, _, err := planOf("g14", "g14.app", []byte(bad)); err == nil {
|
||||
if _, _, err := planOf("node-b", "node-b.app", []byte(bad)); err == nil {
|
||||
t.Error("a plan entry with a field the client does not know was taken")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ func TestAPulledInModulesValueIsReadForItsPlan(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := fmt.Sprintf(`{"node":"g14","module":"mesh-cli","required-by":"desk","provisioning":%s}`, p)
|
||||
module, got, err := planOf("g14", "g14.mesh-cli", []byte(value))
|
||||
value := fmt.Sprintf(`{"node":"node-b","module":"mesh-cli","required-by":"desk","provisioning":%s}`, p)
|
||||
module, got, err := planOf("node-b", "node-b.mesh-cli", []byte(value))
|
||||
if err != nil || module != "mesh-cli" || len(got.Items) != 1 || got.Items[0].Collection != "shell:zsh:normal" {
|
||||
t.Fatalf("a pulled-in module's value reads as %s %+v (%v)", module, got, err)
|
||||
}
|
||||
// Still strict in the plan.
|
||||
spoilt := strings.Replace(value, `"items":`, `"surprise":1,"items":`, 1)
|
||||
if _, _, err := planOf("g14", "g14.mesh-cli", []byte(spoilt)); err == nil {
|
||||
if _, _, err := planOf("node-b", "node-b.mesh-cli", []byte(spoilt)); err == nil {
|
||||
t.Fatalf("a pulled-in module's plan with an unknown field is acted on: %s", spoilt)
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,8 @@ func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
const token, password = "tok-765-never-on-the-bus", "pw-765-never-on-the-bus"
|
||||
vault.keep("own/g14/app/app-token", token, false)
|
||||
vault.keep("own/g14/app/db-password", password, false)
|
||||
vault.keep("own/node-b/app/app-token", token, false)
|
||||
vault.keep("own/node-b/app/db-password", password, false)
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned", zshrc: "owned"})
|
||||
b.config(t, "app", "font-size", "13")
|
||||
b.pullIn(t, "app", "desk", app(t))
|
||||
@@ -60,7 +60,7 @@ func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing
|
||||
eventually(t, "its secret's own file holds it alone", func() bool { return e.read(t, appDB) == password })
|
||||
eventually(t, "the pulled-in contributor's item is recorded", func() bool {
|
||||
got := b.keys(t, CollectBucket, node+".")
|
||||
return len(got) == 1 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli"
|
||||
return len(got) == 1 && got[0] == "node-b.shell=3Azsh=3Anormal.mesh-cli"
|
||||
})
|
||||
eventually(t, "zshrc holds the pulled-in contributor's item", func() bool {
|
||||
return e.read(t, zshrc) == "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\n# end\n"
|
||||
|
||||
@@ -55,7 +55,7 @@ func routeEntryFor(t *testing.T, module, planEntry string) string {
|
||||
bus := &memBus{buckets: map[string]map[string][]byte{provider.AssignmentsBucket: {
|
||||
node + "." + module: []byte(`{"node":"` + node + `","module":"` + module + `","provisioning":{"provisions":[` +
|
||||
planEntry + `]}}`)}}}
|
||||
p := provider.New(provider.Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: t.Logf,
|
||||
p := provider.New(provider.Options{Node: "node-c", Module: "route-proxy", Bus: bus, Logf: t.Logf,
|
||||
Offers: map[string]provider.Offer{"route": {Credential: provider.NoCredential, Serves: map[string]any{}}}})
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
@@ -81,7 +81,7 @@ func routeRun(t *testing.T, module, id, path, templatePath, entryKey, firstEntry
|
||||
tpl, _ := templates(t, map[string]string{id: string(raw)})
|
||||
b.assign(t, module, Plan{Templates: tpl, Files: []PlannedFile{{ID: id, Path: path, Template: id,
|
||||
OwnerAtRuntime: "owned"}},
|
||||
Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}})
|
||||
Provisions: []PlannedProvision{{Provision: "route", Provider: "node-c", ProviderModule: "route-proxy"}}})
|
||||
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-proxy_bound"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -95,14 +95,14 @@ func routeRun(t *testing.T, module, id, path, templatePath, entryKey, firstEntry
|
||||
}
|
||||
|
||||
// gitea's plan entry for its `web` route, as the controller writes it for a node whose public domain is example.org.
|
||||
const giteaRoutePlanEntry = `{"provision":"route","provider":"novox","provider-module":"route-proxy","credential-form":"pair",` +
|
||||
`"at":"novox.internal","from":"novox","as":"mesh_g14_gitea",` +
|
||||
`"names":{"name-web":"git.example.org","internal-name-web":"git.novox.internal",` +
|
||||
`"name-internal-api-refused":"git.example.org","internal-name-internal-api-refused":"git.novox.internal"}}`
|
||||
const giteaRoutePlanEntry = `{"provision":"route","provider":"node-c","provider-module":"route-proxy","credential-form":"pair",` +
|
||||
`"at":"node-c.internal","from":"node-c","as":"mesh_node-b_gitea",` +
|
||||
`"names":{"name-web":"git.example.org","internal-name-web":"git.node-c.internal",` +
|
||||
`"name-internal-api-refused":"git.example.org","internal-name-internal-api-refused":"git.node-c.internal"}}`
|
||||
|
||||
const keycloakRoutePlanEntry = `{"provision":"route","provider":"novox","provider-module":"route-proxy","credential-form":"pair",` +
|
||||
`"at":"novox.internal","from":"novox","as":"mesh_g14_keycloak",` +
|
||||
`"names":{"name":"keycloak.example.org","internal-name":"keycloak.novox.internal"}}`
|
||||
const keycloakRoutePlanEntry = `{"provision":"route","provider":"node-c","provider-module":"route-proxy","credential-form":"pair",` +
|
||||
`"at":"node-c.internal","from":"node-c","as":"mesh_node-b_keycloak",` +
|
||||
`"names":{"name":"keycloak.example.org","internal-name":"keycloak.node-c.internal"}}`
|
||||
|
||||
func TestNatsGiteasOriginsAreFilledFromRouteProxysEntryForThePlanEntryTheControllerWrites(t *testing.T) {
|
||||
e, _, _ := routeRun(t, "gitea", "origins", "/etc/gitea/origins", "../../../gitea/files/origins", "gitea.route",
|
||||
@@ -123,8 +123,8 @@ func TestNatsKeycloaksHostnameIsFilledFromRouteProxysEntryForThePlanEntryTheCont
|
||||
// A module that failed on a missing name is filled once the provider's entry carries it: the entry first held no
|
||||
// names (as the provider wrote it before the controller composed them), then the controller's plan entry arrives.
|
||||
func TestNatsAModuleThatFailedOnAMissingNameIsFilledWhenTheNameArrives(t *testing.T) {
|
||||
bare := routeEntryFor(t, "gitea", `{"provision":"route","provider":"novox","provider-module":"route-proxy",`+
|
||||
`"as":"mesh_g14_gitea"}`)
|
||||
bare := routeEntryFor(t, "gitea", `{"provision":"route","provider":"node-c","provider-module":"route-proxy",`+
|
||||
`"as":"mesh_node-b_gitea"}`)
|
||||
e, b, heard := routeRun(t, "gitea", "origins", "/etc/gitea/origins", "../../../gitea/files/origins", "gitea.route", bare)
|
||||
eventually(t, "the module fails for the missing name", func() bool {
|
||||
r, _ := heard.last("gitea")
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// sealing to the key the caller sends, handing only what it allows that caller (the vault's own tests prove its
|
||||
// rule), and saying `secret-changed` with no value.
|
||||
|
||||
// standInVault answers get on the bus, for provisioner.g14 alone and only the names it allows.
|
||||
// standInVault answers get on the bus, for provisioner.node-b alone and only the names it allows.
|
||||
type standInVault struct {
|
||||
t *testing.T
|
||||
conn *nats.Conn
|
||||
@@ -155,8 +155,8 @@ func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) {
|
||||
wire := sniff(t, b)
|
||||
vault := startVault(t, b)
|
||||
const token, password = "tok-3f9a1c-never-on-the-bus", "pw-77b2e0-never-on-the-bus"
|
||||
vault.keep("own/g14/app/app-token", token, false)
|
||||
vault.keep("own/g14/app/db-password", password, false)
|
||||
vault.keep("own/node-b/app/app-token", token, false)
|
||||
vault.keep("own/node-b/app/db-password", password, false)
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
b.config(t, "app", "font-size", "13")
|
||||
b.assign(t, "app", app(t))
|
||||
@@ -172,7 +172,7 @@ func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) {
|
||||
})
|
||||
eventually(t, "the needs name the secrets, never their values", func() bool {
|
||||
got := b.keys(t, NeedsBucket, node+".app.")
|
||||
return strings.Join(got, " ") == "g14.app.config.font-size g14.app.secret.app-token g14.app.secret.db-password"
|
||||
return strings.Join(got, " ") == "node-b.app.config.font-size node-b.app.secret.app-token node-b.app.secret.db-password"
|
||||
})
|
||||
if wire.heard() == 0 {
|
||||
t.Fatal("the sniffer heard nothing: it proves nothing")
|
||||
@@ -182,9 +182,9 @@ func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) {
|
||||
t.Errorf("the value crossed the bus in the clear: %q", said)
|
||||
}
|
||||
}
|
||||
if vault.times("own/g14/app/app-token") != 1 || vault.times("own/g14/app/db-password") != 1 {
|
||||
t.Errorf("the vault was asked %d and %d times, want once each", vault.times("own/g14/app/app-token"),
|
||||
vault.times("own/g14/app/db-password"))
|
||||
if vault.times("own/node-b/app/app-token") != 1 || vault.times("own/node-b/app/db-password") != 1 {
|
||||
t.Errorf("the vault was asked %d and %d times, want once each", vault.times("own/node-b/app/app-token"),
|
||||
vault.times("own/node-b/app/db-password"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,8 +192,8 @@ func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *tes
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/app/app-token", "first", false)
|
||||
vault.keep("own/g14/app/db-password", "pw", false)
|
||||
vault.keep("own/node-b/app/app-token", "first", false)
|
||||
vault.keep("own/node-b/app/db-password", "pw", false)
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
b.config(t, "app", "font-size", "13")
|
||||
b.assign(t, "app", app(t))
|
||||
@@ -204,7 +204,7 @@ func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *tes
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vault.keep("own/g14/app/app-token", "second", true)
|
||||
vault.keep("own/node-b/app/app-token", "second", true)
|
||||
eventually(t, "the rotated value reaches its file", func() bool {
|
||||
return strings.Contains(e.read(t, appConf), "token = second\n")
|
||||
})
|
||||
@@ -223,13 +223,13 @@ func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *tes
|
||||
t.Errorf("the other secret's file moved %d times: only what changed is written", moved)
|
||||
}
|
||||
// What another machine's secret, or a secret no plan here names, changes is no reason to ask.
|
||||
vault.keep("own/ace/app/app-token", "elsewhere", true)
|
||||
vault.keep("own/g14/other/token", "unplanned", true)
|
||||
vault.keep("own/node-a/app/app-token", "elsewhere", true)
|
||||
vault.keep("own/node-b/other/token", "unplanned", true)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := vault.times("own/g14/app/app-token"); n != 2 {
|
||||
if n := vault.times("own/node-b/app/app-token"); n != 2 {
|
||||
t.Errorf("the vault was asked for app-token %d times, want 2", n)
|
||||
}
|
||||
if vault.times("own/ace/app/app-token")+vault.times("own/g14/other/token") != 0 {
|
||||
if vault.times("own/node-a/app/app-token")+vault.times("own/node-b/other/token") != 0 {
|
||||
t.Error("a change of a secret this machine's plans do not name made the client ask")
|
||||
}
|
||||
}
|
||||
@@ -238,8 +238,8 @@ func TestNatsASecretTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) {
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/app/db-password", "pw", false)
|
||||
vault.allowed["own/g14/app/app-token"] = true // the plan names it; the vault does not hold it yet
|
||||
vault.keep("own/node-b/app/db-password", "pw", false)
|
||||
vault.allowed["own/node-b/app/app-token"] = true // the plan names it; the vault does not hold it yet
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
b.config(t, "app", "font-size", "13")
|
||||
b.assign(t, "app", app(t))
|
||||
@@ -255,7 +255,7 @@ func TestNatsASecretTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) {
|
||||
}
|
||||
// 1553: a file whose own values are all there is filled, though another file of the module waits.
|
||||
eventually(t, "the secret's own file is filled while the template waits", func() bool { return e.read(t, appDB) == "pw" })
|
||||
vault.keep("own/g14/app/app-token", "arrived", true)
|
||||
vault.keep("own/node-b/app/app-token", "arrived", true)
|
||||
eventually(t, "it is filled once the vault holds it", func() bool {
|
||||
return strings.Contains(e.read(t, appConf), "token = arrived\n") && e.read(t, appDB) == "pw"
|
||||
})
|
||||
@@ -267,9 +267,9 @@ func TestNatsARefusalFailsTheModuleWithTheVaultsReasonAndIsNotAskedEveryPass(t *
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/app/app-token", "tok", false)
|
||||
vault.keep("own/g14/app/db-password", "pw", false)
|
||||
vault.refuse = "provisioner.g14 may not ask for own/g14/app/app-token: app's plan on g14 names no secret app-token"
|
||||
vault.keep("own/node-b/app/app-token", "tok", false)
|
||||
vault.keep("own/node-b/app/db-password", "pw", false)
|
||||
vault.refuse = "provisioner.node-b may not ask for own/node-b/app/app-token: app's plan on node-b names no secret app-token"
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
b.config(t, "app", "font-size", "13")
|
||||
b.assign(t, "app", app(t))
|
||||
@@ -285,7 +285,7 @@ func TestNatsARefusalFailsTheModuleWithTheVaultsReasonAndIsNotAskedEveryPass(t *
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if n := vault.times("own/g14/app/app-token"); n != 1 {
|
||||
if n := vault.times("own/node-b/app/app-token"); n != 1 {
|
||||
t.Errorf("a refused secret was asked %d times across several reconciles, want once", n)
|
||||
}
|
||||
if _, asked := e.counts(appConf); asked != 0 {
|
||||
@@ -338,16 +338,16 @@ func TestAPlansSecretsAreReadStrictly(t *testing.T) {
|
||||
const (
|
||||
forgeFile = "/var/lib/claude-code/state/gitea-read.secret"
|
||||
settingsFile = "/etc/claude-code/managed-settings.json"
|
||||
forgeHolder = "provision/novox/gitea-read/" + node + "/claude-code"
|
||||
forgeHolder = "provision/node-c/gitea-read/" + node + "/claude-code"
|
||||
)
|
||||
|
||||
// claudeCode is claude-code's plan with the credential of what it requires, gitea-read from novox, kept in a file of
|
||||
// claudeCode is claude-code's plan with the credential of what it requires, gitea-read from node-c, kept in a file of
|
||||
// its own and named in a template.
|
||||
func claudeCode(t *testing.T) Plan {
|
||||
tpl, _ := templates(t, map[string]string{"managed-settings.json": `{"forge":"${secret:gitea-read}"}`})
|
||||
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "settings", Path: settingsFile,
|
||||
Template: "managed-settings.json", OwnerAtRuntime: "owned"}},
|
||||
Provisions: []PlannedProvision{{Name: "gitea-read", Provision: "gitea-read", Provider: "novox",
|
||||
Provisions: []PlannedProvision{{Name: "gitea-read", Provision: "gitea-read", Provider: "node-c",
|
||||
Path: forgeFile}}}
|
||||
}
|
||||
|
||||
@@ -356,8 +356,8 @@ func TestNatsAModulesRequiredCredentialIssuedForItIsFilledFromTheVaultAndFollows
|
||||
heard := b.listen(t)
|
||||
wire := sniff(t, b)
|
||||
vault := startVault(t, b)
|
||||
const first = "https://agent-g14:gta-first-never-on-the-bus@forge.example"
|
||||
const second = "https://agent-g14:gta-second-never-on-the-bus@forge.example"
|
||||
const first = "https://agent-node-b:gta-first-never-on-the-bus@forge.example"
|
||||
const second = "https://agent-node-b:gta-second-never-on-the-bus@forge.example"
|
||||
vault.keep(forgeHolder, first, false)
|
||||
e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"})
|
||||
b.assign(t, "claude-code", claudeCode(t))
|
||||
@@ -378,13 +378,13 @@ func TestNatsAModulesRequiredCredentialIssuedForItIsFilledFromTheVaultAndFollows
|
||||
return e.read(t, forgeFile) == second
|
||||
})
|
||||
// Another machine's or another module's holder value changing is no reason to ask.
|
||||
vault.keep("provision/novox/gitea-read/ace/claude-code", "elsewhere", true)
|
||||
vault.keep("provision/novox/gitea-read/"+node+"/zsh", "unplanned", true)
|
||||
vault.keep("provision/node-c/gitea-read/node-a/claude-code", "elsewhere", true)
|
||||
vault.keep("provision/node-c/gitea-read/"+node+"/zsh", "unplanned", true)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := vault.times(forgeHolder); n != 2 {
|
||||
t.Errorf("the vault was asked for the holder value %d times, want 2", n)
|
||||
}
|
||||
if vault.times("provision/novox/gitea-read/ace/claude-code")+vault.times("provision/novox/gitea-read/"+node+"/zsh") != 0 {
|
||||
if vault.times("provision/node-c/gitea-read/node-a/claude-code")+vault.times("provision/node-c/gitea-read/"+node+"/zsh") != 0 {
|
||||
t.Error("a change of a value no plan here names made the client ask")
|
||||
}
|
||||
if wire.heard() == 0 {
|
||||
@@ -402,8 +402,8 @@ func TestNatsARequiredCredentialTheVaultRefusesFailsTheModule(t *testing.T) {
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep(forgeHolder, "https://agent-g14:tok@forge.example", false)
|
||||
vault.refuse = "provisioner.g14 may not ask for " + forgeHolder + ": claude-code's plan on g14 names no credential of gitea-read from novox"
|
||||
vault.keep(forgeHolder, "https://agent-node-b:tok@forge.example", false)
|
||||
vault.refuse = "provisioner.node-b may not ask for " + forgeHolder + ": claude-code's plan on node-b names no credential of gitea-read from node-c"
|
||||
e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"})
|
||||
b.assign(t, "claude-code", claudeCode(t))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
@@ -423,12 +423,12 @@ func TestAPlansProvisionsAreReadStrictly(t *testing.T) {
|
||||
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}},
|
||||
Secrets: []PlannedSecret{{Name: "own"}}}
|
||||
}
|
||||
good := PlannedProvision{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: forgeFile}
|
||||
good := PlannedProvision{Name: "gitea-read", Provision: "gitea-read", Provider: "node-c", Path: forgeFile}
|
||||
for why, change := range map[string]func(*PlannedProvision){
|
||||
"a name that is not a secret's": func(p *PlannedProvision) { p.Name = "Gitea Read" },
|
||||
"a name an own secret has": func(p *PlannedProvision) { p.Name = "own" },
|
||||
"no provision": func(p *PlannedProvision) { p.Provision = "" },
|
||||
"a provider that is no machine": func(p *PlannedProvision) { p.Provider = "../ace" },
|
||||
"a provider that is no machine": func(p *PlannedProvision) { p.Provider = "../node-a" },
|
||||
"no provider": func(p *PlannedProvision) { p.Provider = "" },
|
||||
"a relative path": func(p *PlannedProvision) { p.Path = "var/x" },
|
||||
"a template's path": func(p *PlannedProvision) { p.Path = appConf },
|
||||
@@ -455,14 +455,14 @@ func TestAPlansProvisionsAreReadStrictly(t *testing.T) {
|
||||
const (
|
||||
dbFile = "/var/lib/nextcloud/state/database.secret"
|
||||
dbConf = "/etc/nextcloud/config.php"
|
||||
dbHolder = "pair/novox/postgres-database/" + node + "/nextcloud"
|
||||
dbHolder = "pair/node-c/postgres-database/" + node + "/nextcloud"
|
||||
)
|
||||
|
||||
func nextcloud(t *testing.T) Plan {
|
||||
tpl, _ := templates(t, map[string]string{"config.php": `dbpass=${secret:database}`})
|
||||
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "config", Path: dbConf, Template: "config.php",
|
||||
OwnerAtRuntime: "owned"}},
|
||||
Provisions: []PlannedProvision{{Name: "database", Provision: "postgres-database", Provider: "novox",
|
||||
Provisions: []PlannedProvision{{Name: "database", Provision: "postgres-database", Provider: "node-c",
|
||||
ProviderModule: "postgres", CredentialForm: "pair", Path: dbFile}}}
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@ func TestNatsAModulesOwnLoginFromAPerLoginProviderIsFilledFromTheVaultAndFollows
|
||||
return e.read(t, dbFile) == second
|
||||
})
|
||||
// Another consumer's login changing is no reason to ask.
|
||||
other := "pair/novox/postgres-database/ace/nextcloud"
|
||||
other := "pair/node-c/postgres-database/node-a/nextcloud"
|
||||
vault.keep(other, "elsewhere", true)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if n := vault.times(dbHolder); n != 2 {
|
||||
@@ -511,7 +511,7 @@ func TestNatsAModulesOwnLoginFromAPerLoginProviderIsFilledFromTheVaultAndFollows
|
||||
}
|
||||
|
||||
func TestAConsumersLoginIsNamedWithItsLocalName(t *testing.T) {
|
||||
if got := PairName("novox", "postgres-database", "g14", "umami", "stats"); got != "pair/novox/postgres-database/g14/umami/stats" {
|
||||
if got := PairName("node-c", "postgres-database", "node-b", "umami", "stats"); got != "pair/node-c/postgres-database/node-b/umami/stats" {
|
||||
t.Errorf("a login under a local name: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -522,8 +522,8 @@ func TestNatsAKnownSecretsFileIsFilledWhileATemplateWaits1553(t *testing.T) {
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/app/app-token", "tok", false)
|
||||
vault.keep("own/g14/app/db-password", "pw", false)
|
||||
vault.keep("own/node-b/app/app-token", "tok", false)
|
||||
vault.keep("own/node-b/app/db-password", "pw", false)
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
// No config font-size: the template waits for it.
|
||||
b.assign(t, "app", app(t))
|
||||
@@ -546,8 +546,8 @@ func TestNatsATemplateWithAWaitingValueKeepsItsFileAsItIs1553(t *testing.T) {
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
vault := startVault(t, b)
|
||||
vault.keep("own/g14/app/app-token", "tok", false)
|
||||
vault.keep("own/g14/app/db-password", "pw", false)
|
||||
vault.keep("own/node-b/app/app-token", "tok", false)
|
||||
vault.keep("own/node-b/app/db-password", "pw", false)
|
||||
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
||||
b.assign(t, "app", app(t))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
|
||||
+6
-6
@@ -10,14 +10,14 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "domain", password: "x" }
|
||||
{ user: "provisioner.g14", password: "x", permissions: {
|
||||
publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_applied.*.$KV.mesh-provisioner_applied.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_collect.*.$KV.mesh-provisioner_collect.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_needs.*.$KV.mesh-provisioner_needs.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.CREATE.KV_postgres_bound.*.$KV.postgres_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-adapter_bound.*.$KV.route-adapter_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-proxy_bound.*.$KV.route-proxy_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_node-tools_bound.*.$KV.node-tools_bound.g14.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_applied.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_collect.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_needs.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.DELETE.KV_postgres_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-adapter_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-proxy_bound.>", "$JS.API.CONSUMER.DELETE.KV_node-tools_bound.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.g14.>", "$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_applied.$KV.mesh-provisioner_applied.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_collect.$KV.mesh-provisioner_collect.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_needs.$KV.mesh-provisioner_needs.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>", "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-adapter_bound.$KV.route-adapter_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-proxy_bound.$KV.route-proxy_bound.g14.>", "$JS.API.DIRECT.GET.KV_node-tools_bound.$KV.node-tools_bound.g14.>", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-controller_assignments", "$JS.API.STREAM.INFO.KV_mesh-provisioner_applied", "$JS.API.STREAM.INFO.KV_mesh-provisioner_collect", "$JS.API.STREAM.INFO.KV_mesh-provisioner_needs", "$JS.API.STREAM.INFO.KV_mesh-provisioner_registry", "$JS.API.STREAM.INFO.KV_postgres_bound", "$JS.API.STREAM.INFO.KV_route-adapter_bound", "$JS.API.STREAM.INFO.KV_route-proxy_bound", "$JS.API.STREAM.INFO.KV_node-tools_bound", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-controller_assignments.>", "$JS.FC.KV_mesh-provisioner_applied.>", "$JS.FC.KV_mesh-provisioner_collect.>", "$JS.FC.KV_mesh-provisioner_needs.>", "$JS.FC.KV_mesh-provisioner_registry.>", "$JS.FC.KV_postgres_bound.>", "$JS.FC.KV_route-adapter_bound.>", "$JS.FC.KV_route-proxy_bound.>", "$JS.FC.KV_node-tools_bound.>", "$KV.mesh-provisioner_applied.g14.>", "$KV.mesh-provisioner_collect.g14.>", "$KV.mesh-provisioner_needs.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14", "prov.applied.g14.>", "prov.changed.g14.>"] }
|
||||
subscribe: { allow: ["_INBOX.provisioner.g14.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.g14"] }
|
||||
{ user: "provisioner.node-b", password: "x", permissions: {
|
||||
publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.node-b.>", "$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_applied.*.$KV.mesh-provisioner_applied.node-b.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_collect.*.$KV.mesh-provisioner_collect.node-b.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_needs.*.$KV.mesh-provisioner_needs.node-b.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.CREATE.KV_postgres_bound.*.$KV.postgres_bound.node-b.>", "$JS.API.CONSUMER.CREATE.KV_route-adapter_bound.*.$KV.route-adapter_bound.node-b.>", "$JS.API.CONSUMER.CREATE.KV_route-proxy_bound.*.$KV.route-proxy_bound.node-b.>", "$JS.API.CONSUMER.CREATE.KV_node-tools_bound.*.$KV.node-tools_bound.node-b.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_applied.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_collect.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_needs.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.DELETE.KV_postgres_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-adapter_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-proxy_bound.>", "$JS.API.CONSUMER.DELETE.KV_node-tools_bound.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.node-b.>", "$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_applied.$KV.mesh-provisioner_applied.node-b.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_collect.$KV.mesh-provisioner_collect.node-b.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_needs.$KV.mesh-provisioner_needs.node-b.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>", "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.node-b.>", "$JS.API.DIRECT.GET.KV_route-adapter_bound.$KV.route-adapter_bound.node-b.>", "$JS.API.DIRECT.GET.KV_route-proxy_bound.$KV.route-proxy_bound.node-b.>", "$JS.API.DIRECT.GET.KV_node-tools_bound.$KV.node-tools_bound.node-b.>", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-controller_assignments", "$JS.API.STREAM.INFO.KV_mesh-provisioner_applied", "$JS.API.STREAM.INFO.KV_mesh-provisioner_collect", "$JS.API.STREAM.INFO.KV_mesh-provisioner_needs", "$JS.API.STREAM.INFO.KV_mesh-provisioner_registry", "$JS.API.STREAM.INFO.KV_postgres_bound", "$JS.API.STREAM.INFO.KV_route-adapter_bound", "$JS.API.STREAM.INFO.KV_route-proxy_bound", "$JS.API.STREAM.INFO.KV_node-tools_bound", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-controller_assignments.>", "$JS.FC.KV_mesh-provisioner_applied.>", "$JS.FC.KV_mesh-provisioner_collect.>", "$JS.FC.KV_mesh-provisioner_needs.>", "$JS.FC.KV_mesh-provisioner_registry.>", "$JS.FC.KV_postgres_bound.>", "$JS.FC.KV_route-adapter_bound.>", "$JS.FC.KV_route-proxy_bound.>", "$JS.FC.KV_node-tools_bound.>", "$KV.mesh-provisioner_applied.node-b.>", "$KV.mesh-provisioner_collect.node-b.>", "$KV.mesh-provisioner_needs.node-b.>", "mesh.seat.mesh-vault.call.get.provisioner~node-b", "prov.applied.node-b.>", "prov.changed.node-b.>"] }
|
||||
subscribe: { allow: ["_INBOX.provisioner.node-b.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.node-b"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "g14.node-tools", password: "x", permissions: {
|
||||
publish: { allow: ["$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.g14.*", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "mesh.control.g14.tools-alive", "mesh.mod.*.call.*.*.g14~node-tools", "mesh.mod.*.call.*.g14~node-tools", "mesh.mod.*.tool.>", "mesh.seat.*.call.*.*.g14~node-tools", "mesh.seat.*.call.*.g14~node-tools", "mesh.seat.*.tool.>", "prov.ask.g14"], deny: ["mesh.seat.node-launcher.tool.secret", "mesh.seat.node-launcher.tool.secret.*", "mesh.mod.*.tool.node-launcher.secret", "mesh.mod.*.tool.node-launcher.secret.*", "mesh.seat.node-launcher.call.secret.>", "mesh.mod.*.call.node-launcher.secret.>"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.claude-code", "$SRV.INFO.claude-code.>", "$SRV.INFO.node-tools", "$SRV.INFO.node-tools.>", "$SRV.PING", "$SRV.PING.claude-code", "$SRV.PING.claude-code.>", "$SRV.PING.node-tools", "$SRV.PING.node-tools.>", "$SRV.STATS", "$SRV.STATS.claude-code", "$SRV.STATS.claude-code.>", "$SRV.STATS.node-tools", "$SRV.STATS.node-tools.>", "_INBOX.g14.node-tools.>", "mesh.assignment.g14.*", "mesh.mod.claude-code.call.>", "mesh.mod.claude-code.tool.>", "prov.changed.g14.>"] }
|
||||
{ user: "node-b.node-tools", password: "x", permissions: {
|
||||
publish: { allow: ["$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.node-b.*", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "mesh.control.node-b.tools-alive", "mesh.mod.*.call.*.*.node-b~node-tools", "mesh.mod.*.call.*.node-b~node-tools", "mesh.mod.*.tool.>", "mesh.seat.*.call.*.*.node-b~node-tools", "mesh.seat.*.call.*.node-b~node-tools", "mesh.seat.*.tool.>", "prov.ask.node-b"], deny: ["mesh.seat.node-launcher.tool.secret", "mesh.seat.node-launcher.tool.secret.*", "mesh.mod.*.tool.node-launcher.secret", "mesh.mod.*.tool.node-launcher.secret.*", "mesh.seat.node-launcher.call.secret.>", "mesh.mod.*.call.node-launcher.secret.>"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.claude-code", "$SRV.INFO.claude-code.>", "$SRV.INFO.node-tools", "$SRV.INFO.node-tools.>", "$SRV.PING", "$SRV.PING.claude-code", "$SRV.PING.claude-code.>", "$SRV.PING.node-tools", "$SRV.PING.node-tools.>", "$SRV.STATS", "$SRV.STATS.claude-code", "$SRV.STATS.claude-code.>", "$SRV.STATS.node-tools", "$SRV.STATS.node-tools.>", "_INBOX.node-b.node-tools.>", "mesh.assignment.node-b.*", "mesh.mod.claude-code.call.>", "mesh.mod.claude-code.tool.>", "prov.changed.node-b.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
]
|
||||
|
||||
@@ -16,24 +16,24 @@ func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provision := `"provisions":[{"provision":"route","local":"web","provider":"novox","provider-module":"route-proxy",` +
|
||||
`"credential-form":"pair","contributes":{"label":"git"},"at":"10.10.0.1","from":"novox","as":"gitea",` +
|
||||
`"names":{"name-web":"git.example.org"}},{"name":"gitea-read","provision":"gitea-read","provider":"novox",` +
|
||||
provision := `"provisions":[{"provision":"route","local":"web","provider":"node-c","provider-module":"route-proxy",` +
|
||||
`"credential-form":"pair","contributes":{"label":"git"},"at":"10.10.0.1","from":"node-c","as":"gitea",` +
|
||||
`"names":{"name-web":"git.example.org"}},{"name":"gitea-read","provision":"gitea-read","provider":"node-c",` +
|
||||
`"provider-module":"gitea","credential-form":"issued","credential-tool":"gitea_agent_token",` +
|
||||
`"path":"/var/lib/claude-code/gitea-read.secret"}],"items":`
|
||||
value := fmt.Sprintf(`{"node":"g14","module":"mesh-cli","provisioning":%s}`,
|
||||
value := fmt.Sprintf(`{"node":"node-b","module":"mesh-cli","provisioning":%s}`,
|
||||
strings.Replace(string(p), `"items":`, provision, 1))
|
||||
_, got, err := planOf("g14", "g14.mesh-cli", []byte(value))
|
||||
_, got, err := planOf("node-b", "node-b.mesh-cli", []byte(value))
|
||||
if err != nil {
|
||||
t.Fatalf("a plan with the controller's fields is refused: %v", err)
|
||||
}
|
||||
if len(got.Provisions) != 2 || got.Provisions[0].At != "10.10.0.1" || got.Provisions[0].From != "novox" ||
|
||||
if len(got.Provisions) != 2 || got.Provisions[0].At != "10.10.0.1" || got.Provisions[0].From != "node-c" ||
|
||||
got.Provisions[0].As != "gitea" || got.Provisions[0].Names["name-web"] != "git.example.org" ||
|
||||
got.Provisions[1].CredentialTool != "gitea_agent_token" {
|
||||
t.Fatalf("the plan's provisions read as %+v", got.Provisions)
|
||||
}
|
||||
spoilt := strings.Replace(value, `"at":`, `"surprise":1,"at":`, 1)
|
||||
if _, _, err := planOf("g14", "g14.mesh-cli", []byte(spoilt)); err == nil {
|
||||
if _, _, err := planOf("node-b", "node-b.mesh-cli", []byte(spoilt)); err == nil {
|
||||
t.Fatalf("a provision with a field nobody writes is read: %s", spoilt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,17 +106,17 @@ what is said is decided by the state now and by when a thing happened, never by
|
||||
what arrives meanwhile goes out, coalesced, when the minute ends.
|
||||
|
||||
**A message reads in one glance** (novox/hq ADR 0253). Its title is the condition's headline — a few
|
||||
plain words such as "openrazer not working on g14" — prefixed "Urgent:", "Still open:" or "Now urgent:"
|
||||
plain words such as "openrazer not working on node-b" — prefixed "Urgent:", "Still open:" or "Now urgent:"
|
||||
where that applies; its body is the condition's explanation (what happened, what it means, whether the
|
||||
operator needs to act) and since when, in the operator's time zone. No key, id, commit, command or markup:
|
||||
those stay in the controller's `conditions` and in this module's history. A clearance is one line, the
|
||||
condition's resolved line and how long it was open: "Resolved: openrazer works again on g14, after 26 min".
|
||||
condition's resolved line and how long it was open: "Resolved: openrazer works again on node-b, after 26 min".
|
||||
A condition from a controller older than plain words, or a module's notice without a headline, is said from
|
||||
its summary, with code spans taken out (`cmd/messenger/words.go`).
|
||||
|
||||
**A condition's message says which issue tracks it** (novox/hq ADR 0285, issue 400). Below the explanation,
|
||||
one line from the issue tracker's `tracking` verb, asked outside the work at most once a minute and used for
|
||||
ten minutes: "Tracked as issue 386: fix-386 on g14 is fixing it since 21:10.", "…: nobody is working on it.",
|
||||
ten minutes: "Tracked as issue 386: fix-386 on node-b is fixing it since 21:10.", "…: nobody is working on it.",
|
||||
"…: its fix is merged and on its way.", "…: it waits for your answer.", up to three issues and then a count;
|
||||
"No issue tracks this yet." when no issue links the condition's key; "Whether an issue tracks this could not
|
||||
be checked." when the tracker gave no answer in ten minutes. "Nothing for you to do." becomes "Needs you:
|
||||
|
||||
@@ -100,7 +100,7 @@ func (c Condition) When(event string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// SubjectWords is the subject as a person reads it: "machine ace", "plan 41", "provider keycloak".
|
||||
// SubjectWords is the subject as a person reads it: "machine node-a", "plan 41", "provider keycloak".
|
||||
func (c Condition) SubjectWords() string {
|
||||
parts := []string{}
|
||||
if c.Scope != "" {
|
||||
|
||||
@@ -145,7 +145,7 @@ func randomRun(text string) (Refusal, bool) {
|
||||
}
|
||||
|
||||
// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one
|
||||
// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
// machine and one tool (`node-a/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
func isPath(w string) bool {
|
||||
if w == "" {
|
||||
return false
|
||||
@@ -162,7 +162,7 @@ func isPath(w string) bool {
|
||||
}
|
||||
|
||||
// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind
|
||||
// (`machine.ace.silent`), which none of these is.
|
||||
// (`machine.node-a.silent`), which none of these is.
|
||||
func isHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
|
||||
@@ -675,14 +675,14 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
"Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " +
|
||||
"like the controller's conditions.",
|
||||
Input: map[string]any{
|
||||
"key": str("what makes it the same message the next time, e.g. backup.ace.failed"),
|
||||
"key": str("what makes it the same message the next time, e.g. backup.node-a.failed"),
|
||||
"severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}},
|
||||
"summary": str("one line in the mesh's words"),
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on node-a: no ids, keys or commands"),
|
||||
"explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " +
|
||||
"they need to act"),
|
||||
"resolved": str("the one short line said when it is over, e.g. backups run again on ace"),
|
||||
"resolved": str("the one short line said when it is over, e.g. backups run again on node-a"),
|
||||
"clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
@@ -696,7 +696,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
"words are, and offering Details like any message. Never escalated, never kept open, never reminded; " +
|
||||
"its own cap of 10 a channel an hour, apart from the conditions' and notify's.",
|
||||
Input: map[string]any{
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for node-a"),
|
||||
"lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"},
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
},
|
||||
|
||||
@@ -326,7 +326,7 @@ func issueState(is TrackedIssue, now time.Time, loc *time.Location) string {
|
||||
return "nobody is working on it"
|
||||
}
|
||||
|
||||
// workerWords is who@node as said: "fix-400 on g14".
|
||||
// workerWords is who@node as said: "fix-400 on node-b".
|
||||
func workerWords(worker string) string {
|
||||
who, node, ok := strings.Cut(worker, "@")
|
||||
if !ok {
|
||||
|
||||
@@ -9,7 +9,7 @@ package main
|
||||
// controller where the condition is made, so every channel says the same thing. A condition from a
|
||||
// controller older than them, or a module's notice without a headline, is said from its summary,
|
||||
// with what the operator cannot read taken out (plainSummary).
|
||||
// - A clearance is one short line: "Resolved: openrazer works again on g14, after 26 min".
|
||||
// - A clearance is one short line: "Resolved: openrazer works again on node-b, after 26 min".
|
||||
// - A reminder, an escalation and a reopening say so in the title, before the same headline.
|
||||
// - Times are the operator's (the setting time-zone; this machine's zone when not given), and said as a
|
||||
// clock time today, a weekday this week, a date before that.
|
||||
@@ -168,7 +168,7 @@ func fallbackHeadline(r *Record) string {
|
||||
return capital(what) + " on " + about
|
||||
}
|
||||
|
||||
// machineOf is the machine a record's subject words name: "machine ace" or "x on ace".
|
||||
// machineOf is the machine a record's subject words name: "machine node-a" or "x on node-a".
|
||||
func machineOf(subject string) string {
|
||||
if _, on, ok := strings.Cut(subject, " on "); ok {
|
||||
return on
|
||||
|
||||
@@ -55,7 +55,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}},
|
||||
{Name: "nats_connections",
|
||||
Description: "Every client connected to the bus, named by the mesh user it is (a machine's host `node.<machine>`, a machine's runtime `<machine>.node-tools`, the controller, a person): address, uptime, round trip, traffic and pending bytes. Narrowed by a user or connection name, sorted by out_bytes (default), in_bytes, pending, msgs or subscriptions.",
|
||||
Input: map[string]any{"user": str("part of a user or connection name, e.g. g14"), "sort": str("out_bytes, in_bytes, pending, msgs or subscriptions"), "limit": num("at most this many (default 50)")},
|
||||
Input: map[string]any{"user": str("part of a user or connection name, e.g. node-b"), "sort": str("out_bytes, in_bytes, pending, msgs or subscriptions"), "limit": num("at most this many (default 50)")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
connz, err := get("/connz?auth=1&limit=1024")
|
||||
if err != nil {
|
||||
@@ -115,7 +115,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}},
|
||||
{Name: "nats_users",
|
||||
Description: "The bus's users as the controller composed them — never a password hash. Without a user, each with how many grants it holds; with part of a user's name, its publish and subscribe grants in full.",
|
||||
Input: map[string]any{"user": str("part of a user name, e.g. g14.node-tools or controller")},
|
||||
Input: map[string]any{"user": str("part of a user name, e.g. node-b.node-tools or controller")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
c, err := conf()
|
||||
if err != nil {
|
||||
@@ -125,7 +125,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
}},
|
||||
{Name: "nats_user_can",
|
||||
Description: "Whether a bus user may publish or subscribe to a subject, and the grant that allows it — the question behind a request that timed out because the bus refused it. Also whether the running server holds it: `in_force` is true only when the user list on the machine allows it and the server reloaded its configuration after that list was written (novox/hq issue 353: a list sent is not a list the bus enforces until it reloads).",
|
||||
Input: map[string]any{"user": str("the user exactly, e.g. g14.node-tools"), "action": str("publish or subscribe"), "subject": str("a concrete subject, e.g. $KV.claude-code_servers.all.x")},
|
||||
Input: map[string]any{"user": str("the user exactly, e.g. node-b.node-tools"), "action": str("publish or subscribe"), "subject": str("a concrete subject, e.g. $KV.claude-code_servers.all.x")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
c, err := conf()
|
||||
if err != nil {
|
||||
|
||||
@@ -45,7 +45,7 @@ func rig(wait time.Duration) (*Service, *fakeBus) {
|
||||
return &Service{Asks: b, Now: time.Now, Wait: wait, Poll: time.Millisecond}, b
|
||||
}
|
||||
|
||||
const get, put = "http://novox.internal:20001/b/doc.docx?X-Amz-Signature=a", "http://novox.internal:20001/b/doc.pdf?X-Amz-Signature=b"
|
||||
const get, put = "http://node-c.internal:20001/b/doc.docx?X-Amz-Signature=a", "http://node-c.internal:20001/b/doc.pdf?X-Amz-Signature=b"
|
||||
|
||||
// 1595/AC1: one ask with those fields is published on convert-asks, and the verb answers what the converter wrote under
|
||||
// the ask's id.
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestTheToolSideAndTheConverterConvertOverARealBus(t *testing.T) {
|
||||
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := process.Account{URL: s.ClientURL(), Node: "novox", Module: "only-office", User: "novox.only-office.converter", Password: "pw"}
|
||||
account := process.Account{URL: s.ClientURL(), Node: "node-c", Module: "only-office", User: "node-c.only-office.converter", Password: "pw"}
|
||||
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
|
||||
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -85,7 +85,7 @@ func TestTheToolSideAndTheConverterConvertOverARealBus(t *testing.T) {
|
||||
go runConverter(runCtx, account, w)
|
||||
|
||||
svc := &Service{Asks: natsBus{js: js, kv: kv}, Now: time.Now, Wait: 20 * time.Second, Poll: 50 * time.Millisecond}
|
||||
got, err := svc.Convert(ctx, "http://novox.internal:20001/b/in.docx?X-Amz-Signature=a", "docx", tgt.srv.URL+"/b/in.pdf?sig=1", "")
|
||||
got, err := svc.Convert(ctx, "http://node-c.internal:20001/b/in.docx?X-Amz-Signature=a", "docx", tgt.srv.URL+"/b/in.pdf?sig=1", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func worker(d *fakeDocs) (*converterWorker, *answers) {
|
||||
|
||||
func askBody(t *testing.T, target string, sha string) []byte {
|
||||
raw, err := json.Marshal(Ask{ID: "ask1", Op: OpConvert, At: time.Now(), Deadline: time.Now().Add(time.Minute),
|
||||
Source: "http://novox.internal:20001/b/in.docx?X-Amz-Signature=a", Type: "docx", Target: target, SHA256: sha})
|
||||
Source: "http://node-c.internal:20001/b/in.docx?X-Amz-Signature=a", Type: "docx", Target: target, SHA256: sha})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestTheConverterPostsTheRequestAndPutsThePDF(t *testing.T) {
|
||||
t.Fatalf("%d conversion requests", len(d.requests))
|
||||
}
|
||||
r := d.requests[0]
|
||||
if r["url"] != "http://novox.internal:20001/b/in.docx?X-Amz-Signature=a" || r["filetype"] != "docx" || r["outputtype"] != "pdf" ||
|
||||
if r["url"] != "http://node-c.internal:20001/b/in.docx?X-Amz-Signature=a" || r["filetype"] != "docx" || r["outputtype"] != "pdf" ||
|
||||
r["async"] != false || r["key"] != strings.Repeat("ab", 32) || d.accept != "application/json" {
|
||||
t.Fatalf("request %v accept %q", r, d.accept)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ and a fake link reader in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
| | node-b | node-d |
|
||||
|---|---|---|
|
||||
| package | none: `slack-desktop` 4.51.191-1, AUR, installed explicitly | the same |
|
||||
| start | i3's configuration gains the module's start. **Until the predecessor's `~/.config/autostart/slack.desktop` is deleted, the next login starts Slack twice.** The second start hands over to the first and exits, because Slack is single-instance. Running now: started by dex from that entry at the login of 2026-10-04 16:26 | the same entry, the same until deleted. Running now: since 2026-10-04 17:05, **with its stdout on a pipe nobody reads** (that session predates the `i3` module's login entry), so `slack_check` names it until `slack_restart` or the next login |
|
||||
@@ -135,7 +135,7 @@ and a fake link reader in the tests.
|
||||
off. Leave the setting off: ticking it makes the entry again, and `slack_check` names it.
|
||||
2. **Delete `~/.config/i3/config.d/50-slack.conf`.** It is the operator's own file, and its rules are
|
||||
now the module's.
|
||||
3. **shanks only:** run `slack_restart`, or log out and in, so that Slack's output is read.
|
||||
3. **node-d only:** run `slack_restart`, or log out and in, so that Slack's output is read.
|
||||
|
||||
`slack_check` then answers `ok`. Deleting both files before the module is assigned would leave Slack
|
||||
without a start and without its rules until it is.
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestTheDropInGrantsExactlyTheOperatorAccountAndParses(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skip("visudo is not installed here; the rendered drop-in is not checked")
|
||||
}
|
||||
for _, account := range []string{"operator", "ace", "jochen-s"} {
|
||||
for _, account := range []string{"operator", "node-a", "jochen-s"} {
|
||||
file := filepath.Join(t.TempDir(), "10-mesh-operator")
|
||||
rendered := strings.ReplaceAll(content, "${machine:account}", account)
|
||||
if err := os.WriteFile(file, []byte(rendered), 0o440); err != nil {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const listNovox = `Matching Defaults entries for operator on anchor:
|
||||
const listAnchor = `Matching Defaults entries for operator on anchor:
|
||||
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/bin
|
||||
|
||||
User operator may run the following commands on anchor:
|
||||
@@ -14,7 +14,7 @@ User operator may run the following commands on anchor:
|
||||
`
|
||||
|
||||
func TestSudoListIsParsedIntoDefaultsAndRules(t *testing.T) {
|
||||
r := ParseList(listNovox, "x")
|
||||
r := ParseList(listAnchor, "x")
|
||||
if r.Account != "operator" || r.Host != "anchor" {
|
||||
t.Fatalf("who: %+v", r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user