Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cda964a43 | ||
|
|
06954b5a70 | ||
|
|
3d7d896014 | ||
|
|
278610c0c3 | ||
|
|
b58a3b487d | ||
|
|
7b06a7a408 | ||
|
|
f0a6ce8d4a | ||
|
|
6bedcd3f21 | ||
|
|
a093c88c32 | ||
|
|
f67f0ca9bc | ||
|
|
968473219a | ||
|
|
ad219beee2 | ||
|
|
fd09b1a50e | ||
|
|
7aea08d6c3 | ||
|
|
23d735a0bf | ||
|
|
ae99204a8c | ||
|
|
226eab4c6f | ||
|
|
bb8f2e76a9 | ||
|
|
86882cacd4 | ||
|
|
ea7f6796e8 | ||
|
|
372450851f | ||
|
|
f4e4e12c99 | ||
|
|
fd9be011c0 | ||
|
|
a85b0ee346 | ||
|
|
34243c9e34 | ||
|
|
870a541072 | ||
|
|
9b063a77b2 | ||
|
|
a59750fa28 | ||
|
|
81592a3b2c | ||
|
|
705ceec1e7 | ||
|
|
afdd149ab7 | ||
|
|
dde8b15483 | ||
|
|
8a046be198 | ||
|
|
668278bde4 | ||
|
|
6761bb02a1 | ||
|
|
f98c9859d2 | ||
|
|
cac5eab7da | ||
|
|
770c9f6a78 | ||
|
|
5427118614 | ||
|
|
53765335cf | ||
|
|
5fd2ed9686 | ||
|
|
f7887d706d | ||
|
|
d6dd21a091 | ||
|
|
a844701577 | ||
|
|
50a99f022c | ||
|
|
382a44621e | ||
|
|
ddb67fc095 | ||
|
|
78595e4db3 | ||
|
|
37634de1e3 | ||
|
|
36c5f87130 | ||
|
|
b2e39eb2cd | ||
|
|
3d73c9f54e | ||
|
|
fb95eb6e46 | ||
|
|
4d715f8b73 | ||
|
|
afe8aae826 | ||
|
|
fa91be4941 | ||
|
|
87f73dce6a | ||
|
|
fc5ccdfe2a | ||
|
|
4489e56935 | ||
|
|
08e947e4c8 | ||
|
|
7fb9dd0254 | ||
|
|
420d05e8dd | ||
|
|
6769e66c82 | ||
|
|
15b35b53db | ||
|
|
6177565741 | ||
|
|
6b2ea0972a | ||
|
|
a978b53d1c | ||
|
|
7501c1db9e | ||
|
|
00ada1e9f7 | ||
|
|
67b443d5ad | ||
|
|
a2da2e4910 | ||
|
|
bcb9ca8f93 | ||
|
|
2409afda60 | ||
|
|
511200ed9c | ||
|
|
b704bf5ad8 | ||
|
|
142d65c52a | ||
|
|
45dd036623 | ||
|
|
107090310d | ||
|
|
440e3e446e | ||
|
|
20df40c949 | ||
|
|
6a6dd4a7dc | ||
|
|
973d80aaa2 | ||
|
|
945390e59a | ||
|
|
ed0f4602a6 | ||
|
|
13d0361640 | ||
|
|
61eb201f8a |
@@ -18,7 +18,7 @@
|
||||
"broker": "/var/lib/mesh/anthropic-consumer/broker"
|
||||
},
|
||||
"emits": [
|
||||
"module.anthropic-consumer.usage.session"
|
||||
"usage.session"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
|
||||
@@ -123,7 +123,7 @@ async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)],
|
||||
[main, "emit", "usage.session", JSON.stringify(body)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
child.on("exit", () => resolve());
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"broker": "/var/lib/mesh/anthropic-manager/broker"
|
||||
},
|
||||
"emits": [
|
||||
"module.anthropic-manager.usage.read"
|
||||
"usage.read"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
|
||||
@@ -143,7 +143,7 @@ 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", "module.anthropic-manager.usage.read", JSON.stringify(body)], {
|
||||
const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("exit", () => resolve());
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1",
|
||||
"slug": "audit",
|
||||
"consumes": [
|
||||
"#"
|
||||
"**"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/audit-logger/broker"
|
||||
|
||||
@@ -15,16 +15,16 @@ test("audit-logger records every event to the trail as one line each", async ()
|
||||
const path = join(dir, "audit.log");
|
||||
|
||||
// The audit-logger's whole behaviour: consume everything, record it.
|
||||
await on("#", async (event) => record(event, path));
|
||||
await on("**", async (event) => record(event, path));
|
||||
|
||||
process.env.MESH_MODULE = "umami";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
await emit("module.umami.site.created", { domain: "my-app" });
|
||||
await emit("site.created", { domain: "my-app" });
|
||||
await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one
|
||||
|
||||
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.deepEqual(lines.map((l) => l.type), ["module.umami.site.created", "node.anchor.joined"]);
|
||||
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
|
||||
assert.equal(lines[0].source, "umami");
|
||||
assert.equal(lines[0].node, "anchor");
|
||||
assert.equal(lines[0].body.domain, "my-app");
|
||||
|
||||
@@ -24,7 +24,7 @@ async function pollHistory(): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue;
|
||||
if (primed) {
|
||||
await emit("module.bazarr.subtitle.downloaded", {
|
||||
await emit("subtitle.downloaded", {
|
||||
kind: entry.kind,
|
||||
title: entry.title,
|
||||
language: entry.language,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.bazarr.subtitle.downloaded"
|
||||
"subtitle.downloaded"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/bazarr/broker",
|
||||
|
||||
@@ -45,12 +45,12 @@ async function pollQueue(bookshelf: BookshelfClient): Promise<void> {
|
||||
if (primed) {
|
||||
// Entered the queue since last look — Bookshelf grabbed a release.
|
||||
for (const [id, item] of now) {
|
||||
if (!inQueue.has(id)) await emit("module.bookshelf.book.grabbed", { title: item.title, status: item.status });
|
||||
if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status });
|
||||
}
|
||||
// Left the queue — imported and done, unless it was last seen failing.
|
||||
for (const [id, item] of inQueue) {
|
||||
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
|
||||
await emit("module.bookshelf.download.completed", { title: item.title });
|
||||
await emit("download.completed", { title: item.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.bookshelf.book.grabbed",
|
||||
"module.bookshelf.download.completed"
|
||||
"book.grabbed",
|
||||
"download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
|
||||
@@ -6,23 +6,20 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-build-machine",
|
||||
"scope": "node"
|
||||
"name": "mesh-build-machine",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"artifact-store",
|
||||
"package-registry"
|
||||
"npm-package-registry"
|
||||
],
|
||||
"binds": {
|
||||
"package-registry": "/var/lib/mesh/builder/package-registry.json"
|
||||
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
|
||||
},
|
||||
"secrets": {
|
||||
"package-registry": "/var/lib/mesh/builder/package-registry.secret"
|
||||
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
|
||||
},
|
||||
"emits": [
|
||||
"module.builder.built"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/builder/broker"
|
||||
},
|
||||
@@ -80,7 +77,7 @@
|
||||
"on": [
|
||||
{
|
||||
"arg": "GO_BASE",
|
||||
"image": "golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"
|
||||
"image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
|
||||
},
|
||||
{
|
||||
"arg": "ALPINE_BASE",
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"broker": "/var/lib/mesh/cloudflare-dns/broker"
|
||||
},
|
||||
"emits": [
|
||||
"module.cloudflare-dns.record.created",
|
||||
"module.cloudflare-dns.record.removed"
|
||||
"record.created",
|
||||
"record.removed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ runProvisioner("public-dns", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const fqdn = cloudflare.nameFor(p.as);
|
||||
await cloudflare.upsert(fqdn);
|
||||
await announce("module.cloudflare-dns.record.created", {
|
||||
await announce("record.created", {
|
||||
name: fqdn,
|
||||
target: cloudflare.ingress,
|
||||
consumer: p.consumer ?? "",
|
||||
@@ -30,7 +30,7 @@ runProvisioner("public-dns", {
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const fqdn = cloudflare.nameFor(p.as);
|
||||
await cloudflare.remove(fqdn);
|
||||
await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as });
|
||||
await announce("record.removed", { name: fqdn, consumer: p.as });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# dhcpcd
|
||||
|
||||
The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It
|
||||
asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the
|
||||
private network's interface alone. It never declares an interface, an address, a route, a
|
||||
wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches
|
||||
the machine over.
|
||||
|
||||
## What it writes
|
||||
|
||||
Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no
|
||||
drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102):
|
||||
|
||||
- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it
|
||||
takes or renews, which would silently replace the resolver `resolv-conf` names.
|
||||
- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and
|
||||
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
|
||||
rather than relying on it.
|
||||
|
||||
**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line
|
||||
after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in
|
||||
exactly such a block (the interface, its static address), so appended at the end these two would
|
||||
quietly apply to one interface only.
|
||||
|
||||
## Why it declares no service
|
||||
|
||||
dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it
|
||||
drops the address the machine is reached at, and a module unassigned by mistake must not be able
|
||||
to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and
|
||||
a restart drops the lease. So the two lines take effect at **dhcpcd's next start**.
|
||||
|
||||
On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and
|
||||
it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next
|
||||
starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal
|
||||
still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this
|
||||
module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the
|
||||
link for a moment is acceptable.
|
||||
|
||||
## One manager per machine
|
||||
|
||||
It claims `the-uplink`: a machine runs one network manager, and assigning a second module that
|
||||
claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's
|
||||
installs the package and writes the two lines, and starts nothing.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"module": "dhcpcd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "dhcpcd"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/dhcpcd.conf",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117). Global options, so\n# kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,7 @@ async function pollCatalog(): Promise<void> {
|
||||
for (const tag of tags) {
|
||||
const id = `${repo}:${tag}`;
|
||||
if (!seen.has(id)) {
|
||||
if (primed) await emit("module.registry.image.pushed", { repo, tag });
|
||||
if (primed) await emit("image.pushed", { repo, tag });
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.registry.image.pushed"
|
||||
"image.pushed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/registry/broker"
|
||||
|
||||
@@ -20,10 +20,10 @@ async function poll(): Promise<void> {
|
||||
const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address]));
|
||||
if (primed) {
|
||||
for (const [name, address] of now) {
|
||||
if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address });
|
||||
if (!known.has(name)) await emit("name.added", { name, address });
|
||||
}
|
||||
for (const [name] of known) {
|
||||
if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name });
|
||||
if (!now.has(name)) await emit("name.removed", { name });
|
||||
}
|
||||
}
|
||||
known.clear();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-intrusion-prevention",
|
||||
"name": "node-intrusion-prevention",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
@@ -33,7 +33,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
|
||||
@@ -35,7 +35,7 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
for (const repo of repos) {
|
||||
if (!seen.has(repo.full_name)) {
|
||||
if (primed) {
|
||||
await emit("module.gitea.repo.created", {
|
||||
await emit("repo.created", {
|
||||
full_name: repo.full_name,
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
|
||||
+42
-26
@@ -24,23 +24,23 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/gitea/database.json",
|
||||
"route": "/var/lib/gitea/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/gitea/database.secret",
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"internal-token": "/var/lib/gitea/internal-token.secret",
|
||||
"admin": "/var/lib/gitea/admin.secret"
|
||||
"internal-token": "${dir:state}/internal-token.secret",
|
||||
"admin": "${dir:state}/admin.secret"
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.gitea.repo.created",
|
||||
"module.gitea.issue.opened",
|
||||
"module.gitea.pull.merged"
|
||||
"repo.created",
|
||||
"issue.opened",
|
||||
"pull.merged"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -53,22 +53,36 @@
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
|
||||
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"package-registry": {
|
||||
"npm-package-registry": {
|
||||
"scheme": "http",
|
||||
"port": 3000,
|
||||
"npm-path": "/api/packages/novox/npm/"
|
||||
},
|
||||
"git": {
|
||||
"scheme": "http",
|
||||
"port": 3000
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"package-registry": "/var/lib/gitea/grants/mesh.json"
|
||||
"npm-package-registry": "${dir:grants}/npm.json"
|
||||
},
|
||||
"grants": {
|
||||
"package-registry": "/var/lib/gitea/grants"
|
||||
"npm-package-registry": "${dir:grants}"
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
"name": "npm-package-registry",
|
||||
"scope": "mesh"
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/gitea/broker"
|
||||
},
|
||||
@@ -88,26 +102,24 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/gitea",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/gitea/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/gitea/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/gitea/gitea",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
@@ -122,14 +134,14 @@
|
||||
"USER_GID": "1000"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/gitea/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"3000",
|
||||
"222:22"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/gitea/gitea:/data"
|
||||
"${dir:data}:/data"
|
||||
],
|
||||
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it"
|
||||
},
|
||||
@@ -145,11 +157,11 @@
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/gitea/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/gitea/gitea:/data",
|
||||
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
|
||||
"${dir:data}:/data",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro"
|
||||
],
|
||||
"args": [
|
||||
"/bin/sh",
|
||||
@@ -174,8 +186,8 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh/gitea/broker:/run/secrets/broker: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",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"/var/lib/mesh/gitea/state:/run/state"
|
||||
],
|
||||
"env": {
|
||||
@@ -185,7 +197,7 @@
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
||||
"MESH_GITEA_STATE_DIR": "/run/state",
|
||||
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
@@ -195,7 +207,11 @@
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "package-registry",
|
||||
"name": "npm-package-registry",
|
||||
"scope": "mesh"
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// 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).
|
||||
// gitea's provisioner — the adapter that makes gitea a provider of the mesh
|
||||
// `npm-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
|
||||
// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat
|
||||
// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another
|
||||
// `receives` path and another registration below — not widening this one. `git`, which gitea also
|
||||
// provides, mints nothing and so registers nothing here: the mesh's own repositories are public,
|
||||
// and a clone credential is not yet decided (ADR 0111).
|
||||
//
|
||||
// The `npm-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.
|
||||
@@ -26,7 +32,11 @@ const PACKAGE_TEAM = "packages";
|
||||
|
||||
const gitea = GiteaAdmin.fromEnv();
|
||||
|
||||
runProvisioner("package-registry", {
|
||||
// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written
|
||||
// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that
|
||||
// path in code would drift from it. One variable carries one path, so a second registration in this
|
||||
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
||||
runProvisioner("npm-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.
|
||||
|
||||
@@ -124,7 +124,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
labels: labelIds,
|
||||
});
|
||||
// The mesh just opened an issue — announce it the moment it exists.
|
||||
await emit("module.gitea.issue.opened", {
|
||||
await emit("issue.opened", {
|
||||
owner,
|
||||
repo,
|
||||
number: issue.number,
|
||||
@@ -231,7 +231,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||
await emit("module.gitea.pull.merged", {
|
||||
await emit("pull.merged", {
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
|
||||
@@ -40,7 +40,7 @@ async function pollAlerts(client: GrafanaClient): Promise<void> {
|
||||
for (const key of now) {
|
||||
if (!firing.has(key)) {
|
||||
const a = byKey.get(key)!;
|
||||
await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt });
|
||||
await emit("alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "grafana",
|
||||
"version": "1",
|
||||
"emits": [
|
||||
"module.grafana.alert.firing"
|
||||
"alert.firing"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/grafana-module/admin.secret",
|
||||
|
||||
@@ -44,7 +44,7 @@ async function pollStates(): Promise<void> {
|
||||
for (const s of states) {
|
||||
const prev = lastState.get(s.entity_id);
|
||||
if (primed && prev !== undefined && prev !== s.state) {
|
||||
await emit("module.home-assistant.state.changed", {
|
||||
await emit("state.changed", {
|
||||
entity: s.entity_id,
|
||||
name: nameOf(s),
|
||||
from: prev,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.home-assistant.state.changed"
|
||||
"state.changed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/home-assistant/broker",
|
||||
|
||||
@@ -23,7 +23,7 @@ async function pollMounts(): Promise<void> {
|
||||
if (primed) {
|
||||
for (const [mount, m] of now) {
|
||||
if (!live.has(mount)) {
|
||||
await emit("module.icecast.stream.started", {
|
||||
await emit("stream.started", {
|
||||
mount,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
@@ -33,7 +33,7 @@ async function pollMounts(): Promise<void> {
|
||||
}
|
||||
for (const [mount, m] of live) {
|
||||
if (!now.has(mount)) {
|
||||
await emit("module.icecast.stream.stopped", { mount, name: m.name });
|
||||
await emit("stream.stopped", { mount, name: m.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.icecast.stream.started",
|
||||
"module.icecast.stream.stopped"
|
||||
"stream.started",
|
||||
"stream.stopped"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/icecast/broker"
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
"mongodb-database": {
|
||||
"name": "invoicing"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"bucket": "invoicing"
|
||||
},
|
||||
"route": {
|
||||
"label": "invoicing",
|
||||
"port": 80
|
||||
"site": {
|
||||
"label": "invoicing",
|
||||
"port": 80
|
||||
},
|
||||
"api": {
|
||||
"label": "invoicing-api",
|
||||
"port": 9000
|
||||
}
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -63,7 +66,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/invoicing/api.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
@@ -28,17 +28,17 @@ async function announce(type: string, body: Record<string, unknown>): Promise<vo
|
||||
|
||||
export const events = {
|
||||
userCreated: (realm: string, username: string, email?: string) =>
|
||||
announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }),
|
||||
announce("user.created", { realm, username, ...(email ? { email } : {}) }),
|
||||
userDeleted: (realm: string, userId: string) =>
|
||||
announce("module.keycloak.user.deleted", { realm, userId }),
|
||||
announce("user.deleted", { realm, userId }),
|
||||
passwordReset: (realm: string, userId: string) =>
|
||||
announce("module.keycloak.password.reset", { realm, userId }),
|
||||
announce("password.reset", { realm, userId }),
|
||||
clientCreated: (realm: string, clientId: string, name?: string) =>
|
||||
announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }),
|
||||
announce("client.created", { realm, clientId, ...(name ? { name } : {}) }),
|
||||
groupCreated: (realm: string, name: string) =>
|
||||
announce("module.keycloak.group.created", { realm, name }),
|
||||
announce("group.created", { realm, name }),
|
||||
roleCreated: (realm: string, name: string) =>
|
||||
announce("module.keycloak.role.created", { realm, name }),
|
||||
announce("role.created", { realm, name }),
|
||||
};
|
||||
|
||||
console.log("[keycloak] event surface ready — identity, client, group and role changes are announced");
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.keycloak.user.created",
|
||||
"module.keycloak.user.deleted",
|
||||
"module.keycloak.password.reset",
|
||||
"module.keycloak.client.created",
|
||||
"module.keycloak.group.created",
|
||||
"module.keycloak.role.created"
|
||||
"user.created",
|
||||
"user.deleted",
|
||||
"password.reset",
|
||||
"client.created",
|
||||
"group.created",
|
||||
"role.created"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -88,7 +88,9 @@
|
||||
"env": {
|
||||
"KC_DB": "postgres",
|
||||
"KC_HTTP_ENABLED": "true",
|
||||
"KC_HEALTH_ENABLED": "true"
|
||||
"KC_HEALTH_ENABLED": "true",
|
||||
"KC_HOSTNAME": "https://keycloak.novox.be",
|
||||
"KC_PROXY_HEADERS": "xforwarded"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/keycloak/admin.env",
|
||||
@@ -118,7 +120,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
|
||||
@@ -14,11 +14,11 @@ interface AmqpEvent {
|
||||
vhost?: string;
|
||||
}
|
||||
|
||||
await on<AmqpEvent>("module.lavinmq.amqp.provisioned", async (e) => {
|
||||
await on<AmqpEvent>("amqp.provisioned", async (e) => {
|
||||
console.log(`[lavinmq] broker provisioned for ${e.body.consumer ?? "?"} (user ${e.body.user}, vhost ${e.body.vhost})`);
|
||||
});
|
||||
|
||||
await on<AmqpEvent>("module.lavinmq.amqp.deprovisioned", async (e) => {
|
||||
await on<AmqpEvent>("amqp.deprovisioned", async (e) => {
|
||||
console.log(`[lavinmq] broker deprovisioned (user ${e.body.user})`);
|
||||
});
|
||||
|
||||
|
||||
+11
-17
@@ -7,22 +7,16 @@
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-broker",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.lavinmq.amqp.provisioned",
|
||||
"module.lavinmq.amqp.deprovisioned"
|
||||
"amqp.provisioned",
|
||||
"amqp.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.lavinmq.amqp.provisioned",
|
||||
"module.lavinmq.amqp.deprovisioned"
|
||||
"lavinmq.amqp.provisioned",
|
||||
"lavinmq.amqp.deprovisioned"
|
||||
],
|
||||
"serves": {
|
||||
"amqp": {
|
||||
@@ -81,12 +75,6 @@
|
||||
"path": "/var/lib/mesh-broker",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "broker-tls",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-broker-tls",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
@@ -147,5 +135,11 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"accesses": [
|
||||
{
|
||||
"path": "/var/lib/mesh-broker-tls",
|
||||
"mode": "read"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ runProvisioner("amqp", {
|
||||
// The vhost and the user share the consumer's login, so one cannot reach another's broker.
|
||||
await lavinmq.waitReady();
|
||||
await lavinmq.createConsumer(p.as, p.password);
|
||||
await announce("module.lavinmq.amqp.provisioned", {
|
||||
await announce("amqp.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
user: p.as,
|
||||
vhost: p.as,
|
||||
@@ -46,7 +46,7 @@ runProvisioner("amqp", {
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await lavinmq.removeConsumer(p.as);
|
||||
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
|
||||
await announce("amqp.deprovisioned", { user: p.as, vhost: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
|
||||
@@ -40,12 +40,12 @@ async function pollQueue(lidarr: LidarrClient): Promise<void> {
|
||||
if (primed) {
|
||||
// Entered the queue since last look — Lidarr grabbed a release.
|
||||
for (const [id, item] of now) {
|
||||
if (!inQueue.has(id)) await emit("module.lidarr.album.grabbed", { title: item.title, status: item.status });
|
||||
if (!inQueue.has(id)) await emit("album.grabbed", { title: item.title, status: item.status });
|
||||
}
|
||||
// Left the queue — imported and done, unless it was last seen failing.
|
||||
for (const [id, item] of inQueue) {
|
||||
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
|
||||
await emit("module.lidarr.download.completed", { title: item.title });
|
||||
await emit("download.completed", { title: item.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.lidarr.album.grabbed",
|
||||
"module.lidarr.download.completed"
|
||||
"album.grabbed",
|
||||
"download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
|
||||
@@ -36,8 +36,8 @@ function watcher(created: string, deleted: string): (keys: string[]) => Promise<
|
||||
};
|
||||
}
|
||||
|
||||
const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted");
|
||||
const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted");
|
||||
const watchUsers = watcher("user.created", "user.deleted");
|
||||
const watchAliases = watcher("alias.created", "alias.deleted");
|
||||
|
||||
async function pollUsers(): Promise<void> {
|
||||
await watchUsers((await mailu.listUsers()).map((u) => u.email));
|
||||
|
||||
+58
-75
@@ -41,22 +41,22 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/mailu/database.json",
|
||||
"route": "/var/lib/mailu/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/mailu/database.secret",
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"secret-key": "/var/lib/mailu/secret-key.secret",
|
||||
"admin": "/var/lib/mailu/admin.secret",
|
||||
"api-token": "/var/lib/mailu/api-token.secret"
|
||||
"secret-key": "${dir:state}/secret-key.secret",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"api-token": "${dir:state}/api-token.secret"
|
||||
}
|
||||
},
|
||||
"emits": [
|
||||
"module.mailu.user.created",
|
||||
"module.mailu.user.deleted",
|
||||
"module.mailu.alias.created",
|
||||
"module.mailu.alias.deleted"
|
||||
"user.created",
|
||||
"user.deleted",
|
||||
"alias.created",
|
||||
"alias.deleted"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -140,143 +140,125 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mailu",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mailu/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-automx",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/automx",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/mailu.env",
|
||||
"path": "${dir:state}/mailu.env",
|
||||
"mode": "0644",
|
||||
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
|
||||
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
|
||||
},
|
||||
{
|
||||
"id": "secret-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/secret.env",
|
||||
"path": "${dir:state}/secret.env",
|
||||
"mode": "0600",
|
||||
"content": "SECRET_KEY=${secret:secret-key}\n"
|
||||
},
|
||||
{
|
||||
"id": "database-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/database.env",
|
||||
"path": "${dir:state}/database.env",
|
||||
"mode": "0600",
|
||||
"content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n"
|
||||
},
|
||||
{
|
||||
"id": "admin-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/admin.env",
|
||||
"path": "${dir:state}/admin.env",
|
||||
"mode": "0600",
|
||||
"content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n"
|
||||
},
|
||||
{
|
||||
"id": "data-certs",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/certs",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-data",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-dkim",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/dkim",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-mail",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/mail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-mailqueue",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/mailqueue",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "data-filter",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/filter",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-clamav",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/clamav",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-redis",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/redis",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-webmail",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/webmail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-dav",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/dav",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-fetchmail",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/data/fetchmail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-nginx",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/nginx",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-dovecot",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/dovecot",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-postfix",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/postfix",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-rspamd",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/rspamd",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-roundcube",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/roundcube",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
@@ -291,8 +273,8 @@
|
||||
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"ip": "192.168.203.254"
|
||||
@@ -304,7 +286,7 @@
|
||||
"image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d",
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"/services/mailu/data/redis:/data"
|
||||
"${dir:data-redis}:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -314,14 +296,14 @@
|
||||
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env",
|
||||
"/var/lib/mailu/database.env",
|
||||
"/var/lib/mailu/admin.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env",
|
||||
"${dir:state}/database.env",
|
||||
"${dir:state}/admin.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/data:/data",
|
||||
"/services/mailu/data/dkim:/dkim"
|
||||
"${dir:data-data}:/data",
|
||||
"${dir:data-dkim}:/dkim"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -335,11 +317,11 @@
|
||||
"image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/mail:/mail",
|
||||
"/services/mailu/data/overrides/dovecot:/overrides:ro"
|
||||
"${dir:data-mail}:/mail",
|
||||
"${dir:data-overrides-dovecot}:/overrides:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -352,11 +334,11 @@
|
||||
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/mailqueue:/queue",
|
||||
"/services/mailu/data/overrides/postfix:/overrides:ro"
|
||||
"${dir:data-mailqueue}:/queue",
|
||||
"${dir:data-overrides-postfix}:/overrides:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -369,11 +351,11 @@
|
||||
"image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/filter:/var/lib/rspamd",
|
||||
"/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro"
|
||||
"${dir:data-filter}:/var/lib/rspamd",
|
||||
"${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -386,7 +368,7 @@
|
||||
"image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a",
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"/services/mailu/data/clamav:/var/lib/clamav"
|
||||
"${dir:data-clamav}:/var/lib/clamav"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -399,12 +381,12 @@
|
||||
"image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/webmail:/data",
|
||||
"/services/mailu/data/overrides/roundcube:/overrides:ro"
|
||||
"${dir:data-webmail}:/data",
|
||||
"${dir:data-overrides-roundcube}:/overrides:ro"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -418,11 +400,11 @@
|
||||
"image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/dav:/data"
|
||||
"${dir:data-dav}:/data"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -436,11 +418,11 @@
|
||||
"image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/data/fetchmail:/data"
|
||||
"${dir:data-fetchmail}:/data"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -454,7 +436,7 @@
|
||||
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"ports": [
|
||||
"25",
|
||||
@@ -468,8 +450,8 @@
|
||||
"7443:443"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/certs:/certs",
|
||||
"/services/mailu/data/overrides/nginx:/overrides:ro"
|
||||
"${dir:data-certs}:/certs",
|
||||
"${dir:data-overrides-nginx}:/overrides:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -490,8 +472,8 @@
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
|
||||
"/var/lib/mailu/grants:/var/lib/mailu/grants:ro",
|
||||
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
@@ -502,7 +484,7 @@
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_MAILU_DOMAIN": "novox.be",
|
||||
"MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
@@ -516,13 +498,13 @@
|
||||
"artifact": "automx",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"ports": [
|
||||
"4243"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/automx:/data"
|
||||
"${dir:data-automx}:/data"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -565,13 +547,14 @@
|
||||
"serves": {
|
||||
"smtp": {
|
||||
"port": 587,
|
||||
"domain": "novox.be"
|
||||
"domain": "novox.be",
|
||||
"name": "mail.novox.be"
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"smtp": "/var/lib/mailu/grants/mesh.json"
|
||||
"smtp": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"smtp": "/var/lib/mailu/grants"
|
||||
"smtp": "${dir:grants}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072).
|
||||
//
|
||||
// The builder announces what it built; this places it in the graph and announces what that means.
|
||||
// The build-machine role announces what it built; this places it in the graph and announces what that means.
|
||||
// The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so
|
||||
// it never has to interpret an artifact or ask this module anything.
|
||||
//
|
||||
@@ -47,7 +47,7 @@ interface Built {
|
||||
replay?: boolean;
|
||||
}
|
||||
|
||||
await on("module.builder.built", async (event) => {
|
||||
await on("mesh-build-machine.built", async (event) => {
|
||||
const body = event.body as Built;
|
||||
if (!body.module || !body.commit) {
|
||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
||||
@@ -69,7 +69,7 @@ await on("module.builder.built", async (event) => {
|
||||
// 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("registered", {
|
||||
module: body.module, commit: body.commit, upgraded,
|
||||
});
|
||||
|
||||
@@ -77,13 +77,13 @@ await on("module.builder.built", async (event) => {
|
||||
// through modules that did not change, forever (ADR 0072).
|
||||
if (!upgraded) return;
|
||||
|
||||
await emit("module.mesh-catalog.upgraded", {
|
||||
await emit("upgraded", {
|
||||
module: body.module, commit: body.commit, previous,
|
||||
});
|
||||
|
||||
// What can be built now — stale, and waiting on nothing that is itself stale.
|
||||
for (const next of await graph.buildable()) {
|
||||
await emit("module.mesh-catalog.rebuild-needed", {
|
||||
await emit("rebuild-needed", {
|
||||
module: next.module,
|
||||
builtAt: next.commit,
|
||||
because: next.because,
|
||||
@@ -101,4 +101,4 @@ await on("module.builder.built", async (event) => {
|
||||
// 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", {});
|
||||
await emit("catching-up", {});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-catalogue",
|
||||
"name": "mesh-catalog",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
@@ -29,12 +29,12 @@
|
||||
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
||||
},
|
||||
"consumes": [
|
||||
"module.builder.built"
|
||||
"mesh-build-machine.built"
|
||||
],
|
||||
"emits": [
|
||||
"module.mesh-catalog.registered",
|
||||
"module.mesh-catalog.upgraded",
|
||||
"module.mesh-catalog.rebuild-needed"
|
||||
"registered",
|
||||
"upgraded",
|
||||
"rebuild-needed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
|
||||
@@ -16,15 +16,15 @@ interface SecretEvent {
|
||||
rotations?: number;
|
||||
}
|
||||
|
||||
await on<SecretEvent>("module.mesh-vault.secret.provisioned", async (e) => {
|
||||
await on<SecretEvent>("secret.provisioned", async (e) => {
|
||||
console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`);
|
||||
});
|
||||
|
||||
await on<SecretEvent>("module.mesh-vault.secret.rotated", async (e) => {
|
||||
await on<SecretEvent>("secret.rotated", async (e) => {
|
||||
console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`);
|
||||
});
|
||||
|
||||
await on<SecretEvent>("module.mesh-vault.secret.deprovisioned", async (e) => {
|
||||
await on<SecretEvent>("secret.deprovisioned", async (e) => {
|
||||
console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.mesh-vault.secret.provisioned",
|
||||
"module.mesh-vault.secret.rotated",
|
||||
"module.mesh-vault.secret.deprovisioned"
|
||||
"secret.provisioned",
|
||||
"secret.rotated",
|
||||
"secret.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.mesh-vault.secret.provisioned",
|
||||
"module.mesh-vault.secret.rotated",
|
||||
"module.mesh-vault.secret.deprovisioned"
|
||||
"mesh-vault.secret.provisioned",
|
||||
"mesh-vault.secret.rotated",
|
||||
"mesh-vault.secret.deprovisioned"
|
||||
],
|
||||
"receives": {
|
||||
"secret": "/var/lib/mesh-vault/grants/mesh.json"
|
||||
|
||||
@@ -43,6 +43,6 @@ runProvisioner("secret", {
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
if (!ledger.withdraw(p.as)) return;
|
||||
console.log(`[mesh-vault] withdrawn: ${p.as}`);
|
||||
await announce("module.mesh-vault.secret.deprovisioned", { as: p.as });
|
||||
await announce("secret.deprovisioned", { as: p.as });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.minio.bucket.created",
|
||||
"module.minio.bucket.removed"
|
||||
"bucket.created",
|
||||
"bucket.removed"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -95,14 +95,14 @@
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "minio"
|
||||
"name": "minio-net"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "minio",
|
||||
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372",
|
||||
"network": "minio",
|
||||
"network": "minio-net",
|
||||
"args": [
|
||||
"server",
|
||||
"/data",
|
||||
@@ -122,6 +122,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
|
||||
"MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be",
|
||||
"MINIO_REGION": "eu-west"
|
||||
}
|
||||
},
|
||||
@@ -129,7 +130,7 @@
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-minio",
|
||||
"network": "minio",
|
||||
"network": "minio-net",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/minio/grants:/var/lib/minio/grants:ro",
|
||||
|
||||
@@ -30,7 +30,7 @@ runProvisioner("s3-bucket", {
|
||||
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
|
||||
await minio.createAccessKey(bucket, accessKeyId, p.password);
|
||||
|
||||
await announce("module.minio.bucket.created", {
|
||||
await announce("bucket.created", {
|
||||
bucket,
|
||||
consumer: p.consumer ?? "",
|
||||
accessKey: accessKeyId,
|
||||
@@ -51,7 +51,7 @@ runProvisioner("s3-bucket", {
|
||||
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
|
||||
}
|
||||
|
||||
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
|
||||
await announce("bucket.removed", { bucket, accessKey: p.as });
|
||||
},
|
||||
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
|
||||
@@ -22,7 +22,7 @@ const store = UsageStore.fromEnv();
|
||||
// to reach the provider over the overlay would block the very apply that brings the overlay up.
|
||||
await store.migrate();
|
||||
|
||||
await on("module.*.usage.*", async (event) => {
|
||||
await on("*.usage.*", async (event) => {
|
||||
const body = event.body as { rows?: UsageRow[]; raw?: unknown };
|
||||
for (const row of body.rows ?? []) {
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"postgres-database": "/var/lib/model-usage/database.secret"
|
||||
},
|
||||
"consumes": [
|
||||
"module.*.usage.*"
|
||||
"*.usage.*"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/model-usage/broker"
|
||||
|
||||
@@ -14,11 +14,11 @@ interface DatabaseEvent {
|
||||
user?: string;
|
||||
}
|
||||
|
||||
await on<DatabaseEvent>("module.mongodb.database.provisioned", async (e) => {
|
||||
await on<DatabaseEvent>("database.provisioned", async (e) => {
|
||||
console.log(`[mongodb] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
await on<DatabaseEvent>("module.mongodb.database.deprovisioned", async (e) => {
|
||||
await on<DatabaseEvent>("database.deprovisioned", async (e) => {
|
||||
console.log(`[mongodb] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
|
||||
+14
-16
@@ -11,12 +11,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.mongodb.database.provisioned",
|
||||
"module.mongodb.database.deprovisioned"
|
||||
"database.provisioned",
|
||||
"database.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.mongodb.database.provisioned",
|
||||
"module.mongodb.database.deprovisioned"
|
||||
"mongodb.database.provisioned",
|
||||
"mongodb.database.deprovisioned"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -32,13 +32,13 @@
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"mongodb-database": "/var/lib/mongodb/grants/mesh.json"
|
||||
"mongodb-database": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mongodb-database": "/var/lib/mongodb/grants"
|
||||
"mongodb-database": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"root": "/var/lib/mongodb/root.secret",
|
||||
"root": "${dir:state}/root.secret",
|
||||
"broker": "/var/lib/mesh/mongodb/broker"
|
||||
},
|
||||
"secrets-owner": "999:999",
|
||||
@@ -52,19 +52,17 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mongodb",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mongodb/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mongodb/db-data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
@@ -86,8 +84,8 @@
|
||||
"27017"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mongodb/db-data:/data/db",
|
||||
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
|
||||
"${dir:data}:/data/db",
|
||||
"${dir:state}/root.secret:/run/secrets/root:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -97,14 +95,14 @@
|
||||
"network": "mongodb",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mongodb/grants:/var/lib/mongodb/grants:ro",
|
||||
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:state}/root.secret:/run/secrets/root:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ runProvisioner("mongodb-database", {
|
||||
// Database and owning user share the consumer's login, so the consumer owns exactly its own.
|
||||
const database = p.as;
|
||||
await mongo.createDatabaseAndUser(database, p.as, p.password);
|
||||
await announce("module.mongodb.database.provisioned", {
|
||||
await announce("database.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
database,
|
||||
user: p.as,
|
||||
@@ -43,7 +43,7 @@ runProvisioner("mongodb-database", {
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await mongo.dropDatabaseAndUser(p.as, p.as);
|
||||
await announce("module.mongodb.database.deprovisioned", { database: p.as });
|
||||
await announce("database.deprovisioned", { database: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
|
||||
@@ -14,11 +14,11 @@ interface TopicEvent {
|
||||
topicPrefix?: string;
|
||||
}
|
||||
|
||||
await on<TopicEvent>("module.mosquitto.topic.provisioned", async (e) => {
|
||||
await on<TopicEvent>("topic.provisioned", async (e) => {
|
||||
console.log(`[mosquitto] topic provisioned for ${e.body.consumer} (client ${e.body.username})`);
|
||||
});
|
||||
|
||||
await on<TopicEvent>("module.mosquitto.topic.deprovisioned", async (e) => {
|
||||
await on<TopicEvent>("topic.deprovisioned", async (e) => {
|
||||
console.log(`[mosquitto] topic deprovisioned for ${e.body.consumer} (client ${e.body.username})`);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.mosquitto.topic.provisioned",
|
||||
"module.mosquitto.topic.deprovisioned"
|
||||
"topic.provisioned",
|
||||
"topic.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.mosquitto.topic.provisioned",
|
||||
"module.mosquitto.topic.deprovisioned"
|
||||
"mosquitto.topic.provisioned",
|
||||
"mosquitto.topic.deprovisioned"
|
||||
],
|
||||
"serves": {
|
||||
"mqtt-topic": {}
|
||||
|
||||
@@ -32,7 +32,7 @@ runProvisioner("mqtt-topic", {
|
||||
// The topic subtree is scoped to the consumer's own login, so one cannot read another's topics.
|
||||
const topicPrefix = p.as;
|
||||
await mosquitto.createScopedClient(p.as, p.password, topicPrefix);
|
||||
await announce("module.mosquitto.topic.provisioned", {
|
||||
await announce("topic.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
username: p.as,
|
||||
topicPrefix,
|
||||
@@ -41,7 +41,7 @@ runProvisioner("mqtt-topic", {
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await mosquitto.deleteScopedClient(p.as);
|
||||
await announce("module.mosquitto.topic.deprovisioned", { username: p.as });
|
||||
await announce("topic.deprovisioned", { username: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
|
||||
@@ -14,11 +14,11 @@ interface DatabaseEvent {
|
||||
user?: string;
|
||||
}
|
||||
|
||||
await on<DatabaseEvent>("module.mssql.database.provisioned", async (e) => {
|
||||
await on<DatabaseEvent>("database.provisioned", async (e) => {
|
||||
console.log(`[mssql] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
await on<DatabaseEvent>("module.mssql.database.deprovisioned", async (e) => {
|
||||
await on<DatabaseEvent>("database.deprovisioned", async (e) => {
|
||||
console.log(`[mssql] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.mssql.database.provisioned",
|
||||
"module.mssql.database.deprovisioned"
|
||||
"database.provisioned",
|
||||
"database.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.mssql.database.provisioned",
|
||||
"module.mssql.database.deprovisioned"
|
||||
"mssql.database.provisioned",
|
||||
"mssql.database.deprovisioned"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -68,7 +68,6 @@
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mssql/data",
|
||||
"mode": "0700",
|
||||
"owner": "10001:0"
|
||||
},
|
||||
@@ -90,7 +89,7 @@
|
||||
"1433"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mssql/data:/var/opt/mssql"
|
||||
"${dir:data}:/var/opt/mssql"
|
||||
],
|
||||
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ runProvisioner("mssql-database", {
|
||||
// Database, login and user share the consumer's name, so the consumer owns exactly its own.
|
||||
const database = p.as;
|
||||
await mssql.createDatabaseAndLogin(database, p.as, p.password);
|
||||
await announce("module.mssql.database.provisioned", {
|
||||
await announce("database.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
database,
|
||||
user: p.as,
|
||||
@@ -42,7 +42,7 @@ runProvisioner("mssql-database", {
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await mssql.dropDatabaseAndLogin(p.as, p.as);
|
||||
await announce("module.mssql.database.deprovisioned", { database: p.as });
|
||||
await announce("database.deprovisioned", { database: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# nats's server image: the upstream server, plus an entrypoint that reloads it in place when the
|
||||
# mesh rewrites its configuration. See entrypoint.sh for why that belongs here and not in the host.
|
||||
#
|
||||
# **Pinned to the multi-architecture index digest, not a platform's.** `docker manifest inspect`
|
||||
# reports a platform manifest per architecture and the index that lists them; pinning a platform's
|
||||
# digest builds on this workstation and fails on any node of another architecture, with an error
|
||||
# that names a manifest rather than the mistake. This is the index — `docker pull` reports the same
|
||||
# one, and `RepoDigests` confirms it.
|
||||
#
|
||||
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
|
||||
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
|
||||
# purpose — nothing is compiled.
|
||||
FROM nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mesh-nats-entrypoint"]
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
# nats's entrypoint: run the server, and reload it in place when the mesh rewrites its
|
||||
# configuration.
|
||||
#
|
||||
# **Why this exists inside the module** (novox/hq design 25 §5). The controller composes every
|
||||
# account and permission into one file, and that file changes whenever a module is added,
|
||||
# reassigned, or a person's access is granted or revoked — which is often, and on the one server
|
||||
# everything else depends on. The host has no way to say "reload this container": a
|
||||
# container resource has `restart-on` and nothing else, and a container's `restart-on` means
|
||||
# *recreate* — every connection dropped and every in-flight JetStream ack lost, mid-flight, for a
|
||||
# permission change. `reload-on` is real but it is a *service* field, not a container's.
|
||||
#
|
||||
# nats-server already reloads its own configuration on SIGHUP — accounts, permissions, everything
|
||||
# the mesh composes — without dropping a connection. That is the server's own documented
|
||||
# capability, not something built for the mesh. So the configuration is mounted as a directory
|
||||
# (a directory's contents are not digest-tracked the way a directly-mounted file's are, novox/hq
|
||||
# issue 103), and this watches the one file inside it and signals the server itself. The host's
|
||||
# only job is what it already does for any directory: keep the file's content current. Nothing
|
||||
# here is declared `restart-on` or `reload-on`.
|
||||
set -eu
|
||||
|
||||
# **Two files, and only one of them is the mesh's** (novox/hq design 25 §4, task 1.7). CONF is this
|
||||
# module's own — ports, TLS, JetStream — declared in its manifest, because those are properties of
|
||||
# the container this module raises. USERS is every account and permission, composed by the
|
||||
# controller, and CONF includes it. So what is watched here is the mesh's half: the module's own
|
||||
# does not change without a new declaration, and that recreates the container anyway.
|
||||
CONF="${MESH_NATS_CONF:-/etc/nats/nats.conf}"
|
||||
USERS="${MESH_NATS_USERS:-/etc/nats/accounts.conf}"
|
||||
POLL="${MESH_NATS_CONF_POLL_SECONDS:-5}"
|
||||
|
||||
# Both are written as part of the same declaration that creates this container, but none of the
|
||||
# three are ordered against each other. Waiting is correct and starting without them is not:
|
||||
# nats-server given a configuration whose include is missing refuses to start, and one given no
|
||||
# configuration at all comes up with its compiled-in defaults — no TLS, no accounts, every subject
|
||||
# open to anyone who can reach the port. A bus that is briefly open to everything is not a bus that
|
||||
# is briefly wrong; it is an open bus.
|
||||
for needed in "$CONF" "$USERS"; do
|
||||
while [ ! -s "$needed" ]; do
|
||||
echo "[nats] waiting for the mesh to write $needed"
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
|
||||
digest() { sha256sum "$USERS" 2>/dev/null | cut -d' ' -f1; }
|
||||
|
||||
nats-server --config "$CONF" "$@" &
|
||||
server=$!
|
||||
|
||||
# Forward a stop to the server and let it drain, rather than dying and leaving it orphaned as
|
||||
# PID 1's child.
|
||||
stop() { kill -TERM "$server" 2>/dev/null || true; }
|
||||
trap stop TERM INT
|
||||
|
||||
last=$(digest)
|
||||
while kill -0 "$server" 2>/dev/null; do
|
||||
sleep "$POLL"
|
||||
now=$(digest)
|
||||
# An empty digest means the file is mid-write or briefly gone. Reloading on that would hand the
|
||||
# server a truncated configuration; the next tick sees the finished one.
|
||||
[ -n "$now" ] || continue
|
||||
if [ "$now" != "$last" ]; then
|
||||
last=$now
|
||||
echo "[nats] the mesh's user list changed; reloading in place"
|
||||
kill -HUP "$server" || true
|
||||
fi
|
||||
done
|
||||
|
||||
# `wait` on an already-exited child still yields its status, which becomes this container's.
|
||||
wait "$server"
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"module": "nats",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "mesh-bus",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-broker",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"bus-users": "/var/lib/nats-module/conf/accounts.conf",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [],
|
||||
"consumes": [],
|
||||
"listens": [
|
||||
{
|
||||
"port": 4222,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the mesh bus \u2014 every link the mesh has, over TLS, reached across the overlay"
|
||||
}
|
||||
],
|
||||
"guards": [
|
||||
8222
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "jetstream-data",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-broker-nats",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "conf-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/nats-module/conf",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/nats-module/conf/nats.conf",
|
||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mesh-broker-nats",
|
||||
"ports": [
|
||||
"4222:4222",
|
||||
"127.0.0.1:8222:8222"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh-broker-nats:/data",
|
||||
"/var/lib/nats-module/conf:/etc/nats:ro",
|
||||
"/var/lib/mesh-broker-nats-tls:/tls:ro"
|
||||
],
|
||||
"artifact": "server"
|
||||
}
|
||||
],
|
||||
"accesses": [
|
||||
{
|
||||
"path": "/var/lib/mesh-broker-nats-tls",
|
||||
"mode": "read"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "server",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"module": "networkmanager",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "networkmanager"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/NetworkManager/conf.d/50-mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n"
|
||||
},
|
||||
{
|
||||
"id": "service",
|
||||
"type": "service",
|
||||
"unit": "NetworkManager.service",
|
||||
"reload-on": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,7 @@ async function pollUsers(client: NextcloudClient): Promise<void> {
|
||||
const users = client.listUsers();
|
||||
for (const u of users) {
|
||||
if (knownUsers.has(u.uid)) continue;
|
||||
if (usersPrimed) await emit("module.nextcloud.user.created", { uid: u.uid, displayName: u.displayName });
|
||||
if (usersPrimed) await emit("user.created", { uid: u.uid, displayName: u.displayName });
|
||||
knownUsers.add(u.uid);
|
||||
}
|
||||
usersPrimed = true;
|
||||
@@ -38,7 +38,7 @@ async function pollShares(client: NextcloudClient): Promise<void> {
|
||||
const shares = await client.listShares();
|
||||
for (const s of shares) {
|
||||
if (knownShares.has(s.id)) continue;
|
||||
if (sharesPrimed) await emit("module.nextcloud.share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner });
|
||||
if (sharesPrimed) await emit("share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner });
|
||||
knownShares.add(s.id);
|
||||
}
|
||||
sharesPrimed = true;
|
||||
|
||||
@@ -11,29 +11,26 @@
|
||||
"postgres-database": {
|
||||
"name": "nextcloud"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"bucket": "nextcloud"
|
||||
},
|
||||
"route": {
|
||||
"label": "drive",
|
||||
"port": 80
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/nextcloud-module/database.json",
|
||||
"s3-bucket": "/var/lib/nextcloud-module/store.json",
|
||||
"route": "/var/lib/nextcloud-module/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"s3-bucket": "${dir:state}/store.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/nextcloud-module/database.secret",
|
||||
"s3-bucket": "/var/lib/nextcloud-module/store.secret"
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"s3-bucket": "${dir:state}/store.secret"
|
||||
},
|
||||
"emits": [
|
||||
"module.nextcloud.user.created",
|
||||
"module.nextcloud.share.created"
|
||||
"user.created",
|
||||
"share.created"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/nextcloud-module/admin.secret",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"broker": "/var/lib/mesh/nextcloud/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
@@ -57,20 +54,19 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/nextcloud-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/nextcloud-module/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=nextcloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=mesh-novox-ncloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||
},
|
||||
{
|
||||
"id": "html",
|
||||
"type": "directory",
|
||||
"path": "/services/nextcloud/html",
|
||||
"mode": "0750",
|
||||
"owner": "33:33"
|
||||
},
|
||||
@@ -80,13 +76,13 @@
|
||||
"name": "nextcloud",
|
||||
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
|
||||
"env-file": [
|
||||
"/var/lib/nextcloud-module/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"80"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/nextcloud/html:/var/www/html"
|
||||
"${dir:html}:/var/www/html"
|
||||
],
|
||||
"secrets-in-environment": "the image honours POSTGRES_PASSWORD_FILE and NEXTCLOUD_ADMIN_PASSWORD_FILE (entrypoint file_env); OBJECTSTORE_S3_SECRET has none and needs a generated config fragment; convertible, awaiting a bed"
|
||||
},
|
||||
@@ -106,7 +102,7 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
|
||||
"/var/lib/nextcloud-module/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-packet-filter",
|
||||
"name": "node-packet-filter",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
@@ -30,7 +30,7 @@
|
||||
"id": "stock-unit-stop",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/nftables.service.d/mesh.conf",
|
||||
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
|
||||
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"module": "nodered",
|
||||
"version": "1",
|
||||
"emits": [
|
||||
"module.nodered.flows.deployed"
|
||||
"flows.deployed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/nodered/broker"
|
||||
|
||||
@@ -43,7 +43,7 @@ export function getNodeRedTools(nodered: NodeRedClient): ToolDefinition[] {
|
||||
const result = await nodered.deployFlows(flows, type);
|
||||
// Best-effort announcement — a deploy must not fail because the broker is unbound here.
|
||||
try {
|
||||
await emit("module.nodered.flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type });
|
||||
await emit("flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type });
|
||||
} catch (err) {
|
||||
console.error(`[nodered] deployed but could not emit: ${err}`);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ async function pollQueue(): Promise<void> {
|
||||
if (queuePrimed) {
|
||||
for (const item of items) {
|
||||
if (!inQueue.has(item.id)) {
|
||||
await emit("module.nzbget.download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB });
|
||||
await emit("download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ async function pollHistory(): Promise<void> {
|
||||
// A newly-appeared history entry is a completion only if it actually succeeded; a failure or
|
||||
// a manual delete lands in history too, and neither is a "download.completed".
|
||||
if (historyPrimed && item.success) {
|
||||
await emit("module.nzbget.download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB });
|
||||
await emit("download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB });
|
||||
}
|
||||
seenHistory.add(item.id);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.nzbget.download.added",
|
||||
"module.nzbget.download.completed"
|
||||
"download.added",
|
||||
"download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
|
||||
@@ -31,7 +31,7 @@ async function pollRequests(): Promise<void> {
|
||||
const key = keyOf(r);
|
||||
const known = approvedState.has(key);
|
||||
if (primed && !known) {
|
||||
await emit("module.ombi.request.created", {
|
||||
await emit("request.created", {
|
||||
kind: r.kind,
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
@@ -41,7 +41,7 @@ async function pollRequests(): Promise<void> {
|
||||
}
|
||||
// Approval: the flag went from false to true for a request we already knew about.
|
||||
if (primed && known && r.approved && approvedState.get(key) === false) {
|
||||
await emit("module.ombi.request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId });
|
||||
await emit("request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId });
|
||||
}
|
||||
approvedState.set(key, r.approved);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.ombi.request.created",
|
||||
"module.ombi.request.approved"
|
||||
"request.created",
|
||||
"request.approved"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/ombi/broker",
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/only-office/route.json"
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"jwt": "/var/lib/only-office/jwt.secret"
|
||||
"jwt": "${dir:state}/jwt.secret"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -32,56 +32,49 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/only-office",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/only-office/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "JWT_ENABLED=true\nJWT_SECRET=${secret:jwt}\nJWT_HEADER=Authorization\nJWT_IN_BODY=true\nALLOW_PRIVATE_IP_ADDRESS=true\n"
|
||||
},
|
||||
{
|
||||
"id": "logs",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/logs",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "lib",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/lib",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "db",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/db",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "rabbitmq",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/rabbitmq",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "redis",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/redis",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "fonts",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/fonts",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
@@ -96,19 +89,19 @@
|
||||
"image": "onlyoffice/documentserver@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212",
|
||||
"network": "only-office",
|
||||
"env-file": [
|
||||
"/var/lib/only-office/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"80"
|
||||
"9070:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/only-office/logs:/var/log/onlyoffice",
|
||||
"/services/only-office/data:/var/www/onlyoffice/Data",
|
||||
"/services/only-office/lib:/var/lib/onlyoffice",
|
||||
"/services/only-office/db:/var/lib/postgresql",
|
||||
"/services/only-office/rabbitmq:/var/lib/rabbitmq",
|
||||
"/services/only-office/redis:/var/lib/redis",
|
||||
"/services/only-office/fonts:/usr/share/fonts/truetype/custom"
|
||||
"${dir:logs}:/var/log/onlyoffice",
|
||||
"${dir:data}:/var/www/onlyoffice/Data",
|
||||
"${dir:lib}:/var/lib/onlyoffice",
|
||||
"${dir:db}:/var/lib/postgresql",
|
||||
"${dir:rabbitmq}:/var/lib/rabbitmq",
|
||||
"${dir:redis}:/var/lib/redis",
|
||||
"${dir:fonts}:/usr/share/fonts/truetype/custom"
|
||||
],
|
||||
"secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ async function pollRecent(): Promise<void> {
|
||||
for (const asset of items) {
|
||||
if (!seen.has(asset.id)) {
|
||||
if (primed) {
|
||||
await emit("module.photos.item.added", {
|
||||
await emit("item.added", {
|
||||
id: asset.id,
|
||||
fileName: asset.fileName,
|
||||
kind: asset.type,
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"item.added"
|
||||
],
|
||||
"requires": [
|
||||
"s3-bucket",
|
||||
"mongodb-database",
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"s3-bucket": {
|
||||
"bucket": "photos"
|
||||
},
|
||||
"mongodb-database": {
|
||||
"name": "photos"
|
||||
},
|
||||
@@ -56,7 +56,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/photos/server.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
@@ -24,10 +24,10 @@ async function pollSessions(): Promise<void> {
|
||||
const now = new Map(sessions.map((s) => [s.key, s]));
|
||||
if (playbackPrimed) {
|
||||
for (const [key, s] of now) {
|
||||
if (!active.has(key)) await emit("module.plex.playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type });
|
||||
if (!active.has(key)) await emit("playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type });
|
||||
}
|
||||
for (const [key, s] of active) {
|
||||
if (!now.has(key)) await emit("module.plex.playback.stopped", { title: s.title, user: s.user, player: s.player });
|
||||
if (!now.has(key)) await emit("playback.stopped", { title: s.title, user: s.user, player: s.player });
|
||||
}
|
||||
}
|
||||
active.clear();
|
||||
@@ -44,7 +44,7 @@ async function pollRecent(): Promise<void> {
|
||||
for (const item of items) {
|
||||
const id = `${item.title}@${item.addedAt ?? ""}`;
|
||||
if (!seen.has(id)) {
|
||||
if (itemsPrimed) await emit("module.plex.item.added", item);
|
||||
if (itemsPrimed) await emit("item.added", item);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ async function pollRecent(): Promise<void> {
|
||||
|
||||
// A downloader finished somewhere on the mesh: rescan, so what it fetched becomes a visible item
|
||||
// rather than a file Plex has not noticed. Idempotent — a rescan too many costs a little disk I/O.
|
||||
await on("module.*.download.completed", async () => {
|
||||
await on("*.download.completed", async () => {
|
||||
await plex.refreshAll();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.plex.playback.started",
|
||||
"module.plex.playback.stopped",
|
||||
"module.plex.item.added"
|
||||
"playback.started",
|
||||
"playback.stopped",
|
||||
"item.added"
|
||||
],
|
||||
"consumes": [
|
||||
"module.*.download.completed"
|
||||
"*.download.completed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/plex/broker",
|
||||
|
||||
@@ -6,11 +6,17 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 9090,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||
},
|
||||
{
|
||||
"port": 9443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the container dashboard, over its own tls"
|
||||
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -23,19 +29,19 @@
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/portainer/data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "portainer",
|
||||
"image": "portainer/portainer-ce@sha256:511f3f06c96fe3b993ebeaafde311c1959cae73a7ef825dba6397d51b450dffa",
|
||||
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
|
||||
"ports": [
|
||||
"9443"
|
||||
"9090:9000",
|
||||
"9443:9443"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/portainer/data:/data",
|
||||
"${dir:data}:/data",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
]
|
||||
},
|
||||
@@ -90,5 +96,17 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "portainer",
|
||||
"port": 9090
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/mesh/portainer/route.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ interface DatabaseEvent {
|
||||
user?: string;
|
||||
}
|
||||
|
||||
await on<DatabaseEvent>("module.postgres.database.provisioned", async (e) => {
|
||||
await on<DatabaseEvent>("database.provisioned", async (e) => {
|
||||
console.log(`[postgres] database provisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
await on<DatabaseEvent>("module.postgres.database.deprovisioned", async (e) => {
|
||||
await on<DatabaseEvent>("database.deprovisioned", async (e) => {
|
||||
console.log(`[postgres] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.postgres.database.provisioned",
|
||||
"module.postgres.database.deprovisioned"
|
||||
"database.provisioned",
|
||||
"database.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.postgres.database.provisioned",
|
||||
"module.postgres.database.deprovisioned"
|
||||
"postgres.database.provisioned",
|
||||
"postgres.database.deprovisioned"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -73,7 +73,8 @@
|
||||
"id": "store-data",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-store",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"owner": "999:70"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
|
||||
@@ -34,7 +34,7 @@ runProvisioner("postgres-database", {
|
||||
// Database and owning role share the consumer's login, so the consumer owns exactly its own.
|
||||
const database = p.as;
|
||||
await postgres.createDatabaseAndRole(database, p.as, p.password);
|
||||
await announce("module.postgres.database.provisioned", {
|
||||
await announce("database.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
database,
|
||||
user: p.as,
|
||||
@@ -43,7 +43,7 @@ runProvisioner("postgres-database", {
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await postgres.dropDatabaseAndRole(p.as, p.as);
|
||||
await announce("module.postgres.database.deprovisioned", { database: p.as });
|
||||
await announce("database.deprovisioned", { database: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
|
||||
@@ -30,9 +30,9 @@ async function pollTorrents(): Promise<void> {
|
||||
for (const [hash, t] of now) {
|
||||
const before = progressByHash.get(hash);
|
||||
if (before === undefined) {
|
||||
await emit("module.qbittorrent.download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes });
|
||||
await emit("download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes });
|
||||
} else if (before < 1 && t.progress >= 1) {
|
||||
await emit("module.qbittorrent.download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes });
|
||||
await emit("download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.qbittorrent.download.added",
|
||||
"module.qbittorrent.download.completed"
|
||||
"download.added",
|
||||
"download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
|
||||
@@ -40,12 +40,12 @@ async function pollQueue(radarr: RadarrClient): Promise<void> {
|
||||
if (primed) {
|
||||
// Entered the queue since last look — Radarr grabbed a release.
|
||||
for (const [id, item] of now) {
|
||||
if (!inQueue.has(id)) await emit("module.radarr.movie.grabbed", { title: item.title, status: item.status });
|
||||
if (!inQueue.has(id)) await emit("movie.grabbed", { title: item.title, status: item.status });
|
||||
}
|
||||
// Left the queue — imported and done, unless it was last seen failing.
|
||||
for (const [id, item] of inQueue) {
|
||||
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
|
||||
await emit("module.radarr.download.completed", { title: item.title });
|
||||
await emit("download.completed", { title: item.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.radarr.movie.grabbed",
|
||||
"module.radarr.download.completed"
|
||||
"movie.grabbed",
|
||||
"download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
|
||||
@@ -14,11 +14,11 @@ interface CacheEvent {
|
||||
keyspacePrefix?: string;
|
||||
}
|
||||
|
||||
await on<CacheEvent>("module.redis.cache.provisioned", async (e) => {
|
||||
await on<CacheEvent>("cache.provisioned", async (e) => {
|
||||
console.log(`[redis] cache provisioned for ${e.body.consumer} (user ${e.body.username})`);
|
||||
});
|
||||
|
||||
await on<CacheEvent>("module.redis.cache.deprovisioned", async (e) => {
|
||||
await on<CacheEvent>("cache.deprovisioned", async (e) => {
|
||||
console.log(`[redis] cache deprovisioned for ${e.body.consumer} (user ${e.body.username})`);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.redis.cache.provisioned",
|
||||
"module.redis.cache.deprovisioned"
|
||||
"cache.provisioned",
|
||||
"cache.deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"module.redis.cache.provisioned",
|
||||
"module.redis.cache.deprovisioned"
|
||||
"redis.cache.provisioned",
|
||||
"redis.cache.deprovisioned"
|
||||
],
|
||||
"serves": {
|
||||
"redis-cache": {
|
||||
|
||||
@@ -32,7 +32,7 @@ runProvisioner("redis-cache", {
|
||||
// The keyspace is scoped to the consumer's own login, so one cannot read another's keys.
|
||||
const keyspacePrefix = p.as;
|
||||
await redis.createAclUser(p.as, p.password, keyspacePrefix);
|
||||
await announce("module.redis.cache.provisioned", {
|
||||
await announce("cache.provisioned", {
|
||||
consumer: p.consumer ?? "",
|
||||
username: p.as,
|
||||
keyspacePrefix,
|
||||
@@ -41,7 +41,7 @@ runProvisioner("redis-cache", {
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await redis.deleteAclUser(p.as);
|
||||
await announce("module.redis.cache.deprovisioned", { username: p.as });
|
||||
await announce("cache.deprovisioned", { username: p.as });
|
||||
},
|
||||
|
||||
// This server keeps its ACL users in memory only, so a restart of it forgets every consumer while
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
"module": "resolv-conf",
|
||||
"version": "1",
|
||||
"slug": "resolv",
|
||||
|
||||
"requires": ["wildcard-resolution"],
|
||||
"claims": [{"name": "the-resolver-configuration", "scope": "node"}],
|
||||
|
||||
"requires": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-resolver-config",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it — the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know — a resolver's second line is asked only when the first does not\n# answer at all — and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"}
|
||||
{
|
||||
"id": "resolv",
|
||||
"type": "file",
|
||||
"path": "/etc/resolv.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,38 @@
|
||||
"module": "resolved-split-dns",
|
||||
"version": "1",
|
||||
"slug": "splitdns",
|
||||
|
||||
"requires": ["wildcard-resolution"],
|
||||
"claims": [{"name": "the-resolver-configuration", "scope": "node"}],
|
||||
|
||||
"requires": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-resolver-config",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{"id": "drop-in", "type": "directory", "path": "/etc/systemd/resolved.conf.d", "mode": "0755"},
|
||||
|
||||
{"id": "route", "type": "file",
|
||||
"path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine — a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"},
|
||||
|
||||
{"id": "resolved", "type": "service", "unit": "systemd-resolved.service",
|
||||
"state": "running", "boot": "enabled", "restart-on": ["route"]}
|
||||
{
|
||||
"id": "drop-in",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/resolved.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/resolved.conf.d/mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
|
||||
},
|
||||
{
|
||||
"id": "resolved",
|
||||
"type": "service",
|
||||
"unit": "systemd-resolved.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"route"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -159,3 +159,15 @@ cd modules/route-adapter && npm test
|
||||
They hold it to what ADR 0104 says holds it: one file per contribution, a file removed when its
|
||||
contribution goes, every file it did not write left alone — and the two facts a route file has to
|
||||
get right, the port the contributor publishes and the address of the machine it is on.
|
||||
|
||||
## A body limit
|
||||
|
||||
A contribution may say `max-request-body`, in bytes, and the adapter writes it as the predecessor's
|
||||
own `buffering` middleware, named after the router so the two halves cannot drift. A route that says
|
||||
nothing gets no middleware and the predecessor's default stands.
|
||||
|
||||
This is the one thing the file shape *can* say that a policy cannot, which is why it is written
|
||||
rather than skipped: the predecessor already served its own registry name this way. A limit that is
|
||||
not a whole positive number of bytes takes the route with it — written without the limit, the
|
||||
predecessor would carry exactly what the module said not to carry, and this module would report
|
||||
success doing it.
|
||||
|
||||
@@ -75,6 +75,15 @@ export interface Route {
|
||||
from: string;
|
||||
/** Where the predecessor's proxy is to send it. */
|
||||
target: string;
|
||||
/**
|
||||
* The largest request body, in bytes, the predecessor may carry to it — the contribution's
|
||||
* `max-request-body`. Absent is whatever the predecessor does by default.
|
||||
*
|
||||
* Unlike a policy, this file shape *can* say it: the predecessor has a buffering middleware, and
|
||||
* its own registry route used exactly this. A registry takes image layers in single requests of
|
||||
* gigabytes, so a route that could not say it would be a name nothing could be pushed to.
|
||||
*/
|
||||
maxRequestBody?: number;
|
||||
}
|
||||
|
||||
/** What one pass changed. */
|
||||
@@ -176,12 +185,40 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[
|
||||
}
|
||||
// Where the mesh says that machine is. Empty means this one, and this one is reached from
|
||||
// inside the predecessor's container by the machine's own name, not by loopback.
|
||||
// A limit it cannot honour is a route it does not write — skipped and named, like a port that
|
||||
// is not one. Written without the limit instead, the predecessor would carry exactly what the
|
||||
// module said not to carry, and this adapter would report success.
|
||||
const askedLimit = entry.values?.["max-request-body"];
|
||||
const limit = asBodyLimit(askedLimit);
|
||||
if (limit === null) {
|
||||
skipped.push(
|
||||
`${from} asked for route ${name} with a max-request-body of ${JSON.stringify(askedLimit)}, ` +
|
||||
`which is not a whole positive number of bytes`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const at = typeof entry.at === "string" && entry.at.trim() !== "" ? entry.at.trim() : machine;
|
||||
routes.push({ name, from, target: `http://${at}:${port}` });
|
||||
routes.push({ name, from, target: `http://${at}:${port}`, ...(limit === undefined ? {} : { maxRequestBody: limit }) });
|
||||
}
|
||||
return { routes, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* The body limit a contribution asked for: a number, `undefined` for silence, `null` for unusable.
|
||||
*
|
||||
* Three answers rather than two, because "said nothing" and "said something wrong" must not become
|
||||
* the same route.
|
||||
*/
|
||||
function asBodyLimit(value: unknown): number | undefined | null {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The file one route is written to. The prefix is how the mesh recognises its own. */
|
||||
export function fileNameFor(name: string): string {
|
||||
return `mesh-${name}.yml`;
|
||||
@@ -201,6 +238,10 @@ export function routerNameFor(name: string): string {
|
||||
*/
|
||||
export function routeFile(route: Route, settings: Settings): string {
|
||||
const id = routerNameFor(route.name);
|
||||
// The body limit is a middleware in the predecessor's vocabulary — its `buffering`, with the one
|
||||
// field the predecessor's own registry route set — named after the router so the two halves cannot
|
||||
// drift, and written only when the contribution asked for it.
|
||||
const limited = route.maxRequestBody !== undefined;
|
||||
return [
|
||||
marker,
|
||||
`# ${route.from} contributed this route. It is removed when that contribution goes.`,
|
||||
@@ -210,10 +251,19 @@ export function routeFile(route: Route, settings: Settings): string {
|
||||
` entryPoints: [${settings.entrypoint}]`,
|
||||
` rule: Host(\`${route.name}\`)`,
|
||||
` service: ${id}`,
|
||||
...(limited ? [` middlewares: [${id}-body]`] : []),
|
||||
" tls:",
|
||||
` certResolver: ${settings.resolver}`,
|
||||
" domains:",
|
||||
` - main: ${route.name}`,
|
||||
...(limited
|
||||
? [
|
||||
" middlewares:",
|
||||
` ${id}-body:`,
|
||||
" buffering:",
|
||||
` maxRequestBodyBytes: ${route.maxRequestBody}`,
|
||||
]
|
||||
: []),
|
||||
" services:",
|
||||
` ${id}:`,
|
||||
" loadBalancer:",
|
||||
|
||||
@@ -22,7 +22,9 @@ async function predecessor(already: Record<string, string> = {}): Promise<Settin
|
||||
}
|
||||
|
||||
/** The contributions file the mesh writes, in the shape the mesh's own proxy also reads. */
|
||||
function contributed(...given: { from: string; node?: string; at?: string; name: string; port: number }[]) {
|
||||
function contributed(
|
||||
...given: { from: string; node?: string; at?: string; name: string; port: number; limit?: unknown }[]
|
||||
) {
|
||||
return {
|
||||
contributions: 1,
|
||||
requirement: "route",
|
||||
@@ -30,7 +32,7 @@ function contributed(...given: { from: string; node?: string; at?: string; name:
|
||||
from: g.from,
|
||||
node: g.node ?? "control-node",
|
||||
at: g.at ?? "",
|
||||
values: { name: g.name, port: g.port },
|
||||
values: { name: g.name, port: g.port, ...(g.limit === undefined ? {} : { "max-request-body": g.limit }) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -232,3 +234,41 @@ test("it refuses when the predecessor's directory is not there, and says why", a
|
||||
const settings = { ...defaults, dynamic: join(await mkdtemp(join(tmpdir(), "route-adapter-")), "absent") };
|
||||
await assert.rejects(reconcile([], settings), /is not there.*`dynamic` setting.*mounts it/s);
|
||||
});
|
||||
|
||||
// **A registry is why a route needs to say this.** Image layers arrive as single requests of
|
||||
// gigabytes, and the predecessor served its own registry name with a `buffering` middleware for
|
||||
// exactly that reason. The contribution carries the limit as `max-request-body`, the adapter writes
|
||||
// the middleware the predecessor already understands, named after the router so the two halves
|
||||
// cannot drift — and writes nothing of the kind for a route that did not ask.
|
||||
test("a body limit is written as the predecessor's buffering middleware", async () => {
|
||||
const settings = await predecessor();
|
||||
const changed = await pass(settings, contributed(
|
||||
{ from: "registry", name: "images.example", port: 5001, limit: 21474836480 },
|
||||
{ from: "forge", name: "git.example", port: 2999 },
|
||||
));
|
||||
assert.deepEqual(changed.written, ["mesh-git.example.yml", "mesh-images.example.yml"]);
|
||||
|
||||
const written = await readFile(join(settings.dynamic, "mesh-images.example.yml"), "utf8");
|
||||
assert.match(written, /^ {6}middlewares: \[mesh-images-example-body\]$/m);
|
||||
assert.match(written, /^ {2}middlewares:\n {4}mesh-images-example-body:\n {6}buffering:\n {8}maxRequestBodyBytes: 21474836480$/m);
|
||||
|
||||
// The route that asked for nothing carries no middleware — the predecessor's default stands.
|
||||
const plain = await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8");
|
||||
assert.doesNotMatch(plain, /middlewares|buffering/);
|
||||
});
|
||||
|
||||
// A limit it cannot honour is a route it does not write. Written without it, the predecessor would
|
||||
// carry exactly what the module said not to carry, and this module would report success.
|
||||
test("a body limit that is not a whole number of bytes is skipped and named", () => {
|
||||
for (const limit of ["20g", 0, -1, 1.5, true, null]) {
|
||||
const { routes, skipped } = routesFrom(
|
||||
contributed({ from: "registry", name: "images.example", port: 5001, limit }), defaults.machine);
|
||||
assert.deepEqual(routes, [], `a limit of ${JSON.stringify(limit)} was served`);
|
||||
assert.equal(skipped.length, 1);
|
||||
assert.match(skipped[0]!, /max-request-body/);
|
||||
}
|
||||
// And a limit the mesh's own proxy would accept is carried through, as a number.
|
||||
const { routes } = routesFrom(
|
||||
contributed({ from: "registry", name: "images.example", port: 5001, limit: 1024 }), defaults.machine);
|
||||
assert.equal(routes[0]?.maxRequestBody, 1024);
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
"on": [
|
||||
{
|
||||
"arg": "GO_BASE",
|
||||
"image": "golang@sha256:699337d620559a59b4a2bb298ad59611e535d2ee755a34cf2d2a98f37578dc80"
|
||||
"image": "golang@sha256:6c2a5538f964f1c82f97ad14988bf05de100d922d159d0e398b54c7b0ca0c6c9"
|
||||
},
|
||||
{
|
||||
"arg": "ALPINE_BASE",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// 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) => {
|
||||
await on<{ who?: string }>("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" });
|
||||
await emit("acknowledged", { who: event.body.who ?? "somebody" });
|
||||
});
|
||||
|
||||
+183
-59
@@ -2,72 +2,196 @@
|
||||
"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"],
|
||||
|
||||
"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": [
|
||||
"greeted",
|
||||
"acknowledged"
|
||||
],
|
||||
"consumes": [
|
||||
"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)" }
|
||||
{
|
||||
"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" }
|
||||
{
|
||||
"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",
|
||||
{
|
||||
"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": "the-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"] }
|
||||
"volumes": [
|
||||
"/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker"
|
||||
},
|
||||
"args": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
]
|
||||
}
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "the-showcase",
|
||||
"scope": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ async function pollQueue(sonarr: SonarrClient): Promise<void> {
|
||||
if (primed) {
|
||||
// Entered the queue since last look — Sonarr grabbed a release.
|
||||
for (const [id, item] of now) {
|
||||
if (!inQueue.has(id)) await emit("module.sonarr.episode.grabbed", { title: item.title, status: item.status });
|
||||
if (!inQueue.has(id)) await emit("episode.grabbed", { title: item.title, status: item.status });
|
||||
}
|
||||
// Left the queue — imported and done, unless it was last seen failing.
|
||||
for (const [id, item] of inQueue) {
|
||||
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
|
||||
await emit("module.sonarr.download.completed", { title: item.title });
|
||||
await emit("download.completed", { title: item.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.sonarr.episode.grabbed",
|
||||
"module.sonarr.download.completed"
|
||||
"episode.grabbed",
|
||||
"download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"module": "sshd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
"why": "the operator's own door. From anywhere because the machines that need it are exactly the ones not on the mesh yet \u2014 and locking the operator out is the one failure a firewall must never arrange"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "openssh"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/ssh/sshd_config.d/10-mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead.\nPort 22\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\nX11Forwarding no\nPrintMotd no\nAcceptEnv LANG LC_*\n"
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
"type": "service",
|
||||
"unit": "sshd.service",
|
||||
"state": "running",
|
||||
"restart-on": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"module": "systemd-networkd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "systemd"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/network/00-mesh0.network",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module systemd-networkd). Replaced on every push; edit\n# the catalogue instead.\n#\n# This machine's uplink is systemd-networkd's, and the mesh asks one thing of it\n# here (novox/hq ADR 0117): leave the private network's interface alone. mesh0\n# is the mesh's; the mesh brings it up and configures it itself. The mesh never\n# declares a link, an address, a route, a wireless network or its credentials,\n# nor a network file for any of this machine's own interfaces \u2014 those are\n# the operator's, and the link they make is the only channel the mesh reaches\n# this machine over.\n#\n# 00-: networkd applies the first .network file, in alphanumeric order across\n# every directory, that matches an interface, and ignores every later one even\n# if it matches too (systemd.network(5), [Match]). A catch-all of the operator's\n# \u2014 Name=*, Type=ether, a file with no [Match] at all \u2014 sorted before\n# this one would claim mesh0 first. 00 sorts before every numbered prefix the\n# man page recommends.\n#\n# Unmanaged=yes: \"no attempts are made to bring up or configure matching links,\n# equivalent to when there are no matching network files\" (systemd.network(5),\n# [Link], since 233). A match that ends the search, and does nothing else.\n#\n# No DNS setting, because none is needed: networkd never writes\n# /etc/resolv.conf. What it learns from a lease it hands only to\n# systemd-resolved, and the resolver file stays whatever resolv-conf wrote.\n# Whether resolved runs, and what it does with that, is the resolver\n# configuration's question, not the uplink's.\n#\n# The service is reloaded when this file changes, never restarted: a restart\n# drops the links networkd holds, this machine's channel to the mesh among them.\n[Match]\nName=mesh0\n\n[Link]\nUnmanaged=yes\n"
|
||||
},
|
||||
{
|
||||
"id": "service",
|
||||
"type": "service",
|
||||
"unit": "systemd-networkd.service",
|
||||
"reload-on": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,7 @@ async function pollHistory(client: TautulliClient): Promise<void> {
|
||||
}
|
||||
|
||||
async function emitWatch(w: TautulliWatch): Promise<void> {
|
||||
await emit("module.tautulli.watch.recorded", {
|
||||
await emit("watch.recorded", {
|
||||
title: w.title, user: w.user, mediaType: w.mediaType,
|
||||
watchedStatus: w.watchedStatus, percentComplete: w.percentComplete, at: w.date,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user