@mycadet/cli 0.12.11 → 0.13.0-dev.202610171617.9ea363e
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 +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/package.json +8 -3
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +2 -2
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/nginx-ingress.test.ts +536 -23
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +6 -6
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +3 -3
- package/src/__tests__/shell-mode.test.ts +83 -0
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel.test.ts +10 -2
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/wake.test.ts +4 -1
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +51 -17
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/pair.ts +2 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/tunnel.ts +48 -7
- package/src/commands/upgrade.ts +78 -156
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +23 -18
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login.ts +2 -1
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +59 -22
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +17 -1
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/ingress-config.ts +31 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +1 -1
- package/src/lib/local.ts +4 -2
- package/src/lib/nginx-ingress.ts +454 -79
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/tunnel.ts +1 -0
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +79 -1
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Persist gateway identity in the service root without following an existing file link. */
|
|
12
|
+
export function persistRootSigningKey(root: string, key: string): void {
|
|
13
|
+
if (!/^[a-f0-9]{64}$/i.test(key)) {
|
|
14
|
+
throw new Error("Invalid gateway signing key.");
|
|
15
|
+
}
|
|
16
|
+
const directory = join(root, "gateway-security");
|
|
17
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
18
|
+
if (!lstatSync(directory).isDirectory()) {
|
|
19
|
+
throw new Error("Gateway security root must be a directory.");
|
|
20
|
+
}
|
|
21
|
+
const temporary = join(directory, `.signing-key-${randomUUID()}`);
|
|
22
|
+
try {
|
|
23
|
+
writeFileSync(temporary, Buffer.from(key, "hex"), {
|
|
24
|
+
mode: 0o600,
|
|
25
|
+
flag: "wx",
|
|
26
|
+
});
|
|
27
|
+
renameSync(temporary, join(directory, "actor-token-signing-key"));
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(temporary, { force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/lib/runtime-url.ts
CHANGED
|
@@ -4,35 +4,6 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { getLocalLanIPv4 } from "./local";
|
|
5
5
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Resolve the URL for a runtime migration endpoint, taking the assistant's
|
|
9
|
-
* topology into account.
|
|
10
|
-
*
|
|
11
|
-
* - For local/docker assistants, `runtimeUrl` is the loopback gateway and
|
|
12
|
-
* the runtime serves `/v1/migrations/<subpath>` directly. The CLI hits
|
|
13
|
-
* that path with guardian-token bearer auth.
|
|
14
|
-
* - For cloud-managed (cloud="cadet") assistants, `runtimeUrl` is the
|
|
15
|
-
* platform host (e.g. `https://cloud.mycadet.ai`). The cloud's
|
|
16
|
-
* `MigrationViewSet` does NOT expose `export-to-gcs` or arbitrary runtime
|
|
17
|
-
* migration paths under `/v1/migrations/...`. The wildcard runtime proxy
|
|
18
|
-
* at `/v1/assistants/<id>/<path:rest>` is what forwards arbitrary runtime
|
|
19
|
-
* paths to the cloud runtime — vembda's unified proxy bootstraps the
|
|
20
|
-
* guardian token internally for the runtime call. From the CLI side it's
|
|
21
|
-
* user-session auth.
|
|
22
|
-
*
|
|
23
|
-
* The `subpath` is appended to the migrations namespace verbatim
|
|
24
|
-
* (e.g. `"export-to-gcs"`, `"import-from-gcs"`, `\`jobs/${jobId}\``).
|
|
25
|
-
*/
|
|
26
|
-
export function resolveRuntimeMigrationUrl(
|
|
27
|
-
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
28
|
-
subpath: string,
|
|
29
|
-
): string {
|
|
30
|
-
if (isCloudHome(entry.cloud)) {
|
|
31
|
-
return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/migrations/${subpath}`;
|
|
32
|
-
}
|
|
33
|
-
return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
7
|
/**
|
|
37
8
|
* Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
|
|
38
9
|
* taking the assistant's topology into account.
|
|
@@ -1,17 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Platform (cloud="cadet") half of the pre-teleport safety backup.
|
|
3
|
-
*
|
|
4
|
-
* A platform source is snapshotted through Django's user-facing backup
|
|
5
|
-
* endpoint, which cuts a PVC VolumeSnapshot via vembda. The POST returns as
|
|
6
|
-
* soon as the snapshot object exists; it becomes restorable asynchronously,
|
|
7
|
-
* so {@link createPlatformBackup} polls the listing until the new snapshot
|
|
8
|
-
* reports `ready_to_use` before returning. A snapshot that never becomes
|
|
9
|
-
* ready within the timeout is a backup failure, not a success.
|
|
10
|
-
*
|
|
11
|
-
* Local and docker sources are backed up host-side by `backup-ops` instead,
|
|
12
|
-
* so the restore point survives the source's retirement. Policy shared with
|
|
13
|
-
* the web teleport lives in `@mycadet/local-mode/teleport-backup-policy`.
|
|
14
|
-
*/
|
|
1
|
+
/** Cloud API safety backups use session auth and wait for a restorable root tar. */
|
|
15
2
|
|
|
16
3
|
import {
|
|
17
4
|
MANAGED_BACKUP_READY_POLL_INTERVAL_MS,
|
|
@@ -93,7 +80,7 @@ async function fetchPlatformBackups(
|
|
|
93
80
|
}
|
|
94
81
|
|
|
95
82
|
/**
|
|
96
|
-
* `created_at` of every restorable
|
|
83
|
+
* `created_at` of every restorable root archive of a cloud assistant:
|
|
97
84
|
* `GET /v1/assistants/<id>/backups/`.
|
|
98
85
|
*/
|
|
99
86
|
export async function listPlatformBackups(
|
|
@@ -109,7 +96,7 @@ export interface CreatePlatformBackupOptions {
|
|
|
109
96
|
}
|
|
110
97
|
|
|
111
98
|
/**
|
|
112
|
-
*
|
|
99
|
+
* Capture a root archive of a cloud assistant and wait until it is
|
|
113
100
|
* restorable: `POST /v1/assistants/<id>/backups/`, then re-list until the
|
|
114
101
|
* returned `snapshot_name` reports `ready_to_use`. Throws if the POST fails
|
|
115
102
|
* or the snapshot is not ready within `timeoutMs`.
|
package/src/lib/tunnel.ts
CHANGED
|
@@ -20,18 +20,16 @@ import {
|
|
|
20
20
|
pullImage,
|
|
21
21
|
startContainers,
|
|
22
22
|
stopContainers,
|
|
23
|
+
sleepContainers,
|
|
24
|
+
wakeContainers,
|
|
23
25
|
} from "./docker.js";
|
|
24
26
|
import { getStateDir } from "./environments/paths.js";
|
|
25
27
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
26
28
|
import { loadGuardianToken } from "./guardian-token.js";
|
|
27
29
|
import { classifyReadyzResponse } from "./http-client.js";
|
|
28
|
-
import {
|
|
30
|
+
import { resolveImageRefs } from "./cloud-releases.js";
|
|
29
31
|
import { z } from "zod";
|
|
30
32
|
|
|
31
|
-
const GatewayHealthSchema = z.object({
|
|
32
|
-
version: z.string().optional(),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
33
|
const MigrationGateBodySchema = z.object({
|
|
36
34
|
status: z.string().optional(),
|
|
37
35
|
reason: z.string().optional(),
|
|
@@ -42,7 +40,7 @@ import {
|
|
|
42
40
|
type ServiceName,
|
|
43
41
|
} from "./statefulset.js";
|
|
44
42
|
import { execOutput } from "./step-runner.js";
|
|
45
|
-
import { compareVersions
|
|
43
|
+
import { compareVersions } from "./version-compat.js";
|
|
46
44
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
47
45
|
|
|
48
46
|
// ---------------------------------------------------------------------------
|
|
@@ -231,7 +229,7 @@ export function buildReplayEnv(
|
|
|
231
229
|
interface ReplayState {
|
|
232
230
|
bootstrapSecret: string | undefined;
|
|
233
231
|
cesServiceToken: string;
|
|
234
|
-
signingKey
|
|
232
|
+
signingKey?: string;
|
|
235
233
|
extraAssistantEnv: Record<string, string>;
|
|
236
234
|
extraGatewayEnv: Record<string, string>;
|
|
237
235
|
}
|
|
@@ -239,8 +237,9 @@ interface ReplayState {
|
|
|
239
237
|
/**
|
|
240
238
|
* Derive the secrets and replay env for replacement containers from
|
|
241
239
|
* already-captured assistant/gateway envs. GUARDIAN_BOOTSTRAP_SECRET is only
|
|
242
|
-
* set on the gateway; CES_SERVICE_TOKEN
|
|
243
|
-
*
|
|
240
|
+
* set on the gateway; CES_SERVICE_TOKEN can be generated for older instances.
|
|
241
|
+
* An absent signing-key override stays absent so replacements use the persisted
|
|
242
|
+
* gateway root rather than rotate its identity. CADET_DEVICE_ID is
|
|
244
243
|
* backfilled on the gateway from the host, and the assistant inherits the
|
|
245
244
|
* gateway's value (captured values win) so the pair always matches.
|
|
246
245
|
*/
|
|
@@ -259,7 +258,8 @@ export function buildReplayState(
|
|
|
259
258
|
cesServiceToken:
|
|
260
259
|
capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex"),
|
|
261
260
|
signingKey:
|
|
262
|
-
|
|
261
|
+
gatewayEnv["ACTOR_TOKEN_SIGNING_KEY"] ||
|
|
262
|
+
capturedEnv["ACTOR_TOKEN_SIGNING_KEY"],
|
|
263
263
|
extraAssistantEnv,
|
|
264
264
|
extraGatewayEnv,
|
|
265
265
|
};
|
|
@@ -287,28 +287,6 @@ export async function captureReplayState(
|
|
|
287
287
|
return buildReplayState(capturedEnv, gatewayEnv);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
/**
|
|
291
|
-
* Best-effort fetch of the running service group version from the gateway
|
|
292
|
-
* `/healthz` endpoint. Returns `undefined` when the endpoint is
|
|
293
|
-
* unreachable or does not include a version field.
|
|
294
|
-
*/
|
|
295
|
-
export async function fetchCurrentVersion(
|
|
296
|
-
runtimeUrl: string,
|
|
297
|
-
): Promise<string | undefined> {
|
|
298
|
-
try {
|
|
299
|
-
const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
|
|
300
|
-
signal: AbortSignal.timeout(5000),
|
|
301
|
-
});
|
|
302
|
-
if (resp.ok) {
|
|
303
|
-
const parsed = GatewayHealthSchema.safeParse(await resp.json());
|
|
304
|
-
return parsed.success ? parsed.data.version : undefined;
|
|
305
|
-
}
|
|
306
|
-
} catch {
|
|
307
|
-
// Best-effort
|
|
308
|
-
}
|
|
309
|
-
return undefined;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
290
|
/**
|
|
313
291
|
* Best-effort fetch of the assistant's configured public ingress URL from the
|
|
314
292
|
* gateway `integrations/ingress/config` endpoint. Returns `undefined` when
|
|
@@ -344,41 +322,6 @@ export async function fetchAssistantIngressUrl(
|
|
|
344
322
|
return undefined;
|
|
345
323
|
}
|
|
346
324
|
|
|
347
|
-
/**
|
|
348
|
-
* Determine the version that was running before the current one.
|
|
349
|
-
*
|
|
350
|
-
* Checks (in order):
|
|
351
|
-
* 1. `entry.previousVersion` (saved by the upgrade flow from health).
|
|
352
|
-
* 2. The releases list from the cloud API — finds the version
|
|
353
|
-
* immediately before `currentVersion`.
|
|
354
|
-
*
|
|
355
|
-
* Returns `undefined` when neither source yields a result.
|
|
356
|
-
*/
|
|
357
|
-
export async function fetchPreviousVersion(
|
|
358
|
-
currentVersion: string | undefined,
|
|
359
|
-
previousVersionFromLockfile: string | undefined,
|
|
360
|
-
): Promise<string | undefined> {
|
|
361
|
-
// 1. Lockfile-cached value (written during upgrade from health endpoint)
|
|
362
|
-
if (previousVersionFromLockfile) return previousVersionFromLockfile;
|
|
363
|
-
|
|
364
|
-
// 2. Derive from releases list
|
|
365
|
-
if (!currentVersion) return undefined;
|
|
366
|
-
const releases = await fetchReleases();
|
|
367
|
-
if (!releases) return undefined;
|
|
368
|
-
|
|
369
|
-
const normalizedCurrent = stripVersionPrefix(currentVersion);
|
|
370
|
-
|
|
371
|
-
// Releases are ordered newest-first; find the entry right after the
|
|
372
|
-
// current version (i.e. the one that was running before the upgrade).
|
|
373
|
-
const idx = releases.findIndex(
|
|
374
|
-
(r) => stripVersionPrefix(r.version ?? "") === normalizedCurrent,
|
|
375
|
-
);
|
|
376
|
-
if (idx >= 0 && idx + 1 < releases.length) {
|
|
377
|
-
return releases[idx + 1].version;
|
|
378
|
-
}
|
|
379
|
-
return undefined;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
325
|
/**
|
|
383
326
|
* Poll the gateway `/readyz` endpoint until the assistant reports migrations
|
|
384
327
|
* ready or the timeout elapses. Returns whether the assistant became ready.
|
|
@@ -577,12 +520,8 @@ export async function rollbackMigrations(
|
|
|
577
520
|
}
|
|
578
521
|
|
|
579
522
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* exemption admits it, and the import replaces the DB wholesale), then
|
|
583
|
-
* restart the containers — the failed readiness latch only clears on
|
|
584
|
-
* restart — and re-check readiness. Shared by the upgrade failure branch
|
|
585
|
-
* and performDockerRollback's auto-rollback.
|
|
523
|
+
* Restore the host root archive for a reverted stack with failed migrations,
|
|
524
|
+
* restart its containers, and check readiness before resuming the installation.
|
|
586
525
|
*/
|
|
587
526
|
export async function attemptFailedStateRestore(opts: {
|
|
588
527
|
runtimeUrl: string;
|
|
@@ -601,7 +540,7 @@ export async function attemptFailedStateRestore(opts: {
|
|
|
601
540
|
opts.runtimeUrl,
|
|
602
541
|
opts.assistantId,
|
|
603
542
|
opts.backupPath,
|
|
604
|
-
{
|
|
543
|
+
{ resume: false },
|
|
605
544
|
);
|
|
606
545
|
if (!restored) {
|
|
607
546
|
return { restored: false, ready: false };
|
|
@@ -609,6 +548,7 @@ export async function attemptFailedStateRestore(opts: {
|
|
|
609
548
|
console.log(
|
|
610
549
|
" Backup imported — restarting containers to complete recovery...",
|
|
611
550
|
);
|
|
551
|
+
await sleepContainers(opts.res);
|
|
612
552
|
await stopContainers(opts.res);
|
|
613
553
|
await startContainers(opts.containerOptions, (msg) => console.log(msg));
|
|
614
554
|
return { restored: true, ready: await waitForReady(opts.runtimeUrl) };
|
|
@@ -619,28 +559,29 @@ export async function attemptFailedStateRestore(opts: {
|
|
|
619
559
|
// ---------------------------------------------------------------------------
|
|
620
560
|
|
|
621
561
|
export interface PerformDockerRollbackOptions {
|
|
562
|
+
/** Root archive must be installed before the target runtime starts. */
|
|
563
|
+
backupPath: string;
|
|
622
564
|
/** Specific version to roll back to. */
|
|
623
565
|
targetVersion?: string;
|
|
624
566
|
}
|
|
625
567
|
|
|
626
568
|
/**
|
|
627
|
-
*
|
|
628
|
-
* (targeted version rollback) and `restore.ts` (version + data restore).
|
|
569
|
+
* Switch Docker runtime versions as part of a versioned backup restore.
|
|
629
570
|
*
|
|
630
571
|
* This function handles the full lifecycle:
|
|
631
572
|
* - Version validation (target must be older than current)
|
|
632
573
|
* - Image resolution and pulling
|
|
633
|
-
* -
|
|
574
|
+
* - Post-swap migration rollback to the target runtime's registry ceiling
|
|
634
575
|
* - Container stop/start with target images
|
|
635
576
|
* - Readiness check
|
|
636
|
-
* - Lockfile update with
|
|
577
|
+
* - Lockfile update with the running images
|
|
637
578
|
* - Auto-rollback on failure
|
|
638
579
|
*/
|
|
639
580
|
export async function performDockerRollback(
|
|
640
581
|
entry: AssistantEntry,
|
|
641
582
|
options: PerformDockerRollbackOptions,
|
|
642
583
|
): Promise<void> {
|
|
643
|
-
const { targetVersion } = options;
|
|
584
|
+
const { targetVersion, backupPath } = options;
|
|
644
585
|
|
|
645
586
|
if (!targetVersion) {
|
|
646
587
|
throw new Error("targetVersion is required for performDockerRollback");
|
|
@@ -708,21 +649,6 @@ export async function performDockerRollback(
|
|
|
708
649
|
}
|
|
709
650
|
}
|
|
710
651
|
|
|
711
|
-
// Persist rollback state to lockfile BEFORE any destructive changes
|
|
712
|
-
if (entry.containerInfo) {
|
|
713
|
-
const rollbackEntry: AssistantEntry = {
|
|
714
|
-
...entry,
|
|
715
|
-
previousContainerInfo: { ...entry.containerInfo },
|
|
716
|
-
previousVersion: currentVersion,
|
|
717
|
-
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
718
|
-
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
719
|
-
};
|
|
720
|
-
saveAssistantEntry(rollbackEntry);
|
|
721
|
-
if (currentVersion) {
|
|
722
|
-
console.log(` Saved rollback state: ${currentVersion}\n`);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
|
|
726
652
|
// Record rollback start in workspace git history
|
|
727
653
|
await commitWorkspaceViaGateway(
|
|
728
654
|
entry.runtimeUrl,
|
|
@@ -747,15 +673,17 @@ export async function performDockerRollback(
|
|
|
747
673
|
entry.runtimeUrl,
|
|
748
674
|
entry.assistantId,
|
|
749
675
|
{
|
|
750
|
-
prefix: `${entry.assistantId}-pre-
|
|
676
|
+
prefix: `${entry.assistantId}-pre-rollback`,
|
|
751
677
|
description: `Pre-rollback snapshot before ${currentVersion ?? "unknown"} → ${targetVersion}`,
|
|
752
678
|
},
|
|
753
679
|
);
|
|
754
680
|
if (preRollbackBackupPath) {
|
|
755
681
|
console.log(` Backup saved: ${preRollbackBackupPath}\n`);
|
|
756
|
-
pruneOldBackups(entry.assistantId, 3);
|
|
682
|
+
pruneOldBackups(entry.assistantId, 3, "pre-rollback");
|
|
757
683
|
} else {
|
|
758
|
-
|
|
684
|
+
throw new Error(
|
|
685
|
+
"Pre-rollback root backup failed; rollback aborted before replacing services.",
|
|
686
|
+
);
|
|
759
687
|
}
|
|
760
688
|
|
|
761
689
|
const {
|
|
@@ -831,46 +759,47 @@ export async function performDockerRollback(
|
|
|
831
759
|
|
|
832
760
|
// Stop containers, migrate security files, start with target images
|
|
833
761
|
console.log("🛑 Stopping existing containers...");
|
|
762
|
+
await sleepContainers(res);
|
|
834
763
|
await stopContainers(res);
|
|
835
764
|
console.log("✅ Containers stopped\n");
|
|
836
765
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
766
|
+
let ready = false;
|
|
767
|
+
try {
|
|
768
|
+
console.log("Creating stopped containers with the target version...");
|
|
769
|
+
await startContainers(
|
|
770
|
+
{
|
|
771
|
+
signingKey,
|
|
772
|
+
bootstrapSecret,
|
|
773
|
+
cesServiceToken,
|
|
774
|
+
extraAssistantEnv,
|
|
775
|
+
extraGatewayEnv,
|
|
776
|
+
gatewayPort,
|
|
777
|
+
assistantPort,
|
|
778
|
+
imageTags: targetImageTags,
|
|
779
|
+
instanceName,
|
|
780
|
+
res,
|
|
781
|
+
createOnly: true,
|
|
782
|
+
},
|
|
783
|
+
(msg) => console.log(msg),
|
|
784
|
+
);
|
|
785
|
+
if (
|
|
786
|
+
!(await restoreBackup(entry.runtimeUrl, entry.assistantId, backupPath, {
|
|
787
|
+
resume: false,
|
|
788
|
+
}))
|
|
789
|
+
)
|
|
790
|
+
throw new Error("Target backup roots could not be restored.");
|
|
791
|
+
await wakeContainers(res);
|
|
792
|
+
console.log("Waiting for restored assistant to become ready...");
|
|
793
|
+
ready = await waitForReady(entry.runtimeUrl);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
console.error(
|
|
796
|
+
`Versioned restore failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
858
799
|
|
|
859
800
|
if (ready) {
|
|
860
801
|
// Success path
|
|
861
802
|
|
|
862
|
-
// Post-swap migration rollback: ask the now-running old daemon to roll
|
|
863
|
-
// back any migrations above its own registry ceiling.
|
|
864
|
-
await rollbackMigrations(
|
|
865
|
-
entry.runtimeUrl,
|
|
866
|
-
entry.assistantId,
|
|
867
|
-
undefined,
|
|
868
|
-
undefined,
|
|
869
|
-
true,
|
|
870
|
-
);
|
|
871
|
-
|
|
872
|
-
// Capture new digests from the rolled-back containers
|
|
873
|
-
// Swap current/previous state to enable "rollback the rollback"
|
|
874
803
|
const updatedEntry: AssistantEntry = {
|
|
875
804
|
...entry,
|
|
876
805
|
containerInfo: {
|
|
@@ -879,10 +808,6 @@ export async function performDockerRollback(
|
|
|
879
808
|
cesImage: targetImageTags["credential-executor"],
|
|
880
809
|
assistantPort,
|
|
881
810
|
},
|
|
882
|
-
previousContainerInfo: entry.containerInfo,
|
|
883
|
-
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
884
|
-
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
885
|
-
preUpgradeBackupPath: undefined,
|
|
886
811
|
};
|
|
887
812
|
saveAssistantEntry(updatedEntry);
|
|
888
813
|
|
|
@@ -950,6 +875,7 @@ export async function performDockerRollback(
|
|
|
950
875
|
);
|
|
951
876
|
}
|
|
952
877
|
|
|
878
|
+
await sleepContainers(res);
|
|
953
879
|
await stopContainers(res);
|
|
954
880
|
|
|
955
881
|
await startContainers(
|
|
@@ -1013,13 +939,13 @@ export async function performDockerRollback(
|
|
|
1013
939
|
entry.runtimeUrl,
|
|
1014
940
|
entry.assistantId,
|
|
1015
941
|
preRollbackBackupPath,
|
|
1016
|
-
{
|
|
942
|
+
{},
|
|
1017
943
|
);
|
|
1018
944
|
if (restored) {
|
|
1019
945
|
console.log(" ✅ Data restored successfully\n");
|
|
1020
946
|
} else {
|
|
1021
|
-
|
|
1022
|
-
|
|
947
|
+
throw new Error(
|
|
948
|
+
`Root restore failed; rollback recovery is incomplete. Backup: ${preRollbackBackupPath}`,
|
|
1023
949
|
);
|
|
1024
950
|
}
|
|
1025
951
|
}
|
|
@@ -1038,10 +964,6 @@ export async function performDockerRollback(
|
|
|
1038
964
|
currentImageRefs["credential-executor"],
|
|
1039
965
|
assistantPort,
|
|
1040
966
|
},
|
|
1041
|
-
previousContainerInfo: undefined,
|
|
1042
|
-
previousDbMigrationVersion: undefined,
|
|
1043
|
-
previousWorkspaceMigrationId: undefined,
|
|
1044
|
-
preUpgradeBackupPath: undefined,
|
|
1045
967
|
};
|
|
1046
968
|
saveAssistantEntry(revertedEntry);
|
|
1047
969
|
|
package/src/lib/web-dist.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stripVersionPrefix } from "./version-compat.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isValidReleaseVersion } from "@mycadet/local-mode";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
5
|
import { createRequire } from "node:module";
|
|
3
6
|
import { dirname, join } from "node:path";
|
|
4
7
|
|
|
@@ -46,3 +49,78 @@ export function findWebDistDir(options: WebDistOptions = {}): string | null {
|
|
|
46
49
|
}
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
52
|
+
|
|
53
|
+
interface AppDistOptions {
|
|
54
|
+
execPath?: string;
|
|
55
|
+
platform?: NodeJS.Platform;
|
|
56
|
+
resolvePackage?: () => string;
|
|
57
|
+
startDir?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Locate the pre-built standalone app shell bundle for installed, packaged,
|
|
62
|
+
* or source CLIs. Resolution mirrors {@link findWebDistDir}: the published
|
|
63
|
+
* `@mycadet/app` package carries `dist/standalone`, packaged Windows CLIs
|
|
64
|
+
* ship it beside the binary, and source checkouts fall back to the build
|
|
65
|
+
* output in the repo tree.
|
|
66
|
+
*/
|
|
67
|
+
export function findAppDistDir(options: AppDistOptions = {}): string | null {
|
|
68
|
+
try {
|
|
69
|
+
const packageJson = (
|
|
70
|
+
options.resolvePackage ??
|
|
71
|
+
(() => loadModule.resolve("@mycadet/app/package.json"))
|
|
72
|
+
)();
|
|
73
|
+
const distDir = join(dirname(packageJson), "dist", "standalone");
|
|
74
|
+
if (existsSync(join(distDir, "index.html"))) {
|
|
75
|
+
return distDir;
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
|
|
79
|
+
if ((options.platform ?? process.platform) === "win32") {
|
|
80
|
+
const runtimeDir = dirname(options.execPath ?? process.execPath);
|
|
81
|
+
const packagedDist = join(runtimeDir, "app-dist");
|
|
82
|
+
if (existsSync(join(packagedDist, "index.html"))) {
|
|
83
|
+
return packagedDist;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let dir = options.startDir ?? import.meta.dir;
|
|
88
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
89
|
+
const candidate = join(
|
|
90
|
+
dir,
|
|
91
|
+
"clients",
|
|
92
|
+
"app",
|
|
93
|
+
"dist",
|
|
94
|
+
"standalone",
|
|
95
|
+
"index.html",
|
|
96
|
+
);
|
|
97
|
+
if (existsSync(candidate)) {
|
|
98
|
+
return dirname(candidate);
|
|
99
|
+
}
|
|
100
|
+
const parent = dirname(dir);
|
|
101
|
+
if (parent === dir) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
dir = parent;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const WebReleaseSchema = z.object({
|
|
110
|
+
version: z
|
|
111
|
+
.string()
|
|
112
|
+
.min(1)
|
|
113
|
+
.refine((value) => value !== "latest" && isValidReleaseVersion(value)),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export function readWebReleaseVersion(distDir: string): string | undefined {
|
|
117
|
+
try {
|
|
118
|
+
const payload: unknown = JSON.parse(
|
|
119
|
+
readFileSync(join(distDir, "release.json"), "utf8"),
|
|
120
|
+
);
|
|
121
|
+
const parsed = WebReleaseSchema.safeParse(payload);
|
|
122
|
+
return parsed.success ? stripVersionPrefix(parsed.data.version) : undefined;
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|