feat: runtime config management API + supporting backend work#24
feat: runtime config management API + supporting backend work#24roziscoding wants to merge 16 commits into
Conversation
Introduce a dedicated management surface (getManagementApp) served on its own MANAGEMENT_PORT via a second Bun.serve, guarded by X-Management-Key (constant-time compare) and started only when MANAGEMENT_KEY is set. Adds GET /config, /config/peers, /config/servers reading the live ConnectorManager.
ConfigService is the single serialized writer: holds the raw (ref-preserving) config object, persists atomically (tmp + rename) through an async-mutex queue, and reconciles the live connector map. addPeer validates + resolves secrets, rejects duplicate url/name (409), strips unknown keys before persisting, and adds a live PeerConnector. POST /config/peers wired into the management app.
Connector-map getters now honor the enabled flag (peers/servers/sources/ destinations/connectors skip disabled connectors; sources/destinations also gate on canSource/canDestination). removePeer persists the removal then disables the live connector (in-flight drains, restart prunes). Same-URL updatePeer replaces the connector under its stable id. Adds NotFoundError (404); DELETE/PATCH /config/peers/:id routes.
updatePeer now handles a changed URL inside the serialized write: persist the file, add the connector under the new id, drain the old one (disable), and cascade download rows via DownloadsRepository.reassignPeerId (manual ON UPDATE CASCADE) so downloads follow the peer. Rejects a URL that collides with another peer (409).
addServer/removeServer/updateServer mirror the peer lifecycle through the same serialized write queue. addServerConnector now reconciles _sourceIds/_destinationIds so a live-added server is immediately a usable source/destination, and a toggled capability drops out. URL change rekeys the connector (no download cascade; *arr re-registration needs a restart). POST/DELETE/PATCH /config/servers.
… crash
getAppConfig now returns a shared { appConfig, raw } so ConfigService is seeded
from the same parsed object (no divergent second read). On a real migration it
backs up the original bytes to <path>.bak (comments intact) and atomically
rewrites the file. Using 'migrated ?? fileContent' also fixes a pre-existing crash
where an already-current config threw AppConfig.parse(undefined) on the next boot.
Fan-out consumers now read connectors live from ConnectorManager instead of snapshot arrays captured at boot, so management-API add/remove is visible without restart. Object-taking controllers (Servers/Items/qB) get lazy getter objects; array-taking consumers (PeerController, TorznabController, getDownloadRouter) take () => Connector[] providers.
- ConfigService: collapse the 6 near-identical add/remove/update methods behind generic #addEntry/#removeEntry/#updateEntry helpers parameterized by slice + an optional onRekey hook (peers cascade download rows; servers don't). - ConfigController: funnel all mutations through one #mutate helper (service-presence guard + ZodError->400); expose canMutate. - Router: mount mutation routes only when a ConfigService is wired, so an unconfigured management surface returns 404 instead of 500. - atomic-write: randomized temp name, preserve target perms (new files 0o600), cleanup-on-error. - Document removeConnector's resident-until-restart trade-off.
Finishes the in-progress refactor and fixes its mechanical fallout:
- DownloadsService and getApp accept the structural { peers } / { servers, peers }
shape they actually use, so a real ConnectorManager (live) or a lightweight test
object both satisfy them.
- Widen the base ServerConnector input so PeerConnector's type: 'jack' plumbs
through ConnectorType; ArrServerConnector tolerates omitted headers (base defaults
to {}), fixing the radarr/sonarr ctors.
- Rename the init decorator require->requiresInitialization and fix its docstring.
- Update tests to the new signatures; peer-download's markInitialized now resolves
the initialization promise the @requiresInitialization guard awaits.
Full suite: 223 pass / 0 fail; tsc clean; lint clean.
🐳 Docker image publishedThis PR has been built and pushed to GHCR: Pull and run it locally: docker pull ghcr.io/roziscoding/jack:pr-24
docker run --rm ghcr.io/roziscoding/jack:pr-24
|
| const configObject = z | ||
| .looseObject({ version: z.number().max(LATEST_MIGRATION).min(0).default(0) }) | ||
| .catch({ version: 0 }) | ||
| .parse(rawConfigObject) |
There was a problem hiding this comment.
The
.catch({ version: 0 }) on the entire z.looseObject(...) parse fires when the version field fails validation — including when version > LATEST_MIGRATION (a downgrade scenario). When it fires, configObject becomes { version: 0 }, discarding every other field (servers, peers, jack, etc.). All migrations then run against that empty object, and the stripped result is atomically written back to disk with a .bak of the original. The backup means data is recoverable, but the production config is silently emptied on first boot after a downgrade — operators may not notice until all their peers and servers are gone.
Applying the catch only to the version field, not the entire schema, keeps the rest of the config intact regardless of the version value.
| const configObject = z | |
| .looseObject({ version: z.number().max(LATEST_MIGRATION).min(0).default(0) }) | |
| .catch({ version: 0 }) | |
| .parse(rawConfigObject) | |
| const configObject = z | |
| .looseObject({ version: z.number().max(LATEST_MIGRATION).min(0).default(0).catch(0) }) | |
| .parse(rawConfigObject) |
| let managementServer: ReturnType<typeof Bun.serve> | undefined | ||
| if (envs.MANAGEMENT_KEY) { | ||
| if (envs.MANAGEMENT_PORT === server.port) { | ||
| logger.fatal({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') |
There was a problem hiding this comment.
logger.fatal conventionally signals that the process is about to terminate. Here the application continues serving on the public port — only the management listener is skipped. Using logger.error (or logger.warn) avoids a false alarm in log aggregators and on-call alerting that are configured to page on fatal-level entries.
| logger.fatal({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') | |
| logger.error({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Context
Adds a runtime config management API so peers, servers, and settings can be managed live (no restart), plus the supporting backend work this branch accumulated. jack's config stays a hand-editable, version-controllable
config.jsonc(the file is the source of truth) — the management API mutates it through a single serialized writer and applies changes to the live connector map.What's included
Config management API (the main deliverable)
getManagementApp+ a 2ndBun.serveonMANAGEMENT_PORT), guarded byX-Management-Key(constant-time compare), started only whenMANAGEMENT_KEYis set. The public peer port never exposes/config.ConfigService: one in-process serialized write queue; rollback-safe atomic file writes (tmp→rename, randomized temp name, preserved perms); persists the raw config so{env}/{file}secret refs are never resolved into the file.peer_iddownload cascade./torznab,/peer,/items,/servers, qB) reads connectors live, so add/remove is searchable without restart..bak; also fixes a pre-existing crash where an up-to-date config failed to parse on the next boot.Supporting backend work (earlier commits on this branch)
noUnusedLocals/Parameters).ConnectorManager-shaped deps).Notes
MANAGEMENT_KEYis operator-set via env (no first-boot key generation).Testing
bun test: 223 pass / 0 fail ·tsc --noEmit: clean ·eslint .: cleanGreptile Summary
This PR adds a runtime config management API on a separate port (
MANAGEMENT_PORT), guarded byX-Management-Keywith a constant-time comparison. It introduces aConnectorManagerclass that replaces the oldinitializeConnectorsfunction, aConfigServicewith a serialized write queue and rollback-safe atomic file writes, and a versioned config migration system. Live CRUD for peers and servers is fully wired through controller, router, and service layers.config.jsoncthroughConfigService, which uses an async mutex to serialize writes andtmp→renamefor atomic persistence. Mutation routes are dynamically registered only when aConfigServiceis injected.servers/peersgetters are read live on each request, enabling add/remove without restart. Soft-delete (disable rather than evict) lets in-flight downloads drain.migrateConfigapplies versioned transformations on startup and writes the migrated file atomically after backing up the original; a pre-existing crash on up-to-date configs is fixed.Confidence Score: 4/5
Safe to merge for new deployments; a downgrade after first boot risks silently emptying the config file due to a mis-scoped Zod
.catch.The migration's
.catch({ version: 0 })is placed on the entirez.looseObject(...)rather than on just theversionfield. Any future config (version number above the current maximum) collapses the whole object to{ version: 0 }, runs all migrations against the resulting empty structure, and writes the stripped result back to disk — permanently overwriting all peer and server configuration with no visible warning beyond a routine 'Config migrated' log line. A.bakis created so recovery is possible, but operators won't know they need it until their connectors are gone. Everything else — the constant-time key comparison, the serialized write queue, atomic tmp→rename persistence, and the soft-delete drain strategy — is correct and well-tested.apps/backend/src/lib/config.ts — specifically the
migrateConfigfunction's.catchplacement on line 188.Important Files Changed
.catch({ version: 0 })in migrateConfig discards all config content when the version is out of range.Sequence Diagram
sequenceDiagram participant Client as API Client participant MgmtApp as Management App (MANAGEMENT_PORT) participant Middleware as requireManagementKey participant Controller as ConfigController participant Service as ConfigService (queue) participant Manager as ConnectorManager participant Disk as config.jsonc Client->>MgmtApp: "POST /config/peers {X-Management-Key}" MgmtApp->>Middleware: validate key (SHA-256 constant-time) alt invalid key Middleware-->>Client: 401 Unauthorized end Middleware->>Controller: addPeer(body) Controller->>Service: addPeer(input) Service->>Service: "#enqueue(task) — serialize write" Service->>Service: validate (PeerConfig + RawPeerConfig) Service->>Service: check url/name uniqueness Service->>Disk: atomicWrite (tmp → rename) Service->>Service: "this.#raw = next" Service->>Manager: addPeerConnector(resolved) Manager->>Manager: PeerConnector.init() Controller-->>Client: "201 { ok: true }" Note over Client,Disk: Public app reads connectorManager.peers live on next requestReviews (1): Last reviewed commit: "refactor: complete connector-lifecycle r..." | Re-trigger Greptile