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 84275cfc (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.
264 lines
15 KiB
TypeScript
264 lines
15 KiB
TypeScript
// The mesh's own forge accounts read every repository on the forge (the operator, 2026-10-26: "the mesh's forge account
|
|
// 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
|
|
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
|
|
// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every
|
|
// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on
|
|
// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership.
|
|
//
|
|
// - **at the start**, one pass over every repository of every user and organisation;
|
|
// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at
|
|
// once, and the first event of any repository this process has not yet seen readable, since the forge may not
|
|
// send a repository's creation for every owner. Nothing polls for it.
|
|
//
|
|
// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public
|
|
// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at
|
|
// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile.
|
|
//
|
|
// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration,
|
|
// which on another user's repository only the site administrator holds; the module holds that login for setting the
|
|
// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read
|
|
// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools'
|
|
// own calls, which keep their token.
|
|
//
|
|
// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator
|
|
// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and
|
|
// `gitea_grant_read` grants again now.
|
|
|
|
import type { ForgeRepository } from "./client.js";
|
|
|
|
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
|
|
export interface ReadAllForge {
|
|
everyRepository(): Promise<ForgeRepository[]>;
|
|
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>;
|
|
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
|
}
|
|
|
|
/** A repository an account cannot read, and why. */
|
|
export interface Unreadable {
|
|
account: string;
|
|
repository: string;
|
|
permission: string;
|
|
private: boolean;
|
|
archived: boolean;
|
|
why: string;
|
|
}
|
|
|
|
export interface Granted {
|
|
/** Repositories looked at (for each account). */
|
|
checked: number;
|
|
/** The accounts looked at: those the forge holds. */
|
|
accounts: string[];
|
|
/** Accounts of the list the forge does not hold yet: nothing is granted to them until it does. */
|
|
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 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. */
|
|
export function whyUnreadable(repo: ForgeRepository, account: string): string {
|
|
const parts: string[] = [];
|
|
if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`);
|
|
else if (repo.owner_visibility !== "public") {
|
|
parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`);
|
|
} else parts.push(`the forge answers ${account} no read on it`);
|
|
if (repo.archived) parts.push("archived");
|
|
return parts.join("; ");
|
|
}
|
|
|
|
export class ReadAll {
|
|
/** Per account, the repositories this process has seen it read, by full name: an event of a repository every account
|
|
* was seen reading asks nothing more. */
|
|
readonly readable = new Map<string, Set<string>>();
|
|
readonly accounts: readonly string[];
|
|
|
|
constructor(private readonly forge: ReadAllForge, accounts: readonly string[] | string,
|
|
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {
|
|
this.accounts = [...new Set(typeof accounts === "string" ? [accounts] : accounts)];
|
|
if (!this.accounts.length) throw new Error("no forge account to give read: the list is empty");
|
|
for (const a of this.accounts) this.readable.set(a, new Set());
|
|
}
|
|
|
|
/** The accounts of the list (or of those named) the forge holds, and those it does not hold yet. */
|
|
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[];
|
|
if (only && only.length) {
|
|
repos = [];
|
|
for (const full of only) {
|
|
const [owner, name, ...rest] = full.split("/");
|
|
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
|
|
const repo = await this.forge.repository(owner, name);
|
|
if (!repo) throw new Error(`the forge holds no repository ${full}`);
|
|
repos.push(repo);
|
|
}
|
|
} else repos = await this.forge.everyRepository();
|
|
const result: Granted = { checked: repos.length, accounts, absent, granted: [], refused: [] };
|
|
for (const account of accounts) {
|
|
for (const repo of repos) {
|
|
const repository = repo.full_name;
|
|
let u: Unreadable | null;
|
|
try {
|
|
u = await this.judge(repo, account);
|
|
} catch (err) {
|
|
// 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)}` });
|
|
continue;
|
|
}
|
|
if (!u) continue;
|
|
try {
|
|
await this.forge.addReadCollaborator(repo.owner, repo.name, account);
|
|
} catch (err) {
|
|
result.refused.push({ account, repository, why: said(err) });
|
|
continue;
|
|
}
|
|
// Read back: the forge's own word that the account reads it now, never the grant's answer alone.
|
|
let after: string;
|
|
try {
|
|
after = await this.forge.repositoryPermission(repo.owner, repo.name, account);
|
|
} catch (err) {
|
|
result.refused.push({ account, repository, why: `granted, but its permission could not be read back — ${said(err)}` });
|
|
continue;
|
|
}
|
|
if (READS.has(after)) {
|
|
this.readable.get(account)!.add(repository);
|
|
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;
|
|
}
|
|
|
|
/** 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> {
|
|
const r = await this.grant();
|
|
const per = r.accounts.map((a) => {
|
|
const g = r.granted.filter((x) => x.account === a).map((x) => x.repository);
|
|
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;
|
|
}
|
|
|
|
/** 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> {
|
|
const b = (event.body ?? {}) as { owner?: string; repo?: string };
|
|
if (!b.owner || !b.repo) return;
|
|
const full = `${b.owner}/${b.repo}`;
|
|
if (event.name === "forge.repository.deleted") {
|
|
for (const seen of this.readable.values()) seen.delete(full);
|
|
return;
|
|
}
|
|
const made = event.name === "forge.repository.created" || event.name === "forge.fork";
|
|
// Seen readable once by every account, its events ask nothing: so a repository made private after that is not
|
|
// caught here (the forge says no change of visibility), only at the next start or by gitea_grant_read.
|
|
if (!made && [...this.readable.values()].every((seen) => seen.has(full))) return;
|
|
const r = await this.grant([full]);
|
|
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(`${x.account} could not be given read on ${x.repository} — ${x.why}`);
|
|
}
|
|
|
|
private async judge(repo: ForgeRepository, account: string): Promise<Unreadable | null> {
|
|
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, account);
|
|
const seen = this.readable.get(account)!;
|
|
if (READS.has(permission)) {
|
|
seen.add(repo.full_name);
|
|
return null;
|
|
}
|
|
seen.delete(repo.full_name);
|
|
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> };
|
|
|
|
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[] {
|
|
return [
|
|
{
|
|
name: "gitea_unreadable_repos",
|
|
description: "Every repository on the forge one of the mesh's own forge accounts cannot read, and why, per account: " +
|
|
"the tools' (mesh-tools), the build modules' fetch over ssh (nox-builder) and the machines' agents' (nox-agent). " +
|
|
"For each, the permission the forge gives it there, whether the repository is private or archived, and in words " +
|
|
"the reason (a private repository of a user with the account no collaborator, an owner not public); a count per " +
|
|
"account, and the accounts the forge does not hold yet. Empty when every account reads every repository. Read " +
|
|
"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",
|
|
description: "Give each of the mesh's own forge accounts (mesh-tools, nox-builder, nox-agent) read on every " +
|
|
"repository it cannot read now, or on the repositories named, or for the accounts named: each becomes a " +
|
|
"collaborator that reads only, the least the forge offers. The module does this itself at its start and on the " +
|
|
"forge's event of a repository made; this is the same act by hand, such as when a build's fetch says its account " +
|
|
"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: {
|
|
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) => readAll().grant(strings(args.repositories), strings(args.accounts)),
|
|
},
|
|
];
|
|
}
|