@or3/cloud 0.1.54 → 0.1.55

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.
@@ -28,6 +28,7 @@ const maxRegistryBytes = 256 * 1024;
28
28
  const maxAttestationBytes = 1024 * 1024;
29
29
  const maxProcessOutputBytes = 64 * 1024;
30
30
  const maxUpdateDurationMs = 15 * 60 * 1000;
31
+ const operatorHandoffTimeoutMs = 2 * 60 * 1000;
31
32
  let updateClaimed = false;
32
33
  const checkAttempts = [];
33
34
  const startAttempts = [];
@@ -607,35 +608,49 @@ function closeAfter(server, code) {
607
608
  setTimeout(() => server.close(() => process.exit(code)), 250).unref();
608
609
  }
609
610
 
610
- /**
611
- * A dashboard-origin update cannot recreate its own container before the CLI
612
- * has committed terminal state. Once the job is durable, ask Docker to replace
613
- * only this sidecar from the newly written digest-qualified overlay. The
614
- * detached Docker client survives the current container's termination.
615
- */
616
- function recreateOperatorAfterCommit(environment) {
617
- const project = envValue(environment, 'OR3_COMPOSE_PROJECT');
618
- if (!project) return;
619
- try {
620
- const child = spawn('docker', [
621
- 'compose',
622
- '--project-name', project,
623
- '--project-directory', deploymentDirectory,
624
- '--env-file', join(deploymentDirectory, '.env'),
625
- '-f', join(deploymentDirectory, 'compose.yaml'),
626
- '-f', join(deploymentDirectory, 'compose.operator.yaml'),
627
- 'up', '-d', '--no-deps', '--force-recreate', 'or3-operator',
628
- ], {
629
- cwd: deploymentDirectory,
630
- env: process.env,
631
- detached: process.platform !== 'win32',
632
- stdio: 'ignore',
633
- });
634
- child.unref();
635
- } catch {
636
- // The durable terminal job and restart policy still leave a host-CLI
637
- // recovery path if Docker cannot schedule the sidecar replacement.
611
+ async function completeOperatorHandoff(jobId, project) {
612
+ if (!requestIdPattern.test(jobId || '') || !/^[a-z0-9][a-z0-9_-]*$/i.test(project || '')) {
613
+ throw new Error('The dashboard operator handoff arguments are invalid.');
638
614
  }
615
+ const deadline = Date.now() + operatorHandoffTimeoutMs;
616
+ while (Date.now() < deadline) {
617
+ const job = await readJob();
618
+ let state;
619
+ try {
620
+ state = JSON.parse(await readFile(statePath, 'utf8'));
621
+ } catch {
622
+ state = undefined;
623
+ }
624
+ if (
625
+ job?.id === jobId
626
+ && ['succeeded', 'failed', 'needs_attention'].includes(job.phase)
627
+ && state
628
+ && !state.incompleteOperation
629
+ ) {
630
+ await delay(1_000);
631
+ const environment = await deploymentEnv();
632
+ if (envValue(environment, 'OR3_DASHBOARD_UPDATES_ENABLED') !== 'true') return;
633
+ const restarted = await runProcess('docker', [
634
+ 'compose',
635
+ '--project-name', project,
636
+ '--project-directory', deploymentDirectory,
637
+ '--env-file', join(deploymentDirectory, '.env'),
638
+ '-f', join(deploymentDirectory, 'compose.yaml'),
639
+ '-f', join(deploymentDirectory, 'compose.operator.yaml'),
640
+ 'up', '-d', '--no-deps', '--force-recreate', 'or3-operator',
641
+ ], {
642
+ cwd: deploymentDirectory,
643
+ env: process.env,
644
+ timeoutMs: 60_000,
645
+ });
646
+ if (restarted.code !== 0) {
647
+ throw new Error(`The dashboard operator handoff could not start its successor: ${restarted.output}`);
648
+ }
649
+ return;
650
+ }
651
+ await delay(250);
652
+ }
653
+ throw new Error('The dashboard operator handoff did not observe a durable terminal job.');
639
654
  }
640
655
 
641
656
  async function finish(server, job, error) {
@@ -644,21 +659,17 @@ async function finish(server, job, error) {
644
659
  if (error) job.error = error instanceof Error ? error.message : 'The update did not complete. OR3 restored the previous verified deployment when possible.';
645
660
  await writeJob(job);
646
661
  await audit(error ? 'update_failed' : 'update_succeeded', { jobId: job.id, targetVersion: job.targetVersion });
647
- // A successful run restarts this narrowly scoped service so it reloads the
648
- // just-installed operator program. Failed first-time enablement exits cleanly
649
- // because the restored .env no longer declares the profile.
662
+ // The target CLI schedules a separate helper container before returning.
663
+ // That helper can survive replacing this container after the terminal job
664
+ // and lifecycle journal are durable. A process inside this container cannot.
650
665
  let enabled = true;
651
666
  try {
652
667
  const environment = await deploymentEnv();
653
668
  enabled = envValue(environment, 'OR3_DASHBOARD_UPDATES_ENABLED') === 'true';
654
- if (enabled) recreateOperatorAfterCommit(environment);
655
669
  } catch {
656
- // Keep the service restart-on-failure behavior when state cannot be read.
670
+ // Keep serving the durable job when state cannot be read.
657
671
  }
658
- // A disabled bridge must stay down after a host update/restore. When it is
659
- // still enabled, restart the sidecar so Docker recreates it from the just
660
- // committed digest-qualified overlay and reloads the operator asset.
661
- closeAfter(server, enabled ? 75 : 0);
672
+ if (!enabled) closeAfter(server, 0);
662
673
  }
663
674
 
664
675
  async function reconcileInterruptedJob() {
@@ -870,5 +881,9 @@ async function main() {
870
881
  }
871
882
 
872
883
  if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
873
- await main();
884
+ if (process.argv[2] === '--complete-handoff') {
885
+ await completeOperatorHandoff(process.argv[3], process.argv[4]);
886
+ } else {
887
+ await main();
888
+ }
874
889
  }
package/dist/cli.mjs CHANGED
@@ -30,7 +30,7 @@ import { fileURLToPath } from "node:url";
30
30
  import { createGunzip } from "node:zlib";
31
31
  var execFile = promisify(execFileCallback);
32
32
  var PACKAGE_ROOT = resolve(fileURLToPath(new URL("../", import.meta.url)));
33
- var PACKAGE_VERSION = "0.1.54";
33
+ var PACKAGE_VERSION = "0.1.55";
34
34
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
35
35
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
36
36
  var STATE_SCHEMA_VERSION = 1;
@@ -42,6 +42,8 @@ var BACKUP_RETENTION_KEEP = 5;
42
42
  var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
43
43
  var FREE_SPACE_HEADROOM_BYTES = 64 * 1024 * 1024;
44
44
  var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
45
+ var DASHBOARD_JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
46
+ var COMPOSE_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
45
47
  var MANAGED_ASSET_INVENTORY_VERSION = 3;
46
48
  var DASHBOARD_LEASE_STALE_MS = 30000;
47
49
  var PROVISIONING_CREDENTIAL_KEYS = [
@@ -1398,6 +1400,54 @@ async function prepareVerifiedDashboardOperator(directory, version) {
1398
1400
  console.warn("Dashboard updates are unavailable on this Docker setup; the deployment will remain host-CLI managed.");
1399
1401
  return;
1400
1402
  }
1403
+ function dashboardOperatorHandoffArgs(directory, env, jobId) {
1404
+ const project = env.OR3_COMPOSE_PROJECT;
1405
+ if (!isAbsolute(directory) || !DASHBOARD_JOB_ID_PATTERN.test(jobId) || !COMPOSE_PROJECT_PATTERN.test(project ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_UID ?? "") || !/^\d+$/.test(env.OR3_OPERATOR_GID ?? "") || !/^\d+$/.test(env.OR3_DOCKER_GID ?? "") || !isAbsolute(env.OR3_DOCKER_SOCKET ?? "") || !/@sha256:[0-9a-f]{64}$/i.test(env.OR3_OPERATOR_IMAGE ?? "")) {
1406
+ throw new Error("Refusing to schedule an invalid dashboard operator handoff.");
1407
+ }
1408
+ return [
1409
+ "run",
1410
+ "--detach",
1411
+ "--rm",
1412
+ "--network",
1413
+ "none",
1414
+ "--read-only",
1415
+ "--name",
1416
+ `${project}-operator-handoff-${jobId}`,
1417
+ "--user",
1418
+ `${env.OR3_OPERATOR_UID}:${env.OR3_OPERATOR_GID}`,
1419
+ "--group-add",
1420
+ env.OR3_DOCKER_GID,
1421
+ "--security-opt",
1422
+ "no-new-privileges:true",
1423
+ "--cap-drop",
1424
+ "ALL",
1425
+ "--mount",
1426
+ `type=bind,src=${env.OR3_DOCKER_SOCKET},dst=/var/run/docker.sock`,
1427
+ "--mount",
1428
+ `type=bind,src=${directory},dst=${directory},readonly`,
1429
+ "--workdir",
1430
+ directory,
1431
+ "--tmpfs",
1432
+ "/tmp:rw,noexec,nosuid,size=16m",
1433
+ "--entrypoint",
1434
+ "/usr/local/bin/node",
1435
+ env.OR3_OPERATOR_IMAGE,
1436
+ join(directory, "dashboard-operator.mjs"),
1437
+ "--complete-handoff",
1438
+ jobId,
1439
+ project
1440
+ ];
1441
+ }
1442
+ async function scheduleDashboardOperatorHandoff(directory, env) {
1443
+ const jobId = process.env.OR3_DASHBOARD_UPDATE_JOB_ID?.trim();
1444
+ if (!jobId || env.OR3_DASHBOARD_UPDATES_ENABLED !== "true")
1445
+ return;
1446
+ const scheduled = await run("docker", dashboardOperatorHandoffArgs(directory, env, jobId), directory);
1447
+ if (!scheduled.ok || !/^[0-9a-f]{12,64}$/i.test(scheduled.stdout.trim())) {
1448
+ throw new Error(`Could not schedule the dashboard operator handoff. ${scheduled.stderr.trim()}`);
1449
+ }
1450
+ }
1401
1451
  var DASHBOARD_OPERATOR_ENV_KEYS = [
1402
1452
  "OR3_DASHBOARD_UPDATES_ENABLED",
1403
1453
  "OR3_OPERATOR_IMAGE",
@@ -2926,8 +2976,9 @@ async function updateCommand(directory, flags) {
2926
2976
  state.deploymentRoot = resolve(loaded.directory);
2927
2977
  state.lastSuccessfulOperation = "update";
2928
2978
  state.lastError = undefined;
2929
- await clearPending(loaded.directory, state);
2930
2979
  await pruneBackups(loaded.directory, state, BACKUP_RETENTION_KEEP, false);
2980
+ await scheduleDashboardOperatorHandoff(loaded.directory, nextEnv);
2981
+ await clearPending(loaded.directory, state);
2931
2982
  console.log(`OR3 updated to ${targetVersion}. Image digest: ${digest}`);
2932
2983
  console.log(`Rollback point: ${backup.backupId}. Keep it until login, chat, and file checks pass.`);
2933
2984
  } catch (error) {
@@ -4590,6 +4641,7 @@ export {
4590
4641
  parseEnv,
4591
4642
  managedAssetNames,
4592
4643
  isVersion,
4644
+ dashboardOperatorHandoffArgs,
4593
4645
  copyAssets,
4594
4646
  checkResolvedLoopbackBinding,
4595
4647
  buildEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@or3/cloud",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "description": "Install and operate the OR3 Cloud container distribution",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",
@@ -18,9 +18,9 @@
18
18
  "or3Cloud": {
19
19
  "dashboardUpdateProtocol": 1,
20
20
  "dashboardUpdateMinimumSourceVersion": "0.1.40",
21
- "imageDigest": "sha256:1879df6bcd518473bc4bed7374ac7a94bce802118a113945249b3e4b5437d103",
22
- "operatorImageDigest": "sha256:6426859d57ad2d13c1be6ec7c37211b738298a6944e9e6e55e972696ecf2bb9a",
23
- "sourceRevision": "ed4fe2466010032aeace7de75a5a447be6bac0c1"
21
+ "imageDigest": "sha256:d92a7a6b8e8a198a59c6506a5385beda6b7c829fc30406bc4653a388b8c5b638",
22
+ "operatorImageDigest": "sha256:e8149df09dc59d3184c325de09d5662b4efe8dccba70dfdde453b2fb5365c335",
23
+ "sourceRevision": "7a73e723edd4ca01ca7a387aad6902e185a8d16c"
24
24
  },
25
25
  "files": [
26
26
  "dist",