Compare commits

..
Author SHA1 Message Date
jschoubben 1247b8c27e redis: place its directories and run the build in use
The manifest stated /services/redis/data and /var/lib/redis-module - novox's
old layout, paths no definition may carry (ADR 0112). State is now the
assignment's root, grants and data are placed, and the config file, the
secret file, receives and grants all name them as ${dir:...}. Paths inside
the sidecar are its own view and are unchanged.

The data directory and config are owned 999:1000: the image's redis user is
uid 999 in gid 1000 (checked in both builds), which is who owns ace's data
today; 999:999 named a group the image does not use.

Image pinned to the 7.4.11-alpine build ace runs (2026-09-17); the old pin was
the same version, built in August. Older-than-running is never the pin.

Nothing is assigned it anywhere today, so no machine changes.

Verified: catalogue tests pass with MESH_CATALOGUE on this tree; the
declaration composes for ace with every path under /var/lib/redis. The pinned
image ran as a throwaway with a 0600 999:1000 config and a 0700 data dir:
unauthenticated PING is refused (NOAUTH), authenticated SET/GET works,
appendonly is on, the server runs as redis.
2026-09-30 11:57:46 +02:00
4 changed files with 40 additions and 84 deletions
+16 -53
View File
@@ -2,8 +2,7 @@
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
// talks its /api/v2.0 REST API, and only jackett's tools import it.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { readFileSync } from "node:fs";
export interface JackettIndexer {
id: string;
@@ -44,36 +43,18 @@ export class JackettClient {
/**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
* the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
* rather than failing half-configured.
* the key must be present — without them there is nothing to talk to, so this throws and the
* module contributes no tools rather than failing half-configured.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY;
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY");
return new JackettClient(url, apiKey);
}
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
* <config>/Jackett/ServerConfig.json), falling back to null. */
static detectApiKey(configDir: string): string | null {
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
if (!existsSync(file)) continue;
try {
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
if (typeof key === "string" && key) return key;
} catch { /* unreadable or mid-write: try the next, then give up */ }
}
return null;
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey);
@@ -83,36 +64,18 @@ export class JackettClient {
return res.json();
}
/**
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
* what the key is for. The feed carries no last error, so `lastError` stays unset.
*/
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
url.searchParams.set("apikey", this.apiKey);
url.searchParams.set("t", "indexers");
url.searchParams.set("configured", configuredOnly ? "true" : "false");
const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
const xml = await res.text();
// Torznab reports failures (a wrong key among them) as 200 with an <error> body.
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
const text = (block: string, tag: string) =>
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
const out: JackettIndexer[] = [];
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
out.push({
id: m[1],
name: text(m[3], "title") ?? m[1],
type: text(m[3], "type") ?? "unknown",
configured: m[2] === "true",
siteLink: text(m[3], "link"),
});
}
return out;
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" });
const list = Array.isArray(raw) ? raw : [];
return list.map((i: any) => ({
id: i.id,
name: i.name,
type: i.type,
configured: i.configured ?? false,
siteLink: i.site_link,
lastError: i.last_error || undefined,
}));
}
/**
+9 -14
View File
@@ -10,7 +10,7 @@
"port": 9117,
"protocol": "tcp",
"from": "mesh",
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through"
"why": "the indexer proxy"
}
],
"resources": [
@@ -20,15 +20,10 @@
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/jackett/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -36,7 +31,7 @@
"id": "server",
"type": "container",
"name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -46,13 +41,13 @@
"9117"
],
"volumes": [
"${dir:config}:/config"
"/services/jackett/config:/config"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"path": "/var/lib/mesh/jackett/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
@@ -64,12 +59,12 @@
"network": "host",
"volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/jackett/config:ro"
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
"/services/jackett/config:/var/lib/jackett/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
},
@@ -92,7 +87,7 @@
}
},
"binds": {
"route": "${dir:state}/route.json"
"route": "/var/lib/mesh/jackett/route.json"
},
"build": {
"on": [
+1 -1
View File
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [
{
name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and site.",
description: "List the indexers Jackett proxies, with their type and any last error.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => {
const indexers = await jackett.getIndexers(!args.all);
+14 -16
View File
@@ -27,13 +27,13 @@
}
},
"receives": {
"redis-cache": "/var/lib/redis-module/grants/mesh.json"
"redis-cache": "${dir:grants}/mesh.json"
},
"grants": {
"redis-cache": "/var/lib/redis-module/grants"
"redis-cache": "${dir:grants}"
},
"secrets": {
"secret": "/var/lib/redis-module/default.secret"
"secret": "${dir:state}/default.secret"
},
"own-secrets": {
"broker": "/var/lib/mesh/redis/broker"
@@ -57,29 +57,27 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/redis-module",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "grants-dir",
"id": "grants",
"type": "directory",
"path": "/var/lib/redis-module/grants",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/redis/data",
"mode": "0700",
"owner": "999:999"
"owner": "999:1000"
},
{
"id": "server-conf",
"type": "file",
"path": "/var/lib/redis-module/redis.conf",
"path": "${dir:state}/redis.conf",
"mode": "0600",
"content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n",
"owner": "999:999"
"owner": "999:1000"
},
{
"id": "net",
@@ -90,14 +88,14 @@
"id": "server",
"type": "container",
"name": "redis",
"image": "redis@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf",
"image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7",
"network": "redis",
"ports": [
"6379"
],
"volumes": [
"/services/redis/data:/data",
"/var/lib/redis-module/redis.conf:/etc/redis/redis.conf:ro"
"${dir:data}:/data",
"${dir:state}/redis.conf:/etc/redis/redis.conf:ro"
],
"args": [
"/etc/redis/redis.conf"
@@ -113,8 +111,8 @@
"network": "redis",
"volumes": [
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro",
"/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro",
"/var/lib/redis-module/default.secret:/run/secrets/default:ro"
"${dir:grants}:/var/lib/redis-module/grants:ro",
"${dir:state}/default.secret:/run/secrets/default:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",