redis, postgres: full nox provider modules — client, tools, provisioner, events

redis provides redis-cache: a real admin client speaking RESP over a raw socket
(node:net, no deps); provisioner makes a keyspace-scoped ACL user per grant.
postgres provides postgres-database: admin client executing through psql (the
consistent shell-out port, like minio's mc), full DDL for create/drop database+
role, a CSV row parser, read-only query tool. Both emit
module.<x>.<thing>.provisioned/.deprovisioned from the provisioner. Both
typecheck; manifests parse.
This commit is contained in:
2026-09-04 02:39:03 +02:00
parent f689b7dfa6
commit 4d04585831
12 changed files with 706 additions and 2 deletions
+232
View File
@@ -0,0 +1,232 @@
// redis's admin client — redis's own code, living in the module (novox/hq ADR 0044). It speaks
// RESP directly over a raw TCP socket (node:net) so the module carries no npm dependency beyond
// @novox/mesh-sdk: no redis driver, no redis-cli in the image. Both this module's tools and its
// provisioner import it, and nothing outside redis does.
//
// The client keeps a single connection and runs one command at a time in FIFO order — enough for
// an admin surface (PING, INFO, ACL SETUSER/DELUSER, arbitrary commands). Replies come back in the
// order requests were sent, which is what the queue below relies on.
import { createConnection, type Socket } from "node:net";
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */
export type RespValue = string | number | null | RespValue[];
interface Waiter {
resolve: (v: RespValue) => void;
reject: (e: Error) => void;
}
export class RedisClient {
private socket: Socket | null = null;
private buffer = Buffer.alloc(0);
private queue: Waiter[] = [];
constructor(
readonly host: string,
readonly port: number,
private readonly password: string,
private readonly username = "default",
) {}
/**
* Build from the module's resolved environment. Reads MESH_REDIS_* first (the documented
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the
* provisioner container so the module runs unchanged there. Throws if it cannot find a host and
* an admin password — the right failure, because without them nothing it does can work.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): RedisClient {
const endpoint = env.MESH_PROVISION_REDIS ?? ""; // "host:port"
const host = env.MESH_REDIS_HOST ?? (endpoint ? endpoint.split(":")[0] : undefined);
const port = Number(env.MESH_REDIS_PORT ?? (endpoint.includes(":") ? endpoint.split(":")[1] : "") ?? "6379") || 6379;
const username = env.MESH_REDIS_USERNAME ?? "default";
const password = env.MESH_REDIS_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
if (!host || !password) {
throw new Error("redis host or admin password is not set — redis's own code cannot reach the server");
}
return new RedisClient(host, port, password, username);
}
/** Open the connection (idempotent) and authenticate. Reconnects if the socket has gone away. */
async connect(): Promise<void> {
if (this.socket && !this.socket.destroyed) return;
await new Promise<void>((resolve, reject) => {
const sock = createConnection({ host: this.host, port: this.port });
this.socket = sock;
sock.on("data", (chunk: Buffer | string) => this.onData(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
sock.on("error", (err) => {
this.failAll(err);
reject(err);
});
sock.on("close", () => this.failAll(new Error("redis connection closed")));
sock.once("connect", () => resolve());
});
if (this.password) {
const args = this.username && this.username !== "default"
? ["AUTH", this.username, this.password]
: ["AUTH", this.password];
await this.send(args);
}
}
/** Run one command and return its parsed reply. A RESP error reply rejects the promise. */
async command(...args: (string | number)[]): Promise<RespValue> {
await this.connect();
return this.send(args.map(String));
}
async ping(): Promise<boolean> {
return (await this.command("PING")) === "PONG";
}
/** Server INFO, returned both raw and parsed into the flat key/value map redis emits. */
async info(section?: string): Promise<{ raw: string; fields: Record<string, string> }> {
const raw = String((await this.command("INFO", ...(section ? [section] : []))) ?? "");
const fields: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith("#")) continue;
const idx = line.indexOf(":");
if (idx > 0) fields[line.slice(0, idx)] = line.slice(idx + 1);
}
return { raw, fields };
}
/**
* Create (or reset to a known state) an ACL user scoped to one keyspace prefix. `reset` first
* clears any prior rules so the call is idempotent, then the user is enabled with the given
* password, confined to keys matching `<prefix>:*`, and allowed the ordinary command set. The
* consumer connects as this user and can touch nothing outside its prefix.
*/
async createAclUser(username: string, password: string, keyspacePrefix: string): Promise<void> {
await this.command("ACL", "SETUSER", username, "reset", "on", `>${password}`, `~${keyspacePrefix}:*`, "+@all");
}
async deleteAclUser(username: string): Promise<void> {
await this.command("ACL", "DELUSER", username);
}
close(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
}
// --- connection plumbing ---
/** Send an already-connected command, queuing its reply against the FIFO of in-flight requests. */
private send(args: string[]): Promise<RespValue> {
const sock = this.socket;
if (!sock) return Promise.reject(new Error("redis socket is not connected"));
return new Promise<RespValue>((resolve, reject) => {
this.queue.push({ resolve, reject });
sock.write(encodeCommand(args));
});
}
/** Feed incoming bytes to the parser, resolving as many queued replies as the buffer completes. */
private onData(chunk: Buffer): void {
this.buffer = Buffer.concat([this.buffer, chunk]);
while (this.queue.length > 0) {
let parsed: { value: RespValue | Error; next: number } | null;
try {
parsed = parseReply(this.buffer, 0);
} catch (err) {
const waiter = this.queue.shift();
waiter?.reject(err instanceof Error ? err : new Error(String(err)));
this.buffer = Buffer.alloc(0);
continue;
}
if (!parsed) break; // one full reply not yet in the buffer
this.buffer = this.buffer.subarray(parsed.next);
const waiter = this.queue.shift();
if (!waiter) break;
if (parsed.value instanceof Error) waiter.reject(parsed.value);
else waiter.resolve(parsed.value);
}
}
/** A socket error or close fails every pending command and forces a fresh connect next time. */
private failAll(err: Error): void {
const pending = this.queue;
this.queue = [];
for (const waiter of pending) waiter.reject(err);
this.buffer = Buffer.alloc(0);
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
}
}
/** Generate a URL-safe password with no RESP-hostile characters. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
// --- RESP wire format ---
/** Encode a command as a RESP array of bulk strings. */
function encodeCommand(args: string[]): Buffer {
let head = `*${args.length}\r\n`;
for (const a of args) head += `$${Buffer.byteLength(a)}\r\n${a}\r\n`;
return Buffer.from(head, "utf8");
}
/**
* Parse one RESP reply starting at `i`. Returns the value and the offset just past it, or null if
* the buffer does not yet hold a complete reply (the caller waits for more bytes). A `-` error
* reply is returned as an Error value; the client turns that into a rejection.
*/
function parseReply(buf: Buffer, i: number): { value: RespValue | Error; next: number } | null {
if (i >= buf.length) return null;
const type = buf[i];
const eol = buf.indexOf("\r\n", i + 1);
if (eol === -1) return null; // header line not yet complete
const line = buf.toString("utf8", i + 1, eol);
const after = eol + 2;
switch (type) {
case 0x2b: // '+' simple string
return { value: line, next: after };
case 0x2d: // '-' error
return { value: new Error(line), next: after };
case 0x3a: // ':' integer
return { value: Number(line), next: after };
case 0x24: {
// '$' bulk string
const len = Number(line);
if (len === -1) return { value: null, next: after };
const end = after + len;
if (buf.length < end + 2) return null; // body not fully arrived
return { value: buf.toString("utf8", after, end), next: end + 2 };
}
case 0x2a: {
// '*' array
const count = Number(line);
if (count === -1) return { value: null, next: after };
const arr: RespValue[] = [];
let cursor = after;
for (let k = 0; k < count; k++) {
const el = parseReply(buf, cursor);
if (!el) return null; // array not fully arrived
if (el.value instanceof Error) throw el.value;
arr.push(el.value);
cursor = el.next;
}
return { value: arr, next: cursor };
}
default:
return { value: new Error(`unexpected RESP type byte 0x${type.toString(16)}`), next: after };
}
}
+6 -1
View File
@@ -10,6 +10,10 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.redis.cache.provisioned",
"module.redis.cache.deprovisioned"
],
"serves": {
"redis-cache": {}
},
@@ -20,7 +24,8 @@
"redis-cache": "/var/lib/redis-module/grants"
},
"own-secrets": {
"default": "/var/lib/redis-module/default.secret"
"default": "/var/lib/redis-module/default.secret",
"broker": "/var/lib/redis-module/broker"
},
"listens": [
{
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-redis",
"version": "0.1.0",
"description": "redis — provides the mesh redis-cache interface. Its RESP client, provisioner, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+57
View File
@@ -0,0 +1,57 @@
// redis's provisioner — the adapter that makes redis a provider of the mesh `redis-cache`
// interface. The watching, sealing and grant-file handling are the sdk harness's; this writes only
// the per-service half: how redis creates and removes a per-consumer cache (novox/hq ADR 0044/0045).
//
// The `redis-cache` interface: a consumer receives `{ host, port, username, password,
// keyspacePrefix }` and stores its keys under `<keyspacePrefix>:*`, isolated from every other
// consumer by an ACL user scoped to exactly that prefix.
//
// Identity (the ACL username and keyspace) is derived from `grant.consumer` alone — never from
// `grant.values` — because on removal the harness hands the adapter a grant carrying only the
// consumer. Deriving from the consumer keeps create and remove naming the same resource.
import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { RedisClient, generatePassword } from "../client.js";
const redis = RedisClient.fromEnv();
/** A stable, ACL-safe identity for a consumer: only [A-Za-z0-9_.-], never empty. */
function identity(consumer: string): string {
const safe = consumer.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^_+|_+$/g, "");
return safe || "consumer";
}
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:redis-cache] emit ${type} failed: ${err}`);
}
}
runProvisioner("redis-cache", {
async create(grant: Grant): Promise<Credential> {
const username = identity(grant.consumer);
const keyspacePrefix = username;
const password = generatePassword();
await redis.createAclUser(username, password, keyspacePrefix);
await announce("module.redis.cache.provisioned", { consumer: grant.consumer, username, keyspacePrefix });
return {
fields: {
host: redis.host,
port: String(redis.port),
username,
password,
keyspacePrefix,
},
};
},
async remove(grant: Grant): Promise<void> {
const username = identity(grant.consumer);
await redis.deleteAclUser(username);
await announce("module.redis.cache.deprovisioned", { consumer: grant.consumer, username });
},
});
+52
View File
@@ -0,0 +1,52 @@
// redis's tools — redis's own code (novox/hq ADR 0044), importing redis's own RESP client. They
// return structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { RedisClient, type RespValue } from "../client.js";
export function getRedisTools(redis: RedisClient): ToolDefinition[] {
return [
{
name: "redis_ping",
description: "Check that the redis server is reachable and responding (PONG).",
input: {},
run: async () => ({ ok: await redis.ping() }),
},
{
name: "redis_info",
description: "Redis server info — memory, clients, keyspace. Omit section for everything.",
input: { section: { type: "string", description: "an INFO section, e.g. 'memory', 'clients', 'keyspace'" } },
run: async (args) => {
const { raw, fields } = await redis.info(args.section ? String(args.section) : undefined);
return { fields, raw };
},
},
{
name: "redis_command",
description: "Run an arbitrary redis command, e.g. 'DBSIZE', 'GET key', 'ACL LIST'. Admin surface.",
input: { command: { type: "string", description: "the command and its arguments, space-separated" } },
run: async (args) => {
const parts = tokenize(String(args.command ?? ""));
if (parts.length === 0) throw new Error("redis_command: empty command");
const reply: RespValue = await redis.command(...parts);
return { command: parts.join(" "), reply };
},
},
];
}
/** Split a command line into arguments, honouring double-quoted spans. */
function tokenize(command: string): string[] {
const matches = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
return matches.map((p) => p.replace(/^"|"$/g, ""));
}
// The tools exist only when the server can be reached from the environment; without it, redis
// contributes none rather than failing the whole tool runtime.
registerModuleTools("redis", (env) => {
try {
return getRedisTools(RedisClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}