gitea provides the package registry; the builder gets its npm credential
gitea gains the package-registry provision: serves/receives/grants, an admin own-secret, a postgres-shaped build, and a provisioner that creates a gitea user per consumer with the mesh-minted password and seals nothing (hq ADR 0048). The builder takes its registry credential as an own-secret rather than a resolved provision, because gitea-as-module needs the base to build its provisioner and so cannot resolve before the base — a cycle the own-secret avoids. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user