Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc53353609 | ||
|
|
2a03c106f4 | ||
|
|
f5d4b2f86e | ||
|
|
7c2aa0a739 | ||
|
|
e057670bc4 | ||
|
|
0c6b7b5d64 | ||
|
|
9fd95693db | ||
|
|
1a9570f5e9 | ||
|
|
d871258f8b | ||
|
|
e7abe3e3e4 | ||
|
|
0974d1a57d | ||
|
|
6cea12a023 | ||
|
|
a17356a162 | ||
|
|
35ad58e6e2 | ||
|
|
da5da4f6f7 | ||
|
|
671a6a1f47 | ||
|
|
47ed384d97 | ||
|
|
3fb7951524 | ||
|
|
ae96afcaa8 | ||
|
|
0c63b4c906 | ||
|
|
354d62340d | ||
|
|
dea390ac97 | ||
|
|
00d155da60 | ||
|
|
89c30c44be | ||
|
|
bd65c839a8 | ||
|
|
b7a036e186 | ||
|
|
3abf7cd50b | ||
|
|
d7e8a8824e | ||
|
|
83e9ce5b19 |
@@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools:
|
||||
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||
|
||||
|
||||
@@ -83,6 +83,20 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
for k := range MarketplaceKeys() {
|
||||
delete(m, k)
|
||||
}
|
||||
// The wait bound is new since the TypeScript (issue 1871), and is held below.
|
||||
if env, ok := m["env"].(map[string]any); ok {
|
||||
delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT")
|
||||
if len(env) == 0 {
|
||||
delete(m, "env")
|
||||
}
|
||||
}
|
||||
}
|
||||
if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" {
|
||||
if servers, ok := m["mcpServers"].(map[string]any); ok {
|
||||
if entry, ok := servers["mesh"].(map[string]any); ok {
|
||||
delete(entry, "timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
@@ -104,6 +118,21 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
// The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env
|
||||
// (issue 1871): a session's wait is not cut at the agent's one-minute default.
|
||||
if mcp.MCPServers["mesh"]["timeout"] != float64(660000) {
|
||||
t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"])
|
||||
}
|
||||
for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} {
|
||||
var m struct {
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{})
|
||||
_ = json.Unmarshal([]byte(got["managed-settings.json"]), &m)
|
||||
if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") {
|
||||
t.Errorf("env %v rendered as %v", held, m.Env)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -98,6 +99,27 @@ func jsonFile(v any) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest
|
||||
// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP
|
||||
// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that
|
||||
// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted
|
||||
// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a
|
||||
// minute, whatever its timeout (issue 1871).
|
||||
const MeshWaitBoundMillis = 660000
|
||||
|
||||
// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning
|
||||
// over the operator's, like the forge's keys.
|
||||
func meshWaitEnv(managed map[string]any) {
|
||||
env := map[string]any{}
|
||||
if held, ok := managed["env"].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis)
|
||||
managed["env"] = env
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||
@@ -119,7 +141,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
servers[name] = entry
|
||||
}
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis}
|
||||
|
||||
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
|
||||
// through this module's tools is laid over it (novox/hq issue 340).
|
||||
@@ -143,6 +165,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
managed[key] = entries
|
||||
}
|
||||
forgeEnv(managed, facts.ForgeCredentials)
|
||||
meshWaitEnv(managed)
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
|
||||
@@ -312,6 +312,15 @@ read-only. With a `user` it adds the teams that user is in and its effective rig
|
||||
listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The
|
||||
tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only.
|
||||
|
||||
### Two settings have named tools, and a retargeted pull request is judged again
|
||||
|
||||
`gitea_api` refuses a repository's settings and a pull request's base (guard.ts). Issue 1422 note 7 gave each its tool:
|
||||
`gitea_default_branch_set` (owner, repo, branch, why) and `gitea_pull_base_set` (owner, repo, number, base, why). Both
|
||||
act as the tools' account, need a reason, refuse a branch the repository does not have, and answer the value before and
|
||||
after; a default must also be a branch a protection rule guards, and only an open pull request has its base changed.
|
||||
A pull request's announced head is kept under `owner/repo#number@base`, so a retarget (same head, new base) is
|
||||
announced again into the new base by the next poll instead of being skipped as already announced.
|
||||
|
||||
### The merging account writes the repositories the mesh delivers
|
||||
|
||||
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
|
||||
|
||||
@@ -834,6 +834,30 @@ export class GiteaClient {
|
||||
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
|
||||
}
|
||||
|
||||
/** One branch as the forge keeps it: whether a protection rule guards it; null when the repository has no such branch. */
|
||||
async branchState(owner: string, repo: string, branch: string): Promise<{ protected: boolean } | null> {
|
||||
try {
|
||||
const b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
|
||||
return { protected: Boolean(b?.protected) };
|
||||
} catch (err) {
|
||||
if (/: 404 /.test(err instanceof Error ? err.message : "")) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Make a branch the repository's default (issue 1422): what the forge shows, clones and opens pull requests against. */
|
||||
async setDefaultBranch(owner: string, repo: string, branch: string): Promise<GiteaRepo> {
|
||||
return GiteaClient.mapRepo(await this.request<any>(repoPath(owner, repo), { method: "PATCH", body: JSON.stringify({ default_branch: branch }) }));
|
||||
}
|
||||
|
||||
/** Change the branch an open pull request merges into (issue 1422). The head stays; the forge recomputes the diff. */
|
||||
async setPullBase(owner: string, repo: string, index: number, base: string): Promise<GiteaPull> {
|
||||
indexOf(index);
|
||||
return GiteaClient.mapPull(
|
||||
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ base }) }),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
|
||||
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// forge as themselves;
|
||||
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps
|
||||
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
|
||||
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
|
||||
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
|
||||
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
|
||||
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
|
||||
// that head;
|
||||
|
||||
@@ -53,7 +53,7 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, headsToAnnounce, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { GateWriter } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
@@ -243,7 +243,7 @@ let heads: Announced = {};
|
||||
let primedPulls = false;
|
||||
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const key = headKey(repo.full_name, pull.number, pull.base);
|
||||
const sha = String(pull.head_sha ?? "");
|
||||
const before = heads[key];
|
||||
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
|
||||
@@ -307,7 +307,7 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
// beyond them unannounced. A sweep and the first pass read every page.
|
||||
const open = await client.listOpenPullsSince(repo.owner, repo.name, sweep ? "" : floor);
|
||||
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const key = headKey(repo.full_name, pull.number, pull.base);
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
|
||||
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
|
||||
|
||||
@@ -141,13 +141,19 @@ export function planText(plan: ChangePlan): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
|
||||
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */
|
||||
export type Announced = Record<string, string>;
|
||||
|
||||
/** The key a pull request's announced head is kept under: `owner/repo#number@base`. The base is in it because a pull
|
||||
* request retargeted keeps its head, and a head judged into one base is not judged into another (issue 1422, de-spiegel #6). */
|
||||
export function headKey(full: string, number: number, base?: string): string {
|
||||
return `${full}#${number}@${base ?? ""}`;
|
||||
}
|
||||
|
||||
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
|
||||
* open and is never asked about. */
|
||||
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[headKey(full, p.number, p.base)] !== p.head_sha);
|
||||
}
|
||||
|
||||
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
|
||||
|
||||
@@ -34,6 +34,12 @@ const state = {
|
||||
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
|
||||
admin: false,
|
||||
lastCommit: "2026-01-01T00:00:00Z",
|
||||
// The repository's default branch and pull request 7's base, as the forge keeps them (issue 1422 note 7).
|
||||
defaultBranch: "main",
|
||||
base: "main",
|
||||
prState: "open",
|
||||
// Branches the forge answers 404 for; "scratch" exists but no protection rule guards it.
|
||||
absent: ["nope"],
|
||||
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
||||
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||
// merge commit's own sha.
|
||||
@@ -66,7 +72,10 @@ beforeEach(() => {
|
||||
state.statuses = judgedAt(TRUNK);
|
||||
state.trunk = TRUNK;
|
||||
state.emitted = [];
|
||||
state.rules = [protectedMain()];
|
||||
state.rules = [protectedMain(), { rule_name: "release/*", enable_push: false }];
|
||||
state.defaultBranch = "main";
|
||||
state.base = "main";
|
||||
state.prState = "open";
|
||||
state.admin = false;
|
||||
state.head = REVIEWED;
|
||||
state.landed = "";
|
||||
@@ -98,12 +107,22 @@ const server = createServer(async (req, res) => {
|
||||
state.asked.push(`${method} ${path}${url.search}`);
|
||||
if (method !== "GET") state.sent.push({ method, path, body });
|
||||
const repo = "/repos/novox/mesh-catalog";
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
if (method === "PATCH" && path === repo) {
|
||||
if (body?.default_branch) state.defaultBranch = body.default_branch;
|
||||
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
|
||||
}
|
||||
if (method === "PATCH" && path === `${repo}/pulls/7`) {
|
||||
if (body?.base) state.base = body.base;
|
||||
return json(res, 201, { number: 7, title: "x", state: state.prState, merged: false, head: { ref: "feat/x", sha: state.head },
|
||||
base: { ref: state.base }, html_url: "http://fake/7" });
|
||||
}
|
||||
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
|
||||
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
||||
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
|
||||
if (branch !== null && state.absent.includes(branch)) return json(res, 404, { message: "branch does not exist" });
|
||||
if (branch !== null) {
|
||||
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
|
||||
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
||||
@@ -125,8 +144,8 @@ const server = createServer(async (req, res) => {
|
||||
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/pulls/7`) {
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged,
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7",
|
||||
merge_commit_sha: state.merged ? state.mergeCommit : null });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
|
||||
@@ -809,3 +828,47 @@ test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on
|
||||
state.statuses = [judgedAt(TRUNK)[1]];
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
|
||||
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
|
||||
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_default_branch_set").run({ owner: "novox", repo: "mesh-catalog", why: "the trunk was recorded as unknown", ...args });
|
||||
await assert.rejects(set({ branch: "nope" }), /no branch nope/);
|
||||
await assert.rejects(set({ branch: "scratch" }), /no protection rule/);
|
||||
await assert.rejects(set({ branch: "release/1", why: " " }), /why/);
|
||||
await assert.rejects(set({ branch: ".." }), /dot segment|not a branch/);
|
||||
assert.deepEqual(writes(), []);
|
||||
assert.deepEqual(await set({ branch: "release/1" }), { branch: "release/1", before: "main", after: "release/1", changed: true,
|
||||
why: "the trunk was recorded as unknown" });
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog"]);
|
||||
assert.deepEqual(state.sent[0].body, { default_branch: "release/1" });
|
||||
assert.equal(state.defaultBranch, "release/1");
|
||||
state.sent = [];
|
||||
assert.equal(((await set({ branch: "release/1" })) as any).changed, false, "already the default: nothing is sent");
|
||||
assert.deepEqual(writes(), []);
|
||||
});
|
||||
|
||||
test("gitea_pull_base_set answers the base before and after, and refuses a base that is missing, a closed pull request or no reason", async () => {
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_pull_base_set").run({ owner: "novox", repo: "mesh-catalog", number: 7, why: "it was opened against the wrong trunk", ...args });
|
||||
await assert.rejects(set({ base: "nope" }), /no branch nope/);
|
||||
await assert.rejects(set({ base: "release/1", why: "" }), /why/);
|
||||
await assert.rejects(set({ base: "release/1", number: 0 }), /pull request's number/);
|
||||
state.prState = "closed";
|
||||
await assert.rejects(set({ base: "release/1" }), /not open/);
|
||||
state.prState = "open";
|
||||
assert.deepEqual(writes(), []);
|
||||
assert.deepEqual(await set({ base: "release/1" }), { number: 7, before: "main", after: "release/1", changed: true,
|
||||
why: "it was opened against the wrong trunk" });
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/pulls/7"]);
|
||||
assert.deepEqual(state.sent[0].body, { base: "release/1" });
|
||||
assert.equal(state.base, "release/1");
|
||||
});
|
||||
|
||||
test("the two new tools are named for what mesh_search is asked, and gitea_api still refuses both settings", () => {
|
||||
assert.match(tool("gitea_default_branch_set").description, /default branch/i);
|
||||
assert.match(tool("gitea_pull_base_set").description, /pull request.s base/i);
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
|
||||
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
// 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head.
|
||||
const announced: Record<string, string> = {};
|
||||
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}`] = sha(n, "a");
|
||||
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}@main`] = sha(n, "a");
|
||||
const { client, server, asked } = await serve(25, { 3: sha(3, "b") });
|
||||
try {
|
||||
// The fault: the newest 20 alone do not hold it.
|
||||
@@ -58,7 +58,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
|
||||
const open = await client.listOpenPullsSince("novox", "hq", "");
|
||||
for (const p of headsToAnnounce("novox/hq", open, announced)) {
|
||||
said.push(`#${p.number}@${p.head_sha}`);
|
||||
announced[`novox/hq#${p.number}`] = String(p.head_sha);
|
||||
announced[`novox/hq#${p.number}@${p.base}`] = String(p.head_sha);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes");
|
||||
|
||||
@@ -8,16 +8,27 @@ import { createServer } from "node:http";
|
||||
test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
const pulls = [
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", base: "main", html_url: "" },
|
||||
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
|
||||
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
|
||||
{ number: 4, title: "d", state: "open", merged: false, html_url: "" },
|
||||
];
|
||||
const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
|
||||
const announced = { "novox/mesh-catalog#1@main": "aaa", "novox/mesh-catalog#2@": "old" };
|
||||
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
|
||||
"only a head not announced, of a pull request that is open");
|
||||
});
|
||||
|
||||
test("a pull request retargeted keeps its head and is announced again, into its new base (de-spiegel #6)", async () => {
|
||||
const { headsToAnnounce, headKey } = await import("../pulls.ts");
|
||||
const was = { number: 6, title: "a", state: "open", merged: false, head_sha: "aaa", base: "old-trunk", html_url: "" };
|
||||
const announced: Record<string, string> = { [headKey("novox/de-spiegel", 6, "old-trunk")]: "aaa" };
|
||||
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [was], announced), [], "the same head into the same base is announced once");
|
||||
const retargeted = { ...was, base: "main" };
|
||||
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [retargeted], announced).map((p) => p.number), [6],
|
||||
"the same head into a new base is judged afresh");
|
||||
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
|
||||
});
|
||||
|
||||
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
|
||||
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
|
||||
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
|
||||
|
||||
@@ -539,6 +539,7 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set",
|
||||
"gitea_default_branch_set", "gitea_pull_base_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
|
||||
@@ -488,6 +488,61 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ----
|
||||
{
|
||||
name: "gitea_default_branch_set",
|
||||
description: "Set a repository's default branch — the branch the forge shows, clones and opens pull requests against, and the one gitea_api never writes a file to. Refused when the branch does not exist, when no protection rule guards it (a default nothing protects would open it to direct writes), or when no reason (why) is given; acts as the tools' account, so the forge refuses it where that account is no administrator of the repository. Answers the default branch before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to make the default; it must exist and be guarded by a protection rule" },
|
||||
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
const why = String(args.why ?? "").trim();
|
||||
if (!branch) throw new Error("name the branch to make the default");
|
||||
if (!why) throw new Error("say why (why): a default branch is changed for a reason, which the call's record keeps");
|
||||
pathSegments("branch", branch);
|
||||
const state = await gitea.branchState(owner, repo, branch);
|
||||
if (!state) throw new Refused(`${owner}/${repo} has no branch ${branch}, so it cannot be the default branch. Nothing was sent`);
|
||||
if (!state.protected) {
|
||||
throw new Refused(`no protection rule guards ${branch} of ${owner}/${repo}: a default branch nothing protects takes direct writes ` +
|
||||
`(gitea_branch_protection_set guards it first). Nothing was sent`);
|
||||
}
|
||||
const before = String((await gitea.getRepo(owner, repo)).default_branch ?? "");
|
||||
if (before === branch) return { branch, before, after: before, changed: false, why };
|
||||
const after = String((await gitea.setDefaultBranch(owner, repo, branch)).default_branch ?? "");
|
||||
return { branch, before, after, changed: before !== after, why };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_pull_base_set",
|
||||
description: "Change an open pull request's base — the branch it merges into — keeping its head. The checks were judged against the base it had, so the pull request is announced again into the new one. Refused when the base branch does not exist, when the pull request is not open, or when no reason (why) is given; acts as the tools' account. Answers the pull request's base before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the pull request's number" },
|
||||
base: { type: "string", description: "the branch it should merge into; it must exist" },
|
||||
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
|
||||
const base = String(args.base ?? "").trim(), why = String(args.why ?? "").trim();
|
||||
if (!Number.isInteger(number) || number < 1) throw new Error(`${args.number} is not a pull request's number`);
|
||||
if (!base) throw new Error("name the base branch");
|
||||
if (!why) throw new Error("say why (why): a pull request's base is changed for a reason, which the call's record keeps");
|
||||
pathSegments("branch", base);
|
||||
if (!(await gitea.branchState(owner, repo, base))) throw new Refused(`${owner}/${repo} has no branch ${base}, so a pull request cannot merge into it. Nothing was sent`);
|
||||
const had = await gitea.getPullRequest(owner, repo, number);
|
||||
if (had.state !== "open" || had.merged) throw new Refused(`${owner}/${repo}#${number} is not open, so its base is not changed. Nothing was sent`);
|
||||
const before = String(had.base ?? "");
|
||||
if (before === base) return { number, before, after: before, changed: false, why };
|
||||
const after = String((await gitea.setPullBase(owner, repo, number, base)).base ?? "");
|
||||
return { number, before, after, changed: before !== after, why };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
|
||||
{
|
||||
name: "gitea_note_append",
|
||||
|
||||
@@ -411,6 +411,9 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
}
|
||||
}
|
||||
check := h.checkAt(members)
|
||||
if !h.checkGates() {
|
||||
check = unansweredWords(check) // asked with no verdict only past its bound (verdictWaits)
|
||||
}
|
||||
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now)
|
||||
taken := []*Delivery{}
|
||||
var reads []memberRead
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -27,3 +32,24 @@ func statusOn(w *world, repo, commit string) string {
|
||||
defer w.forge.mu.Unlock()
|
||||
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
|
||||
}
|
||||
|
||||
// checkerSays puts the checker's verdict at these heads together into its verdicts bucket, as captured on the bus
|
||||
// (mesh-delivery-checker, key `<group>.<heads hash>`).
|
||||
func checkerSays(w *world, group, state, failed string, heads ...sdk.Head) {
|
||||
checkerSaysWith(w, group, state, failed, nil, heads...)
|
||||
}
|
||||
|
||||
// checkerSaysWith is checkerSays with more of the verdict's fields (its composition); the state "gone" deletes the key.
|
||||
func checkerSaysWith(w *world, group, state, failed string, more map[string]any, heads ...sdk.Head) {
|
||||
hash, _ := sdk.HeadsHash(heads)
|
||||
if state == "gone" {
|
||||
w.h.CheckerVerdictChanged(group+"."+hash, nil)
|
||||
return
|
||||
}
|
||||
v := map[string]any{"v": 1, "group": group, "heads": hash, "state": state, "failed": map[string]string{"words": failed}}
|
||||
for k, x := range more {
|
||||
v[k] = x
|
||||
}
|
||||
value, _ := json.Marshal(v)
|
||||
w.h.CheckerVerdictChanged(group+"."+hash, value)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ type Holder struct {
|
||||
|
||||
// catchUp asks the next look to weigh again the newest merge of each repository against source.main (SourceCatchUp).
|
||||
catchUp bool
|
||||
// seeded are the repositories source.main was found to name, or was seeded with, since this owner started (seedTrunks).
|
||||
seeded map[string]bool
|
||||
|
||||
mu sync.Mutex
|
||||
deliveries map[string]*Delivery
|
||||
@@ -77,7 +79,10 @@ type Holder struct {
|
||||
unchecked map[string]unchecked
|
||||
// checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them
|
||||
// (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go).
|
||||
checker map[string]checkerVerdict
|
||||
checker map[string]checkerVerdict
|
||||
// verdictGone is when the checker's verdict at a heads hash was taken away (its key deleted): the Approve's wait for a
|
||||
// verdict counts its bound from then, as from a check that starts again (verdictWaits).
|
||||
verdictGone map[string]time.Time
|
||||
runningSince map[string]time.Time
|
||||
// unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275,
|
||||
// group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly.
|
||||
@@ -141,7 +146,7 @@ func (h *Holder) init() {
|
||||
h.lostRead = map[string]time.Time{}
|
||||
h.unchecked = map[string]unchecked{}
|
||||
h.checker = map[string]checkerVerdict{}
|
||||
h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{}
|
||||
h.runningSince, h.turnsHeld, h.verdictGone = map[string]time.Time{}, map[string]sdk.Turn{}, map[string]time.Time{}
|
||||
h.reach = map[string]sdk.Reach{}
|
||||
h.started = h.Now()
|
||||
}
|
||||
@@ -340,13 +345,15 @@ func (h *Holder) pullUpdated(p PullEvent) (isNew bool) {
|
||||
// Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its
|
||||
// check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound
|
||||
// runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it.
|
||||
if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA {
|
||||
d.BaseSHA = p.BaseSHA
|
||||
if p.BaseSHA != "" {
|
||||
d.BaseSHA, d.BaseSHAAt = p.BaseSHA, now
|
||||
h.keep(d)
|
||||
}
|
||||
// A delivery made from its verdict before its head was heard (issue 1726) holds none of what an announcement
|
||||
// carries: filled from this one, so its base is known and the operator is asked.
|
||||
h.fillFromAnnouncement(d, p)
|
||||
// A pull request retargeted to another base (review of issue 1422): what it merges into is the forge's word now.
|
||||
h.retarget(d, p)
|
||||
// The review record, corrected in the description without a push, is read again (ADR 0421 §5).
|
||||
if by, at := ReviewIn(p.Body); p.Body != "" && (by != d.ReviewedBy || at != d.ReviewedAt) {
|
||||
d.ReviewedBy, d.ReviewedAt = by, at
|
||||
@@ -436,7 +443,7 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
|
||||
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
|
||||
d.PushedBy = PushedIn(p.Body)
|
||||
if p.BaseSHA != "" {
|
||||
d.BaseSHA = p.BaseSHA
|
||||
d.BaseSHA, d.BaseSHAAt = p.BaseSHA, h.Now()
|
||||
}
|
||||
d.Number = p.Number
|
||||
h.Logf("[mesh-delivery] %s: its pull request's announcement filled its base (%s), branch and title", d.ID, p.Base)
|
||||
@@ -448,6 +455,27 @@ func (h *Holder) fillFromAnnouncement(d *Delivery, p PullEvent) {
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// retarget takes the base a live, unmerged delivery's pull request was announced again with when it differs from the one
|
||||
// kept: into main it is asked and merged as any other, and source.main may be seeded from it (seedTrunks); out of main an
|
||||
// ask nobody answered yet is taken back, and the next look says it is not asked (ready.go). A delivery whose base is not yet
|
||||
// known is fillFromAnnouncement's. Said in the log. Under the lock.
|
||||
func (h *Holder) retarget(d *Delivery, p PullEvent) {
|
||||
if p.Base == "" || d.Base == "" || p.Base == d.Base || d.State.Final() || d.MergedAs != "" ||
|
||||
(d.Ready != nil && d.Ready.Merged != nil) {
|
||||
return
|
||||
}
|
||||
from := d.Base
|
||||
d.Base = p.Base
|
||||
if r := d.Ready; r != nil {
|
||||
r.OtherBase = ""
|
||||
if r.AskID != "" && r.Approved == nil {
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, fmt.Sprintf("%s was retargeted from %s to %s", d.ID, from, p.Base)))
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: its pull request was retargeted from %s to %s", d.ID, from, p.Base)
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// PullEdited takes a pull request's edit (novox/hq issue 1079): its description is read again for the review record, the
|
||||
// `after:` lines and the issues it names, exactly as an announcement reads them. An Approve ask still unanswered whose words
|
||||
// the new record changes is withdrawn and asked again with it read, without a word on the pull request: nothing was
|
||||
@@ -555,6 +583,9 @@ func (h *Holder) freshAnnouncement(p PullEvent, now time.Time, startedOver strin
|
||||
Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
|
||||
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
||||
After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, BaseSHA: p.BaseSHA}
|
||||
if p.BaseSHA != "" {
|
||||
d.BaseSHAAt = now
|
||||
}
|
||||
d.ReviewedBy, d.ReviewedAt = ReviewIn(p.Body)
|
||||
d.PushedBy = PushedIn(p.Body)
|
||||
why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,86 @@ func TestStartingWritesTheNewestMergeOfEachRepositoryNotYetInSourceMain(t *testi
|
||||
t.Fatalf("a second catch-up moved source.main again: %d moves, then %d", n, len(w.source.moves))
|
||||
}
|
||||
|
||||
// Issue 1422: a repository never merged through the gate gets main's head as the forge announced its open pull request
|
||||
// with, once, beside the others; a pull request into another base, or one announced with no base head (the forge could
|
||||
// not read it), seeds nothing; a head source.main holds is never replaced.
|
||||
const never, trunkN, trunkM = "novox/never-merged", "eeee000000000000000000000000000000000001", "eeee000000000000000000000000000000000002"
|
||||
s := newWorld(t)
|
||||
s.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{i1726Repo: i1726Merge1}}
|
||||
other := pr("novox/other-base", 6, "f1f1000000000000000000000000000000000001", "fix/c", "a.go")
|
||||
other.Base, other.BaseSHA = "nox-mesh", trunkM
|
||||
s.h.PullUpdated(other)
|
||||
unread := pr("novox/unread", 2, "f2f2000000000000000000000000000000000002", "fix/d", "a.go")
|
||||
s.h.PullUpdated(unread)
|
||||
seed := pr(never, 4, "f3f3000000000000000000000000000000000003", "fix/e", "a.go")
|
||||
seed.BaseSHA = trunkN
|
||||
s.h.PullUpdated(seed)
|
||||
s.settleAll()
|
||||
if h := s.source.main.Heads; h[never] != trunkN || h[i1726Repo] != i1726Merge1 || h["novox/other-base"] != "" || h["novox/unread"] != "" || len(s.source.moves) != 1 {
|
||||
t.Fatalf("source.main is %v after %d moves; wanted %s seeded at %s, the catalogue kept and nothing else, in one move", h, len(s.source.moves), never, trunkN)
|
||||
}
|
||||
if !strings.Contains(s.logs(), "seeded with "+trunkN[:12]) {
|
||||
t.Errorf("the seed was not said: %s", s.logs())
|
||||
}
|
||||
again := pr(never, 5, "f4f4000000000000000000000000000000000004", "fix/f", "a.go")
|
||||
again.BaseSHA = trunkM
|
||||
s.h.PullUpdated(again)
|
||||
s.h.SourceCatchUp()
|
||||
s.settleAll()
|
||||
if got := s.source.main.Heads[never]; got != trunkN || len(s.source.moves) != 1 {
|
||||
t.Fatalf("a seeded head was replaced by %s (%d moves)", got, len(s.source.moves))
|
||||
}
|
||||
|
||||
// The freshest announcement's base head is the seed, not the newest delivery's: #4, made first, is announced again after #5
|
||||
// with main's head moved on.
|
||||
const trunkF = "eeee000000000000000000000000000000000003"
|
||||
f := newWorld(t)
|
||||
first := pr(never, 4, "f5f5000000000000000000000000000000000005", "fix/g", "a.go")
|
||||
first.BaseSHA = trunkN
|
||||
f.h.PullUpdated(first)
|
||||
f.later(time.Minute)
|
||||
second := pr(never, 5, "f6f6000000000000000000000000000000000006", "fix/h", "a.go")
|
||||
second.BaseSHA = trunkM
|
||||
f.h.PullUpdated(second)
|
||||
f.later(time.Minute)
|
||||
first.BaseSHA = trunkF
|
||||
f.h.PullUpdated(first)
|
||||
f.settleAll()
|
||||
if got := f.source.main.Heads[never]; got != trunkF {
|
||||
t.Fatalf("seeded with %s; wanted %s, the base head of the latest announcement", got, trunkF)
|
||||
}
|
||||
|
||||
// A green pull request retargeted from nox-mesh to main is seeded and asked; one retargeted from main to nox-mesh has
|
||||
// its ask taken back and is not merged (review round 2).
|
||||
retarget := func(from, to string) (*world, int) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr(never, 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Base, p.BaseSHA = from, trunkN
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt(never, 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
open := openPull(never, 7, readyHead)
|
||||
open.Base = from
|
||||
w.forge.open = []OpenPull{open}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
before := len(w.op.open())
|
||||
p.Base, open.Base = to, to
|
||||
w.forge.open = []OpenPull{open}
|
||||
w.h.PullUpdated(p)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
return w, before
|
||||
}
|
||||
if r, before := retarget("nox-mesh", "main"); before != 0 || r.source.main == nil || r.source.main.Heads[never] != trunkN || len(r.op.open()) != 1 {
|
||||
t.Fatalf("retargeted to main: %d asks before, source.main %+v, open asks %v; wanted none before, then seeded at %s and asked", before, r.source.main, r.op.open(), trunkN)
|
||||
}
|
||||
if r, before := retarget("main", "nox-mesh"); before != 1 || len(r.op.open()) != 0 || len(r.forge.merges) != 0 || !strings.Contains(r.logs(), "retargeted") {
|
||||
t.Fatalf("retargeted to nox-mesh: %d asks before, open asks %v, merges %v; wanted one before, then none and the retarget said", before, r.op.open(), r.forge.merges)
|
||||
}
|
||||
|
||||
o := newWorld(t)
|
||||
o.source.main = &sdk.Source{V: sdk.Version, Group: "newer", Heads: map[string]string{i1726Repo: i1726Merge2}}
|
||||
o.h.PullUpdated(pr(i1726Repo, 471, head2, "fix/b", "modules/app/b.go"))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -524,6 +525,75 @@ func (h *Holder) settleForgeMerges() []func() {
|
||||
return out
|
||||
}
|
||||
|
||||
// trunkHead is a full commit id as the forge names a branch's head.
|
||||
var trunkHead = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// seedTrunks gives source.main the trunk head of a repository it names none of (issue 1422): one never merged through the
|
||||
// gate, whose open pull requests the checker could otherwise never judge. The head is the base_sha of the latest announcement of
|
||||
// any open pull request into main (gitea.pull.updated; ADR 0323; lastAnnounced), the trunk's freshest head the gate heard. It is
|
||||
// added in one compare-and-set write that never replaces a head source.main holds, so a merge always wins; the first merge
|
||||
// through the gate moves it as any other. source.main is per repository and names main's head only (ADR 0484 §11, MergeBranch):
|
||||
// a pull request into another base seeds nothing. A repository is weighed once per start after it was found or seeded.
|
||||
// Under the lock; the writing happens outside it.
|
||||
func (h *Holder) seedTrunks() []func() {
|
||||
newest := map[string]*Delivery{}
|
||||
for _, d := range h.deliveries {
|
||||
if h.seeded[d.Repository] || d.Base != MergeBranch || !trunkHead.MatchString(d.BaseSHA) || d.State.Final() || d.MergedAs != "" {
|
||||
continue
|
||||
}
|
||||
if o := newest[d.Repository]; o == nil || lastAnnounced(d).After(lastAnnounced(o)) {
|
||||
newest[d.Repository] = d
|
||||
}
|
||||
}
|
||||
var out []func()
|
||||
for repository, d := range newest {
|
||||
id, head, group := d.ID, d.BaseSHA, firstOf(d.Branch, d.Repository)
|
||||
out = append(out, func() {
|
||||
seeded := false
|
||||
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
|
||||
seeded = false
|
||||
next := sdk.Source{Group: group, Heads: map[string]string{}}
|
||||
if prev != nil {
|
||||
if prev.Heads[repository] != "" {
|
||||
return *prev, false // named already: never replaced
|
||||
}
|
||||
next.Group = prev.Group
|
||||
for r, held := range prev.Heads {
|
||||
next.Heads[r] = held
|
||||
}
|
||||
}
|
||||
next.Heads[repository] = head
|
||||
seeded = true
|
||||
return next, true
|
||||
})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s: source.main names no trunk head of %s, and it cannot be seeded yet: %v; asked again in %s", id, repository, err, askAgainAfter)
|
||||
h.again("source.main, seeded with the trunk head of a repository it names none of")
|
||||
return
|
||||
}
|
||||
if h.seeded == nil {
|
||||
h.seeded = map[string]bool{}
|
||||
}
|
||||
h.seeded[repository] = true
|
||||
if seeded {
|
||||
h.Logf("[mesh-delivery] source.main named no trunk head of %s: seeded with %s, main's head as the forge announced %s with, so its pull requests are judged (issue 1422)", repository, shortOf(head, 12), id)
|
||||
}
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lastAnnounced is when the forge last announced a delivery's head with its base head: BaseSHAAt, or when the gate made it
|
||||
// for one kept before that was written.
|
||||
func lastAnnounced(d *Delivery) time.Time {
|
||||
if !d.BaseSHAAt.IsZero() {
|
||||
return d.BaseSHAAt
|
||||
}
|
||||
return d.Created
|
||||
}
|
||||
|
||||
// replayLate is how long after its merge a merge heard only from the forge's word is a replay: the forge announces a merge
|
||||
// within seconds, so one heard this late was replayed from the bus's history, and the source already holds what came after.
|
||||
const replayLate = 15 * time.Minute
|
||||
|
||||
@@ -66,6 +66,9 @@ type Delivery struct {
|
||||
// BaseSHA is the base branch's head the forge last announced its head with (ADR 0323): sent with a check this
|
||||
// owner asks, for a verdict no build judges to name its trunk commit.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
// BaseSHAAt is when the forge last announced its head with BaseSHA: the freshest of a repository's announcements gives
|
||||
// source.main its trunk head when it names none (seedTrunks, issue 1422). Zero for one kept before it was written.
|
||||
BaseSHAAt time.Time `json:"base_sha_at,omitzero"`
|
||||
// AskedAgain is when its head was announced again while its verdict was out of date — the merge tool's ask of
|
||||
// its check against the trunk's head (ADR 0323 §5) — and no verdict has come since: its one-hour bound runs from
|
||||
// then. Nil while nothing is asked, and cleared by a verdict or another move of its trunk.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -57,6 +58,12 @@ type checkerVerdict struct {
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
} `json:"failed,omitzero"`
|
||||
// Composition is what the check of the machines' composition made of it (mesh-sdk delivery.Composition): its state
|
||||
// not-run says the composition, and with it a placement change's answers from its nodes, was not judged.
|
||||
Composition *struct {
|
||||
State string `json:"state"`
|
||||
Words string `json:"words,omitempty"`
|
||||
} `json:"composition,omitempty"`
|
||||
}
|
||||
|
||||
// CheckerVerdictChanged takes a key of the checker's verdicts bucket (`<group>.<heads hash>`) put or deleted (an empty
|
||||
@@ -75,9 +82,13 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
||||
if h.runningSince == nil {
|
||||
h.runningSince = map[string]time.Time{}
|
||||
}
|
||||
if h.verdictGone == nil {
|
||||
h.verdictGone = map[string]time.Time{}
|
||||
}
|
||||
if len(value) == 0 || string(value) == "null" {
|
||||
delete(h.checker, hash)
|
||||
delete(h.runningSince, hash)
|
||||
h.verdictGone[hash] = h.Now()
|
||||
h.Poke()
|
||||
return
|
||||
}
|
||||
@@ -93,6 +104,7 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
||||
h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2)
|
||||
} else if v.State != checkerRunning {
|
||||
delete(h.runningSince, v.Heads)
|
||||
delete(h.verdictGone, v.Heads)
|
||||
}
|
||||
h.checker[v.Heads] = v
|
||||
h.Poke()
|
||||
@@ -329,11 +341,11 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
||||
if ok {
|
||||
switch v.State {
|
||||
case checkerPass:
|
||||
return checkSaid{Key: checkSaidPass, Words: "passed its check"}
|
||||
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v, members)}
|
||||
case checkerRunning:
|
||||
words := "its check has no verdict yet: it is running"
|
||||
if since, held := h.runningSince[hash]; held && !since.IsZero() {
|
||||
words += " (since " + since.UTC().Format("15:04") + " UTC)"
|
||||
words += " (since " + clockSaid(since) + ")"
|
||||
}
|
||||
return checkSaid{Key: checkSaidRunning, Words: words}
|
||||
default:
|
||||
@@ -371,3 +383,31 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
||||
}
|
||||
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
|
||||
}
|
||||
|
||||
// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun): a
|
||||
// placement change's pass then holds no node's answer to its placement (ADR 0559 §5), which the ask says. Empty otherwise.
|
||||
func notComposed(v checkerVerdict, members []*Delivery) string {
|
||||
if v.Composition == nil || v.Composition.State != sdk.CompositionNotRun {
|
||||
return ""
|
||||
}
|
||||
why := firstOf(oneLine(v.Composition.Words), "it was not run")
|
||||
for _, m := range members {
|
||||
for _, p := range m.Paths {
|
||||
if strings.HasPrefix(p, "placement/") && strings.HasSuffix(p, ".json") {
|
||||
return ", but its composition was not judged (" + why + "), so no node answered its placement"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ", but its composition was not judged (" + why + ")"
|
||||
}
|
||||
|
||||
// operatorZone is the zone the operator reads times in, as the messenger says them (its time-zone setting).
|
||||
var operatorZone = func() *time.Location {
|
||||
if loc, err := time.LoadLocation("Europe/Brussels"); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.UTC
|
||||
}()
|
||||
|
||||
// clockSaid is a time of day as the operator reads it: in their zone, named ("00:35 CET").
|
||||
func clockSaid(t time.Time) string { return t.In(operatorZone).Format("15:04 MST") }
|
||||
|
||||
@@ -294,6 +294,7 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3)
|
||||
asks = append(asks, h.settleMerges()...)
|
||||
asks = append(asks, h.settleForgeMerges()...)
|
||||
asks = append(asks, h.seedTrunks()...)
|
||||
asks = append(asks, h.withdrawnKeys()...)
|
||||
asks = append(h.cancels, asks...)
|
||||
h.cancels = nil
|
||||
@@ -446,9 +447,75 @@ func (h *Holder) ungatedWaits(d *Delivery) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.covering(d) == nil {
|
||||
return h.verdictWaits(d)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// askCheckBound is how long an Approve waits for the check's verdict at its heads while the check gates no merge: past it,
|
||||
// the operator is asked anyway, and the ask says the check has not answered. The checker's own step bound is 30 minutes
|
||||
// and a check runs several steps; an hour covers a whole check.
|
||||
const askCheckBound = time.Hour
|
||||
|
||||
// waitsCheckBeforeAsking is how the wait for the check's verdict begins, as `show` and the delivery's wait say it.
|
||||
const waitsCheckBeforeAsking = "waits its check before asking the operator's Approve: "
|
||||
|
||||
// verdictWaits is why an Approve is not asked yet while the check gates no merge: the check at the heads the ask would cover
|
||||
// has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request").
|
||||
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and so is a check that has not
|
||||
// answered within askCheckBound of the heads becoming ready, which the ask says. For a group the check is the one of all its
|
||||
// heads together; for a placement change it ends only once every node it places answered (ADR 0559 §5). Under the lock.
|
||||
func (h *Holder) verdictWaits(d *Delivery) string {
|
||||
members := h.askMembers(d)
|
||||
c := h.checkAt(members)
|
||||
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||
return ""
|
||||
}
|
||||
until := h.waitSince(d, members).Add(askCheckBound)
|
||||
if !h.Now().Before(until) {
|
||||
return ""
|
||||
}
|
||||
h.lookAt(until, "an Approve that waits its check, asked anyway at its bound")
|
||||
return waitsCheckBeforeAsking + c.Words + "; asked anyway at " + clockSaid(until) + " if it has not answered"
|
||||
}
|
||||
|
||||
// waitSince is when the wait for a verdict began, the moment the bound of verdictWaits counts from: the latest of the
|
||||
// heads becoming ready (a member with no ready run yet, such as one re-pushed or settled ready by an event this look has
|
||||
// not reached, became ready now), the check at those heads starting again, and its verdict being taken away. Under the lock.
|
||||
func (h *Holder) waitSince(d *Delivery, members []*Delivery) time.Time {
|
||||
now := h.Now()
|
||||
since := time.Time{}
|
||||
later := func(t time.Time) {
|
||||
if t.After(since) {
|
||||
since = t
|
||||
}
|
||||
}
|
||||
for _, m := range append([]*Delivery{d}, members...) {
|
||||
if m.Ready == nil {
|
||||
return now
|
||||
}
|
||||
later(m.Ready.Seen)
|
||||
}
|
||||
if hash, err := sdk.HeadsHash(headsOf(members)); err == nil {
|
||||
later(h.runningSince[hash])
|
||||
later(h.verdictGone[hash])
|
||||
}
|
||||
if since.IsZero() || since.After(now) {
|
||||
return now
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
// unansweredWords is the check's words in an ask made past askCheckBound with no verdict: it says the check has not answered.
|
||||
func unansweredWords(c checkSaid) checkSaid {
|
||||
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||
return c
|
||||
}
|
||||
c.Words += ", and has given none in " + strconv.Itoa(int(askCheckBound.Minutes())) + " minutes, so the operator is asked anyway"
|
||||
return c
|
||||
}
|
||||
|
||||
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
||||
const waitsForReview = "waits for a review at "
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -70,27 +69,42 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
|
||||
// its check" only when it passed. While the check gates no merge, the ask is made before any verdict, says that Approve
|
||||
// merges before the check ends, and is asked again in new words once the check passes or fails; a running check is no
|
||||
// new word.
|
||||
// its check" only when it passed. While the check gates no merge, the ask waits for the check's verdict (the operator,
|
||||
// 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request"): a pass or a failure is asked,
|
||||
// the failure in its words; with no verdict nothing is asked and the delivery says it waits its check, until the bound,
|
||||
// past which it is asked anyway, saying so, and asked again in new words once the check passes. The verdict is the
|
||||
// checker's verdicts bucket value as captured on the bus (mesh-delivery-checker, `<group>.<heads hash>`).
|
||||
for _, row := range []struct {
|
||||
name, state, failed string
|
||||
gated bool
|
||||
asks int
|
||||
want, not []string
|
||||
name, state, failed, then, path string
|
||||
gated bool
|
||||
notComposed bool
|
||||
after time.Duration
|
||||
asks, open int
|
||||
want, not []string
|
||||
}{
|
||||
{name: "gated, passed", gated: true, state: "pass", asks: 1,
|
||||
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
|
||||
not: []string{"before its check ends", "no verdict yet"}},
|
||||
{name: "ungated, no verdict", asks: 1,
|
||||
want: []string{"its check has no verdict yet", "Approve merges it into main at " + readyHead[:12] + " now, before its check ends"},
|
||||
not: []string{"passed"}},
|
||||
{name: "ungated, running", state: "running", asks: 1,
|
||||
want: []string{"its check has no verdict yet", "before its check ends"}, not: []string{"passed"}},
|
||||
{name: "ungated, then passed", state: "pass", asks: 2,
|
||||
{name: "ungated, no verdict", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 15:00 CEST if"}},
|
||||
{name: "ungated, running", state: "running", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running"}},
|
||||
{name: "ungated, running past its bound", state: "running", after: askCheckBound, asks: 1,
|
||||
want: []string{"its check has no verdict yet: it is running", "has given none in 60 minutes, so the operator is asked anyway",
|
||||
"Approve merges it into main at " + readyHead[:12] + " now, before its check ends"}, not: []string{"passed"}},
|
||||
{name: "ungated, past its bound, then passed", state: "running", after: askCheckBound, then: "pass", asks: 2,
|
||||
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends", "anyway"}},
|
||||
{name: "ungated, passed", state: "pass", asks: 1,
|
||||
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}},
|
||||
{name: "ungated, then failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 2,
|
||||
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed"}},
|
||||
// A pass that turns back into a check with no verdict (a check started again, its verdict taken away) past the bound
|
||||
// withdraws the ask, and the bound counts again from that moment: nothing is asked at once as unanswered.
|
||||
{name: "ungated, passed, running again past its bound", state: "pass", after: askCheckBound, then: "running", asks: 1, open: -1,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running (since 15:00 CEST); asked anyway at 16:00 CEST"}},
|
||||
{name: "ungated, passed, its verdict gone past its bound", state: "pass", after: askCheckBound, then: "gone", asks: 1, open: -1,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 16:00 CEST"}},
|
||||
// A placement change's pass with its composition not judged holds no node's answer, and the ask says so (ADR 0559 §5).
|
||||
{name: "ungated, placement passed uncomposed", state: "pass", path: "placement/node-a.json", notComposed: true, asks: 1,
|
||||
want: []string{"passed its check, but its composition was not judged (the assigner is not running), so no node answered its placement"}},
|
||||
{name: "ungated, failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 1,
|
||||
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed", "anyway"}},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
v := newWorld(t)
|
||||
@@ -98,26 +112,46 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
ungated(v)
|
||||
}
|
||||
v.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", firstOf(row.path, "src/a.go"))
|
||||
p.Title = "Fix it"
|
||||
v.h.PullUpdated(p)
|
||||
put := func() {
|
||||
hash, _ := sdk.HeadsHash([]sdk.Head{headOf("novox/app", 7, readyHead)})
|
||||
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "state": row.state,
|
||||
"failed": map[string]string{"words": row.failed}})
|
||||
v.h.CheckerVerdictChanged("g."+hash, value)
|
||||
}
|
||||
head := headOf("novox/app", 7, readyHead)
|
||||
if row.gated {
|
||||
v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
}
|
||||
v.settleAll()
|
||||
if !row.gated && row.state != "" {
|
||||
put()
|
||||
var more map[string]any
|
||||
if row.notComposed {
|
||||
more = map[string]any{"composition": map[string]string{"state": "not-run", "words": "the assigner is not running"}}
|
||||
}
|
||||
checkerSaysWith(v, "g", row.state, row.failed, more, head)
|
||||
v.settleAll()
|
||||
}
|
||||
if row.after > 0 {
|
||||
v.later(row.after)
|
||||
v.settleAll()
|
||||
}
|
||||
if row.then != "" {
|
||||
checkerSays(v, "g", row.then, row.failed, head)
|
||||
v.settleAll()
|
||||
}
|
||||
open := v.op.open()
|
||||
if len(open) != 1 || v.op.count() != row.asks {
|
||||
t.Fatalf("open asks %v, asked %d times; wanted one open, asked %d times", open, v.op.count(), row.asks)
|
||||
wantOpen := min(row.asks, 1)
|
||||
if row.open < 0 {
|
||||
wantOpen = 0
|
||||
}
|
||||
if v.op.count() != row.asks || len(open) != wantOpen {
|
||||
t.Fatalf("open asks %v, asked %d times; wanted asked %d times, %d open", open, v.op.count(), row.asks, wantOpen)
|
||||
}
|
||||
if wantOpen == 0 {
|
||||
d := v.delivery(IDOf("novox/app", readyHead))
|
||||
for _, w := range row.want {
|
||||
if d.Ready == nil || !strings.Contains(d.Ready.Waits, w) {
|
||||
t.Fatalf("the delivery's wait does not say %q: %+v", w, d.Ready)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
e := v.op.ask(open[0]).Explanation
|
||||
for _, w := range row.want {
|
||||
@@ -146,8 +180,8 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go"))
|
||||
u.settleAll()
|
||||
if u.op.count() != 2 || u.state(IDOf("novox/app", readyHead)) != Ready {
|
||||
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s", u.op.count(), u.state(IDOf("novox/app", readyHead)))
|
||||
if u.op.count() != 0 || u.state(IDOf("novox/app", readyHead)) != Ready {
|
||||
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s; wanted ready and not asked", u.op.count(), u.state(IDOf("novox/app", readyHead)))
|
||||
}
|
||||
if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates {
|
||||
t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening"))
|
||||
@@ -157,8 +191,12 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
t.Fatalf("show does not say the check gates no merge: %v", shown)
|
||||
}
|
||||
u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1"))
|
||||
checkerSays(u, "feat/y", "fail", "TestB", headOf("novox/app", 8, other))
|
||||
u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
u.settleAll()
|
||||
if u.op.count() != 1 {
|
||||
t.Fatalf("ungated, a failed verdict is asked once (the check gates no merge): %d asks", u.op.count())
|
||||
}
|
||||
d = u.delivery(IDOf("novox/app", other))
|
||||
if d.State != Ready || d.Check == nil || d.Check.Passes() {
|
||||
t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check)
|
||||
@@ -323,14 +361,44 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
||||
u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
u.settleAll()
|
||||
group := []sdk.Head{headOf("novox/mesh-controller", 1, ctlHead), headOf("novox/mesh-catalog", 2, catHead)}
|
||||
checkerSays(u, "feat/x", "running", "", group...)
|
||||
u.settleAll()
|
||||
// The group's check is the one of its heads together: while it runs, nothing is asked and each member says it waits.
|
||||
if d := u.delivery(IDOf("novox/mesh-catalog", catHead)); u.op.count() != 0 || d.Ready == nil ||
|
||||
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking+"its check has no verdict yet: it is running") {
|
||||
t.Fatalf("ungated, the group is asked %d times while its check runs; wanted none, and the wait said: %+v", u.op.count(), d.Ready)
|
||||
}
|
||||
checkerSays(u, "feat/x", "pass", "", group...)
|
||||
u.settleAll()
|
||||
if u.op.count() != 1 {
|
||||
t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count())
|
||||
t.Fatalf("ungated, the group is asked %d times once its check passed; wanted once", u.op.count())
|
||||
}
|
||||
// 1733: the group's ask claims no pass it has not had.
|
||||
if e := u.op.ask(u.op.open()[0]).Explanation; strings.Contains(e, "passed") ||
|
||||
!strings.Contains(e, "2 pull requests: its check has no verdict yet. Approve merges all of them into main in one pass, at the heads named, now, before its check ends;") {
|
||||
t.Fatalf("ungated, the group's ask does not say its check has no verdict yet:\n%s", e)
|
||||
if e := u.op.ask(u.op.open()[0]).Explanation; !strings.Contains(e, "2 pull requests, passed its check. Approve merges all of them into main in one pass, at the heads named;") {
|
||||
t.Fatalf("ungated, the group's ask does not say its check passed:\n%s", e)
|
||||
}
|
||||
// A member re-pushed past the bound after its sibling became ready: the bound counts from the new head, so nothing is
|
||||
// asked as unanswered at once (review round 1).
|
||||
r := newWorld(t)
|
||||
ungated(r)
|
||||
r.op.auto = false
|
||||
const ctlHead2 = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"
|
||||
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
r.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
r.settleAll()
|
||||
r.later(askCheckBound + time.Minute)
|
||||
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead2, "feat/x", "internal/a.go"))
|
||||
// Made ready by a handler, before the look gives it a run: no verdict and no status at the new heads, so only the rule
|
||||
// that a member with no run counts as ready now keeps the older sibling's time from asking it as unanswered.
|
||||
r.h.mu.Lock()
|
||||
r.h.ungate(r.h.deliveries[IDOf("novox/mesh-controller", ctlHead2)], r.h.Now())
|
||||
r.h.mu.Unlock()
|
||||
r.settleAll()
|
||||
if d := r.delivery(IDOf("novox/mesh-catalog", catHead)); r.op.count() != 0 || d.Ready == nil ||
|
||||
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking) {
|
||||
t.Fatalf("a member re-pushed past the bound: asked %d times, wait %+v; wanted none asked and the wait said", r.op.count(), d.Ready)
|
||||
}
|
||||
|
||||
u.op.answer(u.op.open()[0], OptionApprove)
|
||||
u.settleAll()
|
||||
if len(u.forge.merges) != 2 {
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"check-gates-merge": {
|
||||
"type": "boolean",
|
||||
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready and its Approve asked at once, with no verdict waited on; no merge is refused for a check judged on an older trunk; a verdict that comes is shown on the pull request and the delivery, never waited on; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
|
||||
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready, and its Approve is asked once the check at its heads (a group: of all its heads together) has a verdict, a pass or a failure said in the ask, or after 60 minutes with none, said so (the operator, 2026-10-27 23:54: \"if you don't want me to approve it, don't send the approval request\"); an approved merge waits for no verdict; no merge is refused for a check judged on an older trunk; a later verdict is shown on the pull request and the delivery, and asks a pending Approve again in its words; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
|
||||
"trusted": false,
|
||||
"default": true
|
||||
}
|
||||
|
||||
@@ -206,10 +206,29 @@ account (and, until R1, the controller) and no other caller, and only sealed to
|
||||
answers the controller alone today; it must also answer `<node>.mesh-vault` when it moves with the media catalogue,
|
||||
after release 57.
|
||||
|
||||
## Taking what runs: `move-in` with `replace_running` (issue 1126, cause C4)
|
||||
|
||||
`move-in` never replaces a value the vault holds, so where the controller made an own secret anew after the move (a
|
||||
rotation at start, a re-issue), the vault kept the first value while the new one runs. The node-engine of the machine
|
||||
the secret is for hands the vault what runs, through `move-in` with `replace_running: true`, in an intake's two steps
|
||||
(`owntake.go`): module, name and why, then the value sealed to the call's key. It is `move-in`'s and no verb of its
|
||||
own, so the node-engine asks it on the subject it may publish already and the seat promises nothing new. Its own machine's secrets alone (the machine is the caller's, as the bus proved it); a
|
||||
value the vault holds, never a person's (`accept`) or a provider's software's (`issued`, but for a covered bus
|
||||
account); never over a value the vault changed in the last 15 minutes. The same value changes nothing and says so.
|
||||
The replaced value is kept sealed as the record's `previous` (dropped by any later change), the generation is raised,
|
||||
`secret-changed` and `own-replaced` are said, and the act is in `secret_handed` (`own-replaced`, `own-same`,
|
||||
`own-take-refused`). A covered bus account so replaced has its hash written again. `own_drift` lists, from every
|
||||
node's latest shadow answer (`<node>.node` in `node-engine.have`), each own secret whose node says another value,
|
||||
with what the vault holds and whether the node's ask would be taken now. **The switch** is the vault's: a person
|
||||
switches every machine's asks off or on with `own_take_switch` (`on`, `why`), kept in the vault's store over a
|
||||
restart; it is on until switched off, and `own_drift` shows it.
|
||||
|
||||
## Events
|
||||
|
||||
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
|
||||
or a sealed copy.
|
||||
- `own-replaced` — `secret`, `node`, `module`, `name`, `from_generation`, `to_generation`, `by`, `why`, `at`:
|
||||
an own secret replaced by what runs on its machine. Never a value or a fingerprint.
|
||||
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
|
||||
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
|
||||
`reenabled`, `deleted`, naming holders, never a value.
|
||||
|
||||
@@ -91,7 +91,9 @@ type callKey struct {
|
||||
// ask is the ask's record for `secret-ask`, which the call's id is.
|
||||
ask *AskRecord
|
||||
// move is what `move-in` takes: the row of the controller's store (move.go).
|
||||
move *MoveOf
|
||||
move *MoveOf
|
||||
// why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go).
|
||||
why string
|
||||
public [32]byte
|
||||
private [32]byte
|
||||
until time.Time
|
||||
@@ -149,6 +151,16 @@ func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, strin
|
||||
return id, base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||
}
|
||||
|
||||
// callKind is the kind of the open call id, or "" when none is open: what its second step is for.
|
||||
func (v *Vault) callKind(id string) string {
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
if c, ok := v.pending[id]; ok {
|
||||
return c.verb
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// takeCall ends a call: its key is forgotten whatever follows.
|
||||
func (v *Vault) takeCall(verb, id string) (*callKey, error) {
|
||||
v.callsMu.Lock()
|
||||
|
||||
@@ -51,7 +51,8 @@ func build() *Env {
|
||||
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
|
||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
|
||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave}
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave,
|
||||
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }}
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
@@ -319,3 +321,139 @@ func TestMoveInThroughTheTool(t *testing.T) {
|
||||
t.Fatalf("asks through the tool: %+v, %v", moved, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126, cause C4: a machine's node-engine hands the vault what runs of its own machine's own secret, where the
|
||||
// vault holds another. Refused for another machine's secret, one that is not an own secret or not held, a person's or a
|
||||
// provider's software's value, an empty value, and one the vault changed in the last 15 minutes; the same value changes
|
||||
// nothing. A replacement keeps the old value as the previous generation, raises the generation, and says own-replaced
|
||||
// with both generations and never a value; own_drift lists it from the node's shadow answer.
|
||||
func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
tick, later := v.Now, time.Duration(0)
|
||||
v.Now = func() time.Time { return tick().Add(later) }
|
||||
old, running := "the-value-moved-in-first", "the-value-the-controller-made-at-start"
|
||||
if _, err := moveIn(t, v, "node.anchor", superuser(), old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, origin := range []string{"accepted", "issued"} {
|
||||
m := superuser()
|
||||
m.Name, m.Origin = "from-"+origin, origin
|
||||
if _, err := moveIn(t, v, "node.anchor", m, "value-"+origin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
later = time.Hour
|
||||
// Through move-in's tool as the node-engine asks it: replace_running in the first step, the second known by its call.
|
||||
moveInTool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-in")
|
||||
take := func(caller string, of OwnTakeOf, value string) (OwnReplaced, error) {
|
||||
got, err := moveInTool.runAs(caller, map[string]any{ReplaceRunningArg: true, "node": of.Node, "module": of.Module,
|
||||
"name": of.Name, "secret": of.Secret, "why": of.Why})
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
opened := got.(Opened)
|
||||
got, err = moveInTool.runAs(caller, map[string]any{"call": opened.Call, "sealed": sealToCall(t, opened.SealTo, value)})
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
return got.(OwnReplaced), nil
|
||||
}
|
||||
pg := OwnTakeOf{Module: "postgres", Name: "superuser-password", Why: "the shadow answer says another value"}
|
||||
for name, row := range map[string]struct {
|
||||
caller string
|
||||
of OwnTakeOf
|
||||
value string
|
||||
says string
|
||||
}{
|
||||
"another machine's, named": {"node.shanks", OwnTakeOf{Node: "anchor", Module: "postgres", Name: "superuser-password"}, running, "anchor's"},
|
||||
"another machine's, whole name": {"node.shanks", OwnTakeOf{Secret: "own/anchor/postgres/superuser-password"}, running, "anchor's"},
|
||||
"not a node-engine": {ControllerCaller, pg, running, "not a node-engine"},
|
||||
"a module's tools": {"anchor.node-tools", pg, running, "not a node-engine"},
|
||||
"not an own secret": {"node.anchor", OwnTakeOf{Secret: "pair/anchor/database/shanks/gitea"}, running, "not a module's own secret"},
|
||||
"not held": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "never-made"}, running, "nothing to replace"},
|
||||
"a person's value": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "from-accepted"}, running, "by a person"},
|
||||
"a provider's software's value": {"node.anchor", OwnTakeOf{Module: "postgres", Name: "from-issued"}, running, "issued by its provider"},
|
||||
"an empty value": {"node.anchor", pg, " ", "empty"},
|
||||
} {
|
||||
if _, err := take(row.caller, row.of, row.value); err == nil || !strings.Contains(err.Error(), row.says) {
|
||||
t.Errorf("%s: %v, want refused saying %q", name, err, row.says)
|
||||
}
|
||||
}
|
||||
if rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password"); rec.Generation != 1 || rec.Previous != nil {
|
||||
t.Fatalf("a refusal changed the secret: generation %d, previous %+v", rec.Generation, rec.Previous)
|
||||
}
|
||||
// The value the vault holds already changes nothing, and says so.
|
||||
if same, err := take("node.anchor", pg, old); err != nil || !same.Unchanged || same.To != 1 {
|
||||
t.Errorf("the same value: %+v %v", same, err)
|
||||
}
|
||||
// What runs replaces it: one generation more, the old kept as the previous, origin kept, its fingerprint answered.
|
||||
got, err := take("node.anchor", pg, running)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password")
|
||||
if got.From != 1 || got.To != 2 || got.Unchanged || got.Fingerprint != Fingerprint(running) || rec.Generation != 2 ||
|
||||
rec.Origin != OriginMade || rec.ChangedBy != "node.anchor" || rec.Previous == nil || rec.Previous.Generation != 1 {
|
||||
t.Fatalf("replaced %+v, the record at generation %d origin %q by %s, previous %+v", got, rec.Generation, rec.Origin,
|
||||
rec.ChangedBy, rec.Previous)
|
||||
}
|
||||
if now, _ := seal.Open(rec.AtRest, v.Own.PrivateKey); now != running {
|
||||
t.Error("the vault does not hold what runs")
|
||||
}
|
||||
if was, _ := seal.Open(rec.Previous.AtRest, v.Own.PrivateKey); was != old ||
|
||||
openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Previous.Operator.Sealed) != old {
|
||||
t.Error("the previous value is not kept, for the vault and the operator")
|
||||
}
|
||||
// Said once, with both generations and why, never a value.
|
||||
var replaced []map[string]any
|
||||
for _, e := range v.bus.events {
|
||||
if e["event"] == OwnReplacedEvent {
|
||||
replaced = append(replaced, e["body"].(map[string]any))
|
||||
}
|
||||
}
|
||||
if len(replaced) != 1 || replaced[0]["secret"] != "own/anchor/postgres/superuser-password" ||
|
||||
replaced[0]["from_generation"] != 1 || replaced[0]["to_generation"] != 2 || replaced[0]["node"] != "anchor" ||
|
||||
replaced[0]["why"] != pg.Why {
|
||||
t.Errorf("own-replaced said %v", replaced)
|
||||
}
|
||||
if all := everything(t, v, got); strings.Contains(all, running) || strings.Contains(all, old) {
|
||||
t.Fatal("a value is in something the vault said, answered or wrote in the clear")
|
||||
}
|
||||
// Taken again at once, over the value the vault just took: refused.
|
||||
if _, err := take("node.anchor", pg, "a-value-older-than-the-vaults"); err == nil || !strings.Contains(err.Error(), "less than") {
|
||||
t.Errorf("a running value over one the vault just took: %v", err)
|
||||
}
|
||||
// Switched off by a person, every ask is refused, saying who and why; on again, taken again.
|
||||
later += time.Hour
|
||||
if _, err := v.SwitchOwnTake("jochen", false, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := take("node.anchor", pg, "after-the-switch"); err == nil || !strings.Contains(err.Error(), "switched off (by jochen") {
|
||||
t.Errorf("an ask while switched off: %v", err)
|
||||
}
|
||||
if _, err := v.SwitchOwnTake("jochen", true, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later -= time.Hour
|
||||
// own_drift reads it from the node's latest shadow answer, mesh-wide; the id as a node runs it, its module's name
|
||||
// first (captured from novox's shadow answer, 2026-10-28: only-office.needs-jwt).
|
||||
answer := delivery.CandidateAnswer{Head: "h", Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{
|
||||
Module: "postgres", Why: "the resource differs", Bodies: []delivery.ResourceBodies{{ID: "postgres.needs-superuser-password",
|
||||
Composed: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`, Running: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`}}}}}
|
||||
have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": answer},
|
||||
Composes: delivery.ComposesController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.HaveKeys = func() ([]string, error) { return []string{"anchor.node", "anchor.postgres"}, nil }
|
||||
v.Haves = func(key string) ([]byte, error) { return have, nil }
|
||||
drift, err := v.OwnDrift()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(drift.Secrets) != 1 || drift.Secrets[0].Secret != "own/anchor/postgres/superuser-password" ||
|
||||
drift.Secrets[0].Generation != 2 || drift.Secrets[0].Previous != 1 || drift.Secrets[0].Takeable ||
|
||||
len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["g"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController {
|
||||
t.Errorf("own_drift: %+v", drift)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package main
|
||||
|
||||
// The vault takes what runs (novox/hq issue 1126, cause C4; ADR 0550 §5: a module's own secrets are the vault's).
|
||||
//
|
||||
// `move-in` never replaces a value the vault holds (move.go), so its first import of a machine's own secret is fixed.
|
||||
// Where the controller later made that secret anew (a rotation at start, a re-issue) without handing it here, the new
|
||||
// value is what runs and what its consumers hold, and the vault keeps the old one. Only the machine the value is sealed
|
||||
// to can open what runs, so its node-engine hands it in through `move-in` with replace_running: true, in the two steps
|
||||
// of every intake (intake.go): name the module and the secret and why, and get a call and a key made for it; then send
|
||||
// the value sealed to that key. It is move-in's, not a verb of its own, so a node-engine asks it on the subject it may
|
||||
// already publish and no seat promises anything new (the lead, 2026-10-28).
|
||||
//
|
||||
// **From the node-engine of the machine the secret is for, about its own machine alone**: the machine is the caller's,
|
||||
// as the bus proved it (`node.<machine>`), never an argument; a machine named that is not the caller's is refused, and so
|
||||
// is a name that is not an own secret. **Only a value the vault holds is replaced**, and never a person's (accept) or a
|
||||
// provider's software's (issued) but for a covered bus account, nor one the vault changed less than RecentlyChanged ago:
|
||||
// a running value is older than a value the vault just took (a re-issue the machine has not applied yet), and is never
|
||||
// put back over it. The same value again changes nothing and says so; an empty one is refused.
|
||||
//
|
||||
// The value replaced is kept as the record's previous generation (Record.Previous), the generation is raised,
|
||||
// `secret-changed` and `own-replaced` are said (the secret, the machine, both generations and why; never a value), and the
|
||||
// act is written in the record of hand-outs (secret_handed). A covered bus account so replaced has its hash written again
|
||||
// (accounts.go), so the bus-accounts key is of the value the bus runs.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ReplaceRunningArg is the argument of `move-in` that makes it take a running own secret over the one the vault holds.
|
||||
const ReplaceRunningArg = "replace_running"
|
||||
|
||||
// ReplaceRunning names the act in what the vault says, and is the kind of its open calls.
|
||||
const ReplaceRunning = "move-in with " + ReplaceRunningArg
|
||||
|
||||
// OwnReplacedEvent is what the vault says when it replaced an own secret with what runs: never a value.
|
||||
const OwnReplacedEvent = "own-replaced"
|
||||
|
||||
// RecentlyChanged is how long after the vault changed a secret a running value is not taken over it.
|
||||
var RecentlyChanged = 15 * time.Minute
|
||||
|
||||
// OwnTakeOf names the secret of one `move-in` with replace_running: Module and Name (or Secret whole), Node only to be checked
|
||||
// against the caller's machine, and Why in the caller's words.
|
||||
type OwnTakeOf struct {
|
||||
Node, Module, Name, Secret, Why string
|
||||
}
|
||||
|
||||
// OwnReplaced is what one `move-in` with replace_running did: the secret, the generation it replaced and the one it holds now, the
|
||||
// fingerprint of the value held now (what the node-engine checks against the value it handed), whether the value was
|
||||
// held already. Never a value.
|
||||
type OwnReplaced struct {
|
||||
Secret string `json:"secret"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
From int `json:"from_generation"`
|
||||
To int `json:"to_generation"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// ownTakeSecret is the secret a node-engine names, and its machine: refused for a caller that is no node-engine, for
|
||||
// another machine, and for a name that is not an own secret.
|
||||
func ownTakeSecret(caller string, of OwnTakeOf) (secret, node string, err error) {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
if !ok {
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
caller = "an unproven caller"
|
||||
}
|
||||
return "", "", fmt.Errorf("%s takes a value from a machine's node-engine alone, about its own machine; %s is "+
|
||||
"not a node-engine; nothing is taken", ReplaceRunning, caller)
|
||||
}
|
||||
if of.Secret != "" {
|
||||
parts := strings.Split(of.Secret, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" {
|
||||
return "", "", fmt.Errorf("%s is not a module's own secret (own/<machine>/<module>/<name>): %s replaces "+
|
||||
"those alone; nothing is taken", of.Secret, ReplaceRunning)
|
||||
}
|
||||
if of.Node != "" && of.Node != parts[1] {
|
||||
return "", "", fmt.Errorf("%s is not on %s; nothing is taken", of.Secret, of.Node)
|
||||
}
|
||||
of.Node, of.Module, of.Name = parts[1], parts[2], parts[3]
|
||||
}
|
||||
if of.Node != "" && of.Node != node {
|
||||
return "", "", fmt.Errorf("%s may hand the vault its own machine's secrets alone, and %s/%s is %s's: nothing "+
|
||||
"is taken", caller, of.Module, of.Name, of.Node)
|
||||
}
|
||||
secret, err = OwnSecret(node, of.Module, of.Name)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%w; nothing is taken", err)
|
||||
}
|
||||
return secret, node, nil
|
||||
}
|
||||
|
||||
// ownTakeRefusal says why the held record rec is not replaced by a running value, or "".
|
||||
func ownTakeRefusal(rec Record, now time.Time) string {
|
||||
switch {
|
||||
case rec.Origin == OriginAccepted:
|
||||
return fmt.Sprintf("%s was given by a person, and a person alone gives it anew (accept); nothing is taken", rec.Name)
|
||||
case rec.Origin == OriginIssued && !Covered(rec.Name):
|
||||
return fmt.Sprintf("%s is issued by its provider's software, and `issued` is the only way it enters the vault "+
|
||||
"(ADR 0305 §10); nothing is taken", rec.Name)
|
||||
case now.Sub(rec.ChangedAt) < RecentlyChanged:
|
||||
return fmt.Sprintf("the vault changed %s at %s (generation %d, by %s), less than %s ago: a running value is "+
|
||||
"taken only over an older one, so a value the vault just took is never put back; nothing is taken",
|
||||
rec.Name, rec.ChangedAt.Format(time.RFC3339), rec.Generation, rec.ChangedBy, RecentlyChanged)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OwnTakeOpen opens a call to take the running value of one of the caller's machine's own secrets.
|
||||
func (v *Vault) OwnTakeOpen(caller string, of OwnTakeOf) (Opened, error) {
|
||||
secret, _, err := ownTakeSecret(caller, of)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
why := strings.TrimSpace(of.Why)
|
||||
if len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
|
||||
return Opened{}, errors.New("why is not one short line; nothing is taken")
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
refused := v.ownTakeOff()
|
||||
switch {
|
||||
case refused != "":
|
||||
case !held:
|
||||
refused = fmt.Sprintf("the vault holds no secret named %s, so there is nothing to replace; nothing is taken", secret)
|
||||
default:
|
||||
refused = ownTakeRefusal(rec, v.Now())
|
||||
}
|
||||
if refused != "" {
|
||||
_ = v.recordHanding(Handing{To: caller, Name: secret, Generation: rec.Generation, Outcome: "own-take-refused", Why: refused})
|
||||
return Opened{}, errors.New(refused)
|
||||
}
|
||||
id, key, err := v.openCall(ReplaceRunning, secret, caller, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
v.callsMu.Lock()
|
||||
if c := v.pending[id]; c != nil {
|
||||
c.why = why
|
||||
}
|
||||
v.callsMu.Unlock()
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// OwnTakeTake opens the value sealed to the call's key and replaces the secret with it, keeping the value it replaces.
|
||||
func (v *Vault) OwnTakeTake(caller, call, sealed string) (OwnReplaced, error) {
|
||||
if _, ok := nodeEngineOf(caller); !ok {
|
||||
return OwnReplaced{}, fmt.Errorf("%s takes a value from a machine's node-engine alone; %q is not one",
|
||||
ReplaceRunning, caller)
|
||||
}
|
||||
c, err := v.takeCall(ReplaceRunning, call)
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
if c.by != caller {
|
||||
return OwnReplaced{}, fmt.Errorf("the %s call %q was opened by another caller; nothing was taken", ReplaceRunning, call)
|
||||
}
|
||||
value, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
defer zero(value)
|
||||
parts := strings.Split(c.secret, "/")
|
||||
out := OwnReplaced{Secret: c.secret, Node: parts[1], Module: parts[2], Name: parts[3]}
|
||||
if strings.TrimSpace(string(value)) == "" {
|
||||
why := "the value handed is empty; nothing was taken"
|
||||
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: why})
|
||||
return OwnReplaced{}, errors.New(why)
|
||||
}
|
||||
now := v.Now()
|
||||
changed, err := v.keepWith(c.secret, string(value), caller, "", "", keepOptions{replaceOnly: true, keepPrevious: true,
|
||||
refuse: func(before Record) string { return ownTakeRefusal(before, now) }})
|
||||
if err != nil {
|
||||
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: err.Error()})
|
||||
return OwnReplaced{}, err
|
||||
}
|
||||
out.Fingerprint = Fingerprint(string(value))
|
||||
if changed.Unchanged {
|
||||
out.From, out.To, out.Unchanged = changed.Generation, changed.Generation, true
|
||||
out.Said = fmt.Sprintf("the vault holds this value of %s already (generation %d): nothing changed", c.secret, changed.Generation)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Generation: changed.Generation, Outcome: "own-same", Why: c.why})
|
||||
v.log("%s handed %s's running value, the one the vault holds (generation %d): nothing changed", caller, c.secret,
|
||||
changed.Generation)
|
||||
return out, nil
|
||||
}
|
||||
out.From, out.To = changed.Generation-1, changed.Generation
|
||||
out.Said = fmt.Sprintf("%s is now the value that runs on %s, generation %d; generation %d is kept as the previous",
|
||||
c.secret, out.Node, out.To, out.From)
|
||||
if err := v.recordHanding(Handing{To: caller, Name: c.secret, Generation: out.To, Outcome: "own-replaced", Why: c.why}); err != nil {
|
||||
out.Said += "; the record of hand-outs could not be written: " + err.Error()
|
||||
}
|
||||
v.log("replaced %s (generation %d → %d) with the value that runs, handed by %s: %s", c.secret, out.From, out.To,
|
||||
caller, someoneOr(c.why, "no reason given"))
|
||||
if v.Emit != nil {
|
||||
body := map[string]any{"secret": c.secret, "node": out.Node, "module": out.Module, "name": out.Name,
|
||||
"from_generation": out.From, "to_generation": out.To, "by": caller, "why": c.why,
|
||||
"at": changed.At.Format(time.RFC3339Nano)}
|
||||
if err := v.Emit(OwnReplacedEvent, body); err != nil {
|
||||
v.log("%s is replaced at generation %d, but %s could not be said: %v", c.secret, out.To, OwnReplacedEvent, err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- own_drift ----------------------------------------------------------------------------------------------
|
||||
|
||||
// DriftRow is one own secret a node's latest shadow answer says the vault holds another value of than runs there.
|
||||
type DriftRow struct {
|
||||
Secret string `json:"secret"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Groups []string `json:"groups"`
|
||||
Held bool `json:"held"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Changed time.Time `json:"changed,omitzero"`
|
||||
ChangedBy string `json:"changed_by,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Previous int `json:"previous_generation,omitempty"`
|
||||
// Takeable says move-in with replace_running would take the running value now; Refused why not.
|
||||
Takeable bool `json:"takeable"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// DriftNode is one node's answers as read: whose compositions it applies, and per group how many values differ.
|
||||
type DriftNode struct {
|
||||
Node string `json:"node"`
|
||||
Composes string `json:"composes,omitempty"`
|
||||
ValuesDiffer map[string]int `json:"values_differ,omitempty"`
|
||||
Unread string `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// Drift is own_drift's answer.
|
||||
type Drift struct {
|
||||
Switch OwnTakeSwitch `json:"switch"`
|
||||
Secrets []DriftRow `json:"secrets"`
|
||||
Nodes []DriftNode `json:"nodes"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// OwnDrift reads every node's own have key (`<node>.node`) and lists, from its latest answers to the placement
|
||||
// candidates, every own secret whose shown bodies say another value (delivery.OwnValuesDiffer), with what the vault holds
|
||||
// of it and whether move-in with replace_running would take the running value now.
|
||||
func (v *Vault) OwnDrift() (Drift, error) {
|
||||
if v.Haves == nil || v.HaveKeys == nil {
|
||||
return Drift{}, errors.New("the vault reads no node's have, so it cannot say what drifted")
|
||||
}
|
||||
keys, err := v.HaveKeys()
|
||||
if err != nil {
|
||||
return Drift{}, fmt.Errorf("the keys of %s could not be listed: %w", HaveRead, err)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
rows := map[string]*DriftRow{}
|
||||
out := Drift{Secrets: []DriftRow{}, Nodes: []DriftNode{},
|
||||
Note: "from each node's latest shadow answers; a value is never shown: move-in with replace_running from that node replaces it"}
|
||||
for _, key := range keys {
|
||||
node, isNode := strings.CutSuffix(key, "."+delivery.HaveNodeWord)
|
||||
if !isNode || !namePart.MatchString(node) {
|
||||
continue
|
||||
}
|
||||
dn := DriftNode{Node: node}
|
||||
raw, err := v.Haves(key)
|
||||
if err != nil || raw == nil {
|
||||
dn.Unread = fmt.Sprintf("its have could not be read: %v", err)
|
||||
out.Nodes = append(out.Nodes, dn)
|
||||
continue
|
||||
}
|
||||
have, err := delivery.DecodeHaveNode(raw)
|
||||
if err != nil {
|
||||
dn.Unread = err.Error()
|
||||
out.Nodes = append(out.Nodes, dn)
|
||||
continue
|
||||
}
|
||||
dn.Composes = have.Composes
|
||||
for group, a := range have.Candidates {
|
||||
if n := delivery.ValuesDiffer(a); n > 0 {
|
||||
if dn.ValuesDiffer == nil {
|
||||
dn.ValuesDiffer = map[string]int{}
|
||||
}
|
||||
dn.ValuesDiffer[group] = n
|
||||
}
|
||||
for _, o := range delivery.OwnValuesDiffer(a) {
|
||||
secret, err := OwnSecret(node, o.Module, o.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r := rows[secret]
|
||||
if r == nil {
|
||||
r = &DriftRow{Secret: secret, Node: node, Module: o.Module, Name: o.Name}
|
||||
rows[secret] = r
|
||||
}
|
||||
r.Groups = append(r.Groups, group)
|
||||
}
|
||||
}
|
||||
out.Nodes = append(out.Nodes, dn)
|
||||
}
|
||||
names := make([]string, 0, len(rows))
|
||||
for n := range rows {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if out.Switch, err = v.Store.OwnTakeSwitch(); err != nil {
|
||||
return Drift{}, err
|
||||
}
|
||||
off := v.ownTakeOff()
|
||||
for _, n := range names {
|
||||
r := rows[n]
|
||||
sort.Strings(r.Groups)
|
||||
rec, held, err := v.Store.Read(n)
|
||||
switch {
|
||||
case err != nil:
|
||||
r.Refused = err.Error()
|
||||
case !held:
|
||||
r.Refused = "the vault holds no secret named " + n
|
||||
default:
|
||||
r.Held, r.Generation, r.Changed, r.ChangedBy, r.Origin = true, rec.Generation, rec.ChangedAt, rec.ChangedBy, rec.Origin
|
||||
if rec.Previous != nil {
|
||||
r.Previous = rec.Previous.Generation
|
||||
}
|
||||
r.Refused = ownTakeRefusal(rec, v.Now())
|
||||
if off != "" {
|
||||
r.Refused = off
|
||||
}
|
||||
r.Takeable = r.Refused == ""
|
||||
}
|
||||
out.Secrets = append(out.Secrets, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- own_take_switch ----------------------------------------------------------------------------------------
|
||||
|
||||
// OwnTakeSwitch is whether move-in with replace_running takes values, who switched it last, when and why. On until a person
|
||||
// switches it off; kept in the vault's own store, so it holds over a restart.
|
||||
type OwnTakeSwitch struct {
|
||||
On bool `json:"on"`
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at,omitzero"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) ownTakeSwitchPath() string { return filepath.Join(s.dir, "replace-running.json") }
|
||||
|
||||
// OwnTakeSwitch is the switch as kept: on when nothing was ever switched.
|
||||
func (s *Store) OwnTakeSwitch() (OwnTakeSwitch, error) {
|
||||
raw, err := os.ReadFile(s.ownTakeSwitchPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return OwnTakeSwitch{On: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return OwnTakeSwitch{}, err
|
||||
}
|
||||
var sw OwnTakeSwitch
|
||||
if err := json.Unmarshal(raw, &sw); err != nil {
|
||||
return OwnTakeSwitch{}, fmt.Errorf("the switch of %s is not readable: %w", ReplaceRunning, err)
|
||||
}
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// SwitchOwnTake switches move-in with replace_running on or off, a person's act with why; the caller is recorded as the bus
|
||||
// proved it.
|
||||
func (v *Vault) SwitchOwnTake(caller string, on bool, why string) (OwnTakeSwitch, error) {
|
||||
why = strings.TrimSpace(why)
|
||||
if why == "" || len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
|
||||
return OwnTakeSwitch{}, errors.New("say why in one short line; nothing was switched")
|
||||
}
|
||||
sw := OwnTakeSwitch{On: on, By: someoneOr(caller, stdio.Unproven), At: v.Now().UTC(), Why: why}
|
||||
raw, _ := json.MarshalIndent(sw, "", " ")
|
||||
if err := writeWhole(v.Store.ownTakeSwitchPath(), append(raw, '\n')); err != nil {
|
||||
return OwnTakeSwitch{}, err
|
||||
}
|
||||
v.log("%s is switched %s by %s: %s", ReplaceRunning, map[bool]string{true: "on", false: "off"}[on], sw.By, why)
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// ownTakeOff says why move-in with replace_running takes nothing now, or "".
|
||||
func (v *Vault) ownTakeOff() string {
|
||||
sw, err := v.Store.OwnTakeSwitch()
|
||||
if err != nil {
|
||||
return err.Error() + "; nothing is taken"
|
||||
}
|
||||
if !sw.On {
|
||||
return fmt.Sprintf("%s is switched off (by %s at %s: %s); nothing is taken", ReplaceRunning, sw.By,
|
||||
sw.At.Format(time.RFC3339), sw.Why)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -48,6 +48,20 @@ type Record struct {
|
||||
// MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that
|
||||
// handed it in and the controller's generation for it, kept as it was.
|
||||
MovedFrom *MovedFrom `json:"moved-from,omitempty"`
|
||||
// Previous is the value this one replaced when a machine's node-engine handed the vault what runs there
|
||||
// (move-in with replace_running, owntake.go): kept sealed as it was, so the replacement can be read and undone. Any later
|
||||
// change of the secret drops it.
|
||||
Previous *PreviousValue `json:"previous,omitempty"`
|
||||
}
|
||||
|
||||
// PreviousValue is a replaced value as the store kept it: its generation, both its seals, and who changed it when.
|
||||
type PreviousValue struct {
|
||||
Generation int `json:"generation"`
|
||||
AtRest seal.Box `json:"at-rest"`
|
||||
Operator OperatorCopy `json:"operator"`
|
||||
ChangedAt time.Time `json:"changed-at"`
|
||||
ChangedBy string `json:"changed-by"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorCopy is a value sealed to the operator key named in Key.
|
||||
|
||||
@@ -195,13 +195,53 @@ func definitions(env *Env) []toolDef {
|
||||
}
|
||||
return v.RotateFor(caller, secret, one, arg(a, "by"), arg(a, "why"))
|
||||
}},
|
||||
{Name: "own_take_switch",
|
||||
Description: "Switch move-in's replace_running on or off for every machine, a person's act with why (on until " +
|
||||
"switched off; kept over a restart). While off, every machine's ask is refused, saying who switched it " +
|
||||
"off and why. own_drift shows the switch.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"on", "why"}, "properties": map[string]any{
|
||||
"on": map[string]any{"type": "boolean", "description": "true takes running values, false takes none"},
|
||||
"why": str("why, in one line")}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
on, ok := a["on"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New("say on: true or false; nothing was switched")
|
||||
}
|
||||
return v.SwitchOwnTake(caller, on, arg(a, "why"))
|
||||
}},
|
||||
{Name: "own_drift",
|
||||
Description: "Every own secret whose node's latest shadow answer says the vault holds another value than runs " +
|
||||
"there (the masked word \"another value\", issue 1126), for all nodes in one call: the secret, the " +
|
||||
"groups that show it, the vault's generation, origin and previous generation, and whether the node's " +
|
||||
"move-in with replace_running would take the running value now (or why not); and per node whose compositions it " +
|
||||
"applies and how many values differ per group. Never a value.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
run: func(_ string, _ map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.OwnDrift()
|
||||
}},
|
||||
{Name: Seat + ".move-in",
|
||||
Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " +
|
||||
"node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " +
|
||||
"module and name for a module's own; provider, provision, consumer, consumer_module and local for a " +
|
||||
"pair credential), its origin and the controller's generation, and get a call and a key made for it; " +
|
||||
"then send the call, the value sealed to that key and read_back_to, a key of your own. Never replaces " +
|
||||
"a value the vault holds; answers what it kept, read back from its store sealed to read_back_to.",
|
||||
"a value the vault holds; answers what it kept, read back from its store sealed to read_back_to. " +
|
||||
"With replace_running: true it does the one thing it otherwise never does (issue 1126, cause C4): it " +
|
||||
"replaces one own secret of the asking machine (the machine as the bus proved the caller) with the " +
|
||||
"value that runs there; first step module, name and why, second step call and sealed. It keeps the " +
|
||||
"old value as the previous generation, raises the generation and says own-replaced, never the value; " +
|
||||
"answers both generations and the fingerprint of what it holds. Refused: another machine's secret, " +
|
||||
"one that is not an own secret or not held, an empty value, a person's value, a provider's but a " +
|
||||
"covered bus account, a value the vault changed in the last 15 minutes, and everything while " +
|
||||
"own_take_switch is off. The same value is a no-op.",
|
||||
Input: intakeInput(map[string]string{"node": "a module's own secret: its machine", "module": "its module",
|
||||
"name": "its name in the module", "provider": "a pair credential: the provider's machine",
|
||||
"provision": "the provision", "consumer": "the consumer's machine",
|
||||
@@ -210,6 +250,10 @@ func definitions(env *Env) []toolDef {
|
||||
"generation": "the controller's generation for the value, kept as it is",
|
||||
"made_at": "when the controller made or took it (RFC 3339)",
|
||||
"read_back_to": "the second step: a raw X25519 public key in base64 the read-back is sealed to",
|
||||
ReplaceRunningArg: "true to replace an own secret of this machine with the value that runs here " +
|
||||
"(the second step is known by its call)",
|
||||
"secret": "with replace_running, or the secret's whole name, own/<machine>/<module>/<name>",
|
||||
"why": "with replace_running: why the value is handed, in words",
|
||||
"sealing_key": "the first step, optional: this machine's sealing key, a raw X25519 public key in " +
|
||||
"base64, which the core's credentials are sealed to from then on (ADR 0349 §16)"}),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
@@ -217,9 +261,17 @@ func definitions(env *Env) []toolDef {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replace := isTrue(a[ReplaceRunningArg])
|
||||
if call := arg(a, "call"); call != "" {
|
||||
if replace || v.callKind(call) == ReplaceRunning {
|
||||
return v.OwnTakeTake(caller, call, arg(a, "sealed"))
|
||||
}
|
||||
return v.MoveTake(caller, call, arg(a, "sealed"), arg(a, "read_back_to"))
|
||||
}
|
||||
if replace {
|
||||
return v.OwnTakeOpen(caller, OwnTakeOf{Node: arg(a, "node"), Module: arg(a, "module"),
|
||||
Name: arg(a, "name"), Secret: arg(a, "secret"), Why: arg(a, "why")})
|
||||
}
|
||||
m := MoveOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
|
||||
Provider: arg(a, "provider"), Provision: arg(a, "provision"), Consumer: arg(a, "consumer"),
|
||||
ConsumerModule: arg(a, "consumer_module"), Local: arg(a, "local"), Origin: arg(a, "origin"),
|
||||
@@ -555,6 +607,17 @@ func (e *Env) ready() (*Vault, error) {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
|
||||
// isTrue is a switch argument said true, as a boolean or as the word.
|
||||
func isTrue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(t), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func arg(a map[string]any, key string) string {
|
||||
s, _ := a[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
@@ -782,6 +845,9 @@ func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) {
|
||||
if r.ChangedVia != "" {
|
||||
row["changedVia"] = r.ChangedVia
|
||||
}
|
||||
if r.Previous != nil {
|
||||
row["previousGeneration"] = r.Previous.Generation
|
||||
}
|
||||
if note != nil {
|
||||
row["error"] = note
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ type Vault struct {
|
||||
// Haves reads the node-engines' have bucket, where the bus node hands the loaded list's hashes (adopt.go): nil
|
||||
// adopts nothing.
|
||||
Haves HaveReader
|
||||
// HaveKeys lists the keys of the node-engines' have bucket, for own_drift (owntake.go): nil lists none.
|
||||
HaveKeys func() ([]string, error)
|
||||
|
||||
mu sync.Mutex
|
||||
// hashMu keeps one hashing at a time, and hashes what is owed (accounts.go).
|
||||
@@ -106,6 +108,11 @@ type keepOptions struct {
|
||||
neverReplace bool
|
||||
madeAt time.Time
|
||||
movedFrom *MovedFrom
|
||||
// replaceOnly, keepPrevious and refuse are what move-in with replace_running keeps (owntake.go): only a value held is replaced,
|
||||
// keeping its origin and the value it replaces, and refuse, under the store's lock, says why a held value is not.
|
||||
replaceOnly bool
|
||||
keepPrevious bool
|
||||
refuse func(before Record) string
|
||||
}
|
||||
|
||||
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
|
||||
@@ -147,10 +154,18 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if !held && opt.replaceOnly {
|
||||
return Changed{}, fmt.Errorf("the vault holds no secret named %s, so there is nothing to replace; nothing was taken", name)
|
||||
}
|
||||
if held {
|
||||
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
|
||||
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
|
||||
}
|
||||
if opt.refuse != nil {
|
||||
if why := opt.refuse(before); why != "" {
|
||||
return Changed{}, errors.New(why)
|
||||
}
|
||||
}
|
||||
if opt.neverReplace {
|
||||
return Changed{}, fmt.Errorf("the vault already holds another value for %s (generation %d, changed by %s "+
|
||||
"at %s), and the move never replaces a value the vault holds: nothing was taken", name,
|
||||
@@ -174,6 +189,13 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
|
||||
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
|
||||
if held {
|
||||
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
|
||||
if opt.replaceOnly {
|
||||
rec.Origin, rec.MovedFrom = before.Origin, before.MovedFrom
|
||||
}
|
||||
if opt.keepPrevious {
|
||||
rec.Previous = &PreviousValue{Generation: before.Generation, AtRest: before.AtRest, Operator: before.Operator,
|
||||
ChangedAt: before.ChangedAt, ChangedBy: before.ChangedBy, Origin: before.Origin}
|
||||
}
|
||||
} else if !opt.madeAt.IsZero() {
|
||||
rec.MadeAt = opt.madeAt.UTC()
|
||||
}
|
||||
@@ -343,7 +365,8 @@ func (v *Vault) reseal(key string) (int, error) {
|
||||
n := 0
|
||||
var failed []error
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox {
|
||||
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox &&
|
||||
(rec.Previous == nil || rec.Previous.Operator.Key == key && rec.Previous.Operator.Box == OperatorBox) {
|
||||
continue
|
||||
}
|
||||
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
@@ -352,6 +375,16 @@ func (v *Vault) reseal(key string) (int, error) {
|
||||
continue
|
||||
}
|
||||
sealed, err := operatorSeal(key, []byte(value))
|
||||
if err == nil && rec.Previous != nil {
|
||||
// And the value it replaced (owntake.go): a copy of every value the vault keeps is the operator's.
|
||||
var old string
|
||||
if old, err = seal.Open(rec.Previous.AtRest, v.Own.PrivateKey); err == nil {
|
||||
var prev string
|
||||
if prev, err = operatorSeal(key, []byte(old)); err == nil {
|
||||
rec.Previous.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: prev}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
|
||||
err = v.Store.Write(rec)
|
||||
|
||||
@@ -167,10 +167,11 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
|
||||
}
|
||||
// The haves, in main.go alone: one read and one watch, never a write.
|
||||
// The haves, in main.go alone: one read, one watch and their keys (own_drift), never a write.
|
||||
if f == "main.go" {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Keys(", "", 1))
|
||||
}
|
||||
if m := bucket.Find(code); m != nil {
|
||||
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-vault
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865 h1:VLir9NZbyi1LdH1YwWDIVRuL+lDC+AYlm6FZPlvmlm4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021100209-cb54756c3865/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936 h1:HGBoWimZXYZp+Qn9VCstYDitOP84t9EUQYd1yM9j2eg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026102838-174789941936/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 h1:by2zBASLb8h5jA1IiYJWykXwLnHYpuZh/zFNa4NsbOU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"provisioned",
|
||||
"rotated",
|
||||
"secret-changed",
|
||||
"account-hashed"
|
||||
"account-hashed",
|
||||
"own-replaced"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
@@ -163,7 +164,9 @@
|
||||
"provisioner_retire_reject",
|
||||
"provisioner_delete",
|
||||
"vault_status",
|
||||
"vault_health"
|
||||
"vault_health",
|
||||
"own_drift",
|
||||
"own_take_switch"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
|
||||
@@ -1130,6 +1130,8 @@ func (h *Holder) record(s Sent) {
|
||||
if h.Store != nil {
|
||||
if err := h.Store.PutRecent(recent); err != nil {
|
||||
h.noteStore(err)
|
||||
} else {
|
||||
h.storeWritten()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1143,6 +1145,20 @@ func (h *Holder) persist(r *Record) {
|
||||
h.mu.Unlock()
|
||||
if err := h.Store.Put(c); err != nil {
|
||||
h.noteStore(err)
|
||||
} else {
|
||||
h.storeWritten()
|
||||
}
|
||||
}
|
||||
|
||||
// storeWritten clears a state problem once a write succeeds again: the status says what holds now, never a
|
||||
// failure that passed (issue 1507's reading of 2026-10-28: a verdict kept a single failure of the day before).
|
||||
func (h *Holder) storeWritten() {
|
||||
h.mu.Lock()
|
||||
was := h.storeErr
|
||||
h.storeErr = ""
|
||||
h.mu.Unlock()
|
||||
if was != "" {
|
||||
h.Logf("[messenger] writes its state again (it could not: %s)", was)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,9 +71,14 @@ type memStore struct {
|
||||
passes map[string]Pass
|
||||
// puts counts the writes, so a write of bytes identical to what is stored is still seen (issue 422's review).
|
||||
puts int
|
||||
// fail, when set, refuses every write of a record or of the sends, as the bus does with no responders.
|
||||
fail error
|
||||
}
|
||||
|
||||
func (m *memStore) Put(r Record) error {
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
if m.recs == nil {
|
||||
m.recs = map[string]Record{}
|
||||
}
|
||||
@@ -89,7 +94,13 @@ func (m *memStore) All() ([]Record, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memStore) PutRecent(s []Sent) error { m.recent = s; return nil }
|
||||
func (m *memStore) PutRecent(s []Sent) error {
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
m.recent = s
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) PutWalk(w Walk) error {
|
||||
if m.walks == nil {
|
||||
m.walks = map[string]Walk{}
|
||||
@@ -431,15 +442,21 @@ func TestEscalationIsSaidOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h, tg, _, c, store := newHolder(t)
|
||||
h.Desk = nil
|
||||
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
|
||||
// The state could not be written either, once: said while it lasts, and no longer once a write succeeds
|
||||
// (issue 1507's reading of 2026-10-28: a verdict kept a day-old "no responders").
|
||||
store.fail = errors.New("nats: no responders available for request")
|
||||
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
|
||||
st := h.Status("listening")
|
||||
if !strings.HasPrefix(st.Verdict, "CANNOT SEND") || len(st.Unsent) != 1 || st.Channels[0].LastError == "" {
|
||||
t.Fatalf("status: %+v", st)
|
||||
}
|
||||
tg.fail = nil
|
||||
if st.StateProblem == "" {
|
||||
t.Fatalf("a failed state write is not said: %+v", st)
|
||||
}
|
||||
tg.fail, store.fail = nil, nil
|
||||
h.Flush()
|
||||
if len(tg.sends) != 0 {
|
||||
t.Fatalf("tried again before a minute")
|
||||
@@ -449,6 +466,9 @@ func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
||||
if len(tg.sends) != 1 || h.Status("listening").Verdict != "ok" {
|
||||
t.Fatalf("not retried: %d, %s", len(tg.sends), h.Status("listening").Verdict)
|
||||
}
|
||||
if p := h.Status("listening").StateProblem; p != "" {
|
||||
t.Fatalf("a state write that succeeded again still says the old failure: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
|
||||
|
||||
@@ -677,7 +677,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
Run: func(map[string]any) (any, error) { return h.Open(), nil }},
|
||||
{Name: "operator-channel.history",
|
||||
Description: "What was said to the operator lately, newest first — each send, digest, edit and failure with its " +
|
||||
"channel, key and outcome — and every message refused for carrying an address, a path or a secret.",
|
||||
"channel, key and outcome — every message refused for carrying an address, a path or a secret, and what " +
|
||||
"waits to be sent on each channel, since when and when its burst window ends.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many of each (default 50)"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
|
||||
{Name: "operator-channel.notify",
|
||||
@@ -750,9 +751,10 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Unlink(kind)
|
||||
}},
|
||||
{Name: "messenger_recent",
|
||||
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
|
||||
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — the refusals, " +
|
||||
"and what waits to be sent: each message held on a channel (its burst window is 30 s), since when and when its window ends.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
|
||||
{Name: "messenger_test",
|
||||
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
|
||||
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
|
||||
|
||||
@@ -66,3 +66,40 @@ func TestAnAskAlreadySentIsNotSentAgainAfterARestart(t *testing.T) {
|
||||
t.Fatalf("sent again after a restart: %d", len(tg.sends))
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1507, read again 2026-10-28: three asks taken at 08:50:03-08:50:14 went out together at 08:50:33, at the
|
||||
// end of the first one's burst window; a reading of the history in between showed no send and no refusal, and the
|
||||
// asks read as lost. What waits in an outbox is in the history, with when its window ends, until it is sent.
|
||||
func TestTheHistorySaysAnAskWaitingForItsBurstWindow(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-config", heldAsk(c, "cfg-1"))
|
||||
waiting := func() []Waiting { return h.History(20)["waiting"].([]Waiting) }
|
||||
for _, step := range []struct {
|
||||
name string
|
||||
pass time.Duration
|
||||
want int
|
||||
}{
|
||||
{"taken, inside its window", 0, 1},
|
||||
{"its window passed and it was sent", BurstWindow + time.Second, 0},
|
||||
} {
|
||||
c.pass(step.pass)
|
||||
stand(h, c)
|
||||
h.Flush()
|
||||
w := waiting()
|
||||
if len(w) != step.want {
|
||||
t.Fatalf("%s: %d waiting, want %d: %+v", step.name, len(w), step.want, w)
|
||||
}
|
||||
if step.want == 1 {
|
||||
if w[0].Key != "ask.mesh-config.cfg-1" || w[0].Channel != tg.Name() || w[0].Due == "" {
|
||||
t.Fatalf("%s: waiting %+v, want the ask on %s with when its window ends", step.name, w[0], tg.Name())
|
||||
}
|
||||
if len(tg.sends) != 0 {
|
||||
t.Fatalf("%s: sent before its window ended", step.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tg.sends) != 1 {
|
||||
t.Fatalf("sent %d times, want once", len(tg.sends))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,36 @@ func (h *Holder) History(limit int) map[string]any {
|
||||
for i := len(h.refusals) - 1; i >= 0 && len(refusals) < limit; i-- {
|
||||
refusals = append(refusals, h.refusals[i])
|
||||
}
|
||||
return map[string]any{"sends": sends, "refusals": refusals}
|
||||
return map[string]any{"sends": sends, "refusals": refusals, "waiting": h.waitingNow()}
|
||||
}
|
||||
|
||||
// Waiting is one message held in a channel's outbox, not sent yet: history says it, so a message held for its
|
||||
// burst window reads as waiting and not as never sent (issue 1507, its reading of 2026-10-28).
|
||||
type Waiting struct {
|
||||
Channel string `json:"channel"`
|
||||
Key string `json:"key"`
|
||||
What string `json:"what"`
|
||||
Since string `json:"since"`
|
||||
// Due is when its burst window ends: it goes out at the first flush from then, unless a rule holds it longer.
|
||||
Due string `json:"window_ends"`
|
||||
}
|
||||
|
||||
// waitingNow is every message held in an outbox, oldest first. Called with mu held.
|
||||
func (h *Holder) waitingNow() []Waiting {
|
||||
out := []Waiting{}
|
||||
for ch, list := range h.outbox {
|
||||
for _, it := range list {
|
||||
out = append(out, Waiting{Channel: ch, Key: it.Key, What: it.What,
|
||||
Since: it.At.Format(time.RFC3339Nano), Due: it.At.Add(BurstWindow).Format(time.RFC3339Nano)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Since != out[j].Since {
|
||||
return out[i].Since < out[j].Since
|
||||
}
|
||||
return out[i].Channel < out[j].Channel
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Test sends a test message on one channel or both, through the content rule and the cap.
|
||||
|
||||
+1125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "g14",
|
||||
"decided": {
|
||||
"address": "10.10.0.4",
|
||||
"account": "jochen",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"mtu": 1380,
|
||||
"address": "10.10.0.4/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "mq76YPW0FrExhAvt/ZG1SB2BK6/cPISKgXO53SEMazU="
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "adwaita"
|
||||
},
|
||||
{
|
||||
"module": "asus-zephyrus-g14"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "blueman"
|
||||
},
|
||||
{
|
||||
"module": "bluetooth"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "clipmenu"
|
||||
},
|
||||
{
|
||||
"module": "cups"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "dmenu"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "docker-compose"
|
||||
},
|
||||
{
|
||||
"module": "dunst",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "feh"
|
||||
},
|
||||
{
|
||||
"module": "flatpak"
|
||||
},
|
||||
{
|
||||
"module": "fonts"
|
||||
},
|
||||
{
|
||||
"module": "forticlient"
|
||||
},
|
||||
{
|
||||
"module": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "i3"
|
||||
},
|
||||
{
|
||||
"module": "i3status-rust"
|
||||
},
|
||||
{
|
||||
"module": "kernel"
|
||||
},
|
||||
{
|
||||
"module": "lemurs"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "memory-pressure"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "networkmanager"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud-client"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "nm-applet"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "okioki",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "openrazer"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "picom"
|
||||
},
|
||||
{
|
||||
"module": "polychromatic"
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "rofi"
|
||||
},
|
||||
{
|
||||
"module": "screen-lock"
|
||||
},
|
||||
{
|
||||
"module": "sensors"
|
||||
},
|
||||
{
|
||||
"module": "slack"
|
||||
},
|
||||
{
|
||||
"module": "snapd"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-resolved"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "triggerhappy"
|
||||
},
|
||||
{
|
||||
"module": "xclip"
|
||||
},
|
||||
{
|
||||
"module": "xdg",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "xorg"
|
||||
},
|
||||
{
|
||||
"module": "xterm"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"wildcard-resolution": {
|
||||
"node": "novox",
|
||||
"module": "dnsmasq"
|
||||
}
|
||||
},
|
||||
"holds": [
|
||||
{
|
||||
"claim": "bookkeeping",
|
||||
"scope": "mesh",
|
||||
"module": "okioki"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "novox",
|
||||
"decided": {
|
||||
"endpoint": "142.132.152.141:51820",
|
||||
"site": "hosting",
|
||||
"hub": true,
|
||||
"address": "10.10.0.1",
|
||||
"public-domain": "novox.be",
|
||||
"account": "jochens",
|
||||
"agent-account": "nox",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"address": "10.10.0.1/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY="
|
||||
},
|
||||
"peer-names": {
|
||||
"XT1l25Z0/YhRNQ8FQM+9XsAp7chx5MI0eLOOg1SUEXA=": "shanks",
|
||||
"mq76YPW0FrExhAvt/ZG1SB2BK6/cPISKgXO53SEMazU=": "g14",
|
||||
"zTKYP6Sz1hDwnwxIHgiGXYAeGcWbqti3tcSvKEA0Ew4=": "ace"
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "de-spiegel",
|
||||
"ports": {
|
||||
"35621": 20005
|
||||
},
|
||||
"bindings": {
|
||||
"smtp": {
|
||||
"node": "novox",
|
||||
"module": "mailu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "desk-channel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "distribution",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"5000": 5100
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "dnsmasq"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "gitea",
|
||||
"ports": {
|
||||
"3000": 20000
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"expose": {
|
||||
"22": "anywhere"
|
||||
},
|
||||
"ports": {
|
||||
"22": 222
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "invoicing",
|
||||
"ports": {
|
||||
"80": 20007,
|
||||
"9000": 20008
|
||||
},
|
||||
"bindings": {
|
||||
"mongodb-database": {
|
||||
"node": "novox",
|
||||
"module": "mongodb"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "kernel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "keycloak",
|
||||
"ports": {
|
||||
"8080": 20009
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "link2pay"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mailu",
|
||||
"ports": {
|
||||
"110": 110,
|
||||
"143": 143,
|
||||
"25": 25,
|
||||
"4243": 20004,
|
||||
"465": 465,
|
||||
"587": 587,
|
||||
"8080": 20010,
|
||||
"993": 993,
|
||||
"995": 995
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-catalog",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-config"
|
||||
},
|
||||
{
|
||||
"module": "mesh-controller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-checker"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-forge-bridge"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-gate",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-indexer"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-teller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-watchman"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-grants"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-issues"
|
||||
},
|
||||
{
|
||||
"module": "mesh-knowledge",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-reader",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-web",
|
||||
"bindings": {
|
||||
"oidc-client": {
|
||||
"node": "novox",
|
||||
"module": "keycloak"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"endpoints": {
|
||||
"web": {
|
||||
"reach": "internal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "messenger",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "minio",
|
||||
"ports": {
|
||||
"9000": 20001,
|
||||
"9001": 20002
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mongodb",
|
||||
"ports": {
|
||||
"27017": 20006
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mssql",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"1433": 4848
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "nats"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud",
|
||||
"ports": {
|
||||
"80": 20003
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "only-office"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "photos",
|
||||
"ports": {
|
||||
"9000": 20011
|
||||
},
|
||||
"bindings": {
|
||||
"mongodb-database": {
|
||||
"node": "novox",
|
||||
"module": "mongodb"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "postgres",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"5432": 6852
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "records",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "restic",
|
||||
"layer": {
|
||||
"places": {
|
||||
"repository": "/home/restic"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "route-proxy"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "step-ca"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-networkd"
|
||||
},
|
||||
{
|
||||
"module": "telegram"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "umami",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"expose": {
|
||||
"3000": "mesh"
|
||||
},
|
||||
"ports": {
|
||||
"3000": 1212
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "website",
|
||||
"ports": {
|
||||
"8080": 20012
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "zsh",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"holds": [
|
||||
{
|
||||
"claim": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"module": "claude-licence-manager",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "channel",
|
||||
"scope": "mesh",
|
||||
"module": "desk-channel",
|
||||
"site": "hosting",
|
||||
"kind": "desktop"
|
||||
},
|
||||
{
|
||||
"claim": "channel",
|
||||
"scope": "mesh",
|
||||
"module": "telegram",
|
||||
"site": "hosting",
|
||||
"kind": "telegram"
|
||||
},
|
||||
{
|
||||
"claim": "document-converter",
|
||||
"scope": "mesh",
|
||||
"module": "only-office",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "git",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "intake",
|
||||
"scope": "mesh",
|
||||
"module": "desk-channel",
|
||||
"site": "hosting",
|
||||
"kind": "desktop"
|
||||
},
|
||||
{
|
||||
"claim": "intake",
|
||||
"scope": "mesh",
|
||||
"module": "telegram",
|
||||
"site": "hosting",
|
||||
"kind": "telegram"
|
||||
},
|
||||
{
|
||||
"claim": "issue-tracker",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-issues",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "knowledge-base",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-knowledge",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mail-reading",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-mail-reader",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mail-store",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-mail",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mail-webclient",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-mail-web",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-artifact-store",
|
||||
"scope": "mesh",
|
||||
"module": "distribution",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-broker",
|
||||
"scope": "mesh",
|
||||
"module": "nats",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-catalog",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-catalog",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-config",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-config",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-controller",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-controller",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-delivery",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-delivery-gate",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-dns-resolver",
|
||||
"scope": "mesh",
|
||||
"module": "dnsmasq",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-provisioner",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-provisioner",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-store",
|
||||
"scope": "mesh",
|
||||
"module": "postgres",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-vault",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-vault",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "npm-package-registry",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "object-store",
|
||||
"scope": "mesh",
|
||||
"module": "minio",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "operator-channel",
|
||||
"scope": "mesh",
|
||||
"module": "messenger",
|
||||
"site": "hosting"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "shanks",
|
||||
"decided": {
|
||||
"site": "home",
|
||||
"address": "10.10.0.3",
|
||||
"account": "jochen",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"address": "10.10.0.3/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "XT1l25Z0/YhRNQ8FQM+9XsAp7chx5MI0eLOOg1SUEXA="
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "adwaita"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "blueman"
|
||||
},
|
||||
{
|
||||
"module": "bluetooth"
|
||||
},
|
||||
{
|
||||
"module": "brother",
|
||||
"bindings": {
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "buildah"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "clipmenu"
|
||||
},
|
||||
{
|
||||
"module": "cups"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "dmenu"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "docker-compose"
|
||||
},
|
||||
{
|
||||
"module": "dunst"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "feh"
|
||||
},
|
||||
{
|
||||
"module": "flatpak"
|
||||
},
|
||||
{
|
||||
"module": "fonts"
|
||||
},
|
||||
{
|
||||
"module": "forticlient"
|
||||
},
|
||||
{
|
||||
"module": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "i3"
|
||||
},
|
||||
{
|
||||
"module": "i3status-rust"
|
||||
},
|
||||
{
|
||||
"module": "kernel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "lab",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "lemurs"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-image"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-builder"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-view"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "mounts",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "networkmanager"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud-client"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "nm-applet"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "openrazer"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "picom"
|
||||
},
|
||||
{
|
||||
"module": "polychromatic"
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "rofi"
|
||||
},
|
||||
{
|
||||
"module": "screen-lock"
|
||||
},
|
||||
{
|
||||
"module": "slack"
|
||||
},
|
||||
{
|
||||
"module": "snapd"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-resolved"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "xclip"
|
||||
},
|
||||
{
|
||||
"module": "xdg",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "xorg"
|
||||
},
|
||||
{
|
||||
"module": "xterm"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"wildcard-resolution": {
|
||||
"node": "novox",
|
||||
"module": "dnsmasq"
|
||||
}
|
||||
},
|
||||
"holds": [
|
||||
{
|
||||
"claim": "printer",
|
||||
"scope": "mesh",
|
||||
"module": "brother",
|
||||
"site": "home"
|
||||
},
|
||||
{
|
||||
"claim": "scanner",
|
||||
"scope": "mesh",
|
||||
"module": "brother",
|
||||
"site": "home"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user