Foundation modules adopted, and the mesh-controller/foundation rename #23

Merged
jschoubben merged 18 commits from feat/foundation-and-rename into main 2026-09-16 21:22:48 +00:00
6 changed files with 333 additions and 8 deletions
Showing only changes of commit 065ddd6d69 - Show all commits
+13 -3
View File
@@ -17,7 +17,8 @@
"module.builder.built"
],
"own-secrets": {
"broker": "/var/lib/mesh/builder/broker"
"broker": "/var/lib/mesh/builder/broker",
"npm-password": "/var/lib/mesh/builder/npm-password"
},
"resources": [
{
@@ -37,7 +38,14 @@
"type": "file",
"path": "/var/lib/mesh/builder/builder.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n"
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/workspace\n"
},
{
"id": "package-binding",
"type": "file",
"path": "/var/lib/mesh/builder/package-registry.json",
"mode": "0600",
"content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n"
},
{
"id": "server",
@@ -53,7 +61,9 @@
"/var/run/docker.sock:/var/run/docker.sock"
],
"restart-on": [
"builder-env"
"builder-env",
"package-binding",
"needs-npm-password"
]
}
]
+37
View File
@@ -0,0 +1,37 @@
# gitea's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
# consumer.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings.
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/gitea
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not
# through a CLI the way postgres drives psql — so the runtime base holds everything this needs.
COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist
# What a tool host should load from this module: its event consumer and its tools, which are
# separate entrypoints because they are loaded by different things. The provisioner is the third,
# and is not listed here — the declaration names it in the container's `args`, because it is what
# this module's own container runs. One image, because they are one module and share a client.
ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js
+160
View File
@@ -249,3 +249,163 @@ export class GiteaClient {
};
}
}
/** One raw response the admin client acts on: the status code decides idempotency (a 422/409 on
* create means "already there", a 404 on delete means "already gone"), the body carries ids. */
interface AdminResponse {
readonly status: number;
readonly body: any;
}
/**
* The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only
* by the provisioner (novox/hq ADR 0048/0076).
*
* The token-authenticated {@link GiteaClient} above serves the tools and the event consumer, which
* read repos and open issues. Provisioning is different: it creates and deletes *users* and manages
* org teams — admin-API operations authenticated as the mesh's gitea admin, whose password is a mesh
* own-secret. Basic auth is what the admin API takes, and keeping this separate from GiteaClient
* keeps the two credentials and their two audiences apart.
*
* Every method is idempotent: the reconcile harness calls create repeatedly, so "already exists" is
* success, not an error.
*/
export class GiteaAdmin {
readonly baseUrl: string;
private readonly authorization: string;
constructor(url: string, user: string, password: string) {
this.baseUrl = url.replace(/\/+$/, "");
this.authorization = "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
}
/**
* Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the forge's
* loopback, since the provisioner shares the host's network), the admin login from
* MESH_GITEA_ADMIN_USER, and the admin password from the file MESH_GITEA_ADMIN_PASSWORD_FILE names
* — the mesh own-secret the host unsealed. Trailing newline trimmed, the way the harness trims a
* sealed secret. Throws rather than hand back a client that fails on first call.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaAdmin {
const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
const user = env.MESH_GITEA_ADMIN_USER;
if (!user) throw new Error("no Gitea admin user — set MESH_GITEA_ADMIN_USER");
const file = env.MESH_GITEA_ADMIN_PASSWORD_FILE;
if (!file) throw new Error("no Gitea admin password file — set MESH_GITEA_ADMIN_PASSWORD_FILE");
const password = readFileSync(file, "utf8").replace(/\n$/, "");
return new GiteaAdmin(url, user, password);
}
/** A single admin-API call. Unlike GiteaClient.request, this returns the status rather than
* throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an
* unexpected status becomes an error, and only where the caller says so. */
private async request(path: string, options: RequestInit = {}): Promise<AdminResponse> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authorization,
...(options.headers as Record<string, string> | undefined),
},
});
const text = await res.text();
let body: any = null;
if (text) {
try { body = JSON.parse(text); } catch { body = text; }
}
return { status: res.status, body };
}
/** Fail with the forge's own message when a status the caller did not expect comes back. */
private static fail(path: string, res: AdminResponse): never {
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`);
}
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
* create won the race) are all success. */
async ensureOrg(name: string): Promise<void> {
const existing = await this.request(`/orgs/${encodeURIComponent(name)}`);
if (existing.status === 200) return;
const res = await this.request("/orgs", {
method: "POST",
body: JSON.stringify({ username: name, visibility: "private" }),
});
if (res.status === 201 || res.status === 422 || res.status === 409) return;
GiteaAdmin.fail("/orgs", res);
}
/** Ensure the org's package team exists, granting read+write on packages, and return its id. The
* team is found by name if it is already there, created otherwise; a lost create race is resolved
* by re-listing. */
async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
const found = await this.findTeam(org, team);
if (found !== null) return found;
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
method: "POST",
body: JSON.stringify({
name: team,
permission: "read",
// Package access is a per-unit grant; the team needs write on the packages unit and nothing
// else. includes_all_repositories keeps the team's repo view whole without widening its
// repo permission beyond read.
units_map: { "repo.packages": packageWrite ? "write" : "read" },
includes_all_repositories: true,
can_create_org_repo: false,
}),
});
if (res.status === 201) return Number(res.body?.id);
if (res.status === 422 || res.status === 409) {
const after = await this.findTeam(org, team);
if (after !== null) return after;
}
return GiteaAdmin.fail(`/orgs/${org}/teams`, res);
}
private async findTeam(org: string, team: string): Promise<number | null> {
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
if (res.status !== 200) return null;
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
return match ? Number(match.id) : null;
}
/** Ensure a user exists with exactly this password. Created if absent; if already there, its
* password is patched — so the mesh minting a new secret takes on the next reconcile. */
async ensureUser(username: string, password: string, email: string): Promise<void> {
const res = await this.request("/admin/users", {
method: "POST",
body: JSON.stringify({ username, email, password, must_change_password: false }),
});
if (res.status === 201) return;
if (res.status === 422 || res.status === 409) {
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
method: "PATCH",
// login_name is required by the admin edit endpoint; for a local user it is the username.
body: JSON.stringify({ login_name: username, password, must_change_password: false }),
});
if (patch.status === 200) return;
GiteaAdmin.fail(`/admin/users/${username}`, patch);
}
GiteaAdmin.fail("/admin/users", res);
}
/** Add a user to a team, which also makes them an org member. Idempotent: adding an existing
* member returns 204 again. */
async addUserToTeam(teamId: number, username: string): Promise<void> {
const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, {
method: "PUT",
});
if (res.status === 204 || res.status === 200) return;
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
}
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
* an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> {
const res = await this.request(`/admin/users/${encodeURIComponent(username)}?purge=true`, {
method: "DELETE",
});
if (res.status === 204 || res.status === 200 || res.status === 404) return;
GiteaAdmin.fail(`/admin/users/${username}`, res);
}
}
+78 -4
View File
@@ -43,8 +43,22 @@
"why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it"
}
],
"serves": {
"package-registry": {
"scheme": "http",
"port": 3000,
"npm-path": "/api/packages/novox/npm/"
}
},
"receives": {
"package-registry": "/var/lib/gitea/grants/mesh.json"
},
"grants": {
"package-registry": "/var/lib/gitea/grants"
},
"own-secrets": {
"internal-token": "/var/lib/gitea/internal-token.secret",
"admin": "/var/lib/gitea/admin.secret",
"broker": "/var/lib/mesh/gitea/broker"
},
"resources": [
@@ -60,6 +74,12 @@
"path": "/var/lib/gitea",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/gitea/grants",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
@@ -95,6 +115,30 @@
"/services/gitea/gitea:/data"
]
},
{
"id": "admin-bootstrap",
"type": "container",
"name": "mesh-gitea-admin",
"image": "gitea/gitea@sha256:dfc61e347c8b582df918f4556401bf2cecdfbdb56c5282ae9488dd76fca3e41c",
"run-once": true,
"env": {
"USER_UID": "1000",
"USER_GID": "1000",
"MESH_GITEA_ADMIN_USER": "mesh-admin"
},
"env-file": [
"/var/lib/gitea/server.env"
],
"volumes": [
"/services/gitea/gitea:/data",
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
],
"args": [
"/bin/sh",
"-c",
"su-exec git gitea admin user create --admin --username \"$MESH_GITEA_ADMIN_USER\" --email mesh-admin@localhost --password \"$(cat /run/secrets/admin)\" --must-change-password=false || true"
]
},
{
"id": "runtime-config",
"type": "file",
@@ -107,17 +151,26 @@
"id": "runtime",
"type": "container",
"name": "mesh-gitea",
"image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host",
"volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro"
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
"/var/lib/gitea/grants:/var/lib/gitea/grants:ro",
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GITEA_URL": "http://127.0.0.1:3000",
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json"
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
},
"artifact": "runtime",
"args": [
"run",
"/app/modules/gitea/dist/provisioner/index.js"
],
"restart-on": [
"runtime-config"
]
@@ -128,5 +181,26 @@
"name": "package-registry",
"scope": "mesh"
}
]
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+44
View File
@@ -0,0 +1,44 @@
// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry`
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
// the sdk harness's; this writes only the per-service half: how gitea creates and removes a
// consumer's npm credential (novox/hq ADR 0048/0076).
//
// The `package-registry` interface: a consumer authenticates to the npm registry at
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
// `novox`; a consumer is a gitea *user* placed on that org's package team.
//
// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives
// the login and hands it to both ends, and mints the password. gitea creates a user under exactly
// that login and sets exactly that password every run — so a rotation takes — and seals nothing: the
// consumer already has its copy through the mesh's own channel.
//
// The admin calls run through GiteaAdmin (basic auth as the mesh's gitea admin), which is the
// module's one boundary to the forge's admin API (see client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { GiteaAdmin } from "../client.js";
// The npm registry owner: a gitea org named `novox`, whose package team every consumer joins so it
// can read and write packages under the `@novox` scope (ADR 0076).
const ORG = "novox";
const PACKAGE_TEAM = "packages";
const gitea = GiteaAdmin.fromEnv();
runProvisioner("package-registry", {
async create(p: Provision): Promise<void> {
// The org and its package team are the same for every consumer; ensuring them per-create is
// idempotent and needs no separate bootstrap step.
await gitea.ensureOrg(ORG);
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
// The user carries the consumer's login and the mesh's minted password, set every run so a
// rotation takes. Membership of the package team is what grants read+write on packages.
await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`);
await gitea.addUserToTeam(teamId, p.as);
},
async remove(p: { as: string }): Promise<void> {
await gitea.deleteUser(p.as);
},
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}