Compare commits

..
Author SHA1 Message Date
jschoubben 21f5301268 ombi: its config is placed, its image is the one ace runs
ombi's definition named /services/ombi/config (a HAL machine path) in three
places and pinned an image older than the one ace runs. Ombi migrates its
own SQLite schema, so a take onto the older pin (v4.53.10-ls267) would start
it on a database the newer build (ls269) already touched.

- config is a pathless placed directory, mounted as ${dir:config}
- a state directory placed at the assignment root carries route.json
- image pinned to the digest ace runs today (v4.53.10-ls269)
- the sidecar reaches ombi on the machine port the mesh assigns
  (${port:3579}) rather than assuming 3579 is free
- the sidecar no longer mounts ombi's data directory: MESH_OMBI_CONFIG_DIR
  is read by no code, and the mount exposed the databases for nothing

Verified: catalogue tests (MESH_CATALOGUE set, 6 pass, none skipped); the
pinned image starts as PUID 1000 in a 0700 dir and answers /api/v1/Status
200; data owned 1001:2000 (ace's media ids) under a 1000:1000 dir is
re-owned by the image's init and serves 200; a minted ApiKey is refused
(401) - the api-key secret must be accepted from ombi's own settings.
2026-09-29 23:38:56 +02:00
3 changed files with 52 additions and 79 deletions
+24 -55
View File
@@ -2,15 +2,12 @@
// module's tools and anything else baserow-specific import it; nothing outside baserow does.
//
// Baserow authenticates a person with email + password, exchanged for a JWT at /api/user/token-auth/.
// The standard image creates no admin from env, so the account is one a person made in Baserow: its
// password is the module's `admin` secret, accepted from the operator, and its email and the public
// host Baserow answers to reach the runtime config file the mesh mounts (the email from the
// assignment's settings). Until both are there fromEnv throws and the module exposes no tools — the
// same dormant-until-configured shape gitea uses for its token.
// Those credentials are the mesh's own: a person signs up in Baserow (the standard image creates no
// admin from env), and the credential is placed in the runtime config file the mesh mounts. Until
// that happens fromEnv throws and the module simply exposes no tools — the same dormant-until-
// configured shape gitea uses for its token.
import { readFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
export interface BaserowApplication {
id: number;
@@ -71,63 +68,35 @@ export class BaserowClient {
return h;
}
/**
* One HTTP exchange. Not `fetch`: Node's fetch drops a caller's Host header and sends the URL's
* own, and Baserow answers only the host of its BASEROW_PUBLIC_URL — any other Host is looked up
* as a published builder site and gets 404, `/api/_health/` included. A co-located caller reaching
* it by container name must present the public host, so the request is made with node:http, which
* sends the Host it is given.
*/
private send(path: string, method: string, headers: Record<string, string>, body?: string): Promise<{ status: number; text: string }> {
const url = new URL(`${this.baseUrl}${path}`);
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
// A length, never chunked: Baserow's server reads a chunked body as empty.
const sent = body === undefined ? headers : { ...headers, "Content-Length": String(Buffer.byteLength(body)) };
return new Promise((resolve, reject) => {
const req = request(url, { method, headers: sent }, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (chunk: string) => (text += chunk));
res.on("end", () => resolve({ status: res.statusCode ?? 0, text }));
res.on("error", reject);
});
req.on("error", reject);
if (body !== undefined) req.write(body);
req.end();
});
}
/** Exchange email + password for a JWT, caching it until Baserow refuses it. Handles both the
/** Exchange email + password for a JWT, caching it for the client's lifetime. Handles both the
* older `{ token }` and the newer `{ access_token }` response shapes. */
async authenticate(): Promise<string> {
if (this.token) return this.token;
const res = await this.send(
"/api/user/token-auth/",
"POST",
this.headers(),
JSON.stringify({ email: this.email, password: this.password }),
);
if (res.status < 200 || res.status >= 300) throw new Error(`baserow auth failed: ${res.status} ${res.text}`);
const data = JSON.parse(res.text) as { token?: string; access_token?: string };
const res = await fetch(`${this.baseUrl}/api/user/token-auth/`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ email: this.email, password: this.password }),
});
if (!res.ok) throw new Error(`baserow auth failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { token?: string; access_token?: string };
const token = data.access_token ?? data.token;
if (!token) throw new Error("baserow auth returned no token");
this.token = token;
return token;
}
/** An authenticated GET. A refused token is dropped and the call made once more with a fresh one:
* Baserow's access tokens expire after minutes, and the runtime lives for weeks. */
private async authed<T>(path: string): Promise<T> {
for (let attempt = 0; ; attempt++) {
const token = await this.authenticate();
const res = await this.send(path, "GET", this.headers({ Authorization: `JWT ${token}` }));
if (res.status === 401 && attempt === 0) {
this.token = null;
continue;
}
if (res.status < 200 || res.status >= 300) throw new Error(`baserow ${path}: ${res.status} ${res.text}`);
return (res.text ? JSON.parse(res.text) : null) as T;
}
private async authed<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await this.authenticate();
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: this.headers({
Authorization: `JWT ${token}`,
...(options.headers as Record<string, string> | undefined),
}),
});
if (!res.ok) throw new Error(`baserow ${path}: ${res.status} ${await res.text()}`);
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
/** The applications (databases) the account can see, across all its workspaces. */
+16 -15
View File
@@ -18,14 +18,14 @@
}
},
"binds": {
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
"postgres-database": "/var/lib/baserow/database.json",
"route": "/var/lib/baserow/route.json"
},
"secrets": {
"postgres-database": "${dir:state}/database.secret"
"postgres-database": "/var/lib/baserow/database.secret"
},
"own-secrets": {
"admin": "${dir:state}/admin.secret",
"secret-key": "/var/lib/baserow/secret-key.secret",
"broker": "/var/lib/mesh/baserow/broker"
},
"listens": [
@@ -34,7 +34,7 @@
"port": 80,
"protocol": "tcp",
"from": "mesh",
"why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's"
"why": "the Baserow web UI and REST API; a public name is a route grant later"
}
],
"resources": [
@@ -47,21 +47,22 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/baserow",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/baserow/data",
"mode": "0755",
"owner": "9999:9999"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"path": "/var/lib/baserow/server.env",
"mode": "0600",
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD=${secret:postgres-database}\nSECRET_KEY=${secret:secret-key}\nBASEROW_PUBLIC_URL=http://localhost\n"
},
{
"id": "net",
@@ -72,25 +73,25 @@
"id": "server",
"type": "container",
"name": "baserow",
"image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080",
"image": "baserow/baserow@sha256:834424a10413798567f76428f255dc259445b7f8dcec56598c05b4073bb2a124",
"network": "baserow",
"env-file": [
"${dir:state}/server.env"
"/var/lib/baserow/server.env"
],
"ports": [
"80"
],
"volumes": [
"${dir:data}:/baserow/data",
"${dir:state}/database.secret:/run/secrets/database:ro"
]
"/services/baserow/data:/baserow/data"
],
"secrets-in-environment": "baserow reads DATABASE_PASSWORD and SECRET_KEY with os.getenv and has no _FILE twin (settings/base.py); not convertible"
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/baserow/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"content": "{}\n",
"merge": "json"
},
{
+12 -9
View File
@@ -28,10 +28,15 @@
"path": "/var/lib/mesh/ombi",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/ombi/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -39,7 +44,7 @@
"id": "server",
"type": "container",
"name": "ombi",
"image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d",
"image": "lscr.io/linuxserver/ombi@sha256:22d6ebadbaaa728571353e74dc2173719e0fb02d4eaec551a7e9d2ee99ef68ac",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -49,7 +54,7 @@
"3579"
],
"volumes": [
"/services/ombi/config:/config"
"${dir:config}:/config"
]
},
{
@@ -68,15 +73,13 @@
"volumes": [
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro"
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_OMBI_URL": "http://127.0.0.1:3579",
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
@@ -94,7 +97,7 @@
}
},
"binds": {
"route": "/var/lib/mesh/ombi/route.json"
"route": "${dir:state}/route.json"
},
"build": {
"on": [