The mesh's forge accounts all read every repository, and a build's fetch says the forge's own words
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:
+15
-9
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
Reference in New Issue
Block a user