diff --git a/modules/builder/module.json b/modules/builder/module.json index d061ed7..3d91d70 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -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" ] } ] diff --git a/modules/gitea/Dockerfile b/modules/gitea/Dockerfile new file mode 100644 index 0000000..b0ac565 --- /dev/null +++ b/modules/gitea/Dockerfile @@ -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 diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 1fa36f4..aadb4cc 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -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 { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: this.authorization, + ...(options.headers as Record | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 293af46..54a9b46 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -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" + } + ] + } } diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts new file mode 100644 index 0000000..36d66e2 --- /dev/null +++ b/modules/gitea/provisioner/index.ts @@ -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 { + // 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 { + await gitea.deleteUser(p.as); + }, +}); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json index 3677859..51f4046 100644 --- a/modules/gitea/tsconfig.json +++ b/modules/gitea/tsconfig.json @@ -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"] }