Compare commits
12
Commits
4aacea5453
...
568674fef7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
568674fef7 | ||
|
|
3c6b70845c | ||
|
|
35ef72081f | ||
|
|
59c42b2086 | ||
|
|
3b8164f1c0 | ||
|
|
8877f893e5 | ||
|
|
043ae17fbf | ||
|
|
944f086ec7 | ||
|
|
dd93cfd613 | ||
|
|
64cc292d7e | ||
|
|
7e889adf71 | ||
|
|
7e9ef899c1 |
@@ -1,22 +0,0 @@
|
||||
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh 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 (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/anthropic-consumer
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
|
||||
# No serve-time entrypoints: every container of this module names its command (`run` on a
|
||||
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
|
||||
@@ -14,19 +14,10 @@
|
||||
"secrets": {
|
||||
"model-access": "${dir:state}/access-token"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"emits": [
|
||||
"usage.session"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -46,66 +37,39 @@
|
||||
},
|
||||
{
|
||||
"id": "apply",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-consumer-apply",
|
||||
"network": "host",
|
||||
"type": "process",
|
||||
"name": "anthropic-consumer-apply",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"apply/index.js"
|
||||
],
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-consumer/dist/apply/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
||||
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
|
||||
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"id": "usage",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-consumer-usage",
|
||||
"network": "host",
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-consumer/dist/usage/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
|
||||
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
|
||||
"MESH_TOOLS_MAIN": "/app/dist/main.js"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
|
||||
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
|
||||
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"apply/index.js",
|
||||
"usage/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"usage/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
|
||||
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
|
||||
// attribution is done — just the totals (port map "don't-map" #3).
|
||||
//
|
||||
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
|
||||
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
|
||||
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
|
||||
// the runtime as this module; the totals are also written to a file so the reading is observable
|
||||
// without one.
|
||||
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
|
||||
import { readSessionFile, type SessionUsage } from "../transcript.js";
|
||||
|
||||
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
|
||||
@@ -116,22 +119,20 @@ function atomicWrite(path: string, content: string): void {
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
|
||||
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
|
||||
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
||||
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[main, "emit", "usage.session", JSON.stringify(body)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", (err) => {
|
||||
try {
|
||||
await emit("usage.session", body);
|
||||
} catch (err) {
|
||||
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
|
||||
// runtime's handshake is answered while a long first reading is still under way.
|
||||
const EVERY_MS = 5 * 60 * 1000;
|
||||
const tick = (): void => {
|
||||
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, EVERY_MS);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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
|
||||
@@ -5,27 +5,21 @@
|
||||
"consumes": [
|
||||
"**"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js"
|
||||
],
|
||||
"env": {
|
||||
"AUDIT_LOG": "${dir:trail}/audit.log"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -40,21 +34,6 @@
|
||||
"id": "trail",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
"type": "container",
|
||||
"name": "mesh-audit-logger",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:trail}:/trail"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"AUDIT_LOG": "/trail/audit.log"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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 token.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,/app/modules/gitea/dist/provisioner/index.js
|
||||
+21
-44
@@ -85,9 +85,6 @@
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -180,32 +177,6 @@
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-gitea",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:runtime-state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
||||
"MESH_GITEA_STATE_DIR": "/run/state",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
@@ -219,23 +190,29 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# lab's runtime: the tool runtime, carrying this module's code, and the toolchain the lab's suite
|
||||
# builds the mesh with (novox/hq ADR 0172). It reaches the machine's virtualisation and container
|
||||
# runtime through their sockets, so what it raises is what a hand run on this machine raises.
|
||||
#
|
||||
# Every download is pinned by its checksum: an image that builds the mesh is the last place to take
|
||||
# whatever an upstream serves today.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/lab
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc tools/index.ts tools/runs.ts --rootDir . \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git make ca-certificates curl python3 file iproute2 sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL -o /tmp/go.tgz https://go.dev/dl/go1.26.8.linux-amd64.tar.gz \
|
||||
&& echo "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b /tmp/go.tgz" | sha256sum -c - \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz
|
||||
RUN curl -fsSL -o /usr/local/bin/incus https://github.com/lxc/incus/releases/download/v7.5.1/bin.linux.incus.x86_64 \
|
||||
&& echo "7bd6223b369f4d693fcde695bd8549a73b5b3d403735329212483702aa22c179 /usr/local/bin/incus" | sha256sum -c - \
|
||||
&& chmod 0755 /usr/local/bin/incus
|
||||
RUN curl -fsSL -o /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-28.5.2.tgz \
|
||||
&& echo "ea90cfd12e1eeb12aa1c971741adb8bd4ed88e2a574eaac13f5029a1dbc6300d /tmp/docker.tgz" | sha256sum -c - \
|
||||
&& tar -C /tmp -xzf /tmp/docker.tgz docker/docker && mv /tmp/docker/docker /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
COPY --from=build /app/modules/lab/dist /app/modules/lab/dist
|
||||
ENV MESH_TOOL_MODULES=/app/modules/lab/dist/tools/index.js
|
||||
+55
-44
@@ -5,16 +5,7 @@
|
||||
"container-runtime",
|
||||
"virtualisation"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -35,47 +26,67 @@
|
||||
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-lab",
|
||||
"network": "host",
|
||||
"env-file": [
|
||||
"${dir:state}/lab.env"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:work}:${dir:work}",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/var/lib/incus/unix.socket:/var/lib/incus/unix.socket"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_LAB_WORK": "${dir:work}"
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-env"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
{
|
||||
"id": "make",
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
"package": "python"
|
||||
},
|
||||
{
|
||||
"id": "file",
|
||||
"type": "package",
|
||||
"package": "file"
|
||||
},
|
||||
{
|
||||
"id": "iproute2",
|
||||
"type": "package",
|
||||
"package": "iproute2"
|
||||
},
|
||||
{
|
||||
"id": "sudo",
|
||||
"type": "package",
|
||||
"package": "sudo"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "incus",
|
||||
"type": "package",
|
||||
"package": "incus"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_LAB_WORK": "${dir:work}",
|
||||
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
||||
|
||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
||||
const forge = (env.MESH_LAB_FORGE ?? "").replace(/\/+$/, "");
|
||||
// The forge is an operator's setting, which reaches a file and never a bundle's words (novox/hq
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||
return [
|
||||
{
|
||||
name: "lab_check",
|
||||
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const dir = `${work}/check`;
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
@@ -38,6 +43,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
},
|
||||
},
|
||||
run: async (args) => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
||||
@@ -83,4 +89,20 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
|
||||
export function wordIn(file: string | undefined, word: string): string {
|
||||
if (!file) return "";
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
for (const line of text.split("\n")) {
|
||||
const at = line.indexOf("=");
|
||||
if (at > 0 && line.slice(0, at).trim() === word) return line.slice(at + 1).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
registerModuleTools("lab", (env) => getLabTools(env));
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# mailu's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh 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 (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
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. The compiler
|
||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
||||
# resolved away.
|
||||
WORKDIR /app/modules/mailu
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||
# ran no provisioner at all.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
|
||||
+30
-41
@@ -135,11 +135,15 @@
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
|
||||
},
|
||||
{
|
||||
"name": "admin-api",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
"why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -305,6 +309,9 @@
|
||||
"name": "mailu-admin",
|
||||
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
||||
"network": "mailu",
|
||||
"ports": [
|
||||
"8080"
|
||||
],
|
||||
"env-file": [
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env",
|
||||
@@ -473,31 +480,6 @@
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-mailu",
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
|
||||
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"id": "automx",
|
||||
"type": "container",
|
||||
@@ -517,16 +499,6 @@
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"arg": "PYTHON_BASE",
|
||||
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
|
||||
@@ -534,9 +506,26 @@
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "automx",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# mesh-vault's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
|
||||
# consumer. The same shape as postgres's, minus the client the database needs: mesh-vault reaches no
|
||||
# server, because what it provides is a value the mesh already delivered to its node.
|
||||
#
|
||||
# **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 (novox/hq ADR 0069). Two bases, named rather than
|
||||
# pinned — the image this is COMPILED in and the image it RUNS in — answered by the mesh from
|
||||
# `build.on` in module.json (novox/hq issue 044).
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/vault
|
||||
COPY . .
|
||||
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}
|
||||
COPY --from=build /app/modules/vault/dist /app/modules/vault/dist
|
||||
# The entrypoints a tool host loads from this module: its event consumer, its tools and its
|
||||
# provisioner — one image, one process, one broker account (novox/hq ADR 0052).
|
||||
ENV MESH_TOOL_MODULES=/app/modules/vault/dist/index.js,/app/modules/vault/dist/tools/index.js,/app/modules/vault/dist/provisioner/index.js
|
||||
@@ -27,16 +27,7 @@
|
||||
"secret": "${dir:grants}"
|
||||
},
|
||||
"keeps": "/var/lib/mesh-vault/root",
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -57,45 +48,29 @@
|
||||
"id": "root",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-vault",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:ledger}:${dir:ledger}",
|
||||
"${dir:root}:${dir:root}:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_VAULT_LEDGER": "${dir:ledger}",
|
||||
"MESH_VAULT_ROOT": "${dir:root}"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_VAULT_LEDGER": "${dir:ledger}",
|
||||
"MESH_VAULT_ROOT": "${dir:root}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
|
||||
const (
|
||||
DefaultTimeout = 3 * time.Second
|
||||
MostTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// TCPResult is what netcheck_tcp answers.
|
||||
type TCPResult struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Reachable bool `json:"reachable"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
|
||||
// that does not answer is a result, not a failure of the tool; only a malformed question is.
|
||||
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
|
||||
if port < 1 || port > 65535 {
|
||||
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
|
||||
}
|
||||
out := TCPResult{Host: host, Port: port}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
|
||||
out.ElapsedMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
return out, nil
|
||||
}
|
||||
out.Address = conn.RemoteAddr().String()
|
||||
out.Reachable = true
|
||||
_ = conn.Close()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DNSResult is what netcheck_dns answers.
|
||||
type DNSResult struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Answers []string `json:"answers"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DNSTypes are the record types netcheck_dns looks up.
|
||||
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
|
||||
|
||||
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
|
||||
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
|
||||
// A name that does not resolve is a result with its error; an unknown type is refused.
|
||||
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
|
||||
kind = strings.ToUpper(strings.TrimSpace(kind))
|
||||
if kind == "" {
|
||||
kind = "A"
|
||||
}
|
||||
known := false
|
||||
for _, t := range DNSTypes {
|
||||
known = known || t == kind
|
||||
}
|
||||
if !known {
|
||||
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
|
||||
}
|
||||
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
r := net.DefaultResolver
|
||||
start := time.Now()
|
||||
var err error
|
||||
switch kind {
|
||||
case "A", "AAAA":
|
||||
network := "ip4"
|
||||
if kind == "AAAA" {
|
||||
network = "ip6"
|
||||
}
|
||||
var ips []net.IP
|
||||
if ips, err = r.LookupIP(ctx, network, name); err == nil {
|
||||
for _, ip := range ips {
|
||||
out.Answers = append(out.Answers, ip.String())
|
||||
}
|
||||
}
|
||||
case "CNAME":
|
||||
var cname string
|
||||
if cname, err = r.LookupCNAME(ctx, name); err == nil {
|
||||
out.Answers = append(out.Answers, cname)
|
||||
}
|
||||
case "TXT":
|
||||
var txts []string
|
||||
if txts, err = r.LookupTXT(ctx, name); err == nil {
|
||||
out.Answers = append(out.Answers, txts...)
|
||||
}
|
||||
case "MX":
|
||||
var mxs []*net.MX
|
||||
if mxs, err = r.LookupMX(ctx, name); err == nil {
|
||||
for _, mx := range mxs {
|
||||
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
|
||||
}
|
||||
}
|
||||
}
|
||||
out.ElapsedMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
if kind != "MX" {
|
||||
sort.Strings(out.Answers)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// text is a required string argument.
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
s, _ := args[key].(string)
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
|
||||
func whole(args map[string]any, key string, fallback int) (int, error) {
|
||||
v, given := args[key]
|
||||
if !given || v == nil {
|
||||
if fallback == 0 {
|
||||
return 0, fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n != math.Trunc(n) {
|
||||
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
|
||||
}
|
||||
return int(n), nil
|
||||
case string:
|
||||
i, err := strconv.Atoi(strings.TrimSpace(n))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
return 0, errors.New(key + " must be a whole number")
|
||||
}
|
||||
|
||||
// timeoutOf is timeout_ms, defaulted and bounded.
|
||||
func timeoutOf(args map[string]any) (time.Duration, error) {
|
||||
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ms < 1 {
|
||||
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
|
||||
}
|
||||
d := time.Duration(ms) * time.Millisecond
|
||||
if d > MostTimeout {
|
||||
d = MostTimeout
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestATCPPortThatListensIsReachableAndOneThatDoesNotIsNot(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
got, err := CheckTCP("127.0.0.1", port, time.Second)
|
||||
if err != nil || !got.Reachable || got.Error != "" {
|
||||
t.Fatalf("a listening port: %+v, %v", got, err)
|
||||
}
|
||||
l.Close()
|
||||
got, err = CheckTCP("127.0.0.1", port, time.Second)
|
||||
if err != nil || got.Reachable || got.Error == "" {
|
||||
t.Fatalf("a closed port is reported as a result with its error, not a failure: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPortOutsideTheRangeIsRefused(t *testing.T) {
|
||||
for _, p := range []int{0, -1, 65536} {
|
||||
if _, err := CheckTCP("127.0.0.1", p, time.Second); err == nil {
|
||||
t.Errorf("port %d was accepted", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSAnswersFromTheMachinesResolverAndRefusesAnUnknownType(t *testing.T) {
|
||||
got, err := CheckDNS("localhost", "a", time.Second)
|
||||
if err != nil || got.Type != "A" || len(got.Answers) == 0 {
|
||||
t.Fatalf("localhost A: %+v, %v", got, err)
|
||||
}
|
||||
if _, err := CheckDNS("localhost", "SRV", time.Second); err == nil {
|
||||
t.Fatal("an unknown record type was accepted")
|
||||
}
|
||||
got, err = CheckDNS("no-such-name.invalid", "A", time.Second)
|
||||
if err != nil || got.Error == "" || len(got.Answers) != 0 {
|
||||
t.Fatalf("a name that does not resolve is a result with its error: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
|
||||
if d, _ := timeoutOf(map[string]any{}); d != DefaultTimeout {
|
||||
t.Errorf("default: %v", d)
|
||||
}
|
||||
if d, _ := timeoutOf(map[string]any{"timeout_ms": float64(10 * 60 * 1000)}); d != MostTimeout {
|
||||
t.Errorf("bounded: %v", d)
|
||||
}
|
||||
if _, err := timeoutOf(map[string]any{"timeout_ms": float64(0)}); err == nil {
|
||||
t.Error("a zero timeout was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothToolsAreListedUnprefixed(t *testing.T) {
|
||||
names := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
|
||||
t.Fatalf("tools: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
|
||||
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
|
||||
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
|
||||
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): netcheck.
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "netcheck_tcp",
|
||||
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
|
||||
"and closes it at once, sending nothing. Answers reachable, elapsed_ms and the error when not.",
|
||||
Input: map[string]any{
|
||||
"host": map[string]any{"type": "string", "description": "host name or IP address"},
|
||||
"port": map[string]any{"type": "integer", "description": "TCP port, 1-65535"},
|
||||
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
host, err := text(args, "host")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port, err := whole(args, "port", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout, err := timeoutOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CheckTCP(host, port, timeout)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "netcheck_dns",
|
||||
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
|
||||
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
|
||||
Input: map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "the name to look up"},
|
||||
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
|
||||
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := text(args, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, _ := args["type"].(string)
|
||||
timeout, err := timeoutOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CheckDNS(name, kind, timeout)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module netcheck
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,84 @@
|
||||
// netcheck's HTTP check — the module's own code, in TypeScript (novox/hq ADR 0039, ADR 0188). One
|
||||
// request, HEAD or GET, never a body sent and never a body read: the status, how long it took and
|
||||
// a few headers that say what answered. Redirects are reported, not followed, so a check reaches
|
||||
// exactly the address it was given.
|
||||
|
||||
export const METHODS = ["HEAD", "GET"] as const;
|
||||
export type Method = (typeof METHODS)[number];
|
||||
|
||||
/** The headers worth reporting: what answered and what it says it is, nothing it set for a client. */
|
||||
export const REPORTED_HEADERS = [
|
||||
"content-type", "content-length", "server", "location", "date",
|
||||
"cache-control", "last-modified", "etag",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 5000;
|
||||
export const MOST_TIMEOUT_MS = 30000;
|
||||
|
||||
export interface HttpResult {
|
||||
url: string;
|
||||
method: Method;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
elapsed_ms: number;
|
||||
headers: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Only http and https are checked; anything else — file:, data:, ftp: — is refused by name. */
|
||||
export function checkedUrl(raw: unknown): URL {
|
||||
const text = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!text) throw new Error("url is required");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
throw new Error(`${JSON.stringify(text)} is not a URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`netcheck_http checks http and https URLs only, not ${url.protocol}`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function checkedMethod(raw: unknown): Method {
|
||||
const m = (typeof raw === "string" && raw.trim() ? raw.trim() : "HEAD").toUpperCase();
|
||||
if (!(METHODS as readonly string[]).includes(m)) {
|
||||
throw new Error(`method ${m} is not one netcheck_http uses (${METHODS.join(", ")}): a check never changes anything`);
|
||||
}
|
||||
return m as Method;
|
||||
}
|
||||
|
||||
export function checkedTimeout(raw: unknown): number {
|
||||
if (raw === undefined || raw === null || raw === "") return DEFAULT_TIMEOUT_MS;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 1) throw new Error(`timeout_ms must be a whole number of at least 1, not ${String(raw)}`);
|
||||
return Math.min(n, MOST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Make one request and report how it went. A refused connection or a timeout is a result with its
|
||||
* error; only a malformed question throws. */
|
||||
export async function checkHttp(args: Readonly<Record<string, unknown>>, fetcher: typeof fetch = fetch): Promise<HttpResult> {
|
||||
const url = checkedUrl(args.url);
|
||||
const method = checkedMethod(args.method);
|
||||
const timeout = checkedTimeout(args.timeout_ms);
|
||||
const started = performance.now();
|
||||
const out: HttpResult = { url: url.toString(), method, elapsed_ms: 0, headers: {} };
|
||||
try {
|
||||
const res = await fetcher(url, { method, redirect: "manual", signal: AbortSignal.timeout(timeout) });
|
||||
out.elapsed_ms = Math.round(performance.now() - started);
|
||||
out.status = res.status;
|
||||
out.statusText = res.statusText;
|
||||
for (const h of REPORTED_HEADERS) {
|
||||
const v = res.headers.get(h);
|
||||
if (v !== null) out.headers[h] = v;
|
||||
}
|
||||
// The body is not read: a check asks whether something answers, not what it says.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
} catch (err) {
|
||||
out.elapsed_ms = Math.round(performance.now() - started);
|
||||
const e = err as Error & { cause?: { message?: string; code?: string } };
|
||||
out.error = e.name === "TimeoutError" ? `no answer within ${timeout} ms` : (e.cause?.code ?? e.cause?.message ?? e.message);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"module": "netcheck",
|
||||
"version": "1",
|
||||
"tools": [
|
||||
"netcheck_tcp",
|
||||
"netcheck_dns",
|
||||
"netcheck_http"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools-go",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/netcheck",
|
||||
"binary": "netcheck",
|
||||
"loads": [
|
||||
"netcheck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tools-typescript",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@novox/module-netcheck",
|
||||
"version": "0.1.0",
|
||||
"description": "netcheck — read-only network checks from a machine, as one module carrying a Go tools bundle (TCP, DNS) and a TypeScript one (HTTP) (novox/hq ADR 0188, ADR 0193).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// The HTTP check refuses what is not http(s) and what would change something, and reports a status,
|
||||
// a refusal and a timeout as results (novox/hq ADR 0188: a tools bundle is read-only and harmless).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { checkHttp, checkedMethod, checkedUrl } from "../http.ts";
|
||||
|
||||
test("only http and https URLs are checked", () => {
|
||||
for (const bad of ["file:///etc/passwd", "ftp://example.org/", "data:text/plain,hi", "javascript:1", "", "not a url"]) {
|
||||
assert.throws(() => checkedUrl(bad), `${bad} was accepted`);
|
||||
}
|
||||
assert.equal(checkedUrl("https://example.org/x").protocol, "https:");
|
||||
});
|
||||
|
||||
test("only HEAD and GET are used", () => {
|
||||
assert.equal(checkedMethod(undefined), "HEAD");
|
||||
assert.equal(checkedMethod("get"), "GET");
|
||||
for (const bad of ["POST", "PUT", "DELETE", "PATCH"]) assert.throws(() => checkedMethod(bad));
|
||||
});
|
||||
|
||||
test("a status, its headers and a redirect not followed", async () => {
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/moved") { res.writeHead(302, { location: "/elsewhere" }); res.end(); return; }
|
||||
res.writeHead(200, { "content-type": "text/plain", "x-secret": "not reported" });
|
||||
res.end(req.method === "GET" ? "body" : undefined);
|
||||
});
|
||||
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
||||
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
try {
|
||||
const head = await checkHttp({ url: base + "/" });
|
||||
assert.equal(head.status, 200);
|
||||
assert.equal(head.method, "HEAD");
|
||||
assert.equal(head.headers["content-type"], "text/plain");
|
||||
assert.equal(head.headers["x-secret"], undefined);
|
||||
const moved = await checkHttp({ url: base + "/moved", method: "GET" });
|
||||
assert.equal(moved.status, 302);
|
||||
assert.equal(moved.headers.location, "/elsewhere");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused connection is a result with its error", async () => {
|
||||
const server = createServer();
|
||||
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
await new Promise<void>((ok) => server.close(() => ok()));
|
||||
const got = await checkHttp({ url: `http://127.0.0.1:${port}/`, timeout_ms: 2000 });
|
||||
assert.equal(got.status, undefined);
|
||||
assert.ok(got.error, "no error reported");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// netcheck's TypeScript tools bundle (novox/hq ADR 0188, ADR 0193): what the builder's launcher
|
||||
// imports and serves over MCP on stdio. Its Go bundle serves the TCP and DNS checks; this one the
|
||||
// HTTP check — one module, two languages, one runtime that knows neither.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { checkHttp, DEFAULT_TIMEOUT_MS, METHODS, MOST_TIMEOUT_MS } from "../http.js";
|
||||
|
||||
export function getNetcheckHttpTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "netcheck_http",
|
||||
description:
|
||||
"Check whether an http(s) URL answers from this machine: one HEAD or GET, no body sent or read, " +
|
||||
"redirects reported and not followed. Answers status, elapsed_ms and a few headers.",
|
||||
input: {
|
||||
url: { type: "string", description: "an http:// or https:// URL" },
|
||||
method: { type: "string", enum: [...METHODS], description: "HEAD (default) or GET" },
|
||||
timeout_ms: {
|
||||
type: "integer",
|
||||
description: `give up after this long (default ${DEFAULT_TIMEOUT_MS}, at most ${MOST_TIMEOUT_MS})`,
|
||||
},
|
||||
},
|
||||
run: async (args) => checkHttp(args),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("netcheck", () => getNetcheckHttpTools());
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["http.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
# openai-consumer's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh 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 (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/openai-consumer
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/openai-consumer/dist /app/modules/openai-consumer/dist
|
||||
# No serve-time entrypoints: every container of this module names its command (`run` on a
|
||||
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
|
||||
@@ -28,44 +28,31 @@
|
||||
},
|
||||
{
|
||||
"id": "apply",
|
||||
"type": "container",
|
||||
"name": "mesh-openai-consumer-apply",
|
||||
"network": "host",
|
||||
"type": "process",
|
||||
"name": "openai-consumer-apply",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"apply/index.js"
|
||||
],
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/openai-consumer/dist/apply/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/api-key",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
||||
"MESH_OPENAI_ENV_FILE": "/run/state/config/openai.env",
|
||||
"MESH_OPENAI_CREDENTIALS_FILE": "/run/state/config/auth.json"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/api-key",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
|
||||
"MESH_OPENAI_ENV_FILE": "${dir:state}/config/openai.env",
|
||||
"MESH_OPENAI_CREDENTIALS_FILE": "${dir:state}/config/auth.json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"apply/index.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# records' runtime: the tool runtime, carrying this module's compiled reader and its tools.
|
||||
#
|
||||
# **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 (novox/hq ADR 0069).
|
||||
#
|
||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in
|
||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/records
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc records.ts index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
# **A module may need something the base image does not carry.** The reader keeps a checkout of the
|
||||
# repository it reads (novox/hq ADR 0153) — a git working copy, kept current, not a derived copy — and
|
||||
# the base image has no git. Certificates too, because the origin may be reached over TLS.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/modules/records/dist /app/modules/records/dist
|
||||
# Both entrypoints, loaded in serve mode: the consumer that pulls on a merge, and the tools.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/records/dist/index.js,/app/modules/records/dist/tools/index.js
|
||||
+19
-39
@@ -11,9 +11,6 @@
|
||||
"binds": {
|
||||
"git": "${dir:mesh-state}/git.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"consumes": [
|
||||
"gitea.pull.merged"
|
||||
],
|
||||
@@ -53,47 +50,30 @@
|
||||
"content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "records",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:mesh-state}/origin:/run/config/origin:ro",
|
||||
"${dir:checkout}:${dir:checkout}"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECORDS_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_RECORDS_ORIGIN_FILE": "/run/config/origin",
|
||||
"MESH_RECORDS_DIR": "${dir:checkout}"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"config",
|
||||
"origin"
|
||||
]
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_RECORDS_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_RECORDS_ORIGIN_FILE": "${dir:mesh-state}/origin",
|
||||
"MESH_RECORDS_DIR": "${dir:checkout}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# route-adapter's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh 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 (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/route-adapter
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc adapter.ts index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/route-adapter/dist /app/modules/route-adapter/dist
|
||||
# **No MESH_TOOL_MODULES, deliberately.** This module serves no tool and consumes no event: it is a
|
||||
# step the host runs to completion, named by the container's `args` as `mesh-tools run …`. Setting a
|
||||
# serve-time entrypoint here would give the image a second way to be started — one that connects to
|
||||
# the broker and never exits.
|
||||
@@ -47,23 +47,18 @@
|
||||
},
|
||||
{
|
||||
"id": "adapt",
|
||||
"type": "container",
|
||||
"name": "mesh-route-adapter",
|
||||
"artifact": "runtime",
|
||||
"run-once": true,
|
||||
"volumes": [
|
||||
"${dir:routes-dir}/mesh.json:${dir:routes-dir}/mesh.json:ro",
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${access:dynamic}:/services/traefik/dynamic"
|
||||
"type": "process",
|
||||
"name": "route-adapter",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"index.js"
|
||||
],
|
||||
"run-once": true,
|
||||
"env": {
|
||||
"MESH_RECEIVES": "${dir:routes-dir}/mesh.json",
|
||||
"MESH_ROUTE_ADAPTER_CONFIG": "/run/config/config.json"
|
||||
"MESH_ROUTE_ADAPTER_CONFIG": "${dir:state}/config.json"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/route-adapter/dist/index.js"
|
||||
],
|
||||
"restart-on": [
|
||||
"received-route",
|
||||
"config"
|
||||
@@ -71,23 +66,14 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user