Author SHA1 Message Date
jschoubben d70cb18ea0 The forge watches every repository, not the administrator's own
/user/repos lists what the token's user owns, which for the mesh's administrator is nothing — so
the forge module watched an empty list and never announced a merge. It reads the forge's whole
view through the search endpoint, every page.
2026-09-28 04:31:20 +02:00
mesh-admin 1d71787896 Merge pull request 'The forge announces every merge, whoever made it' (#124) from feat/the-forge-announces-every-merge into main 2026-09-28 01:03:48 +00:00
jschoubben eb62289f89 The forge announces every merge, whoever made it
The merge tool emitted at the instant it acted; a merge made in the forge's own
pages or over its API emitted nothing, and the mesh went on believing every module
current with its source (novox/hq 04-ISSUES/131). Merged pull requests are now
watched the way repositories are: what the forge holds, asked for on a tick,
announced once, with the merge commit and the clone URL a build needs. What has
been announced is kept beside the module's state, so a restart does not announce
the whole history again, and a first tick with no record announces nothing.
2026-09-28 02:54:48 +02:00
jschoubben f5969a2f9f Merge pull request 'nats declares the certificate directory it mounts' (#123) from feat/nats-serves-the-meshs-certificate into main 2026-09-27 22:31:20 +00:00
jschoubben 7f3d259cf5 nats: drop the access to a certificate directory it no longer mounts 2026-09-28 00:16:23 +02:00
jschoubben 721149eda1 nats declares the certificate directory it mounts
The mesh's broker certificate is the operator's, kept outside any module and
mounted read-only by whatever serves the bus; the controller declares that
access and now so does nats, the same way. A mount nothing declares is refused
at registration (ADR 0030), which is how this was found.
2026-09-28 00:15:59 +02:00
jschoubben 8e27bc1e36 Merge pull request 'nats serves the mesh's existing broker certificate' (#122) from feat/nats-serves-the-meshs-certificate into main 2026-09-27 22:07:29 +00:00
jschoubben f1212620e4 nats serves the mesh's existing broker certificate
The module mounted a TLS directory nothing fills, so the server could not start
on a mesh that was not raised by the genesis template. The mesh already has a
broker certificate every machine pins by fingerprint and the controller trusts;
serving the new bus with it means no pin changes when a machine moves and there
is no second certificate to be wrong about. No ca_file: the directory has none,
and a pinning client checks the leaf and nothing else.
2026-09-28 00:04:10 +02:00
jschoubben b1b18ae390 Merge pull request 'nats declares the upstream image it is built from' (#121) from fix/nats-declares-its-base into main 2026-09-27 21:40:50 +00:00
4 changed files with 90 additions and 7 deletions
+24 -2
View File
@@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
/** A repository, trimmed to what the mesh cares about. */
export interface GiteaRepo {
full_name: string;
/** The URL a build clones — what a module records as its source. */
clone_url?: string;
name: string;
owner: string;
private: boolean;
@@ -34,6 +36,9 @@ export interface GiteaPull {
title: string;
state: string;
merged: boolean;
/** The commit the merge produced — what a build of the base branch is made from. */
merge_commit_sha?: string;
merged_at?: string;
user?: string;
head?: string;
base?: string;
@@ -116,9 +121,23 @@ export class GiteaClient {
// ---- Repositories ----
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
return (repos ?? []).map(GiteaClient.mapRepo);
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
return (found?.data ?? []).map(GiteaClient.mapRepo);
}
/** Every repository, all pages. */
async listAllRepos(): Promise<GiteaRepo[]> {
const all: GiteaRepo[] = [];
for (let page = 1; page < 100; page++) {
const batch = await this.listRepos(page, 50);
all.push(...batch);
if (batch.length < 50) break;
}
return all;
}
async createRepo(data: {
@@ -232,6 +251,7 @@ export class GiteaClient {
private static mapRepo(r: any): GiteaRepo {
return {
full_name: r.full_name,
clone_url: r.clone_url ?? undefined,
name: r.name,
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
private: Boolean(r.private),
@@ -259,6 +279,8 @@ export class GiteaClient {
title: p.title,
state: p.state,
merged: Boolean(p.merged),
merge_commit_sha: p.merge_commit_sha ?? undefined,
merged_at: p.merged_at ?? undefined,
user: p.user?.login,
head: p.head?.ref,
base: p.base?.ref,
+59 -2
View File
@@ -31,7 +31,7 @@ try {
const seen = new Set<string>();
let primed = false;
async function pollRepos(client: GiteaClient): Promise<void> {
const repos = await client.listRepos(1, 50);
const repos = await client.listAllRepos();
for (const repo of repos) {
if (!seen.has(repo.full_name)) {
if (primed) {
@@ -49,6 +49,62 @@ async function pollRepos(client: GiteaClient): Promise<void> {
primed = true;
}
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
// What has been announced is kept beside the module's state, so a restart does not announce the
// whole history again — and the first tick on a machine with no record announces nothing, because
// everything it sees then predates the watching.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
const announced = new Set<string>();
let primedMerges = false;
if (mergedRecord && existsSync(mergedRecord)) {
try {
for (const sha of JSON.parse(readFileSync(mergedRecord, "utf8")) as string[]) announced.add(sha);
primedMerges = true;
} catch {
// An unreadable record is treated as no record: prime again rather than re-announce history.
}
}
function keepAnnounced(): void {
if (!mergedRecord) return;
mkdirSync(join(mergedRecord, ".."), { recursive: true });
const tmp = mergedRecord + ".tmp";
writeFileSync(tmp, JSON.stringify([...announced].slice(-2000)));
renameSync(tmp, mergedRecord);
}
async function pollMerged(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos();
let changed = false;
for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
for (const pull of pulls) {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
if (primedMerges) {
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
});
}
announced.add(pull.merge_commit_sha);
changed = true;
}
}
if (!primedMerges || changed) keepAnnounced();
primedMerges = true;
}
if (gitea) {
const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
@@ -70,5 +126,6 @@ if (gitea) {
run();
};
tick(() => pollRepos(client), 60_000);
console.log("[gitea] watching for new repositories");
tick(() => pollMerged(client), 30_000);
console.log("[gitea] watching for new repositories and merged pull requests");
}
+4
View File
@@ -231,6 +231,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// Read the PR first, so the merged event carries a title and branches, not just a number.
const pull = await gitea.getPullRequest(owner, repo, number);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// Read it again: the merge commit only exists now, and it is what a build is made from.
const merged = await gitea.getPullRequest(owner, repo, number);
await emit("pull.merged", {
owner,
repo,
@@ -238,6 +240,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: merged.merge_commit_sha,
merged_at: merged.merged_at,
method,
html_url: pull.html_url,
});
+3 -3
View File
@@ -47,7 +47,7 @@
"id": "server-conf",
"type": "file",
"path": "/var/lib/nats-module/conf/nats.conf",
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\n# The mesh's own broker certificate \u2014 the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
"mode": "0644"
},
{
@@ -61,14 +61,14 @@
"volumes": [
"/var/lib/mesh-broker-nats:/data",
"/var/lib/nats-module/conf:/etc/nats:ro",
"/var/lib/mesh-broker-nats-tls:/tls:ro"
"/var/lib/mesh-broker-tls:/tls:ro"
],
"artifact": "server"
}
],
"accesses": [
{
"path": "/var/lib/mesh-broker-nats-tls",
"path": "/var/lib/mesh-broker-tls",
"mode": "read"
}
],