bunqueue-dashboard 0.0.35 → 0.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -11
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +160 -0
- package/agent/backup/runner.ts +91 -0
- package/agent/backup/standaloneWorker.ts +23 -0
- package/agent/backup/workerExecutor.ts +40 -0
- package/agent/backup/workerFactory.ts +21 -0
- package/agent/backup/workerProtocol.ts +10 -0
- package/agent/db/core.ts +141 -0
- package/agent/db/csvExport.ts +172 -0
- package/agent/db/exportTimeout.ts +190 -0
- package/agent/db/query.ts +82 -0
- package/agent/db/queryTimeout.ts +76 -0
- package/agent/db/schema.ts +79 -0
- package/agent/db/tables.ts +157 -0
- package/agent/db/types.ts +118 -0
- package/agent/db/workerFactory.ts +25 -0
- package/agent/db.ts +43 -1099
- package/agent/errorMessage.ts +9 -0
- package/agent/flow/routes.ts +250 -0
- package/agent/flow/service.ts +266 -0
- package/agent/flow/types.ts +34 -0
- package/agent/flow/validation.ts +230 -0
- package/agent/index.ts +5 -17
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +43 -0
- package/agent/manager/config.ts +84 -0
- package/agent/manager/logs.ts +83 -0
- package/agent/manager/process.ts +233 -0
- package/agent/manager/storage.ts +28 -0
- package/agent/manager/types.ts +38 -0
- package/agent/manager.ts +10 -490
- package/agent/queue/routes.ts +128 -0
- package/agent/queue/runtime.ts +119 -0
- package/agent/queue/types.ts +34 -0
- package/agent/queue/validation.ts +100 -0
- package/agent/server/controlRoutes.ts +153 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +48 -0
- package/agent/server/handler.ts +146 -0
- package/agent/server/jsonBody.ts +109 -0
- package/agent/server/lifecycle.ts +72 -0
- package/agent/server/managedRuntime.ts +41 -0
- package/agent/server/policy.ts +60 -0
- package/agent/server/router.ts +150 -0
- package/agent/server/types.ts +39 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +16 -441
- package/agent/shutdown.ts +156 -0
- package/agent/workflow/codec.ts +78 -0
- package/agent/workflow/detail.ts +63 -0
- package/agent/workflow/list.ts +87 -0
- package/agent/workflow/runtime.ts +274 -0
- package/agent/workflow/runtimeConfig.ts +103 -0
- package/agent/workflow/runtimeRoutes.ts +162 -0
- package/agent/workflow/sqlite.ts +52 -0
- package/agent/workflow/stats.ts +79 -0
- package/agent/workflow/types.ts +81 -0
- package/agent/workflow/validation.ts +114 -0
- package/agent/workflows.ts +14 -489
- package/dist/.vite/manifest.json +595 -234
- package/dist/assets/AddJob-UUa2fSXE.js +3 -0
- package/dist/assets/Alerts-7oGYfQMb.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
- package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
- package/dist/assets/Cron-DfWBhoBf.js +1 -0
- package/dist/assets/CronManager-DAK7RSOX.js +1 -0
- package/dist/assets/Database-DqL7giEj.js +3 -0
- package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
- package/dist/assets/Dlq-BHmA5PTI.js +1 -0
- package/dist/assets/DlqControl-DihxvP-i.js +1 -0
- package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
- package/dist/assets/Flows-B5N5Cil0.js +2 -0
- package/dist/assets/JobInspector-B40nFiGh.js +2 -0
- package/dist/assets/Jobs-Bla3IEef.js +1 -0
- package/dist/assets/JobsPro-CnczVx7x.js +1 -0
- package/dist/assets/Logs-T91Q3cH0.js +1 -0
- package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
- package/dist/assets/{McpServer-CbaU52l2.js → McpServer-BQBabwW-.js} +2 -2
- package/dist/assets/Metrics-DxT_RI2B.js +1 -0
- package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
- package/dist/assets/Overview-XN2ej8ph.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-C-Aferd6.js +1 -0
- package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
- package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
- package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
- package/dist/assets/Queues-COqPMoY9.js +1 -0
- package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
- package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
- package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
- package/dist/assets/ServerControl-D3XwHr72.js +3 -0
- package/dist/assets/Settings-BofKZoHn.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DAR9E22v.js +1 -0
- package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
- package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
- package/dist/assets/Workers-BqUZMJVQ.js +1 -0
- package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
- package/dist/assets/Workflows-BEFMfHmo.js +1 -0
- package/dist/assets/api-CQ5Z7xR7.js +1 -0
- package/dist/assets/bq-BeBxa743.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
- package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
- package/dist/assets/format-Cg7VCIZW.js +1 -0
- package/dist/assets/icons-CIvFfU2b.js +1 -0
- package/dist/assets/index-Cui9IwWy.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-Cd9Is10G.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-kPO4rSu5.js +1 -0
- package/dist/assets/toastStore-CS_9Xndn.js +1 -0
- package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
- package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
- package/dist/index.html +16 -5
- package/package.json +12 -3
- package/scripts/embedded.gen.ts +152 -126
- package/scripts/serve.ts +96 -484
- package/scripts/serveHandler.ts +217 -0
- package/scripts/servePolicy.ts +133 -0
- package/dist/assets/AddJob-CG28MSxU.js +0 -3
- package/dist/assets/Alerts-D9JojJrj.js +0 -1
- package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
- package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
- package/dist/assets/Cron-DoEHDHwy.js +0 -1
- package/dist/assets/CronManager-vulcesqJ.js +0 -1
- package/dist/assets/Database-DlB_t04Z.js +0 -3
- package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
- package/dist/assets/Dlq-DMKrsSMP.js +0 -1
- package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
- package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
- package/dist/assets/Flows-C3-9nXW4.js +0 -1
- package/dist/assets/JobInspector-DwIfABmI.js +0 -2
- package/dist/assets/Jobs-j8tTS3NC.js +0 -1
- package/dist/assets/JobsPro-CS__CJJl.js +0 -1
- package/dist/assets/Logs-BB-VDPA1.js +0 -1
- package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
- package/dist/assets/Metrics-ivcddPwT.js +0 -1
- package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
- package/dist/assets/Overview-D84aD2ph.js +0 -1
- package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
- package/dist/assets/QueueActions-DTbD6uek.js +0 -1
- package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
- package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
- package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
- package/dist/assets/Queues-CKMJqFHX.js +0 -1
- package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
- package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
- package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
- package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
- package/dist/assets/Settings-DkqmZUIS.js +0 -1
- package/dist/assets/Usage-Ba20p0xd.js +0 -1
- package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
- package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
- package/dist/assets/Workers-C1wPU6CS.js +0 -1
- package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
- package/dist/assets/Workflows-C8Qmkflf.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BCNzbhhD.css +0 -1
- package/dist/assets/index-CAtsVmyh.js +0 -8
- package/dist/assets/install-D0eRLf9F.js +0 -3
- package/dist/assets/useThroughputSeries-DjxGN8ob.js +0 -1
package/README.md
CHANGED
|
@@ -94,8 +94,10 @@ safe subset of the API, fails closed where
|
|
|
94
94
|
flow-safety guarantees, and also manages the
|
|
95
95
|
server *process* through a separate guarded agent.
|
|
96
96
|
|
|
97
|
-
It
|
|
98
|
-
|
|
97
|
+
It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
|
|
98
|
+
**control agent** for process, FlowProducer, Workflow Engine, database, and backup operations. The
|
|
99
|
+
agent is pinned to the server it manages and uses the exact installed Bunqueue 2.8.57 client
|
|
100
|
+
contracts; it never patches Bunqueue internals.
|
|
99
101
|
|
|
100
102
|
## Features
|
|
101
103
|
|
|
@@ -105,11 +107,14 @@ never imports or modifies bunqueue itself, so it tracks any bunqueue server you
|
|
|
105
107
|
| **Server** | Control ▸ Server | **Start / stop / restart** the server process, edit its config, tail process logs |
|
|
106
108
|
| **Enqueue** | Control ▸ Add Job | Add jobs (single or bulk) with every option |
|
|
107
109
|
| **Inspect** | Control ▸ Job Inspector | Look up any job; promote / re-prioritize / delay; view data & result |
|
|
108
|
-
| **Queues** | Control ▸ Queue Control | Pause
|
|
110
|
+
| **Queues** | Control ▸ Queue Control | Pause/resume/promote; desired-state writes plus live SDK limits, deduplication, metrics and journal retention |
|
|
109
111
|
| **Cron** | Control ▸ Cron Manager | Submit acknowledged cron/interval upserts and delete schedules |
|
|
112
|
+
| **Workflow Engine** | Workflow ▸ Overview / Executions / Waiting / Compensation / Archive | Start, recover, signal, inspect, compensate, archive and clean durable executions |
|
|
113
|
+
| **Job Flows** | Workflow ▸ Job Flows | Explore DAGs, run all FlowProducer create modes and operate safe Flow Job methods |
|
|
110
114
|
| **DLQ** | Control ▸ DLQ | Inspect entries, failure history and CSV exports; retry and purge stay unavailable |
|
|
111
115
|
| **Webhooks** | Control ▸ Webhooks | Create / enable / delete job-event webhooks |
|
|
112
116
|
| **Ops** | Control ▸ Diagnostics | Health, ping, storage, memory, connections, totals |
|
|
117
|
+
| **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
|
|
113
118
|
| **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
|
|
114
119
|
|
|
115
120
|
> Job actions are gated by the v2.8.57 flow contract. Every DLQ retry and completed-job requeue is
|
|
@@ -130,7 +135,7 @@ agent start one for you from the **Server** page).
|
|
|
130
135
|
bunx bunqueue-dashboard
|
|
131
136
|
```
|
|
132
137
|
|
|
133
|
-
One command
|
|
138
|
+
One command serves the prebuilt dashboard on
|
|
134
139
|
http://127.0.0.1:8080, proxies `/api/*` to your bunqueue server (`BUNQUEUE_URL`, default
|
|
135
140
|
`http://localhost:6790`), and runs the control agent on `127.0.0.1:6800`. Same env knobs as the
|
|
136
141
|
standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
|
|
@@ -271,14 +276,15 @@ Because the deployed build is a static shell, point it at a reachable bunqueue s
|
|
|
271
276
|
|
|
272
277
|
## Testing & quality gate
|
|
273
278
|
|
|
274
|
-
|
|
279
|
+
The canonical gate must be green before a change is considered done; CI runs the same command:
|
|
275
280
|
|
|
276
281
|
```bash
|
|
277
|
-
bun run
|
|
278
|
-
bun run check # Biome lint + format (production-grade config)
|
|
279
|
-
bun test # unit + agent-lifecycle tests
|
|
282
|
+
bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
|
|
280
283
|
```
|
|
281
284
|
|
|
285
|
+
The E2E stage starts disposable Bunqueue 2.8.57 processes and exercises the complete FlowProducer,
|
|
286
|
+
Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
|
|
287
|
+
|
|
282
288
|
CI enforces this on every push and pull request. See [Contributing](#contributing).
|
|
283
289
|
|
|
284
290
|
## Project structure
|
|
@@ -286,15 +292,16 @@ CI enforces this on every push and pull request. See [Contributing](#contributin
|
|
|
286
292
|
```
|
|
287
293
|
bunqueue-dashboard/
|
|
288
294
|
├── .github/workflows/ # CI, Pages deploy, Docker publish, Release
|
|
289
|
-
├── agent/ #
|
|
295
|
+
├── agent/ # guarded process, Flow, Workflow, Queue, database and backup adapters
|
|
290
296
|
├── docker/ # Caddyfile for the container image
|
|
291
297
|
├── docs/ # source-verified reference (architecture, pages, API mapping, known issues)
|
|
292
298
|
├── scripts/dev.ts # one-command dev launcher (`bun start`)
|
|
293
299
|
├── src/
|
|
294
300
|
│ ├── lib/ # api.ts, bq.ts, hooks, formatters, job-action gating
|
|
295
301
|
│ ├── components/ # layout shell, UI kit, Zustand stores
|
|
302
|
+
│ ├── features/ # feature slices with domain, application, infrastructure and UI layers
|
|
296
303
|
│ └── pages/ # view pages + Control ▸ * operator pages
|
|
297
|
-
└── test/ #
|
|
304
|
+
└── test/ # unit, contract, UI race, agent lifecycle and integration tests
|
|
298
305
|
```
|
|
299
306
|
|
|
300
307
|
Full walkthrough in [`docs/README.md`](docs/README.md).
|
|
@@ -320,7 +327,7 @@ boundary applies only to `scripts/serve.ts`. Host/Origin checks are not user aut
|
|
|
320
327
|
|
|
321
328
|
1. Keep it **additive**, prefer new files + minimal glue over rewriting existing ones
|
|
322
329
|
(see [`CLAUDE.md`](CLAUDE.md)).
|
|
323
|
-
2. Make the [gate](#testing--quality-gate) green: `bun run
|
|
330
|
+
2. Make the [gate](#testing--quality-gate) green: `bun run quality`.
|
|
324
331
|
3. Open a PR, the template walks you through the checklist. CI must pass.
|
|
325
332
|
|
|
326
333
|
## License
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import type { BackupOperation } from './runner';
|
|
3
|
+
|
|
4
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
export async function executeBackupProcess(
|
|
7
|
+
environment: Record<string, string>,
|
|
8
|
+
operation: BackupOperation,
|
|
9
|
+
key: string | undefined,
|
|
10
|
+
signal: AbortSignal
|
|
11
|
+
): Promise<unknown> {
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
const bun = Bun.which('bun');
|
|
14
|
+
if (!bun) throw new Error('The Bun executable is required to run Bunqueue backup commands');
|
|
15
|
+
const child = Bun.spawn(
|
|
16
|
+
[
|
|
17
|
+
bun,
|
|
18
|
+
bunqueueCliPath(),
|
|
19
|
+
'--json',
|
|
20
|
+
'backup',
|
|
21
|
+
operation,
|
|
22
|
+
...(operation === 'restore' ? [key as string, '--force'] : []),
|
|
23
|
+
],
|
|
24
|
+
{ env: environment, stdout: 'pipe', stderr: 'pipe' }
|
|
25
|
+
);
|
|
26
|
+
const abort = () => child.kill('SIGKILL');
|
|
27
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
28
|
+
try {
|
|
29
|
+
let stdout: string;
|
|
30
|
+
let stderr: string;
|
|
31
|
+
let exitCode: number;
|
|
32
|
+
try {
|
|
33
|
+
[stdout, stderr, exitCode] = await Promise.all([
|
|
34
|
+
readBounded(child.stdout),
|
|
35
|
+
readBounded(child.stderr),
|
|
36
|
+
child.exited,
|
|
37
|
+
]);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
child.kill('SIGKILL');
|
|
40
|
+
await child.exited;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (signal.aborted) throw abortReason(signal);
|
|
44
|
+
const raw = stdout.trim() || stderr.trim();
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(
|
|
49
|
+
exitCode === 0
|
|
50
|
+
? 'Bunqueue backup command returned malformed JSON'
|
|
51
|
+
: `Bunqueue backup command failed with exit code ${exitCode}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
signal.removeEventListener('abort', abort);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function bunqueueCliPath(): string {
|
|
60
|
+
const main = import.meta.resolve('bunqueue');
|
|
61
|
+
return fileURLToPath(new URL('./cli/index.js', main));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readBounded(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
65
|
+
const reader = stream.getReader();
|
|
66
|
+
const chunks: Uint8Array[] = [];
|
|
67
|
+
let total = 0;
|
|
68
|
+
try {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const next = await reader.read();
|
|
71
|
+
if (next.done) break;
|
|
72
|
+
total += next.value.byteLength;
|
|
73
|
+
if (total > MAX_OUTPUT_BYTES) throw new Error('Bunqueue backup command output exceeded 2 MiB');
|
|
74
|
+
chunks.push(next.value);
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
reader.releaseLock();
|
|
78
|
+
}
|
|
79
|
+
const output = new Uint8Array(total);
|
|
80
|
+
let offset = 0;
|
|
81
|
+
for (const chunk of chunks) {
|
|
82
|
+
output.set(chunk, offset);
|
|
83
|
+
offset += chunk.byteLength;
|
|
84
|
+
}
|
|
85
|
+
return new TextDecoder().decode(output);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function abortReason(signal: AbortSignal): Error {
|
|
89
|
+
return signal.reason instanceof Error ? signal.reason : new Error('Backup operation cancelled');
|
|
90
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { BackupCommandResult, BackupOperation } from './runner';
|
|
2
|
+
|
|
3
|
+
const RESULT_KEYS = new Set(['success', 'message', 'data']);
|
|
4
|
+
const STATUS_KEYS = new Set(['enabled', 'bucket', 'endpoint', 'interval', 'retention']);
|
|
5
|
+
const ITEM_KEYS = new Set(['key', 'size', 'date']);
|
|
6
|
+
const MAX_MESSAGE_LENGTH = 16 * 1024;
|
|
7
|
+
const MAX_LIST_ITEMS = 20_000;
|
|
8
|
+
const MAX_LIST_TEXT = 2 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export function validateBackupResult(
|
|
11
|
+
value: unknown,
|
|
12
|
+
operation: BackupOperation
|
|
13
|
+
): BackupCommandResult {
|
|
14
|
+
const result = exactRecord(value, RESULT_KEYS, 'backup command result');
|
|
15
|
+
if (typeof result.success !== 'boolean') invalid('backup command result success');
|
|
16
|
+
const message = text(result.message, 'backup command result message', MAX_MESSAGE_LENGTH);
|
|
17
|
+
if (!result.success) throw new Error(message);
|
|
18
|
+
const data = operationData(operation, result.data);
|
|
19
|
+
return data === undefined ? { success: true, message } : { success: true, message, data };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function operationData(operation: BackupOperation, value: unknown): unknown {
|
|
23
|
+
if (operation === 'status') return statusData(value);
|
|
24
|
+
if (operation === 'list') return listData(value);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statusData(value: unknown): Record<string, string | boolean> {
|
|
29
|
+
const status = exactRecord(value, STATUS_KEYS, 'backup status data');
|
|
30
|
+
if (typeof status.enabled !== 'boolean') invalid('backup status enabled');
|
|
31
|
+
return {
|
|
32
|
+
enabled: status.enabled,
|
|
33
|
+
bucket: text(status.bucket, 'backup status bucket', 1_024),
|
|
34
|
+
endpoint: text(status.endpoint, 'backup status endpoint', 4_096),
|
|
35
|
+
interval: text(status.interval, 'backup status interval', 128),
|
|
36
|
+
retention: text(status.retention, 'backup status retention', 128),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listData(value: unknown): Array<Record<string, string>> {
|
|
41
|
+
if (!Array.isArray(value) || value.length > MAX_LIST_ITEMS) invalid('backup list data');
|
|
42
|
+
let textSize = 0;
|
|
43
|
+
const keys = new Set<string>();
|
|
44
|
+
return value.map((entry, index) => {
|
|
45
|
+
const item = exactRecord(entry, ITEM_KEYS, `backup list item ${index}`);
|
|
46
|
+
const key = text(item.key, `backup list item ${index} key`, 2_048);
|
|
47
|
+
const size = text(item.size, `backup list item ${index} size`, 128);
|
|
48
|
+
const date = text(item.date, `backup list item ${index} date`, 128);
|
|
49
|
+
textSize += key.length + size.length + date.length;
|
|
50
|
+
if (textSize > MAX_LIST_TEXT || keys.has(key) || !validIsoDate(date)) {
|
|
51
|
+
invalid(`backup list item ${index}`);
|
|
52
|
+
}
|
|
53
|
+
keys.add(key);
|
|
54
|
+
return { key, size, date };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function exactRecord(
|
|
59
|
+
value: unknown,
|
|
60
|
+
allowed: ReadonlySet<string>,
|
|
61
|
+
label: string
|
|
62
|
+
): Record<string, unknown> {
|
|
63
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(label);
|
|
64
|
+
const result = value as Record<string, unknown>;
|
|
65
|
+
if (Object.keys(result).some((key) => !allowed.has(key))) invalid(label);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function text(value: unknown, label: string, maxLength: number): string {
|
|
70
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > maxLength) invalid(label);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validIsoDate(value: string): boolean {
|
|
75
|
+
const timestamp = Date.parse(value);
|
|
76
|
+
return (
|
|
77
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) &&
|
|
78
|
+
Number.isFinite(timestamp) &&
|
|
79
|
+
new Date(timestamp).toISOString() === value
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function invalid(label: string): never {
|
|
84
|
+
throw new Error(`Bunqueue ${label} has an invalid 2.8.57 contract`);
|
|
85
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { assertManagedTarget } from '../managedTarget';
|
|
2
|
+
import type { DbStats, ServerConfig } from '../manager';
|
|
3
|
+
import { readLimitedJsonBody } from '../server/jsonBody';
|
|
4
|
+
import type { BackupRunnerPort } from './runner';
|
|
5
|
+
|
|
6
|
+
const MAX_BACKUP_BODY_BYTES = 64 * 1024;
|
|
7
|
+
|
|
8
|
+
export interface BackupRouteResponse {
|
|
9
|
+
status: number;
|
|
10
|
+
body: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function routeBackupRequest(
|
|
14
|
+
request: Request,
|
|
15
|
+
pathname: string,
|
|
16
|
+
method: string,
|
|
17
|
+
config: ServerConfig,
|
|
18
|
+
serverRunning: boolean,
|
|
19
|
+
database: DbStats,
|
|
20
|
+
runner: BackupRunnerPort,
|
|
21
|
+
configure?: (extraEnv: Record<string, string>) => void
|
|
22
|
+
): Promise<BackupRouteResponse | null> {
|
|
23
|
+
const operation = routeOperation(pathname, method);
|
|
24
|
+
if (!operation) return null;
|
|
25
|
+
const query = exactTargetQuery(request.url);
|
|
26
|
+
assertManagedTarget(query, config);
|
|
27
|
+
if (operation === 'configure') {
|
|
28
|
+
if (!configure) throw new Error('Backup configuration is unavailable');
|
|
29
|
+
const body = await boundedRecord(request, ['environment']);
|
|
30
|
+
const next = mergeBackupEnvironment(config.extraEnv, body.environment);
|
|
31
|
+
configure(next);
|
|
32
|
+
return success({ configured: true, enabled: next.S3_BACKUP_ENABLED === 'true' });
|
|
33
|
+
}
|
|
34
|
+
if (operation === 'restore') {
|
|
35
|
+
if (serverRunning) throw new Error('Stop the managed Bunqueue server before restoring a backup');
|
|
36
|
+
const body = await boundedRecord(request, ['key', 'database']);
|
|
37
|
+
const key = backupKey(body.key);
|
|
38
|
+
assertDatabaseSnapshot(body.database, database);
|
|
39
|
+
return success(await runner.execute(config, operation, key));
|
|
40
|
+
}
|
|
41
|
+
return success(await runner.execute(config, operation));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function routeOperation(pathname: string, method: string) {
|
|
45
|
+
if (method === 'GET' && pathname === '/backup/status') return 'status' as const;
|
|
46
|
+
if (method === 'GET' && pathname === '/backup/list') return 'list' as const;
|
|
47
|
+
if (method === 'POST' && pathname === '/backup/now') return 'now' as const;
|
|
48
|
+
if (method === 'POST' && pathname === '/backup/restore') return 'restore' as const;
|
|
49
|
+
if (method === 'POST' && pathname === '/backup/configure') return 'configure' as const;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BACKUP_ENV_KEYS = new Set([
|
|
54
|
+
'S3_BACKUP_ENABLED',
|
|
55
|
+
'S3_ACCESS_KEY_ID',
|
|
56
|
+
'S3_SECRET_ACCESS_KEY',
|
|
57
|
+
'S3_SESSION_TOKEN',
|
|
58
|
+
'S3_BUCKET',
|
|
59
|
+
'S3_ENDPOINT',
|
|
60
|
+
'S3_VIRTUAL_HOSTED_STYLE',
|
|
61
|
+
'S3_REGION',
|
|
62
|
+
'S3_BACKUP_INTERVAL',
|
|
63
|
+
'S3_BACKUP_RETENTION',
|
|
64
|
+
'S3_BACKUP_PREFIX',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function mergeBackupEnvironment(
|
|
68
|
+
current: Record<string, string>,
|
|
69
|
+
value: unknown
|
|
70
|
+
): Record<string, string> {
|
|
71
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
72
|
+
throw new Error('Backup environment must be an object');
|
|
73
|
+
}
|
|
74
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
75
|
+
for (const [key, entry] of entries) {
|
|
76
|
+
if (!BACKUP_ENV_KEYS.has(key)) throw new Error(`Unknown backup environment key: ${key}`);
|
|
77
|
+
if (typeof entry !== 'string' || entry.length > 4_096) {
|
|
78
|
+
throw new Error(`Backup environment ${key} must be a string of at most 4096 characters`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const enabled = (value as Record<string, unknown>).S3_BACKUP_ENABLED;
|
|
82
|
+
if (enabled !== 'true' && enabled !== 'false') {
|
|
83
|
+
throw new Error('S3_BACKUP_ENABLED must be "true" or "false"');
|
|
84
|
+
}
|
|
85
|
+
const retained = Object.fromEntries(
|
|
86
|
+
Object.entries(current).filter(([key]) => !BACKUP_ENV_KEYS.has(key))
|
|
87
|
+
);
|
|
88
|
+
return { ...retained, ...(Object.fromEntries(entries) as Record<string, string>) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exactTargetQuery(url: string): URLSearchParams {
|
|
92
|
+
const query = new URL(url).searchParams;
|
|
93
|
+
for (const key of query.keys()) {
|
|
94
|
+
if (key !== 'target') throw new Error(`Unknown backup option: ${key}`);
|
|
95
|
+
if (query.getAll(key).length !== 1) throw new Error(`Duplicate backup option: ${key}`);
|
|
96
|
+
}
|
|
97
|
+
return query;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function success(result: unknown): BackupRouteResponse {
|
|
101
|
+
return { status: 200, body: { ok: true, result } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function record(value: unknown, allowed: string[]): Record<string, unknown> {
|
|
105
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
106
|
+
throw new Error('Backup restore body must be an object');
|
|
107
|
+
}
|
|
108
|
+
const body = value as Record<string, unknown>;
|
|
109
|
+
const unknown = Object.keys(body).find((key) => !allowed.includes(key));
|
|
110
|
+
if (unknown) throw new Error(`Unknown backup body option: ${unknown}`);
|
|
111
|
+
return body;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function boundedRecord(
|
|
115
|
+
request: Request,
|
|
116
|
+
allowed: string[]
|
|
117
|
+
): Promise<Record<string, unknown>> {
|
|
118
|
+
const value = await readLimitedJsonBody(request, {
|
|
119
|
+
scope: 'Backup',
|
|
120
|
+
maxBytes: MAX_BACKUP_BODY_BYTES,
|
|
121
|
+
limitLabel: '64 KiB',
|
|
122
|
+
missingMessage: 'Backup request body must be valid JSON',
|
|
123
|
+
invalidUtf8Message: 'Backup request body must be valid JSON',
|
|
124
|
+
invalidJsonMessage: 'Backup request body must be valid JSON',
|
|
125
|
+
});
|
|
126
|
+
return record(value, allowed);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function backupKey(value: unknown): string {
|
|
130
|
+
if (
|
|
131
|
+
typeof value !== 'string' ||
|
|
132
|
+
value.length < 1 ||
|
|
133
|
+
value.length > 2_048 ||
|
|
134
|
+
/[\u0000-\u001f\u007f]/.test(value)
|
|
135
|
+
) {
|
|
136
|
+
throw new Error('Backup key must contain 1–2048 printable characters');
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertDatabaseSnapshot(value: unknown, current: DbStats): void {
|
|
142
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
143
|
+
throw new Error('A database snapshot is required to authorize restore');
|
|
144
|
+
}
|
|
145
|
+
const expected = value as Record<string, unknown>;
|
|
146
|
+
const allowed = ['path', 'exists', 'size', 'walSize', 'shmSize', 'totalSize', 'mtimeMs'];
|
|
147
|
+
const unknown = Object.keys(expected).find((key) => !allowed.includes(key));
|
|
148
|
+
if (unknown) throw new Error(`Unknown database snapshot option: ${unknown}`);
|
|
149
|
+
if (
|
|
150
|
+
expected.path !== current.path ||
|
|
151
|
+
expected.exists !== current.exists ||
|
|
152
|
+
expected.size !== current.size ||
|
|
153
|
+
expected.walSize !== current.walSize ||
|
|
154
|
+
expected.shmSize !== current.shmSize ||
|
|
155
|
+
expected.totalSize !== current.totalSize ||
|
|
156
|
+
expected.mtimeMs !== current.mtimeMs
|
|
157
|
+
) {
|
|
158
|
+
throw new Error('Database changed after restore confirmation; reload status and confirm again');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ServerConfig } from '../manager';
|
|
2
|
+
import { validateBackupResult } from './resultValidation';
|
|
3
|
+
import { executeBackupOperation } from './workerFactory';
|
|
4
|
+
|
|
5
|
+
export type BackupOperation = 'status' | 'list' | 'now' | 'restore';
|
|
6
|
+
|
|
7
|
+
export interface BackupCommandResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
message: string;
|
|
10
|
+
data?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BackupRunnerPort {
|
|
14
|
+
execute(config: ServerConfig, operation: BackupOperation, key?: string): Promise<BackupCommandResult>;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BackupOperationExecutor = (
|
|
19
|
+
environment: Record<string, string>,
|
|
20
|
+
operation: BackupOperation,
|
|
21
|
+
key: string | undefined,
|
|
22
|
+
signal: AbortSignal
|
|
23
|
+
) => Promise<unknown>;
|
|
24
|
+
|
|
25
|
+
const COMMAND_TIMEOUT_MS = 120_000;
|
|
26
|
+
|
|
27
|
+
/** Owns exactly one cancellable local backup operation at a time. */
|
|
28
|
+
export class BunqueueBackupRunner implements BackupRunnerPort {
|
|
29
|
+
private active: Promise<BackupCommandResult> | null = null;
|
|
30
|
+
private controller: AbortController | null = null;
|
|
31
|
+
private closed = false;
|
|
32
|
+
private closing: Promise<void> | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly timeoutMs = COMMAND_TIMEOUT_MS,
|
|
36
|
+
private readonly executor: BackupOperationExecutor = executeBackupOperation
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
execute(
|
|
40
|
+
config: ServerConfig,
|
|
41
|
+
operation: BackupOperation,
|
|
42
|
+
key?: string
|
|
43
|
+
): Promise<BackupCommandResult> {
|
|
44
|
+
if (this.closed) return Promise.reject(new Error('Backup runner is closed'));
|
|
45
|
+
if (this.active) return Promise.reject(new Error('Another backup operation is already running'));
|
|
46
|
+
if (operation === 'restore' && !key) {
|
|
47
|
+
return Promise.reject(new Error('Backup key is required for restore'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
this.controller = controller;
|
|
52
|
+
const timeout = setTimeout(() => {
|
|
53
|
+
controller.abort(new Error(`Bunqueue backup command timed out after ${this.timeoutMs} ms`));
|
|
54
|
+
}, this.timeoutMs);
|
|
55
|
+
const pending = this.executor(
|
|
56
|
+
backupEnvironment(config),
|
|
57
|
+
operation,
|
|
58
|
+
key,
|
|
59
|
+
controller.signal
|
|
60
|
+
).then((value) => validateBackupResult(value, operation));
|
|
61
|
+
const active = pending.finally(() => {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
if (this.active === active) this.active = null;
|
|
64
|
+
if (this.controller === controller) this.controller = null;
|
|
65
|
+
});
|
|
66
|
+
this.active = active;
|
|
67
|
+
return active;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
close(): Promise<void> {
|
|
71
|
+
if (this.closing) return this.closing;
|
|
72
|
+
this.closed = true;
|
|
73
|
+
this.controller?.abort(
|
|
74
|
+
new Error('Bunqueue backup command cancelled because the control agent is closing')
|
|
75
|
+
);
|
|
76
|
+
const active = this.active;
|
|
77
|
+
this.closing = (async () => {
|
|
78
|
+
await active?.catch(() => undefined);
|
|
79
|
+
})();
|
|
80
|
+
return this.closing;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function backupEnvironment(config: ServerConfig): Record<string, string> {
|
|
85
|
+
return {
|
|
86
|
+
...(process.env as Record<string, string>),
|
|
87
|
+
...config.extraEnv,
|
|
88
|
+
BUNQUEUE_DATA_PATH: config.dataPath,
|
|
89
|
+
NO_COLOR: '1',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { executeBackupCommand } from '../../node_modules/bunqueue/dist/cli/commands/backup.js';
|
|
2
|
+
import type { BackupWorkerReply, BackupWorkerRequest } from './workerProtocol';
|
|
3
|
+
|
|
4
|
+
declare const self: Worker;
|
|
5
|
+
|
|
6
|
+
self.onmessage = async ({ data }: MessageEvent<BackupWorkerRequest>) => {
|
|
7
|
+
let reply: BackupWorkerReply;
|
|
8
|
+
try {
|
|
9
|
+
const args = [
|
|
10
|
+
data.operation,
|
|
11
|
+
...(data.operation === 'restore' ? [requiredKey(data.key), '--force'] : []),
|
|
12
|
+
];
|
|
13
|
+
reply = { ok: true, result: await executeBackupCommand(args) };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
reply = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
16
|
+
}
|
|
17
|
+
self.postMessage(reply);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function requiredKey(value: string | undefined): string {
|
|
21
|
+
if (!value) throw new Error('Backup key is required for restore');
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { BackupOperation } from './runner';
|
|
2
|
+
import type { BackupWorkerReply, BackupWorkerRequest } from './workerProtocol';
|
|
3
|
+
|
|
4
|
+
export function executeBackupWorker(
|
|
5
|
+
workerUrl: string,
|
|
6
|
+
environment: Record<string, string>,
|
|
7
|
+
operation: BackupOperation,
|
|
8
|
+
key: string | undefined,
|
|
9
|
+
signal: AbortSignal
|
|
10
|
+
): Promise<unknown> {
|
|
11
|
+
signal.throwIfAborted();
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const worker = new Worker(workerUrl, { type: 'module', env: environment });
|
|
14
|
+
let settled = false;
|
|
15
|
+
const finish = (operation: () => void) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
signal.removeEventListener('abort', abort);
|
|
19
|
+
worker.terminate();
|
|
20
|
+
operation();
|
|
21
|
+
};
|
|
22
|
+
const abort = () =>
|
|
23
|
+
finish(() =>
|
|
24
|
+
reject(
|
|
25
|
+
signal.reason instanceof Error ? signal.reason : new Error('Backup operation cancelled')
|
|
26
|
+
)
|
|
27
|
+
);
|
|
28
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
29
|
+
worker.onmessage = ({ data }: MessageEvent<BackupWorkerReply>) => {
|
|
30
|
+
if (data.ok) finish(() => resolve(data.result));
|
|
31
|
+
else finish(() => reject(new Error(data.error)));
|
|
32
|
+
};
|
|
33
|
+
worker.onerror = (event) => {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
finish(() => reject(new Error(event.message || 'Embedded backup worker failed')));
|
|
36
|
+
};
|
|
37
|
+
const request: BackupWorkerRequest = { operation, key };
|
|
38
|
+
worker.postMessage(request);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BackupOperationExecutor } from './runner';
|
|
2
|
+
import { executeBackupProcess } from './processExecutor';
|
|
3
|
+
import { executeBackupWorker } from './workerExecutor';
|
|
4
|
+
|
|
5
|
+
let embeddedWorkerUrl: string | null = null;
|
|
6
|
+
|
|
7
|
+
export const executeBackupOperation: BackupOperationExecutor = (
|
|
8
|
+
environment,
|
|
9
|
+
operation,
|
|
10
|
+
key,
|
|
11
|
+
signal
|
|
12
|
+
) => {
|
|
13
|
+
if (embeddedWorkerUrl) {
|
|
14
|
+
return executeBackupWorker(embeddedWorkerUrl, environment, operation, key, signal);
|
|
15
|
+
}
|
|
16
|
+
return executeBackupProcess(environment, operation, key, signal);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function setBackupWorkerUrl(url: string | null): void {
|
|
20
|
+
embeddedWorkerUrl = url;
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BackupCommandResult, BackupOperation } from './runner';
|
|
2
|
+
|
|
3
|
+
export interface BackupWorkerRequest {
|
|
4
|
+
operation: BackupOperation;
|
|
5
|
+
key?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type BackupWorkerReply =
|
|
9
|
+
| { ok: true; result: BackupCommandResult }
|
|
10
|
+
| { ok: false; error: string };
|