The mesh's forge accounts all read every repository, and a build's fetch says the forge's own words
mesh/delivery merged: source.main moved to novox/mesh-catalog@61563abf2f93
mesh/delivery-group group fix/fetch-reads-all merged: every member is merged
mesh/check checking 071c6885d923 on 8393e2962193

mesh-catalog #495 gave read on every repository to the tools' account alone. The build modules
fetch over ssh as nox-builder (the forge-account provider's builders account, ADR 0466 §3), so
ace's and g14's fetch of jschoubben/car-hunter and jschoubben/snake still failed; the machines'
agents (nox-agent, ADR 0410 §1) could not read them either.

- gitea: readall.ts grants every account of one list, meshAccounts in tools-account.ts (mesh-tools,
  nox-builder, nox-agent; a Go test holds the two Go names to internal/forgeaccount's constants),
  on the same start pass, forge events and tools. gitea_unreadable_repos answers per account and
  names the accounts the forge does not hold yet; gitea_grant_read takes accounts as well as
  repositories. An absent account is asked nothing; the start pass runs again ten minutes apart
  until every account was there once.
- mesh-build-go, mesh-build-node, mesh-build-image: pin mesh-sdk ff81bdf8 (fix/fetch-reads-all),
  whose forgekey.Said keeps git's words past the bare "error:" line: the mirror's line said only
  "git -C: error:". Their own git reasons (ls-files, ls-remote, rev-parse, repack) go through it too.
This commit is contained in:
2026-10-26 15:27:38 +01:00
parent ceb3aac9b3
commit 071c6885d9
19 changed files with 293 additions and 141 deletions
+15 -9
View File
@@ -275,12 +275,17 @@ reported no key gets no entry, and its client says it waits. Keys titled `mesh:`
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes. a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge. `go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
## The tools' account reads every repository (the operator, 2026-10-26) ## The mesh's accounts read every repository (the operator, 2026-10-26)
The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox` The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox`
through its team. Any other repository it saw only when public, so a private repository of a user answered every tool through its team; the build modules fetch over ssh as `nox-builder` (ADR 0466 §3), in a team that reads the
404 and was missing from every list. `readall.ts` makes the account a collaborator that **reads only** on each organisation's code; the machines' agents use `nox-agent` (ADR 0410 §1). Any other repository each saw only when public,
repository it cannot read: one repository at a time, no organisation membership, the least the forge offers. so a private repository of a user answered every tool 404 and every build's fetch "not authorized to read". `readall.ts`
makes each of these accounts (one list, `meshAccounts` in `tools-account.ts`, whose two Go names
`internal/forgeaccount`'s test holds to its own constants) a collaborator that **reads only** on each repository it
cannot read: one repository at a time, no organisation membership, the least the forge offers. An account the forge
does not hold yet (the forge-account provider makes `nox-builder` and `nox-agent` when their first consumer asks) is
named and asked nothing; the start's pass is run again ten minutes apart until every account was there once.
- **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is - **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is
asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and
@@ -292,11 +297,12 @@ repository it cannot read: one repository at a time, no organisation membership,
the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on. the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on.
- **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a - **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a
repository's administration, which on another user's repository only the site administrator holds. It is used for repository's administration, which on another user's repository only the site administrator holds. It is used for
four calls alone: list every repository, read one, read the account's permission on one, add the account as a read five calls alone: list every repository, read one, whether an account exists, read an account's permission on one,
collaborator. The tools' own calls keep their token. add an account as a read collaborator. The tools' own calls keep their token.
- **Tools:** `gitea_unreadable_repos` (every repository the account cannot read, the permission the forge gives it - **Tools:** `gitea_unreadable_repos` (per account, every repository it cannot read, the permission the forge gives
there and why) and `gitea_grant_read` (the same grant by hand, now, on every repository or on those named; each grant it there and why, a count per account and the accounts the forge does not hold) and `gitea_grant_read` (the same
read back from the forge, a refusal answered in the forge's own words). grant by hand, now, on every repository or on those named, for every account or those named; each grant read back
from the forge, a refusal answered in the forge's own words).
## Files it does not know ## Files it does not know
+9
View File
@@ -1013,6 +1013,15 @@ export class GiteaAdmin {
return forgeRepository(res.body); return forgeRepository(res.body);
} }
/** Whether the forge holds an account of this name. */
async accountExists(user: string): Promise<boolean> {
const path = `/users/${encodeURIComponent(user)}`;
const res = await this.request(path);
if (res.status === 404) return false;
if (res.status !== 200) GiteaAdmin.fail(path, res);
return true;
}
/** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */ /** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> { async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`; const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`;
+13 -7
View File
@@ -61,7 +61,7 @@ import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, si
import { bindInBackground } from "./bind.js"; import { bindInBackground } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js"; import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
import { ReadAll, readAllTools } from "./readall.js"; import { ReadAll, readAllTools } from "./readall.js";
import { TOOLS_ACCOUNT } from "./tools-account.js"; import { TOOLS_ACCOUNT, meshAccounts } from "./tools-account.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -502,11 +502,12 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852). // Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE)); await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// **The tools' account reads every repository** (readall.ts; the operator, 2026-10-26): granted with the site // **The mesh's accounts read every repository** (readall.ts; the operator, 2026-10-26): the tools', the build modules'
// and the machines' agents' (meshAccounts, tools-account.ts), granted with the site
// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on // administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on
// first use, so a module whose admin login is missing still serves the rest and the tools say why. // first use, so a module whose admin login is missing still serves the rest and the tools say why.
let readAllOnce: ReadAll | null = null; let readAllOnce: ReadAll | null = null;
const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)); const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...(gitea ? [commitStatusTool({ registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...(gitea ? [commitStatusTool({
@@ -554,7 +555,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
act: async (event) => { act: async (event) => {
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts). // A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) => await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
console.error(`[gitea] whether ${TOOLS_ACCOUNT} reads the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`)); console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
await actOn(client, event); await actOn(client, event);
} }); } });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -609,15 +610,20 @@ if (gitea) {
void listenForForge(client); void listenForForge(client);
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet // The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant. // is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).catch((err) => { // An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
console.error(`[gitea] whether ${TOOLS_ACCOUNT} reads every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`); // their first consumer asks, and the forge sends no event for a user made) is looked for again ten minutes apart
// until every account was granted once; the line of each pass names it.
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).then((r) => {
if (r.absent.length) setTimeout(readAllAtStart, 600_000);
}, (err) => {
console.error(`[gitea] whether the mesh's accounts read every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`);
setTimeout(readAllAtStart, 60_000); setTimeout(readAllAtStart, 60_000);
}); });
try { try {
readAll(); readAll();
readAllAtStart(); readAllAtStart();
} catch (err) { } catch (err) {
console.error(`[gitea] ${TOOLS_ACCOUNT} is not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`); console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`);
} }
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
@@ -1,6 +1,10 @@
package forgeaccount package forgeaccount
import "testing" import (
"os"
"regexp"
"testing"
)
func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) { func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
if got := ForgeUser("g14", "machine"); got != "nox-agent" { if got := ForgeUser("g14", "machine"); got != "nox-agent" {
@@ -15,6 +19,18 @@ func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
if got := NodeAccount("g14"); got != "agent-g14" { if got := NodeAccount("g14"); got != "agent-g14" {
t.Fatalf("the token tool's per-node account = %q", got) t.Fatalf("the token tool's per-node account = %q", got)
} }
// The accounts that read every repository (readall.ts, meshAccounts in tools-account.ts) name these two users: the
// list is held to the Go constants here, so the two cannot drift apart (the operator, 2026-10-26).
ts, err := os.ReadFile("../../tools-account.ts")
if err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"BUILDER_ACCOUNT": BuilderUser, "AGENT_ACCOUNT": AgentUser} {
m := regexp.MustCompile(`export const ` + name + ` = "([^"]+)";`).FindSubmatch(ts)
if m == nil || string(m[1]) != want {
t.Errorf("tools-account.ts %s = %q, want %q (internal/forgeaccount)", name, m, want)
}
}
} }
func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) { func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) {
+144 -91
View File
@@ -1,5 +1,12 @@
// The tools' account reads every repository on the forge (the operator, 2026-10-26: "the mesh's forge account should // The mesh's own forge accounts read every repository on the forge (the operator, 2026-10-26: "the mesh's forge account
// get read access to all repositories"). // should get read access to all repositories"; and "why wouldn't the forge account get access to all repo's?").
//
// **Every account the mesh's parts use, from one list** (MESH_ACCOUNTS, tools-account.ts): the tools' `mesh-tools`, the
// build modules' `nox-builder` (their fetch over ssh, ADR 0466 §3) and the machines' `nox-agent` (ssh-client's key,
// ADR 0410 §1). The first pass granted the tools' account alone, so the build modules' fetch of jschoubben/car-hunter
// still failed on ace and g14 ("nox-builder ... is not authorized to read"). The two Go names are the ones of
// internal/forgeaccount, whose test holds the list to them. An account the forge does not hold yet (its consumer never
// asked) is said and left out, never a refusal per repository.
// //
// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's // Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private // repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
@@ -32,12 +39,15 @@ import type { ForgeRepository } from "./client.js";
export interface ReadAllForge { export interface ReadAllForge {
everyRepository(): Promise<ForgeRepository[]>; everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>; repository(owner: string, repo: string): Promise<ForgeRepository | null>;
/** Whether the forge holds the account at all. */
accountExists(user: string): Promise<boolean>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>; repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>; addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
} }
/** A repository the account cannot read, and why. */ /** A repository an account cannot read, and why. */
export interface Unreadable { export interface Unreadable {
account: string;
repository: string; repository: string;
permission: string; permission: string;
private: boolean; private: boolean;
@@ -46,16 +56,22 @@ export interface Unreadable {
} }
export interface Granted { export interface Granted {
/** Repositories looked at. */ /** Repositories looked at (for each account). */
checked: number; checked: number;
/** Made readable now, by a read collaborator. */ /** The accounts looked at: those the forge holds. */
granted: string[]; accounts: string[];
/** The forge refused the grant: each repository and the forge's own words. */ /** Accounts of the list the forge does not hold yet: nothing is granted to them until it does. */
refused: { repository: string; why: string }[]; absent: string[];
/** Made readable now, by a read collaborator: each repository and the account. */
granted: { account: string; repository: string }[];
/** The forge refused the grant: each repository, the account and the forge's own words. */
refused: { account: string; repository: string; why: string }[];
} }
const READS = new Set(["read", "write", "admin", "owner"]); const READS = new Set(["read", "write", "admin", "owner"]);
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
/** Why the account cannot read a repository, from what the forge says of it. */ /** Why the account cannot read a repository, from what the forge says of it. */
export function whyUnreadable(repo: ForgeRepository, account: string): string { export function whyUnreadable(repo: ForgeRepository, account: string): string {
const parts: string[] = []; const parts: string[] = [];
@@ -68,31 +84,53 @@ export function whyUnreadable(repo: ForgeRepository, account: string): string {
} }
export class ReadAll { export class ReadAll {
/** Repositories this process has seen the account read, by full name: an event of one of them asks nothing more. */ /** Per account, the repositories this process has seen it read, by full name: an event of a repository every account
readonly readable = new Set<string>(); * was seen reading asks nothing more. */
readonly readable = new Map<string, Set<string>>();
readonly accounts: readonly string[];
constructor(private readonly forge: ReadAllForge, readonly account: string, constructor(private readonly forge: ReadAllForge, accounts: readonly string[] | string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {} private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {
this.accounts = [...new Set(typeof accounts === "string" ? [accounts] : accounts)];
/** Every repository the account cannot read, and why, as the forge says it now. */ if (!this.accounts.length) throw new Error("no forge account to give read: the list is empty");
async unreadable(): Promise<{ checked: number; unreadable: Unreadable[] }> { for (const a of this.accounts) this.readable.set(a, new Set());
const repos = await this.forge.everyRepository();
const out: Unreadable[] = [];
for (const repo of repos) {
try {
const u = await this.judge(repo);
if (u) out.push(u);
} catch (err) {
// A permission the forge would not answer is said for that repository; the rest are still looked at.
out.push({ repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
why: `the forge did not answer ${this.account}'s permission on it — ${err instanceof Error ? err.message : String(err)}` });
}
}
return { checked: repos.length, unreadable: out };
} }
/** Grant read on every repository the account cannot read (or on those named, `owner/name`). */ /** The accounts of the list (or of those named) the forge holds, and those it does not hold yet. */
async grant(only?: string[]): Promise<Granted> { private async present(only?: string[]): Promise<{ accounts: string[]; absent: string[] }> {
const wanted = only && only.length ? only : [...this.accounts];
for (const a of wanted) if (!this.accounts.includes(a)) throw new Error(`"${a}" is not one of the mesh's accounts: ${this.accounts.join(", ")}`);
const accounts: string[] = [];
const absent: string[] = [];
for (const a of wanted) ((await this.forge.accountExists(a)) ? accounts : absent).push(a);
return { accounts, absent };
}
/** Every repository each account cannot read, and why, as the forge says it now. */
async unreadable(only?: string[]): Promise<{ checked: number; accounts: { account: string; unreadable: number }[]; absent: string[]; unreadable: Unreadable[] }> {
const { accounts, absent } = await this.present(only);
const repos = await this.forge.everyRepository();
const out: Unreadable[] = [];
for (const account of accounts) {
for (const repo of repos) {
try {
const u = await this.judge(repo, account);
if (u) out.push(u);
} catch (err) {
// A permission the forge would not answer is said for that repository; the rest are still looked at.
out.push({ account, repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
why: `the forge did not answer ${account}'s permission on it — ${said(err)}` });
}
}
}
return { checked: repos.length, accounts: accounts.map((account) => ({ account, unreadable: out.filter((u) => u.account === account).length })),
absent, unreadable: out };
}
/** Grant read on every repository an account cannot read (or on those named, `owner/name`), for every account of the
* list the forge holds (or for those named). */
async grant(only?: string[], accountsOnly?: string[]): Promise<Granted> {
const { accounts, absent } = await this.present(accountsOnly);
let repos: ForgeRepository[]; let repos: ForgeRepository[];
if (only && only.length) { if (only && only.length) {
repos = []; repos = [];
@@ -104,36 +142,39 @@ export class ReadAll {
repos.push(repo); repos.push(repo);
} }
} else repos = await this.forge.everyRepository(); } else repos = await this.forge.everyRepository();
const result: Granted = { checked: repos.length, granted: [], refused: [] }; const result: Granted = { checked: repos.length, accounts, absent, granted: [], refused: [] };
for (const repo of repos) { for (const account of accounts) {
let u: Unreadable | null; for (const repo of repos) {
try { const repository = repo.full_name;
u = await this.judge(repo); let u: Unreadable | null;
} catch (err) { try {
// One repository whose permission cannot be read is a refusal of its own, never the end of the pass. u = await this.judge(repo, account);
result.refused.push({ repository: repo.full_name, why: `its permission could not be read — ${err instanceof Error ? err.message : String(err)}` }); } catch (err) {
continue; // One repository whose permission cannot be read is a refusal of its own, never the end of the pass.
} result.refused.push({ account, repository, why: `its permission could not be read — ${said(err)}` });
if (!u) continue; continue;
try { }
await this.forge.addReadCollaborator(repo.owner, repo.name, this.account); if (!u) continue;
} catch (err) { try {
result.refused.push({ repository: repo.full_name, why: err instanceof Error ? err.message : String(err) }); await this.forge.addReadCollaborator(repo.owner, repo.name, account);
continue; } catch (err) {
} result.refused.push({ account, repository, why: said(err) });
// Read back: the forge's own word that the account reads it now, never the grant's answer alone. continue;
let after: string; }
try { // Read back: the forge's own word that the account reads it now, never the grant's answer alone.
after = await this.forge.repositoryPermission(repo.owner, repo.name, this.account); let after: string;
} catch (err) { try {
result.refused.push({ repository: repo.full_name, why: `granted, but its permission could not be read back — ${err instanceof Error ? err.message : String(err)}` }); after = await this.forge.repositoryPermission(repo.owner, repo.name, account);
continue; } catch (err) {
} result.refused.push({ account, repository, why: `granted, but its permission could not be read back — ${said(err)}` });
if (READS.has(after)) { continue;
this.readable.add(repo.full_name); }
result.granted.push(repo.full_name); if (READS.has(after)) {
} else { this.readable.get(account)!.add(repository);
result.refused.push({ repository: repo.full_name, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` }); result.granted.push({ account, repository });
} else {
result.refused.push({ account, repository, why: `the grant was answered, but the forge still gives ${account} "${after}"` });
}
} }
} }
return result; return result;
@@ -142,69 +183,81 @@ export class ReadAll {
/** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */ /** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */
async atStart(): Promise<Granted> { async atStart(): Promise<Granted> {
const r = await this.grant(); const r = await this.grant();
this.log(`${this.account} reads every repository: ${r.checked} looked at, ${r.granted.length} granted read now` + const per = r.accounts.map((a) => {
`${r.granted.length ? ` (${r.granted.join(", ")})` : ""}${r.refused.length ? `, ${r.refused.length} refused: ` + const g = r.granted.filter((x) => x.account === a).map((x) => x.repository);
r.refused.map((x) => `${x.repository} — ${x.why}`).join("; ") : ""}`); return `${a} ${g.length} granted read now${g.length ? ` (${g.join(", ")})` : ""}`;
});
this.log(`the mesh's accounts read every repository: ${r.checked} looked at; ${per.join("; ")}` +
`${r.absent.length ? `; not on the forge yet, so not granted: ${r.absent.join(", ")}` : ""}` +
`${r.refused.length ? `; ${r.refused.length} refused: ` + r.refused.map((x) => `${x.account} on ${x.repository} — ${x.why}`).join("; ") : ""}`);
return r; return r;
} }
/** A forge event: a repository made (or forked), or the first event of one not seen readable, is granted read now. */ /** A forge event: a repository made (or forked), or the first event of one some account was not seen reading, is
* granted read now to every account that cannot read it. */
async onEvent(event: { name: string; body: unknown }): Promise<void> { async onEvent(event: { name: string; body: unknown }): Promise<void> {
const b = (event.body ?? {}) as { owner?: string; repo?: string }; const b = (event.body ?? {}) as { owner?: string; repo?: string };
if (!b.owner || !b.repo) return; if (!b.owner || !b.repo) return;
const full = `${b.owner}/${b.repo}`; const full = `${b.owner}/${b.repo}`;
if (event.name === "forge.repository.deleted") { if (event.name === "forge.repository.deleted") {
this.readable.delete(full); for (const seen of this.readable.values()) seen.delete(full);
return; return;
} }
const made = event.name === "forge.repository.created" || event.name === "forge.fork"; const made = event.name === "forge.repository.created" || event.name === "forge.fork";
// Seen readable once, its events ask nothing: so a repository made private after that is not caught here (the // Seen readable once by every account, its events ask nothing: so a repository made private after that is not
// forge says no change of visibility), only at the next start or by gitea_grant_read. // caught here (the forge says no change of visibility), only at the next start or by gitea_grant_read.
if (!made && this.readable.has(full)) return; if (!made && [...this.readable.values()].every((seen) => seen.has(full))) return;
const r = await this.grant([full]); const r = await this.grant([full]);
if (r.granted.length) this.log(`${this.account} was given read on ${full}, on the forge's ${event.name}`); for (const x of r.granted) this.log(`${x.account} was given read on ${full}, on the forge's ${event.name}`);
for (const x of r.refused) this.log(`${this.account} could not be given read on ${x.repository} — ${x.why}`); for (const x of r.refused) this.log(`${x.account} could not be given read on ${x.repository} — ${x.why}`);
} }
private async judge(repo: ForgeRepository): Promise<Unreadable | null> { private async judge(repo: ForgeRepository, account: string): Promise<Unreadable | null> {
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account); const permission = await this.forge.repositoryPermission(repo.owner, repo.name, account);
const seen = this.readable.get(account)!;
if (READS.has(permission)) { if (READS.has(permission)) {
this.readable.add(repo.full_name); seen.add(repo.full_name);
return null; return null;
} }
this.readable.delete(repo.full_name); seen.delete(repo.full_name);
return { repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, this.account) }; return { account, repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, account) };
} }
} }
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> }; type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
/** The two tools: what the account cannot read and why, and the grant now. */ const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : undefined);
/** The two tools: what each account cannot read and why, and the grant now. */
export function readAllTools(readAll: () => ReadAll): Tool[] { export function readAllTools(readAll: () => ReadAll): Tool[] {
return [ return [
{ {
name: "gitea_unreadable_repos", name: "gitea_unreadable_repos",
description: "Every repository on the forge the tools' own account (mesh-tools) cannot read, and why: the " + description: "Every repository on the forge one of the mesh's own forge accounts cannot read, and why, per account: " +
"permission the forge gives it there, whether the repository is private or archived, and in words the reason " + "the tools' (mesh-tools), the build modules' fetch over ssh (nox-builder) and the machines' agents' (nox-agent). " +
"(a private repository of a user with the account no collaborator, an owner not public). Empty when it reads " + "For each, the permission the forge gives it there, whether the repository is private or archived, and in words " +
"every repository. Read with the site administrator's login, which alone sees every repository.", "the reason (a private repository of a user with the account no collaborator, an owner not public); a count per " +
input: {}, "account, and the accounts the forge does not hold yet. Empty when every account reads every repository. Read " +
run: async () => readAll().unreadable(), "with the site administrator's login, which alone sees every repository.",
input: {
accounts: { type: "array", items: { type: "string" }, description: "the accounts to look at; empty or absent, every one of the mesh's" },
},
run: async (args) => readAll().unreadable(strings(args.accounts)),
}, },
{ {
name: "gitea_grant_read", name: "gitea_grant_read",
description: "Give the tools' own account (mesh-tools) read on every repository it cannot read now, or on the " + description: "Give each of the mesh's own forge accounts (mesh-tools, nox-builder, nox-agent) read on every " +
"repositories named: each becomes a collaborator that reads only, the least the forge offers. The module does " + "repository it cannot read now, or on the repositories named, or for the accounts named: each becomes a " +
"this itself at its start and on the forge's event of a repository made; this is the same act by hand. Answers " + "collaborator that reads only, the least the forge offers. The module does this itself at its start and on the " +
"how many were looked at, which were granted (each read back from the forge) and which the forge refused, in its " + "forge's event of a repository made; this is the same act by hand, such as when a build's fetch says its account " +
"own words. Needs the site administrator's login, used for this grant alone.", "is not authorized to read a repository. Answers how many were looked at, which were granted to which account " +
"(each read back from the forge), which the forge refused, in its own words, and the accounts it does not hold " +
"yet. Needs the site administrator's login, used for this grant alone.",
input: { input: {
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" }, repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" },
accounts: { type: "array", items: { type: "string" }, description: "the accounts to grant; empty or absent, every one of the mesh's" },
}, },
run: async (args) => { run: async (args) => readAll().grant(strings(args.repositories), strings(args.accounts)),
const named = Array.isArray(args.repositories) ? args.repositories.map(String) : undefined;
return readAll().grant(named);
},
}, },
]; ];
} }
+48 -17
View File
@@ -16,7 +16,7 @@ import { join } from "node:path";
import { MintedToken } from "../dist/token.js"; import { MintedToken } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js"; import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js"; import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
import { repoCheckFacts } from "../dist/pulls.js"; import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin"; const ADMIN = "mesh-admin";
@@ -447,12 +447,15 @@ test("a mint with keep-main-rules false (the default) writes no branch protectio
assert.deepEqual(writes, []); assert.deepEqual(writes, []);
}); });
// The tools' account reads every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and // The mesh's accounts read every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and
// jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen. // jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen; and
// once the tools' account read them, the build modules' fetch as nox-builder still could not.
test("the start's pass gives the tools' account read, as a collaborator that reads only, on each repository it could not read, and on no other", async () => { test("the start's pass gives each of the mesh's accounts read, as a collaborator that reads only, on each repository it could not read, and on no other; an account the forge does not hold is said, not refused", async () => {
const env = await environment(); const env = await environment();
await fresh(env).listRepos(); // the account and its team exist, as after any mint await fresh(env).listRepos(); // the account and its team exist, as after any mint
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true }); // made by the forge-account provider
forge.users.delete(AGENT_ACCOUNT); // no machine's key asked for it yet
forgeRepos.splice(0); forgeRepos.splice(0);
addRepo("novox/hq", true); addRepo("novox/hq", true);
addRepo("jschoubben/txt-game", false); addRepo("jschoubben/txt-game", false);
@@ -463,14 +466,24 @@ test("the start's pass gives the tools' account read, as a collaborator that rea
const { GiteaAdmin } = await import("../dist/client.js"); const { GiteaAdmin } = await import("../dist/client.js");
const { ReadAll, readAllTools } = await import("../dist/readall.js"); const { ReadAll, readAllTools } = await import("../dist/readall.js");
const lines: string[] = []; const lines: string[] = [];
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, (l: string) => lines.push(l)); const readAll = new ReadAll(GiteaAdmin.fromEnv(env), meshAccounts(), (l: string) => lines.push(l));
assert.deepEqual(readAll.accounts, [TOOLS_ACCOUNT, BUILDER_ACCOUNT, AGENT_ACCOUNT]);
const [unreadableTool, grantTool] = readAllTools(() => readAll); const [unreadableTool, grantTool] = readAllTools(() => readAll);
// What the tool says before: the four the account cannot read, each with why. // What the tool says before, per account: the four the tools' account cannot read, each with why; the builders'
// account, in no write team here, also not novox/hq; nox-agent not on the forge.
const before = await unreadableTool.run({}) as any; const before = await unreadableTool.run({}) as any;
assert.equal(before.checked, 6); assert.equal(before.checked, 6);
assert.deepEqual(before.unreadable.map((u: any) => u.repository).sort(), const of = (account: string) => before.unreadable.filter((u: any) => u.account === account).map((u: any) => u.repository).sort();
["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]); assert.deepEqual(of(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
assert.deepEqual(of(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
assert.deepEqual(before.accounts, [{ account: TOOLS_ACCOUNT, unreadable: 4 }, { account: BUILDER_ACCOUNT, unreadable: 5 }]);
assert.deepEqual(before.absent, [AGENT_ACCOUNT]);
assert.match(before.unreadable.find((u: any) => u.account === BUILDER_ACCOUNT && u.repository === "jschoubben/car-hunter").why,
/a private repository of jschoubben, on which nox-builder is no collaborator/);
const builderOnly = await unreadableTool.run({ accounts: [BUILDER_ACCOUNT] }) as any;
assert.deepEqual(builderOnly.accounts, [{ account: BUILDER_ACCOUNT, unreadable: 5 }]);
await assert.rejects(unreadableTool.run({ accounts: ["someone"] }), /not one of the mesh's accounts/);
assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why, assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why,
/a private repository of jschoubben, on which mesh-tools is no collaborator/); /a private repository of jschoubben, on which mesh-tools is no collaborator/);
assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/); assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/);
@@ -484,13 +497,27 @@ test("the start's pass gives the tools' account read, as a collaborator that rea
const from = forge.calls.length; const from = forge.calls.length;
const r = await readAll.atStart(); const r = await readAll.atStart();
assert.deepEqual(r.granted.sort(), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]); const grantedTo = (account: string) => r.granted.filter((x: any) => x.account === account).map((x: any) => x.repository).sort();
assert.deepEqual(grantedTo(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
assert.deepEqual(grantedTo(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
assert.deepEqual(r.refused, []); assert.deepEqual(r.refused, []);
assert.deepEqual(r.absent, [AGENT_ACCOUNT]);
const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT ")); const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT "));
assert.equal(puts.length, 4, "the repositories it read already were left alone"); assert.equal(puts.length, 9, "the repositories each read already were left alone, and the absent account was asked nothing");
assert.ok(!puts.some((c) => c.includes(AGENT_ACCOUNT)));
assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants"); assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants");
for (const repo of forgeRepos.filter((x) => x.collaborators.size)) assert.equal(repo.collaborators.get(TOOLS_ACCOUNT), "read", `${repo.full} reads only`); for (const repo of forgeRepos.filter((x) => x.collaborators.size)) {
assert.match(lines.join("\n"), /6 looked at, 4 granted read now/); for (const [account, permission] of repo.collaborators) assert.equal(permission, "read", `${account} on ${repo.full} reads only`);
}
assert.equal(forgeRepos.find((x) => x.full === "jschoubben/car-hunter")!.collaborators.get(BUILDER_ACCOUNT), "read");
assert.match(lines.join("\n"), /6 looked at; mesh-tools 4 granted read now .*; nox-builder 5 granted read now .*; not on the forge yet, so not granted: nox-agent/);
// The account made later is granted by hand (or at the next pass), its rows like any other.
forge.users.set(AGENT_ACCOUNT, { admin: false, password: "unused", active: true });
const agent = await grantTool.run({ accounts: [AGENT_ACCOUNT], repositories: ["jschoubben/car-hunter"] }) as any;
assert.deepEqual(agent.granted, [{ account: AGENT_ACCOUNT, repository: "jschoubben/car-hunter" }]);
assert.deepEqual(agent.absent, []);
forge.users.delete(AGENT_ACCOUNT);
// After: the account's own token sees every repository, and the tool names none. // After: the account's own token sees every repository, and the tool names none.
assert.equal((await fresh(env).listAllRepos()).length, 6); assert.equal((await fresh(env).listAllRepos()).length, 6);
@@ -509,7 +536,8 @@ test("on the forge's events: a repository made is granted read at once, a known
const { GiteaAdmin } = await import("../dist/client.js"); const { GiteaAdmin } = await import("../dist/client.js");
const { ReadAll, readAllTools } = await import("../dist/readall.js"); const { ReadAll, readAllTools } = await import("../dist/readall.js");
const lines: string[] = []; const lines: string[] = [];
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, (l: string) => lines.push(l)); forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true });
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), [TOOLS_ACCOUNT, BUILDER_ACCOUNT], (l: string) => lines.push(l));
await readAll.atStart(); await readAll.atStart();
const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [ const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [
@@ -528,17 +556,20 @@ test("on the forge's events: a repository made is granted read at once, a known
const [owner, repo] = c.repo.split("/"); const [owner, repo] = c.repo.split("/");
await readAll.onEvent({ name: c.event, body: { owner, repo } }); await readAll.onEvent({ name: c.event, body: { owner, repo } });
assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`); assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`);
assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(TOOLS_ACCOUNT) ?? (c.make ? "none" : "read"), c.reads, c.name); for (const account of [TOOLS_ACCOUNT, BUILDER_ACCOUNT]) {
assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(account) ?? (c.make ? "none" : "read"), c.reads, `${c.name}: ${account}`);
}
} }
assert.match(lines.join("\n"), /could not be given read on jschoubben\/stuck — .*422/); assert.match(lines.join("\n"), /mesh-tools could not be given read on jschoubben\/stuck — .*422/);
assert.match(lines.join("\n"), /nox-builder was given read on someone\/fork, on the forge's forge.fork/);
const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any; const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any;
assert.deepEqual(named.refused.map((x: any) => x.repository), ["jschoubben/stuck"]); assert.deepEqual(named.refused.map((x: any) => `${x.account} ${x.repository}`), [`${TOOLS_ACCOUNT} jschoubben/stuck`, `${BUILDER_ACCOUNT} jschoubben/stuck`]);
await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/); await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/);
assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/); assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/);
// A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it. // A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it.
addRepo("jschoubben/after-mute", true); addRepo("jschoubben/after-mute", true);
const pass = await readAll.grant(); const pass = await readAll.grant();
assert.ok(pass.granted.includes("jschoubben/after-mute"), "the pass did not stop at the repository it could not judge"); assert.ok(pass.granted.some((x: any) => x.repository === "jschoubben/after-mute" && x.account === BUILDER_ACCOUNT), "the pass did not stop at the repository it could not judge");
assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why))); assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why)));
const listed = await readAllTools(() => readAll)[0].run({}) as any; const listed = await readAllTools(() => readAll)[0].run({}) as any;
assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown"); assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown");
+9
View File
@@ -22,6 +22,15 @@
import { keepMainRules } from "./mainrules.js"; import { keepMainRules } from "./mainrules.js";
export const TOOLS_ACCOUNT = "mesh-tools"; export const TOOLS_ACCOUNT = "mesh-tools";
/** The build modules' account, whose keys fetch over ssh (internal/forgeaccount BuilderUser, ADR 0466 §3). */
export const BUILDER_ACCOUNT = "nox-builder";
/** The machines' agents' account, whose keys ssh-client reports (internal/forgeaccount AgentUser, ADR 0410 §1). */
export const AGENT_ACCOUNT = "nox-agent";
/** Every forge account the mesh's own parts use: each reads every repository on the forge (readall.ts; the operator,
* 2026-10-26). internal/forgeaccount's test holds the two Go names to its own constants. */
export function meshAccounts(tools: string = TOOLS_ACCOUNT): string[] {
return [tools, BUILDER_ACCOUNT, AGENT_ACCOUNT];
}
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */ /** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
export const TOOLS_ORG = "novox"; export const TOOLS_ORG = "novox";
export const TOOLS_TEAM = "mesh-tools"; export const TOOLS_TEAM = "mesh-tools";
@@ -13,6 +13,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"git.novox.be/novox/mesh-sdk/go/forgekey"
) )
// The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace. // The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace.
@@ -108,7 +110,7 @@ func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, err
r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD") r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD")
commit := strings.TrimSpace(r.Stdout) commit := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || commit == "" { if r.Status != 0 || r.Err != "" || commit == "" {
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", firstLine(r.Err+"\n"+r.Stderr)) return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
} }
return commit, nil return commit, nil
} }
@@ -18,6 +18,7 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
) )
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
@@ -255,7 +256,7 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error {
func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) { func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod") r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod")
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr)) return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
} }
dirs := []string{} dirs := []string{}
for _, l := range strings.Split(r.Stdout, "\n") { for _, l := range strings.Split(r.Stdout, "\n") {
@@ -9,6 +9,7 @@ import (
"syscall" "syscall"
"git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
) )
// mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process. // mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process.
@@ -67,7 +68,7 @@ func (m *Module) dissociate(ctx context.Context, dir string) error {
return nil // no mirror was used return nil // no mirror was used
} }
if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" { if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" {
return fmt.Errorf("git repack in %s: %s", dir, firstLine(r.Err+" "+r.Stderr)) return fmt.Errorf("git repack in %s: %s", dir, forgekey.Said(r.Err+"\n"+r.Stderr))
} }
return os.Remove(alternates) return os.Remove(alternates)
} }
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/buildstep"
"os" "os"
"os/exec" "os/exec"
@@ -121,20 +122,35 @@ func TestTwoUpdatesOfOneMirrorNeverRunAtOnce(t *testing.T) {
} }
} }
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it. // A mirror that cannot be updated does not fail the fetch: the fetch goes on without it, and the line saying so carries
// git's own words. The forge's refusal of a repository its account cannot read came out as "git -C: error:" (ace,
// 2026-10-26: git writes a bare "error:" line before the forge's message).
func TestAMirrorThatCannotBeUpdatedLetsTheFetchGoOnWithoutIt(t *testing.T) { func TestAMirrorThatCannotBeUpdatedLetsTheFetchGoOnWithoutIt(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"} f := &fakeStep{gomods: "go.mod\n"}
f.rule = func(line string) (r runAnswer, ok bool) { f.rule = func(line string) (r runAnswer, ok bool) {
if strings.Contains(line, "mirror") && strings.Contains(line, " fetch ") { if strings.Contains(line, "mirror") && strings.Contains(line, " fetch ") {
return runAnswer{Status: 1, Stderr: "fatal: no space left\n"}, true return runAnswer{Status: 128, Stderr: "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.\nerror: \nfatal: Could not read from remote repository.\n"}, true
} }
return r, false return r, false
} }
m, states := fetchModule(t, f) m, states := fetchModule(t, f)
var mu sync.Mutex
var said []string
m.Say = func(format string, a ...any) {
mu.Lock()
defer mu.Unlock()
said = append(said, fmt.Sprintf(format, a...))
}
ws := fetchOnce(t, m, states, fetchArgs).Workspace ws := fetchOnce(t, m, states, fetchArgs).Workspace
if _, err := os.Stat(filepath.Join(m.Work, ws, "tree", ".git", "objects", "info", "alternates")); err == nil { if _, err := os.Stat(filepath.Join(m.Work, ws, "tree", ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the workspace borrows from a mirror that is not there") t.Errorf("the workspace borrows from a mirror that is not there")
} }
mu.Lock()
defer mu.Unlock()
all := strings.Join(said, "\n")
if !strings.Contains(all, "is not used: git fetch: error: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.; fatal: Could not read from remote repository.") {
t.Errorf("the mirror's line lost git's words:\n%s", all)
}
} }
func sleepABit() { time.Sleep(30 * time.Millisecond) } func sleepABit() { time.Sleep(30 * time.Millisecond) }
@@ -15,6 +15,8 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
) )
const forgePrefix = "git.novox.be/novox/" const forgePrefix = "git.novox.be/novox/"
@@ -125,7 +127,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin
repository := "novox/" + p.repo repository := "novox/" + p.repo
r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head") r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head")
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, firstLine(r.Err+" "+r.Stderr)) return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr))
} }
commit, ref := "", "" commit, ref := "", ""
for _, l := range strings.Split(r.Stdout, "\n") { for _, l := range strings.Split(r.Stdout, "\n") {
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d h1:7F/dFerm01wvuGLPgJ67/VfdcvDexc+BQEE3qzOrlu4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d h1:7F/dFerm01wvuGLPgJ67/VfdcvDexc+BQEE3qzOrlu4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -212,7 +212,7 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, grou
func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) { func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) {
r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json") r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return nil, nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr)) return nil, nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
} }
packages = map[string]bool{} packages = map[string]bool{}
for _, l := range strings.Split(r.Stdout, "\n") { for _, l := range strings.Split(r.Stdout, "\n") {
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d h1:7F/dFerm01wvuGLPgJ67/VfdcvDexc+BQEE3qzOrlu4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=