Merge pull request 'Foundation modules adopted, and the mesh-controller/foundation rename' (#23) from feat/foundation-and-rename into main
This commit was merged in pull request #23.
This commit is contained in:
@@ -6,7 +6,7 @@ per module under [`modules/`](modules/).
|
|||||||
This is **data, not a control-plane concern**. The manifests describe *what a module is*: what it
|
This is **data, not a control-plane concern**. The manifests describe *what a module is*: what it
|
||||||
provides, what it requires, the seats it claims, the resources the host applies for it. The
|
provides, what it requires, the seats it claims, the resources the host applies for it. The
|
||||||
engine that reads them — parsing, eligibility resolution, sealing, declaration emission — lives
|
engine that reads them — parsing, eligibility resolution, sealing, declaration emission — lives
|
||||||
in the control plane (`novox/mesh-control`, `internal/catalogue`), which consumes this repository
|
in the control plane (`novox/mesh-controller`, `internal/catalogue`), which consumes this repository
|
||||||
as a build source. The host (`novox/mesh-host`) applies the declarations the control plane emits.
|
as a build source. The host (`novox/mesh-host`) applies the declarations the control plane emits.
|
||||||
Neither is here.
|
Neither is here.
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ manifest names its image (pinned by digest), the resources the host owns for it
|
|||||||
files, the container, the private network it joins), what it `requires` from a provider and what
|
files, the container, the private network it joins), what it `requires` from a provider and what
|
||||||
it `provides` to consumers, and the sealed secrets it needs filled on the machine.
|
it `provides` to consumers, and the sealed secrets it needs filled on the machine.
|
||||||
|
|
||||||
- **Core mesh components are not modules.** The node host, the substrate, the control-plane
|
- **Core mesh components are not modules.** The node host, the foundation, the control-plane
|
||||||
contexts and the surfaces are the mesh itself; they ship as their own repositories
|
contexts and the surfaces are the mesh itself; they ship as their own repositories
|
||||||
(`mesh-host`, `mesh-substrate`, `mesh-control`, `mesh-surfaces`, `mesh-sdk`), not from here.
|
(`mesh-host`, `mesh-foundation`, `mesh-controller`, `mesh-surfaces`, `mesh-sdk`), not from here.
|
||||||
- **Standalone applications are not here either.** A larger application lives in its own
|
- **Standalone applications are not here either.** A larger application lives in its own
|
||||||
repository with its manifest at the root, registered with the mesh as a build source
|
repository with its manifest at the root, registered with the mesh as a build source
|
||||||
(novox/hq [ADR 0010](https://git.novox.be/novox/hq)). This repository holds the modules the
|
(novox/hq [ADR 0010](https://git.novox.be/novox/hq)). This repository holds the modules the
|
||||||
@@ -46,7 +46,7 @@ provider/consumer edge — is data inside the manifests, not a directory the tre
|
|||||||
The shape a manifest must satisfy is owned by the control plane's catalogue engine and is what
|
The shape a manifest must satisfy is owned by the control plane's catalogue engine and is what
|
||||||
validates a manifest before a machine ever sees it — a stray key, a consumer contributing the
|
validates a manifest before a machine ever sees it — a stray key, a consumer contributing the
|
||||||
wrong provision field, an image that nothing builds. That validation belongs with this
|
wrong provision field, an image that nothing builds. That validation belongs with this
|
||||||
repository and is being re-homed here from `mesh-control`; until it is, the pipeline is the
|
repository and is being re-homed here from `mesh-controller`; until it is, the pipeline is the
|
||||||
gate — it builds each module and refuses a manifest it cannot resolve.
|
gate — it builds each module and refuses a manifest it cannot resolve.
|
||||||
|
|
||||||
## Where the reasoning lives
|
## Where the reasoning lives
|
||||||
|
|||||||
@@ -48,3 +48,6 @@ for (;;) {
|
|||||||
await sleep(30000);
|
await sleep(30000);
|
||||||
await pingOnce();
|
await pingOnce();
|
||||||
}
|
}
|
||||||
|
// changed by the one-node test at build 66e54af151df
|
||||||
|
// changed by the one-node test at build 4fb41636cffd
|
||||||
|
// changed by the one-node test at build a69f083bf6a6
|
||||||
|
|||||||
@@ -48,6 +48,12 @@
|
|||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "amqp-ping",
|
"name": "amqp-ping",
|
||||||
"network": "amqp-ping",
|
"network": "amqp-ping",
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/mesh/amqp-ping/broker:/run/secrets/broker:ro"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_BROKER_FILE": "/run/secrets/broker"
|
||||||
|
},
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/amqp-ping/amqp.env"
|
"/var/lib/amqp-ping/amqp.env"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and
|
// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and
|
||||||
// writing a sealed refresh token in the wire shape mesh-control reads.
|
// writing a sealed refresh token in the wire shape mesh-controller reads.
|
||||||
//
|
//
|
||||||
// **The public key is delivered, not derived.** The manager module holds no node key of its own
|
// **The public key is delivered, not derived.** The manager module holds no node key of its own
|
||||||
// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it
|
// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it
|
||||||
// needs the node's PUBLIC sealing key, and mesh-control puts that in the manager holder's bound facts
|
// needs the node's PUBLIC sealing key, and mesh-controller puts that in the manager holder's bound facts
|
||||||
// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every
|
// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every
|
||||||
// rotation read it from there.
|
// rotation read it from there.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function managerPublicKey(boundFile: string): string {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-control reads. */
|
/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-controller reads. */
|
||||||
export function writeSealedGrant(path: string, sealed: string, managerKey: string): void {
|
export function writeSealedGrant(path: string, sealed: string, managerKey: string): void {
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
const tmp = `${path}.tmp`;
|
const tmp = `${path}.tmp`;
|
||||||
|
|||||||
@@ -12,13 +12,13 @@
|
|||||||
// never the refresh token — which it seals per consumer holder and stores;
|
// never the refresh token — which it seals per consumer holder and stores;
|
||||||
// 5. poll usage with the fresh access token and record the licence-grain reading.
|
// 5. poll usage with the fresh access token and record the licence-grain reading.
|
||||||
//
|
//
|
||||||
// mesh-control receives the products of steps 3–4 through `licence submit-refresh` (access token +
|
// mesh-controller receives the products of steps 3–4 through `licence submit-refresh` (access token +
|
||||||
// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to
|
// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to
|
||||||
// be opened here at all — the host did that.
|
// be opened here at all — the host did that.
|
||||||
//
|
//
|
||||||
// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the
|
// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the
|
||||||
// host mounts; the submit itself (the transport to mesh-control) is done by the caller invoking
|
// host mounts; the submit itself (the transport to mesh-controller) is done by the caller invoking
|
||||||
// `mesh-control licence submit-refresh`. In the lab that caller is the scenario; in production it is
|
// `mesh-controller licence submit-refresh`. In the lab that caller is the scenario; in production it is
|
||||||
// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED
|
// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED
|
||||||
// — because a cross-node authenticated command surface is out of this module's scope.
|
// — because a cross-node authenticated command surface is out of this module's scope.
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
// carve-out delivers the refresh token to the manager module the way the mesh delivers every other
|
// carve-out delivers the refresh token to the manager module the way the mesh delivers every other
|
||||||
// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host
|
// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host
|
||||||
// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
|
// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
|
||||||
// internal/identity/sealing.go), and mesh-control seals with `box.SealAnonymous`
|
// internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous`
|
||||||
// (mesh-control internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
|
// (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
|
||||||
//
|
//
|
||||||
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
|
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
|
||||||
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
|
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
|
||||||
//
|
//
|
||||||
// When the vendor rotates the refresh token, the manager module must store the new one back the
|
// When the vendor rotates the refresh token, the manager module must store the new one back the
|
||||||
// same way — sealed to the manager node's own sealing key — so mesh-control keeps it without ever
|
// same way — sealed to the manager node's own sealing key — so mesh-controller keeps it without ever
|
||||||
// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens
|
// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens
|
||||||
// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format
|
// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format
|
||||||
// Go's `Open` accepts, or the host would refuse the delivery.
|
// Go's `Open` accepts, or the host would refuse the delivery.
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
// (package.json dependencies; novox/hq ADR 0052).
|
// (package.json dependencies; novox/hq ADR 0052).
|
||||||
//
|
//
|
||||||
// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go
|
// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go
|
||||||
// (mesh-control internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this
|
// (mesh-controller internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this
|
||||||
// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a
|
// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a
|
||||||
// refresh token silently mangled in production.
|
// refresh token silently mangled in production.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { generateKeyPairSync } from "node:crypto";
|
|||||||
import { seal } from "../sealedbox.ts";
|
import { seal } from "../sealedbox.ts";
|
||||||
|
|
||||||
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal
|
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal
|
||||||
// and mesh-control's secrets.Seal/Open) is a cross-language test in mesh-control
|
// and mesh-controller's secrets.Seal/Open) is a cross-language test in mesh-controller
|
||||||
// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced.
|
// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced.
|
||||||
// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is
|
// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is
|
||||||
// randomised so a rotation that changed nothing looks nothing like one that changed everything.
|
// randomised so a rotation that changed nothing looks nothing like one that changed everything.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
|
||||||
|
#
|
||||||
|
# **Built from this module's own directory and nothing else.** The toolkit 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 laid out beside it.
|
||||||
|
|
||||||
|
# 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 toolkit it will run against.
|
||||||
|
WORKDIR /app/modules/audit-logger
|
||||||
|
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 audit.ts index.ts \
|
||||||
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
|
FROM ${RUNTIME_BASE}
|
||||||
|
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
|
||||||
|
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
|
||||||
|
# imports anything — `run` exists for a step that works offline and exits, and would leave this
|
||||||
|
# with nothing to subscribe to.
|
||||||
|
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
|
||||||
@@ -2,10 +2,33 @@
|
|||||||
"module": "audit-logger",
|
"module": "audit-logger",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"slug": "audit",
|
"slug": "audit",
|
||||||
"consumes": ["#"],
|
"consumes": [
|
||||||
|
"#"
|
||||||
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"broker": "/var/lib/audit-logger/broker"
|
"broker": "/var/lib/audit-logger/broker"
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
@@ -23,7 +46,6 @@
|
|||||||
"id": "run",
|
"id": "run",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-audit-logger",
|
"name": "mesh-audit-logger",
|
||||||
"image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
"network": "host",
|
"network": "host",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/audit-logger/broker:/run/secrets/broker:ro",
|
"/var/lib/audit-logger/broker:/run/secrets/broker:ro",
|
||||||
@@ -32,7 +54,11 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"AUDIT_LOG": "/trail/audit.log"
|
"AUDIT_LOG": "/trail/audit.log"
|
||||||
}
|
},
|
||||||
|
"artifact": "runtime"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
"container-runtime"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"module.builder.built"
|
"module.builder.built"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"broker": "/var/lib/mesh/builder/broker"
|
"broker": "/var/lib/mesh/builder/broker",
|
||||||
|
"npm-password": "/var/lib/mesh/builder/npm-password"
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
@@ -37,7 +38,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mesh/builder/builder.env",
|
"path": "/var/lib/mesh/builder/builder.env",
|
||||||
"mode": "0600",
|
"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=/var/lib/builder/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",
|
"id": "server",
|
||||||
@@ -49,11 +57,13 @@
|
|||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/builder:/run/mesh:ro",
|
"/var/lib/mesh/builder:/run/mesh:ro",
|
||||||
"/var/lib/builder/workspace:/workspace",
|
"/var/lib/builder/workspace:/var/lib/builder/workspace",
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"builder-env"
|
"builder-env",
|
||||||
|
"package-binding",
|
||||||
|
"needs-npm-password"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"module": "registry",
|
"module": "distribution",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"module": "dnsmasq",
|
"module": "dnsmasq",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"requires": [
|
|
||||||
"resolver-data"
|
|
||||||
],
|
|
||||||
"provides": [
|
"provides": [
|
||||||
"wildcard-resolution"
|
"wildcard-resolution"
|
||||||
],
|
],
|
||||||
@@ -56,8 +53,11 @@
|
|||||||
"boot": "enabled",
|
"boot": "enabled",
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"config",
|
"config",
|
||||||
"mesh-resolver.nodes"
|
"dnsmasq.fact-node-zones"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"facts": {
|
||||||
|
"node-zones": "/etc/mesh-resolver/nodes.conf"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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": {
|
"own-secrets": {
|
||||||
"internal-token": "/var/lib/gitea/internal-token.secret",
|
"internal-token": "/var/lib/gitea/internal-token.secret",
|
||||||
|
"admin": "/var/lib/gitea/admin.secret",
|
||||||
"broker": "/var/lib/mesh/gitea/broker"
|
"broker": "/var/lib/mesh/gitea/broker"
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -60,6 +74,12 @@
|
|||||||
"path": "/var/lib/gitea",
|
"path": "/var/lib/gitea",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "grants",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/var/lib/gitea/grants",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "server-env",
|
"id": "server-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
@@ -95,6 +115,30 @@
|
|||||||
"/services/gitea/gitea:/data"
|
"/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",
|
"id": "runtime-config",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
@@ -107,20 +151,56 @@
|
|||||||
"id": "runtime",
|
"id": "runtime",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-gitea",
|
"name": "mesh-gitea",
|
||||||
"image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
"network": "host",
|
"network": "host",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
|
"/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": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_GITEA_URL": "http://127.0.0.1:3000",
|
"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": [
|
"restart-on": [
|
||||||
"runtime-config"
|
"runtime-config"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"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,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# lavinmq's runtime: the tool runtime, carrying this module's compiled bootstrap, 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/lavinmq
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# Four entrypoints and a client, because this module is four things: a run-once bootstrap that
|
||||||
|
# writes the broker's configuration before it first starts, a provisioner that grants consumers
|
||||||
|
# their own vhost and user, a set of tools, and an event consumer.
|
||||||
|
RUN node /app/node_modules/typescript/bin/tsc \
|
||||||
|
client.ts index.ts bootstrap/index.ts provisioner/index.ts tools/index.ts \
|
||||||
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
|
FROM ${RUNTIME_BASE}
|
||||||
|
COPY --from=build /app/modules/lavinmq/dist /app/modules/lavinmq/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 bootstrap and the
|
||||||
|
# provisioner are not listed here — the declaration names each in its container's `args`, because
|
||||||
|
# they are what this module's own containers run. One image, because they are one module and share
|
||||||
|
# a client.
|
||||||
|
ENV MESH_TOOL_MODULES=/app/modules/lavinmq/dist/index.js,/app/modules/lavinmq/dist/tools/index.js
|
||||||
+37
-48
@@ -30,7 +30,6 @@
|
|||||||
"amqp": "/var/lib/lavinmq-module/grants"
|
"amqp": "/var/lib/lavinmq-module/grants"
|
||||||
},
|
},
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"default": "/var/lib/lavinmq-module/default.secret",
|
|
||||||
"broker": "/var/lib/mesh/lavinmq/broker"
|
"broker": "/var/lib/mesh/lavinmq/broker"
|
||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
@@ -60,74 +59,64 @@
|
|||||||
"path": "/var/lib/lavinmq-module/grants",
|
"path": "/var/lib/lavinmq-module/grants",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "data",
|
|
||||||
"type": "directory",
|
|
||||||
"path": "/services/lavinmq/data",
|
|
||||||
"mode": "0700"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "net",
|
|
||||||
"type": "network",
|
|
||||||
"name": "lavinmq"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bootstrap",
|
|
||||||
"type": "container",
|
|
||||||
"name": "lavinmq-bootstrap",
|
|
||||||
"image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
"run-once": true,
|
|
||||||
"volumes": [
|
|
||||||
"/var/lib/lavinmq-module:/var/lib/lavinmq-module",
|
|
||||||
"/var/lib/lavinmq-module/default.secret:/run/secrets/default:ro"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
|
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default",
|
|
||||||
"MESH_LAVINMQ_CONFIG_OUT": "/var/lib/lavinmq-module/lavinmq.ini",
|
|
||||||
"MESH_LAVINMQ_DATA_DIR": "/var/lib/lavinmq"
|
|
||||||
},
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"/app/modules/lavinmq/dist/bootstrap/index.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "lavinmq",
|
"name": "mesh-broker",
|
||||||
"image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b",
|
"image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b",
|
||||||
"network": "lavinmq",
|
|
||||||
"ports": [
|
"ports": [
|
||||||
"5672"
|
"5671:5671",
|
||||||
|
"5672:5672",
|
||||||
|
"127.0.0.1:15672:15672"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/lavinmq/data:/var/lib/lavinmq",
|
"mesh-broker-data:/var/lib/lavinmq",
|
||||||
"/var/lib/lavinmq-module/lavinmq.ini:/etc/lavinmq/lavinmq.ini:ro"
|
"mesh-broker-tls:/tls:ro"
|
||||||
],
|
],
|
||||||
"args": [
|
"args": [
|
||||||
"--config",
|
"--amqps-port=5671",
|
||||||
"/etc/lavinmq/lavinmq.ini"
|
"--cert=/tls/tls.crt",
|
||||||
|
"--key=/tls/tls.key"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "runtime",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-lavinmq",
|
"name": "mesh-lavinmq",
|
||||||
"image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
"artifact": "runtime",
|
||||||
"network": "lavinmq",
|
"network": "host",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro",
|
"/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro"
|
||||||
"/var/lib/lavinmq-module/default.secret:/run/secrets/default:ro"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json",
|
"MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json",
|
||||||
"MESH_PROVISION_LAVINMQ": "http://lavinmq:15672",
|
"MESH_PROVISION_LAVINMQ": "http://127.0.0.1:15672",
|
||||||
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
|
"MESH_PROVISION_ADMIN_USER": "guest",
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default"
|
"MESH_LAVINMQ_ADMIN_PASSWORD": "guest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ interface Built {
|
|||||||
* produced it. Turning that into an edge between module-versions is this module's job.
|
* produced it. Turning that into an edge between module-versions is this module's job.
|
||||||
*/
|
*/
|
||||||
against?: string[];
|
against?: string[];
|
||||||
|
/**
|
||||||
|
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
|
||||||
|
*
|
||||||
|
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
|
||||||
|
* not something anything should act on now: emitting `upgraded` would have the control plane
|
||||||
|
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
|
||||||
|
* current.
|
||||||
|
*/
|
||||||
|
replay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
await on("module.builder.built", async (event) => {
|
await on("module.builder.built", async (event) => {
|
||||||
@@ -56,6 +65,10 @@ await on("module.builder.built", async (event) => {
|
|||||||
manifest: body.manifest ?? {},
|
manifest: body.manifest ?? {},
|
||||||
}, body.made ?? [], body.against ?? []);
|
}, body.made ?? [], body.against ?? []);
|
||||||
|
|
||||||
|
// **A replay is registered and announced to nobody.** See `replay` above: the graph gains what
|
||||||
|
// it was missing, and the mesh is told nothing happened, because nothing did.
|
||||||
|
if (body.replay) return;
|
||||||
|
|
||||||
await emit("module.mesh-catalog.registered", {
|
await emit("module.mesh-catalog.registered", {
|
||||||
module: body.module, commit: body.commit, upgraded,
|
module: body.module, commit: body.commit, upgraded,
|
||||||
});
|
});
|
||||||
@@ -77,3 +90,15 @@ await on("module.builder.built", async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||||
|
//
|
||||||
|
// The queue above is durable, so nothing is missed once this is running. What it cannot have is
|
||||||
|
// what was announced before it first ran — and on a fresh mesh that is never arbitrary: the shared
|
||||||
|
// base, the store this runs on, and this module itself are each necessarily built BEFORE a
|
||||||
|
// catalogue exists to hear about them. The graph's foundation is the part it never sees.
|
||||||
|
//
|
||||||
|
// Asked on every start, not only the first. A catalogue cannot tell whether it has a gap, and the
|
||||||
|
// answer is idempotent: registering a build already held changes nothing and announces nothing.
|
||||||
|
// Asked AFTER subscribing, so a build arriving during the replay is not lost between the two.
|
||||||
|
await emit("module.mesh-catalog.catching-up", {});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"module": "mesh-control",
|
"module": "mesh-controller",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"slug": "control",
|
"slug": "control",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
@@ -7,43 +7,43 @@
|
|||||||
],
|
],
|
||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
"name": "the-control-plane",
|
"name": "the-controller",
|
||||||
"scope": "mesh"
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"inventory": "/var/lib/mesh/mesh-control/inventory",
|
"inventory": "/var/lib/mesh/mesh-controller/inventory",
|
||||||
"identity": "/var/lib/mesh/mesh-control/identity",
|
"identity": "/var/lib/mesh/mesh-controller/identity",
|
||||||
"licences": "/var/lib/mesh/mesh-control/licences",
|
"licences": "/var/lib/mesh/mesh-controller/licences",
|
||||||
"broker": "/var/lib/mesh/mesh-control/broker",
|
"broker": "/var/lib/mesh/mesh-controller/broker",
|
||||||
"broker-management": "/var/lib/mesh/mesh-control/broker-management",
|
"broker-management": "/var/lib/mesh/mesh-controller/broker-management",
|
||||||
"broker-address": "/var/lib/mesh/mesh-control/broker-address"
|
"broker-address": "/var/lib/mesh/mesh-controller/broker-address"
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/var/lib/mesh/mesh-control",
|
"path": "/var/lib/mesh/mesh-controller",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "control-env",
|
"id": "control-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mesh/mesh-control/control.env",
|
"path": "/var/lib/mesh/mesh-controller/control.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "MESH_STORE_INVENTORY=${secret:inventory}\nMESH_STORE_IDENTITY=${secret:identity}\nMESH_STORE_LICENCES=${secret:licences}\nMESH_BROKER_AMQP=${secret:broker}\nMESH_BROKER_MANAGEMENT=${secret:broker-management}\nMESH_BROKER_ADDRESS=${secret:broker-address}\n"
|
"content": "MESH_STORE_INVENTORY=${secret:inventory}\nMESH_STORE_IDENTITY=${secret:identity}\nMESH_STORE_LICENCES=${secret:licences}\nMESH_BROKER_AMQP=${secret:broker}\nMESH_BROKER_MANAGEMENT=${secret:broker-management}\nMESH_BROKER_ADDRESS=${secret:broker-address}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-control",
|
"name": "mesh-controller",
|
||||||
"image": "mesh-control@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
"image": "mesh-controller@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
"network": "host",
|
"network": "host",
|
||||||
"args": [
|
"args": [
|
||||||
"serve"
|
"serve"
|
||||||
],
|
],
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"/var/lib/mesh/mesh-control/control.env"
|
"/var/lib/mesh/mesh-controller/control.env"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
|
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// model-usage's entrypoint — the usage context store's consumer (novox/hq ADR 0054). mesh-control is
|
// model-usage's entrypoint — the usage context store's consumer (novox/hq ADR 0054). mesh-controller is
|
||||||
// a CLI and cannot consume events, so the store that keeps the latest usage reading is a MODULE: it
|
// a CLI and cannot consume events, so the store that keeps the latest usage reading is a MODULE: it
|
||||||
// subscribes to `module.*.usage.*` and upserts each row. Like the audit-logger, the on(...) IS the
|
// subscribes to `module.*.usage.*` and upserts each row. Like the audit-logger, the on(...) IS the
|
||||||
// whole handshake — the runtime imports this once the broker is bound, and every usage event any
|
// whole handshake — the runtime imports this once the broker is bound, and every usage event any
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"module": "firewall",
|
"module": "nftables",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"firewall"
|
"firewall"
|
||||||
@@ -60,56 +60,34 @@
|
|||||||
"path": "/var/lib/postgres/grants",
|
"path": "/var/lib/postgres/grants",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "superuser-env",
|
|
||||||
"type": "file",
|
|
||||||
"path": "/var/lib/postgres/superuser.env",
|
|
||||||
"mode": "0600",
|
|
||||||
"content": "POSTGRES_PASSWORD=${secret:superuser}\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "data",
|
|
||||||
"type": "directory",
|
|
||||||
"path": "/services/postgres/db-data",
|
|
||||||
"mode": "0700"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "net",
|
|
||||||
"type": "network",
|
|
||||||
"name": "postgres"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "postgres",
|
"name": "mesh-store",
|
||||||
"image": "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee",
|
"image": "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee",
|
||||||
"network": "postgres",
|
|
||||||
"env": {
|
"env": {
|
||||||
"POSTGRES_USER": "postgres",
|
"POSTGRES_PASSWORD": "bootstrap",
|
||||||
"POSTGRES_DB": "postgres"
|
"PGDATA": "/var/lib/postgresql/data/pgdata"
|
||||||
},
|
},
|
||||||
"env-file": [
|
|
||||||
"/var/lib/postgres/superuser.env"
|
|
||||||
],
|
|
||||||
"ports": [
|
"ports": [
|
||||||
"5432"
|
"5432:5432"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/postgres/db-data:/var/lib/postgresql/data"
|
"mesh-store-data:/var/lib/postgresql/data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "runtime",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-postgres",
|
"name": "mesh-postgres",
|
||||||
"network": "postgres",
|
"network": "host",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/postgres/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/postgres/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/postgres/grants:/var/lib/postgres/grants:ro",
|
"/var/lib/postgres/grants:/var/lib/postgres/grants:ro",
|
||||||
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
|
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable",
|
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable",
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
|
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json"
|
"MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# The route-proxy module's runtime image: the reference reverse proxy compiled into a container.
|
# The route-proxy module's runtime image: the reference reverse proxy compiled into a container.
|
||||||
#
|
#
|
||||||
# **The proxy source is not vendored here.** The canonical proxy — the contract written as something
|
# **The proxy source is not vendored here.** The canonical proxy — the contract written as something
|
||||||
# that runs — lives in the mesh-control repository at examples/route-proxy (novox/hq 08-connectivity
|
# that runs — lives in the mesh-controller repository at examples/route-proxy (novox/hq 08-connectivity
|
||||||
# §3). This module ships the *packaging*, not a second copy of the contract, so the build context is
|
# §3). This module ships the *packaging*, not a second copy of the contract, so the build context is
|
||||||
# the mesh-control repository root, and this Dockerfile compiles ./examples/route-proxy from it.
|
# the mesh-controller repository root, and this Dockerfile compiles ./examples/route-proxy from it.
|
||||||
#
|
#
|
||||||
# docker build -f mesh-catalog/modules/route-proxy/Dockerfile \
|
# docker build -f mesh-catalog/modules/route-proxy/Dockerfile \
|
||||||
# -t mesh-route-proxy:development <path-to>/mesh-control
|
# -t mesh-route-proxy:development <path-to>/mesh-controller
|
||||||
#
|
#
|
||||||
# The mesh pins the digest of what this produces; the committed module.json carries the placeholder
|
# The mesh pins the digest of what this produces; the committed module.json carries the placeholder
|
||||||
# digest every mesh-built image does, replaced at publish.
|
# digest every mesh-built image does, replaced at publish.
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ an event. It only reads the file the mesh writes. (Contrast `redis`, which mints
|
|||||||
## How it ships the Go proxy
|
## How it ships the Go proxy
|
||||||
|
|
||||||
The proxy is a Go program, unlike the TypeScript tool-runtime modules. The canonical source is
|
The proxy is a Go program, unlike the TypeScript tool-runtime modules. The canonical source is
|
||||||
**not vendored here** — it lives in the mesh-control repository at `examples/route-proxy`, the
|
**not vendored here** — it lives in the mesh-controller repository at `examples/route-proxy`, the
|
||||||
contract written as something that runs. This module ships only the packaging: a multi-stage
|
contract written as something that runs. This module ships only the packaging: a multi-stage
|
||||||
[`Dockerfile`](Dockerfile) whose build context is the mesh-control repository root and which
|
[`Dockerfile`](Dockerfile) whose build context is the mesh-controller repository root and which
|
||||||
compiles `./examples/route-proxy` into `mesh-route-proxy`. The committed `module.json` carries the
|
compiles `./examples/route-proxy` into `mesh-route-proxy`. The committed `module.json` carries the
|
||||||
placeholder digest every mesh-built image does (`@sha256:0000…`); the mesh pins the real digest at
|
placeholder digest every mesh-built image does (`@sha256:0000…`); the mesh pins the real digest at
|
||||||
publish.
|
publish.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// showcase's long-running process — a `process` that stays up.
|
||||||
|
//
|
||||||
|
// **Runs on the machine rather than in a container**, which is the whole point of the process
|
||||||
|
// resource: this is the mesh's own code, it needs no isolation from the mesh, and it should not
|
||||||
|
// need an image to run.
|
||||||
|
const greeting = process.env.SHOWCASE_GREETING ?? "hello";
|
||||||
|
const every = Number(process.env.SHOWCASE_EVERY_SECONDS ?? "30") * 1000;
|
||||||
|
|
||||||
|
console.log(`[showcase] up, saying ${greeting} every ${every / 1000}s`);
|
||||||
|
|
||||||
|
// A daemon that stops is not a daemon, so this does not exit. The unit restarts it if it does,
|
||||||
|
// which is the machine's job rather than this file's.
|
||||||
|
setInterval(() => console.log(`[showcase] ${greeting}`), every);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# showcase's packed files
|
||||||
|
|
||||||
|
Packed as an `archive` artifact and unpacked onto the machine by an `archive` resource.
|
||||||
|
|
||||||
|
This exists to exercise the case inlining cannot serve: a tree of files that belongs on a machine
|
||||||
|
and would make a declaration enormous if it were carried inside one.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
showcase
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// showcase's event consumer — loaded by a tool host, not run on its own.
|
||||||
|
//
|
||||||
|
// **This is one of the four things a module's code can be**, and the one that is easiest to
|
||||||
|
// forget: tools are called, a provisioner is invoked, a process runs, and a consumer simply reacts.
|
||||||
|
// It is here so the module exercises the shape rather than describing it.
|
||||||
|
import { on, emit } from "@novox/mesh-sdk/events";
|
||||||
|
|
||||||
|
await on<{ who?: string }>("module.showcase.greeted", async (event) => {
|
||||||
|
console.log(`[showcase] greeted ${event.body.who ?? "somebody"}`);
|
||||||
|
// A consumer may emit, which is what makes an event graph rather than a list of sinks.
|
||||||
|
await emit("module.showcase.acknowledged", { who: event.body.who ?? "somebody" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"module": "showcase",
|
||||||
|
"version": "1",
|
||||||
|
"slug": "show",
|
||||||
|
|
||||||
|
"capabilities": ["container-runtime"],
|
||||||
|
|
||||||
|
"provides": [{ "name": "greeting", "scope": "mesh" }],
|
||||||
|
"serves": { "greeting": { "path": "/greeting" } },
|
||||||
|
"requires": ["postgres-database"],
|
||||||
|
"binds": { "postgres-database": "/var/lib/showcase/database.json" },
|
||||||
|
"secrets": { "postgres-database": "/var/lib/showcase/database.secret" },
|
||||||
|
"own-secrets": { "broker": "/var/lib/mesh/showcase/broker" },
|
||||||
|
|
||||||
|
"claims": [{ "name": "the-showcase", "scope": "node" }],
|
||||||
|
|
||||||
|
"emits": ["module.showcase.acknowledged"],
|
||||||
|
"consumes": ["module.showcase.greeted"],
|
||||||
|
|
||||||
|
"listens": [
|
||||||
|
{ "port": 8080, "protocol": "tcp", "from": "mesh",
|
||||||
|
"why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{ "name": "code", "kind": "bundle", "language": "typescript",
|
||||||
|
"entrypoints": ["index.js", "tools/index.js", "provisioner/index.js",
|
||||||
|
"daemon/index.js", "step/index.js", "report/index.js"] },
|
||||||
|
{ "name": "files", "kind": "archive", "from": "files" },
|
||||||
|
{ "name": "helper", "kind": "upstream",
|
||||||
|
"from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"resources": [
|
||||||
|
{ "id": "account", "type": "user", "name": "showcase", "shell": "/usr/bin/nologin",
|
||||||
|
"home": "/var/lib/showcase" },
|
||||||
|
|
||||||
|
{ "id": "logs", "type": "access", "path": "/var/log", "mode": "0755" },
|
||||||
|
|
||||||
|
{ "id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/showcase", "mode": "0700" },
|
||||||
|
{ "id": "state", "type": "directory", "path": "/var/lib/showcase", "mode": "0755" },
|
||||||
|
|
||||||
|
{ "id": "settings", "type": "file", "path": "/var/lib/showcase/showcase.env", "mode": "0600",
|
||||||
|
"content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" },
|
||||||
|
|
||||||
|
{ "id": "packed", "type": "archive", "path": "/opt/showcase", "artifact": "files" },
|
||||||
|
|
||||||
|
{ "id": "net", "type": "network", "name": "showcase" },
|
||||||
|
|
||||||
|
{ "id": "tooling", "type": "package", "package": "jq" },
|
||||||
|
|
||||||
|
{ "id": "migrate", "type": "process", "name": "showcase-migrate", "artifact": "code",
|
||||||
|
"run": ["node", "step/index.js"], "run-once": true,
|
||||||
|
"env-file": ["/var/lib/showcase/showcase.env"] },
|
||||||
|
|
||||||
|
{ "id": "server", "type": "process", "name": "showcase", "artifact": "code",
|
||||||
|
"run": ["node", "daemon/index.js"], "user": "showcase",
|
||||||
|
"env-file": ["/var/lib/showcase/showcase.env"],
|
||||||
|
"restart-on": ["settings"] },
|
||||||
|
|
||||||
|
{ "id": "reporting", "type": "process", "name": "showcase-report", "artifact": "code",
|
||||||
|
"run": ["node", "report/index.js"], "schedule": "0 3 * * *",
|
||||||
|
"env-file": ["/var/lib/showcase/showcase.env"] },
|
||||||
|
|
||||||
|
{ "id": "tools", "type": "container", "name": "mesh-showcase", "artifact": "helper",
|
||||||
|
"network": "showcase",
|
||||||
|
"volumes": ["/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"],
|
||||||
|
"env": { "MESH_BROKER_FILE": "/run/secrets/broker" },
|
||||||
|
"args": ["sleep", "infinity"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-showcase",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "showcase — a module that exercises every capability a module has, so the module system has something that proves itself rather than a claim about what it supports.",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
|
||||||
|
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// showcase's provisioner — how a consumer is given an instance of what this module provides.
|
||||||
|
//
|
||||||
|
// **A provider ships the provisioner that creates instances of the resource it offers** (ADR
|
||||||
|
// 0040). The mesh asks; this adapts that request to whatever the software actually needs, and
|
||||||
|
// hands back what the consumer is given.
|
||||||
|
import { provisioner } from "@novox/mesh-sdk/provisioner";
|
||||||
|
|
||||||
|
await provisioner({
|
||||||
|
provision: "greeting",
|
||||||
|
async create({ consumer }: { consumer: string }) {
|
||||||
|
// A real provider would create something here — a database, a vhost, an account. This one has
|
||||||
|
// nothing to create, so it returns what a consumer is told, which is the half that matters:
|
||||||
|
// the mesh seals it and delivers it, and the consumer never sees this code.
|
||||||
|
return { serves: { greeting: `hello ${consumer}` } };
|
||||||
|
},
|
||||||
|
async remove() {
|
||||||
|
// Removal is not optional. A provider that cannot take an instance back leaves the mesh unable
|
||||||
|
// to unassign a consumer without leaking whatever it was given.
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// showcase's scheduled process — a `process` with a schedule, fired on a cadence.
|
||||||
|
//
|
||||||
|
// **Not a daemon that sleeps.** A daemon that sleeps is running between fires and holds whatever
|
||||||
|
// it held; a scheduled process starts, does its work and exits, so what it costs between fires is
|
||||||
|
// nothing.
|
||||||
|
import { appendFileSync, mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const where = process.env.SHOWCASE_STATE ?? "/var/lib/showcase";
|
||||||
|
mkdirSync(where, { recursive: true });
|
||||||
|
appendFileSync(`${where}/report`, `${new Date().toISOString()} ran\n`);
|
||||||
|
console.log("[showcase] report written");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// showcase's run-once step — a `process` with run-once, run to completion at install.
|
||||||
|
//
|
||||||
|
// **What follows it is gated on it finishing.** A migration that did not happen must not be
|
||||||
|
// followed by the thing that needed it, which is why a step is a mode rather than a daemon that
|
||||||
|
// exits.
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const where = process.env.SHOWCASE_STATE ?? "/var/lib/showcase";
|
||||||
|
mkdirSync(where, { recursive: true });
|
||||||
|
writeFileSync(`${where}/installed`, `${new Date().toISOString()}\n`);
|
||||||
|
console.log(`[showcase] step complete, wrote ${where}/installed`);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// showcase's tools — its operator-facing surface, served over the broker.
|
||||||
|
//
|
||||||
|
// Two of them, because one tool proves a tool can exist and two prove a module can have a surface.
|
||||||
|
import { tool } from "@novox/mesh-sdk/tools";
|
||||||
|
|
||||||
|
tool({
|
||||||
|
name: "showcase_greet",
|
||||||
|
description: "Greet somebody, and say which machine did it.",
|
||||||
|
input: { type: "object", properties: { who: { type: "string" } } },
|
||||||
|
async run({ who }: { who?: string }) {
|
||||||
|
return { greeting: `hello ${who ?? "world"}`, from: process.env.MESH_NODE ?? "somewhere" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tool({
|
||||||
|
name: "showcase_state",
|
||||||
|
description: "What this module was configured with, so a test can read it back.",
|
||||||
|
input: { type: "object", properties: {} },
|
||||||
|
async run() {
|
||||||
|
return {
|
||||||
|
greeting: process.env.SHOWCASE_GREETING ?? "",
|
||||||
|
database: process.env.SHOWCASE_DATABASE ?? "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "tools/index.ts", "provisioner/index.ts", "daemon/index.ts", "step/index.ts", "report/index.ts"]
|
||||||
|
}
|
||||||
@@ -99,5 +99,11 @@
|
|||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
"route": "/var/lib/mesh/verdaccio/route.json"
|
"route": "/var/lib/mesh/verdaccio/route.json"
|
||||||
}
|
},
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "package-registry",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user