bunqueue-dashboard 0.0.36 → 0.0.37
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 +20 -7
- package/agent/backup/resultValidation.ts +1 -1
- package/agent/backup/routes.ts +4 -3
- package/agent/flow/routes.ts +7 -6
- package/agent/index.ts +17 -4
- package/agent/managedTarget.ts +41 -2
- package/agent/manager/config.ts +1 -1
- package/agent/queue/routes.ts +21 -14
- package/agent/server/controlRoutes.ts +39 -7
- package/agent/server/controlTarget.ts +116 -0
- package/agent/server/errors.ts +1 -0
- package/agent/server/handler.ts +10 -1
- package/agent/server/router.ts +19 -8
- package/agent/server/types.ts +7 -0
- package/agent/server.ts +10 -0
- package/agent/workflow/runtimeRoutes.ts +5 -4
- package/agent/workflows.ts +1 -1
- package/dist/.vite/manifest.json +207 -207
- package/dist/assets/{AddJob-UUa2fSXE.js → AddJob-CWkkVHmf.js} +1 -1
- package/dist/assets/{Alerts-7oGYfQMb.js → Alerts-Gh1pVbqj.js} +1 -1
- package/dist/assets/{Benchmark-C2NFp_kj.js → Benchmark-Dir2A1CQ.js} +1 -1
- package/dist/assets/{BulkAddJobs-CL4yBae-.js → BulkAddJobs-Di_IgRSH.js} +1 -1
- package/dist/assets/{CopilotPanel-BQ2Cgo9p.js → CopilotPanel-Db5nYpo1.js} +1 -1
- package/dist/assets/{Cron-DfWBhoBf.js → Cron-BRgWcBGk.js} +1 -1
- package/dist/assets/{CronManager-DAK7RSOX.js → CronManager-CbM0x_s0.js} +1 -1
- package/dist/assets/{Database-DqL7giEj.js → Database-BeoEreW0.js} +1 -1
- package/dist/assets/{Diagnostics-D98YwN-6.js → Diagnostics-Cw9iJ84f.js} +1 -1
- package/dist/assets/{Dlq-BHmA5PTI.js → Dlq-DfCg04Jd.js} +1 -1
- package/dist/assets/{DlqControl-DihxvP-i.js → DlqControl-BRCkUXvb.js} +1 -1
- package/dist/assets/{DlqPro-ncgnDtpE.js → DlqPro-BHJwBYsN.js} +1 -1
- package/dist/assets/{Flows-B5N5Cil0.js → Flows-BAzbFP_W.js} +2 -2
- package/dist/assets/{JobInspector-B40nFiGh.js → JobInspector-qvW_APO6.js} +2 -2
- package/dist/assets/{Jobs-Bla3IEef.js → Jobs-URpnFjC6.js} +1 -1
- package/dist/assets/{JobsPro-CnczVx7x.js → JobsPro-pQ_RFSU3.js} +1 -1
- package/dist/assets/{Logs-T91Q3cH0.js → Logs-C5jX2OPE.js} +1 -1
- package/dist/assets/{LogsPro-CMn6-Wwv.js → LogsPro-Cq_npmmS.js} +1 -1
- package/dist/assets/{McpServer-BQBabwW-.js → McpServer-DtCTmQBu.js} +2 -2
- package/dist/assets/{Metrics-DxT_RI2B.js → Metrics-BdxitePr.js} +1 -1
- package/dist/assets/{MetricsPro-Cjen-nAP.js → MetricsPro-orHfW0mg.js} +1 -1
- package/dist/assets/{Overview-XN2ej8ph.js → Overview-DaNns1aj.js} +1 -1
- package/dist/assets/{QueueActions-C-Aferd6.js → QueueActions-jiTzndPH.js} +1 -1
- package/dist/assets/{QueueControl-B8Q9NX6t.js → QueueControl-l5bSdMgb.js} +1 -1
- package/dist/assets/{QueueDetail-BMhG0jUB.js → QueueDetail-URK2pjtH.js} +1 -1
- package/dist/assets/{QueueDetailPro-BKTTyLR4.js → QueueDetailPro-C9M5CV4K.js} +1 -1
- package/dist/assets/{Queues-COqPMoY9.js → Queues-DeLXlww2.js} +1 -1
- package/dist/assets/{QueuesOverview-pWdrPy8Z.js → QueuesOverview-DoQ-MUfT.js} +1 -1
- package/dist/assets/{S3Backup-BmsZ8xtq.js → S3Backup-D2agV9_z.js} +1 -1
- package/dist/assets/{S3BackupPro-AtlsPUJL.js → S3BackupPro--WSZWTQn.js} +3 -3
- package/dist/assets/{ServerControl-D3XwHr72.js → ServerControl-QVd6m_iR.js} +3 -3
- package/dist/assets/{Settings-BofKZoHn.js → Settings-CiJRj_SM.js} +1 -1
- package/dist/assets/{Usage-DAR9E22v.js → Usage-DbHJgA4t.js} +1 -1
- package/dist/assets/{UsagePro-Bc30N2Lo.js → UsagePro-LoPo3k1L.js} +1 -1
- package/dist/assets/{Webhooks-Bg2k1PU4.js → Webhooks-CuzIJHwk.js} +1 -1
- package/dist/assets/{Workers-BqUZMJVQ.js → Workers-BpUgpwUH.js} +1 -1
- package/dist/assets/{WorkersPro-C9aSadUh.js → WorkersPro-BYY0P1Pt.js} +1 -1
- package/dist/assets/{Workflows-BEFMfHmo.js → Workflows-ByJw4FFH.js} +1 -1
- package/dist/assets/{api-CQ5Z7xR7.js → api-V_sVpHDo.js} +1 -1
- package/dist/assets/{bq-BeBxa743.js → bq-DYmbVkgR.js} +1 -1
- package/dist/assets/{connectionStore-DbBYqHEY.js → connectionStore-CKFQrxDo.js} +1 -1
- package/dist/assets/{controlConnectionEpoch-CFSO6237.js → controlConnectionEpoch-JM4omZ6j.js} +1 -1
- package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.js} +1 -1
- package/dist/assets/index-D64v2vNO.js +8 -0
- package/dist/assets/{install-Cd9Is10G.js → install-jXi1SOYY.js} +1 -1
- package/dist/assets/{responses-kPO4rSu5.js → responses-CBLyy1Ob.js} +1 -1
- package/dist/assets/{toastStore-CS_9Xndn.js → toastStore-B7n0zS0y.js} +1 -1
- package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
- package/dist/assets/{usePolledData-DzlUpBOO.js → usePolledData-c53tx1jw.js} +1 -1
- package/dist/assets/{useServerActionGuard-BlH-Kso1.js → useServerActionGuard-OTv6_fU0.js} +1 -1
- package/dist/assets/{useThroughputSeries-C2pa1Wd_.js → useThroughputSeries-DWHQl7cM.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +5 -3
- package/scripts/embedded.gen.ts +160 -160
- package/scripts/serve.ts +38 -7
- package/scripts/serveHandler.ts +27 -7
- package/scripts/servePolicy.ts +54 -0
- package/dist/assets/index-Cui9IwWy.js +0 -8
package/README.md
CHANGED
|
@@ -90,13 +90,13 @@ The full dashboard running on sample data, no server needed.
|
|
|
90
90
|
bunqueue exposes a rich HTTP API, but operating it by hand (curl, ad-hoc scripts) is slow and
|
|
91
91
|
error-prone. This dashboard is a production-oriented operator console: it exposes the verified,
|
|
92
92
|
safe subset of the API, fails closed where
|
|
93
|
-
[v2.8.
|
|
93
|
+
[v2.8.59](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.59) lacks atomic
|
|
94
94
|
flow-safety guarantees, and also manages the
|
|
95
95
|
server *process* through a separate guarded agent.
|
|
96
96
|
|
|
97
97
|
It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
|
|
98
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.
|
|
99
|
+
agent is pinned to the server it manages and uses the exact installed Bunqueue 2.8.59 client
|
|
100
100
|
contracts; it never patches Bunqueue internals.
|
|
101
101
|
|
|
102
102
|
## Features
|
|
@@ -117,7 +117,7 @@ contracts; it never patches Bunqueue internals.
|
|
|
117
117
|
| **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
|
|
118
118
|
| **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
|
|
119
119
|
|
|
120
|
-
> Job actions are gated by the v2.8.
|
|
120
|
+
> Job actions are gated by the v2.8.59 flow contract. Every DLQ retry and completed-job requeue is
|
|
121
121
|
> unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
|
|
122
122
|
> can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
|
|
123
123
|
> registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
|
|
@@ -140,7 +140,7 @@ http://127.0.0.1:8080, proxies `/api/*` to your bunqueue server (`BUNQUEUE_URL`,
|
|
|
140
140
|
`http://localhost:6790`), and runs the control agent on `127.0.0.1:6800`. Same env knobs as the
|
|
141
141
|
standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
|
|
142
142
|
`AGENT_ALLOWED_ORIGINS` · `AGENT_ALLOWED_HOSTS` · `AGENT_TOKEN` · `BUNQUEUE_TOKEN` ·
|
|
143
|
-
`TRUST_PROXY` · `BUNQUEUE_START_CMD`.
|
|
143
|
+
`TRUST_PROXY` · `BASE_PATH` · `BUNQUEUE_MANAGED` · `BUNQUEUE_START_CMD`.
|
|
144
144
|
|
|
145
145
|
Install it permanently instead of running via `bunx`:
|
|
146
146
|
|
|
@@ -196,14 +196,17 @@ Two HTTP clients coexist on purpose: `src/lib/api.ts` (first-generation view pag
|
|
|
196
196
|
|
|
197
197
|
## Configuration
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
in-app **Settings** page.
|
|
199
|
+
The `VITE_*` dashboard defaults are build-time values and can **also** be changed at runtime from
|
|
200
|
+
the in-app **Settings** page. The all-in-one server reads the non-`VITE_*` runtime variables below.
|
|
201
|
+
Copy [`.env.example`](.env.example) to `.env` to set defaults.
|
|
201
202
|
|
|
202
203
|
| Variable | Purpose | Default |
|
|
203
204
|
| --- | --- | --- |
|
|
204
205
|
| `VITE_BUNQUEUE_URL` | bunqueue server origin | `/api` (dev proxy → `:6790`) |
|
|
205
206
|
| `VITE_BUNQUEUE_AGENT_URL` | Control-agent origin | `http://localhost:6800` |
|
|
206
207
|
| `BIND_ADDR` | All-in-one dashboard bind address | `127.0.0.1` |
|
|
208
|
+
| `BASE_PATH` | Runtime mount for the all-in-one server (for example `/internal/queue`) | `/` |
|
|
209
|
+
| `BUNQUEUE_MANAGED` | `0` attaches to the externally supervised `BUNQUEUE_URL`; `1` enables local lifecycle controls | `1` |
|
|
207
210
|
| `AGENT_PORT` | Control-agent port | `6800` |
|
|
208
211
|
| `AGENT_ALLOWED_ORIGINS` | Extra browser origins allowed to drive the agent (comma-separated) | dev defaults |
|
|
209
212
|
| `AGENT_ALLOWED_HOSTS` | Extra Host names/IPs accepted by the all-in-one server and agent | loopback names |
|
|
@@ -224,6 +227,16 @@ proxy, allowlist the public origin; if it rewrites `Host`, also allowlist the
|
|
|
224
227
|
rewritten Host and use `TRUST_PROXY=1` only when the proxy overwrites
|
|
225
228
|
`X-Forwarded-Host`.
|
|
226
229
|
|
|
230
|
+
For a broker already owned by systemd, Docker or Kubernetes, run the all-in-one
|
|
231
|
+
dashboard with `BUNQUEUE_MANAGED=0`. **Control ▸ Server** then reports health
|
|
232
|
+
from `BUNQUEUE_URL` and cannot spawn, stop, restart or reconfigure a child.
|
|
233
|
+
Stopped-only backup restore is also rejected because the dashboard cannot prove
|
|
234
|
+
that an externally supervised broker is inactive.
|
|
235
|
+
To mount the same process below a reverse-proxy prefix, set for example
|
|
236
|
+
`BASE_PATH=/internal/queue` and preserve that prefix when proxying; the SPA,
|
|
237
|
+
assets, `/api` proxy and `/agent` bridge all move below it, including the
|
|
238
|
+
target-pinned Flow, Workflow, Queue and Backup agent operations.
|
|
239
|
+
|
|
227
240
|
## Scripts
|
|
228
241
|
|
|
229
242
|
| Command | Does |
|
|
@@ -282,7 +295,7 @@ The canonical gate must be green before a change is considered done; CI runs the
|
|
|
282
295
|
bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
|
|
283
296
|
```
|
|
284
297
|
|
|
285
|
-
The E2E stage starts disposable Bunqueue 2.8.
|
|
298
|
+
The E2E stage starts disposable Bunqueue 2.8.59 processes and exercises the complete FlowProducer,
|
|
286
299
|
Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
|
|
287
300
|
|
|
288
301
|
CI enforces this on every push and pull request. See [Contributing](#contributing).
|
package/agent/backup/routes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertManagedTarget } from '../managedTarget';
|
|
1
|
+
import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
|
|
2
2
|
import type { DbStats, ServerConfig } from '../manager';
|
|
3
3
|
import { readLimitedJsonBody } from '../server/jsonBody';
|
|
4
4
|
import type { BackupRunnerPort } from './runner';
|
|
@@ -18,12 +18,13 @@ export async function routeBackupRequest(
|
|
|
18
18
|
serverRunning: boolean,
|
|
19
19
|
database: DbStats,
|
|
20
20
|
runner: BackupRunnerPort,
|
|
21
|
-
configure?: (extraEnv: Record<string, string>) => void
|
|
21
|
+
configure?: (extraEnv: Record<string, string>) => void,
|
|
22
|
+
targetPolicy?: ManagedTargetPolicy
|
|
22
23
|
): Promise<BackupRouteResponse | null> {
|
|
23
24
|
const operation = routeOperation(pathname, method);
|
|
24
25
|
if (!operation) return null;
|
|
25
26
|
const query = exactTargetQuery(request.url);
|
|
26
|
-
assertManagedTarget(query, config);
|
|
27
|
+
assertManagedTarget(query, config, targetPolicy);
|
|
27
28
|
if (operation === 'configure') {
|
|
28
29
|
if (!configure) throw new Error('Backup configuration is unavailable');
|
|
29
30
|
const body = await boundedRecord(request, ['environment']);
|
package/agent/flow/routes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServerConfig } from '../manager';
|
|
2
|
-
import { assertManagedTarget } from '../managedTarget';
|
|
2
|
+
import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
|
|
3
3
|
import type {
|
|
4
4
|
ManagedRuntimeAdmission,
|
|
5
5
|
ManagedRuntimeSnapshot,
|
|
@@ -27,6 +27,7 @@ export interface FlowOperationsPort {
|
|
|
27
27
|
export interface FlowRouteDependencies {
|
|
28
28
|
admission?: ManagedRuntimeAdmission;
|
|
29
29
|
operations?: FlowOperationsPort;
|
|
30
|
+
targetPolicy?: ManagedTargetPolicy;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const DEFAULT_OPERATIONS: FlowOperationsPort = {
|
|
@@ -95,7 +96,7 @@ export async function routeFlowRequest(
|
|
|
95
96
|
}
|
|
96
97
|
if (pathname === '/flows/create' && method === 'POST') {
|
|
97
98
|
const query = exactQuery(request.url, ['target']);
|
|
98
|
-
assertManagedTarget(query, config);
|
|
99
|
+
assertManagedTarget(query, config, dependencies.targetPolicy);
|
|
99
100
|
const body = await readFlowJsonBody(request);
|
|
100
101
|
return success(
|
|
101
102
|
await admitted(query, config, serverRunning, dependencies, ({ config: current }) =>
|
|
@@ -105,7 +106,7 @@ export async function routeFlowRequest(
|
|
|
105
106
|
}
|
|
106
107
|
if (pathname === '/flows/tree' && method === 'GET') {
|
|
107
108
|
const query = exactQuery(request.url, ['id', 'queueName', 'depth', 'maxChildren', 'target']);
|
|
108
|
-
assertManagedTarget(query, config);
|
|
109
|
+
assertManagedTarget(query, config, dependencies.targetPolicy);
|
|
109
110
|
const target = {
|
|
110
111
|
id: required(query, 'id'),
|
|
111
112
|
queueName: required(query, 'queueName'),
|
|
@@ -120,7 +121,7 @@ export async function routeFlowRequest(
|
|
|
120
121
|
}
|
|
121
122
|
if (pathname === '/flows/results' && method === 'POST') {
|
|
122
123
|
const query = exactQuery(request.url, ['target']);
|
|
123
|
-
assertManagedTarget(query, config);
|
|
124
|
+
assertManagedTarget(query, config, dependencies.targetPolicy);
|
|
124
125
|
const body = await readFlowJsonBody(request);
|
|
125
126
|
return success(
|
|
126
127
|
await admitted(query, config, serverRunning, dependencies, ({ config: current }) =>
|
|
@@ -136,7 +137,7 @@ export async function routeFlowRequest(
|
|
|
136
137
|
? ['queueName', 'target', 'ttl']
|
|
137
138
|
: ['queueName', 'target']
|
|
138
139
|
);
|
|
139
|
-
assertManagedTarget(query, config);
|
|
140
|
+
assertManagedTarget(query, config, dependencies.targetPolicy);
|
|
140
141
|
const target = { id: decodeURIComponent(match[1]), queueName: required(query, 'queueName') };
|
|
141
142
|
const operation = match[2];
|
|
142
143
|
if (method === 'GET' && operation === 'waitUntilFinished') {
|
|
@@ -176,7 +177,7 @@ function admitted<T>(
|
|
|
176
177
|
operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
|
|
177
178
|
): Promise<T> {
|
|
178
179
|
const execute = async (snapshot: ManagedRuntimeSnapshot) => {
|
|
179
|
-
assertManagedTarget(query, snapshot.config);
|
|
180
|
+
assertManagedTarget(query, snapshot.config, dependencies.targetPolicy);
|
|
180
181
|
assertRunning(snapshot.running);
|
|
181
182
|
return operation(snapshot);
|
|
182
183
|
};
|
package/agent/index.ts
CHANGED
|
@@ -9,11 +9,17 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Run: bun run agent/index.ts (default port 6800)
|
|
11
11
|
* Env: AGENT_PORT, AGENT_ALLOWED_ORIGINS, AGENT_TOKEN,
|
|
12
|
-
* BUNQUEUE_START_CMD,
|
|
12
|
+
* BUNQUEUE_START_CMD, BUNQUEUE_MANAGED, BUNQUEUE_URL, BUNQUEUE_TOKEN,
|
|
13
|
+
* HTTP_PORT, TCP_PORT, BUNQUEUE_DATA_PATH
|
|
13
14
|
*/
|
|
14
15
|
import { logger } from './logger';
|
|
15
16
|
import { ProcessManager } from './manager';
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createFetchHandler,
|
|
19
|
+
resolveAllowedHosts,
|
|
20
|
+
resolveAllowedOrigins,
|
|
21
|
+
resolveServerControlTarget,
|
|
22
|
+
} from './server';
|
|
17
23
|
import { installAgentShutdown } from './shutdown';
|
|
18
24
|
|
|
19
25
|
const mgr = new ProcessManager();
|
|
@@ -25,8 +31,9 @@ const allowedOrigins = resolveAllowedOrigins();
|
|
|
25
31
|
// GETs. Extend via AGENT_ALLOWED_HOSTS when fronted by a proxy on another host.
|
|
26
32
|
const allowedHosts = resolveAllowedHosts();
|
|
27
33
|
const token = process.env.AGENT_TOKEN || undefined;
|
|
34
|
+
const controlTarget = resolveServerControlTarget(process.env);
|
|
28
35
|
|
|
29
|
-
const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token });
|
|
36
|
+
const handle = createFetchHandler(mgr, { allowedOrigins, allowedHosts, token, controlTarget });
|
|
30
37
|
const stopAccepting: Array<() => unknown | Promise<unknown>> = [];
|
|
31
38
|
installAgentShutdown(handle, { stopAccepting });
|
|
32
39
|
|
|
@@ -38,6 +45,12 @@ const server = Bun.serve({
|
|
|
38
45
|
stopAccepting.push(() => server.stop());
|
|
39
46
|
|
|
40
47
|
logger.info(
|
|
41
|
-
{
|
|
48
|
+
{
|
|
49
|
+
url: `http://127.0.0.1:${PORT}/control`,
|
|
50
|
+
allowedOrigins,
|
|
51
|
+
allowedHosts,
|
|
52
|
+
tokenAuth: Boolean(token),
|
|
53
|
+
serverManagement: controlTarget.mode,
|
|
54
|
+
},
|
|
42
55
|
'bunqueue dashboard control agent ready'
|
|
43
56
|
);
|
package/agent/managedTarget.ts
CHANGED
|
@@ -1,14 +1,53 @@
|
|
|
1
1
|
import type { ServerConfig } from './manager';
|
|
2
2
|
|
|
3
|
+
const MANAGED_PROXY_PATH_PATTERN = /^\/(?:[A-Za-z0-9._~-]+\/)*api$/;
|
|
4
|
+
|
|
5
|
+
export interface ManagedTargetPolicy {
|
|
6
|
+
/** Exact browser-visible proxy alias accepted in addition to legacy `/api`. */
|
|
7
|
+
proxyPath: string;
|
|
8
|
+
/** Local upstream represented by the alias; it still passes the anti-SSRF gate. */
|
|
9
|
+
proxyUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Validate the one browser-visible proxy path that aliases the local managed server. */
|
|
13
|
+
export function resolveManagedProxyPath(value: string | undefined): string {
|
|
14
|
+
const path = value?.trim() || '/api';
|
|
15
|
+
if (
|
|
16
|
+
!MANAGED_PROXY_PATH_PATTERN.test(path) ||
|
|
17
|
+
path.split('/').some((segment) => segment === '.' || segment === '..')
|
|
18
|
+
) {
|
|
19
|
+
throw new Error('Managed proxy path must be /api or a safe root-relative path ending in /api');
|
|
20
|
+
}
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve the alias once without cloning a request or touching its body stream. */
|
|
25
|
+
export function resolveManagedTargetPolicy(
|
|
26
|
+
proxyPath: string | undefined,
|
|
27
|
+
proxyUrl?: string
|
|
28
|
+
): ManagedTargetPolicy {
|
|
29
|
+
return {
|
|
30
|
+
proxyPath: resolveManagedProxyPath(proxyPath),
|
|
31
|
+
proxyUrl: proxyUrl?.trim() || undefined,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
3
35
|
/** Refuse agent-side SDK operations for a server the agent does not manage. */
|
|
4
36
|
export function assertManagedTarget(
|
|
5
37
|
query: URLSearchParams,
|
|
6
38
|
config: ServerConfig,
|
|
7
|
-
|
|
39
|
+
policy: ManagedTargetPolicy | string =
|
|
40
|
+
process.env.BUNQUEUE_URL?.trim() || 'http://localhost:6790'
|
|
8
41
|
): void {
|
|
9
42
|
const target = query.get('target');
|
|
10
43
|
if (!target) throw new Error('Managed Bunqueue target is required');
|
|
11
|
-
|
|
44
|
+
const proxyPath =
|
|
45
|
+
typeof policy === 'string' ? '/api' : resolveManagedProxyPath(policy.proxyPath);
|
|
46
|
+
const proxyTarget =
|
|
47
|
+
(typeof policy === 'string' ? policy : policy.proxyUrl?.trim()) ||
|
|
48
|
+
process.env.BUNQUEUE_URL?.trim() ||
|
|
49
|
+
'http://localhost:6790';
|
|
50
|
+
if (target === '/api' || target === proxyPath) {
|
|
12
51
|
assertLocalTarget(proxyTarget, config, 'Dashboard /api proxy');
|
|
13
52
|
return;
|
|
14
53
|
}
|
package/agent/manager/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ const CONFIG_KEYS = new Set<keyof ServerConfig>([
|
|
|
10
10
|
|
|
11
11
|
export function defaultConfig(): ServerConfig {
|
|
12
12
|
return {
|
|
13
|
-
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.
|
|
13
|
+
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.8.59 start',
|
|
14
14
|
httpPort: Number(process.env.HTTP_PORT) || 6790,
|
|
15
15
|
tcpPort: Number(process.env.TCP_PORT) || 6789,
|
|
16
16
|
dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
|
package/agent/queue/routes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { QueueMetricType } from 'bunqueue/client';
|
|
2
2
|
import type { ServerConfig } from '../manager';
|
|
3
|
-
import { assertManagedTarget } from '../managedTarget';
|
|
3
|
+
import { assertManagedTarget, type ManagedTargetPolicy } from '../managedTarget';
|
|
4
4
|
import type {
|
|
5
5
|
ManagedRuntimeAdmission,
|
|
6
6
|
ManagedRuntimeSnapshot,
|
|
@@ -29,32 +29,33 @@ export async function routeQueueOperationsRequest(
|
|
|
29
29
|
config: ServerConfig,
|
|
30
30
|
runtime: QueueOperationsPort,
|
|
31
31
|
running: boolean,
|
|
32
|
-
admission?: ManagedRuntimeAdmission
|
|
32
|
+
admission?: ManagedRuntimeAdmission,
|
|
33
|
+
targetPolicy?: ManagedTargetPolicy
|
|
33
34
|
): Promise<RouteResponse | null> {
|
|
34
35
|
const read = pathname.match(READ_ROUTE);
|
|
35
36
|
if (read && method === 'GET') {
|
|
36
37
|
const queue = decodedQueue(read[1]);
|
|
37
38
|
if (read[2] === 'limits') {
|
|
38
|
-
const query = pinnedQuery(request, config, ['target', 'maxJobs']);
|
|
39
|
+
const query = pinnedQuery(request, config, ['target', 'maxJobs'], targetPolicy);
|
|
39
40
|
assertRunning(running);
|
|
40
41
|
const maxJobs = optionalMaxJobs(query);
|
|
41
|
-
return admitted(query, config, running, admission, async ({ config: current }) =>
|
|
42
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
42
43
|
ok({ limits: await runtime.limits(current, queue, maxJobs) })
|
|
43
44
|
);
|
|
44
45
|
}
|
|
45
46
|
if (read[2] === 'deduplication') {
|
|
46
|
-
const query = pinnedQuery(request, config, ['target', 'deduplicationId']);
|
|
47
|
+
const query = pinnedQuery(request, config, ['target', 'deduplicationId'], targetPolicy);
|
|
47
48
|
const id = requiredDeduplicationId(query.get('deduplicationId'));
|
|
48
49
|
assertRunning(running);
|
|
49
|
-
return admitted(query, config, running, admission, async ({ config: current }) =>
|
|
50
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
50
51
|
ok({ jobId: await runtime.deduplicationJobId(current, queue, id) })
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
|
-
const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end']);
|
|
54
|
+
const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end'], targetPolicy);
|
|
54
55
|
const type = metricType(query.get('type'));
|
|
55
56
|
const range = metricsRange(query);
|
|
56
57
|
assertRunning(running);
|
|
57
|
-
return admitted(query, config, running, admission, async ({ config: current }) =>
|
|
58
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
58
59
|
ok({ metrics: await runtime.metrics(current, queue, type, range.start, range.end) })
|
|
59
60
|
);
|
|
60
61
|
}
|
|
@@ -62,20 +63,20 @@ export async function routeQueueOperationsRequest(
|
|
|
62
63
|
const mutation = pathname.match(MUTATION_ROUTE);
|
|
63
64
|
if (mutation && method === 'POST') {
|
|
64
65
|
const queue = decodedQueue(mutation[1]);
|
|
65
|
-
const query = pinnedQuery(request, config, ['target']);
|
|
66
|
+
const query = pinnedQuery(request, config, ['target'], targetPolicy);
|
|
66
67
|
assertRunning(running);
|
|
67
68
|
if (mutation[2] === 'deduplication/remove') {
|
|
68
69
|
const body = await exactJsonBody(request, ['deduplicationId']);
|
|
69
70
|
const id = requiredDeduplicationId(
|
|
70
71
|
typeof body.deduplicationId === 'string' ? body.deduplicationId : null
|
|
71
72
|
);
|
|
72
|
-
return admitted(query, config, running, admission, async ({ config: current }) =>
|
|
73
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
73
74
|
ok({ removed: await runtime.removeDeduplicationKey(current, queue, id) })
|
|
74
75
|
);
|
|
75
76
|
}
|
|
76
77
|
const body = await exactJsonBody(request, ['maxLength']);
|
|
77
78
|
const retention = eventRetention(body.maxLength);
|
|
78
|
-
return admitted(query, config, running, admission, async ({ config: current }) =>
|
|
79
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
79
80
|
ok({ removed: await runtime.trimEvents(current, queue, retention) })
|
|
80
81
|
);
|
|
81
82
|
}
|
|
@@ -90,19 +91,25 @@ function admitted<T>(
|
|
|
90
91
|
config: ServerConfig,
|
|
91
92
|
running: boolean,
|
|
92
93
|
admission: ManagedRuntimeAdmission | undefined,
|
|
94
|
+
targetPolicy: ManagedTargetPolicy | undefined,
|
|
93
95
|
operation: (snapshot: ManagedRuntimeSnapshot) => Promise<T>
|
|
94
96
|
): Promise<T> {
|
|
95
97
|
const execute = async (snapshot: ManagedRuntimeSnapshot) => {
|
|
96
|
-
assertManagedTarget(query, snapshot.config);
|
|
98
|
+
assertManagedTarget(query, snapshot.config, targetPolicy);
|
|
97
99
|
assertRunning(snapshot.running);
|
|
98
100
|
return operation(snapshot);
|
|
99
101
|
};
|
|
100
102
|
return admission ? admission(execute) : execute({ config, running });
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
function pinnedQuery(
|
|
105
|
+
function pinnedQuery(
|
|
106
|
+
request: Request,
|
|
107
|
+
config: ServerConfig,
|
|
108
|
+
allowed: string[],
|
|
109
|
+
targetPolicy?: ManagedTargetPolicy
|
|
110
|
+
) {
|
|
104
111
|
const query = exactQuery(request.url, allowed);
|
|
105
|
-
assertManagedTarget(query, config);
|
|
112
|
+
assertManagedTarget(query, config, targetPolicy);
|
|
106
113
|
return query;
|
|
107
114
|
}
|
|
108
115
|
|
|
@@ -5,13 +5,32 @@ import {
|
|
|
5
5
|
type WorkflowRuntimePort,
|
|
6
6
|
} from '../workflow/runtime';
|
|
7
7
|
import { readLimitedJsonBody } from './jsonBody';
|
|
8
|
+
import {
|
|
9
|
+
MANAGED_CONTROL_TARGET,
|
|
10
|
+
probeExternalHealth,
|
|
11
|
+
type ServerControlTarget,
|
|
12
|
+
} from './controlTarget';
|
|
8
13
|
import type { AgentLifecyclePort } from './lifecycle';
|
|
9
14
|
import type { RouteResponse } from './types';
|
|
10
15
|
|
|
11
16
|
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
|
|
12
17
|
|
|
13
|
-
async function statusWithHealth(manager: ProcessManager) {
|
|
18
|
+
async function statusWithHealth(manager: ProcessManager, controlTarget: ServerControlTarget) {
|
|
14
19
|
const snapshot = manager.getStatus();
|
|
20
|
+
if (controlTarget.mode === 'external') {
|
|
21
|
+
const health = await probeExternalHealth(controlTarget);
|
|
22
|
+
return {
|
|
23
|
+
...snapshot,
|
|
24
|
+
managementMode: 'external' as const,
|
|
25
|
+
healthy: health.healthy,
|
|
26
|
+
version: health.version,
|
|
27
|
+
reachable: health.reachable,
|
|
28
|
+
externalUrl: controlTarget.url,
|
|
29
|
+
healthStatus: health.statusCode,
|
|
30
|
+
healthError: health.error,
|
|
31
|
+
db: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
15
34
|
let healthy = false;
|
|
16
35
|
let version: string | undefined;
|
|
17
36
|
if (snapshot.status === 'running') {
|
|
@@ -33,7 +52,7 @@ async function statusWithHealth(manager: ProcessManager) {
|
|
|
33
52
|
}
|
|
34
53
|
}
|
|
35
54
|
const database = await manager.dbStats().catch(() => null);
|
|
36
|
-
return { ...snapshot, healthy, version, db: database };
|
|
55
|
+
return { ...snapshot, managementMode: 'managed' as const, healthy, version, db: database };
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
export async function routeControlRequest(
|
|
@@ -42,40 +61,45 @@ export async function routeControlRequest(
|
|
|
42
61
|
method: string,
|
|
43
62
|
manager: ProcessManager,
|
|
44
63
|
runtime: WorkflowRuntimePort,
|
|
45
|
-
lifecycle?: AgentLifecyclePort
|
|
64
|
+
lifecycle?: AgentLifecyclePort,
|
|
65
|
+
controlTarget: ServerControlTarget = MANAGED_CONTROL_TARGET
|
|
46
66
|
): Promise<RouteResponse | null> {
|
|
47
67
|
if (pathname === '/control/status') {
|
|
48
|
-
return { status: 200, body: await statusWithHealth(manager) };
|
|
68
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
49
69
|
}
|
|
50
70
|
if (pathname === '/control/logs') {
|
|
51
71
|
return { status: 200, body: { lines: manager.getLogs() } };
|
|
52
72
|
}
|
|
53
73
|
if (pathname === '/control/start' && method === 'POST') {
|
|
74
|
+
assertManagedControl(controlTarget);
|
|
54
75
|
return coordinated(lifecycle, async () => {
|
|
55
76
|
await manager.start();
|
|
56
|
-
return { status: 200, body: await statusWithHealth(manager) };
|
|
77
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
57
78
|
});
|
|
58
79
|
}
|
|
59
80
|
if (pathname === '/control/stop' && method === 'POST') {
|
|
81
|
+
assertManagedControl(controlTarget);
|
|
60
82
|
return coordinated(lifecycle, async () => {
|
|
61
83
|
await stopWithRuntimeCleanup(manager, runtime);
|
|
62
|
-
return { status: 200, body: await statusWithHealth(manager) };
|
|
84
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
63
85
|
});
|
|
64
86
|
}
|
|
65
87
|
if (pathname === '/control/restart' && method === 'POST') {
|
|
88
|
+
assertManagedControl(controlTarget);
|
|
66
89
|
return coordinated(lifecycle, async () => {
|
|
67
90
|
const closeResult = await settle(() => runtime.close());
|
|
68
91
|
if (!closeResult.ok) {
|
|
69
92
|
await stopAfterCloseFailure(manager, workflowCloseError(closeResult.error));
|
|
70
93
|
}
|
|
71
94
|
await manager.restart();
|
|
72
|
-
return { status: 200, body: await statusWithHealth(manager) };
|
|
95
|
+
return { status: 200, body: await statusWithHealth(manager, controlTarget) };
|
|
73
96
|
});
|
|
74
97
|
}
|
|
75
98
|
if (pathname === '/control/config' && method === 'GET') {
|
|
76
99
|
return { status: 200, body: manager.getConfig() };
|
|
77
100
|
}
|
|
78
101
|
if (pathname === '/control/config' && method === 'PUT') {
|
|
102
|
+
assertManagedControl(controlTarget);
|
|
79
103
|
const patch = validateConfigPatch(
|
|
80
104
|
await readLimitedJsonBody(request, {
|
|
81
105
|
scope: 'Agent configuration',
|
|
@@ -88,6 +112,14 @@ export async function routeControlRequest(
|
|
|
88
112
|
return null;
|
|
89
113
|
}
|
|
90
114
|
|
|
115
|
+
export function assertManagedControl(controlTarget: ServerControlTarget): void {
|
|
116
|
+
if (controlTarget.mode === 'external') {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'Server lifecycle is disabled in external mode (BUNQUEUE_MANAGED=0); manage this broker with its external supervisor'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
91
123
|
async function stopWithRuntimeCleanup(
|
|
92
124
|
manager: ProcessManager,
|
|
93
125
|
runtime: WorkflowRuntimePort
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { safeErrorMessage } from '../errorMessage';
|
|
2
|
+
|
|
3
|
+
export type ServerManagementMode = 'managed' | 'external';
|
|
4
|
+
|
|
5
|
+
export interface ServerControlTarget {
|
|
6
|
+
mode: ServerManagementMode;
|
|
7
|
+
/** Canonical external HTTP base; present only in attach-only mode. */
|
|
8
|
+
url?: string;
|
|
9
|
+
/** Server bearer used only by the agent-side health probe. */
|
|
10
|
+
token?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExternalHealthProbe {
|
|
14
|
+
reachable: boolean;
|
|
15
|
+
healthy: boolean;
|
|
16
|
+
statusCode: number | null;
|
|
17
|
+
version?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const MANAGED_CONTROL_TARGET: ServerControlTarget = Object.freeze({ mode: 'managed' });
|
|
22
|
+
|
|
23
|
+
/** Resolve the immutable lifecycle mode once, before the agent starts listening. */
|
|
24
|
+
export function resolveServerControlTarget(
|
|
25
|
+
env: Record<string, string | undefined> = process.env
|
|
26
|
+
): ServerControlTarget {
|
|
27
|
+
const configured = env.BUNQUEUE_MANAGED?.trim().toLowerCase();
|
|
28
|
+
if (!configured || ['1', 'true', 'yes', 'on'].includes(configured)) {
|
|
29
|
+
return MANAGED_CONTROL_TARGET;
|
|
30
|
+
}
|
|
31
|
+
if (!['0', 'false', 'no', 'off'].includes(configured)) {
|
|
32
|
+
throw new Error('BUNQUEUE_MANAGED must be 1/0, true/false, yes/no, or on/off');
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
mode: 'external',
|
|
36
|
+
url: normalizeExternalUrl(env.BUNQUEUE_URL),
|
|
37
|
+
token: env.BUNQUEUE_TOKEN?.trim() || undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeExternalUrl(value: string | undefined): string {
|
|
42
|
+
const candidate = value?.trim() || 'http://localhost:6790';
|
|
43
|
+
let parsed: URL;
|
|
44
|
+
try {
|
|
45
|
+
parsed = new URL(candidate);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error('BUNQUEUE_URL must be an absolute HTTP(S) URL in external mode');
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
|
51
|
+
!parsed.hostname ||
|
|
52
|
+
parsed.username ||
|
|
53
|
+
parsed.password ||
|
|
54
|
+
parsed.search ||
|
|
55
|
+
parsed.hash
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'BUNQUEUE_URL must be a credential-free HTTP(S) URL without query or fragment in external mode'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
62
|
+
return `${parsed.origin}${path}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Probe an externally supervised broker without following credential-bearing redirects. */
|
|
66
|
+
export async function probeExternalHealth(target: ServerControlTarget): Promise<ExternalHealthProbe> {
|
|
67
|
+
if (target.mode !== 'external' || !target.url) {
|
|
68
|
+
throw new Error('External health probe requires an external server target');
|
|
69
|
+
}
|
|
70
|
+
let response: Response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(`${target.url}/health`, {
|
|
73
|
+
headers: target.token ? { Authorization: `Bearer ${target.token}` } : undefined,
|
|
74
|
+
redirect: 'error',
|
|
75
|
+
signal: AbortSignal.timeout(1500),
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return {
|
|
79
|
+
reachable: false,
|
|
80
|
+
healthy: false,
|
|
81
|
+
statusCode: null,
|
|
82
|
+
error: safeErrorMessage(error),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let body: unknown;
|
|
87
|
+
try {
|
|
88
|
+
body = await response.json();
|
|
89
|
+
} catch {
|
|
90
|
+
return {
|
|
91
|
+
reachable: true,
|
|
92
|
+
healthy: false,
|
|
93
|
+
statusCode: response.status,
|
|
94
|
+
error: `Health endpoint returned non-JSON HTTP ${response.status}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const health = isRecord(body) ? body : {};
|
|
98
|
+
const healthy = response.ok && health.ok === true;
|
|
99
|
+
const version = typeof health.version === 'string' ? health.version : undefined;
|
|
100
|
+
const reportedStatus = typeof health.status === 'string' ? health.status : undefined;
|
|
101
|
+
return {
|
|
102
|
+
reachable: true,
|
|
103
|
+
healthy,
|
|
104
|
+
statusCode: response.status,
|
|
105
|
+
version,
|
|
106
|
+
error: healthy
|
|
107
|
+
? undefined
|
|
108
|
+
: reportedStatus
|
|
109
|
+
? `Health reported ${reportedStatus} (HTTP ${response.status})`
|
|
110
|
+
: `Health endpoint returned HTTP ${response.status}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
115
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
116
|
+
}
|
package/agent/server/errors.ts
CHANGED
|
@@ -18,6 +18,7 @@ const CONFLICT_MESSAGES = [
|
|
|
18
18
|
'BUNQUEUE_WORKFLOW_QUEUE_NAME must',
|
|
19
19
|
'BUNQUEUE_WORKFLOW_CONCURRENCY must',
|
|
20
20
|
'Managed Bunqueue server restarted',
|
|
21
|
+
'Server lifecycle is disabled in external mode',
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
export function errorStatus(error: unknown): number {
|
package/agent/server/handler.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { ProcessManager } from '../manager';
|
|
|
3
3
|
import { QueueOperationsRuntime } from '../queue/runtime';
|
|
4
4
|
import type { QueueOperationsPort } from '../queue/types';
|
|
5
5
|
import { WorkflowRuntime, type WorkflowRuntimePort } from '../workflow/runtime';
|
|
6
|
+
import { resolveManagedTargetPolicy } from '../managedTarget';
|
|
6
7
|
import { errorMessage, errorStatus } from './errors';
|
|
8
|
+
import { MANAGED_CONTROL_TARGET } from './controlTarget';
|
|
7
9
|
import { AgentLifecycleGate, type AgentLifecyclePort } from './lifecycle';
|
|
8
10
|
import { corsHeaders, isHostAllowed, isOriginAllowed, tokenOk } from './policy';
|
|
9
11
|
import { routeAgentRequest } from './router';
|
|
@@ -39,7 +41,12 @@ export function createFetchHandler(
|
|
|
39
41
|
allowedHosts,
|
|
40
42
|
token,
|
|
41
43
|
requireTokenForAll = false,
|
|
44
|
+
controlTarget = MANAGED_CONTROL_TARGET,
|
|
42
45
|
} = options;
|
|
46
|
+
const managedTargetPolicy = resolveManagedTargetPolicy(
|
|
47
|
+
options.managedProxyPath,
|
|
48
|
+
options.managedProxyUrl
|
|
49
|
+
);
|
|
43
50
|
const json = (body: unknown, status: number, origin: string | null) =>
|
|
44
51
|
responseOf({ body, status }, origin, allowedOrigins);
|
|
45
52
|
|
|
@@ -74,7 +81,9 @@ export function createFetchHandler(
|
|
|
74
81
|
queueRuntime,
|
|
75
82
|
lifecycle,
|
|
76
83
|
origin,
|
|
77
|
-
allowedOrigins
|
|
84
|
+
allowedOrigins,
|
|
85
|
+
controlTarget,
|
|
86
|
+
managedTargetPolicy
|
|
78
87
|
);
|
|
79
88
|
return responseOf(result, origin, allowedOrigins);
|
|
80
89
|
} catch (error) {
|