@mycadet/cli 0.12.10 → 0.13.0
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 +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +3 -2
- 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-login-poll.test.ts +69 -0
- 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 +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +233 -36
- 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 +8 -7
- 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 +17 -7
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +13 -3
- 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__/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 +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -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 +43 -4
- package/src/commands/upgrade.ts +84 -157
- 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 +20 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- 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 +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +254 -36
- 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 +86 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +23 -1
- package/src/lib/xdg-log.ts +11 -0
- 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,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { findAvailableIngressPort } from "../lib/nginx-ingress";
|
|
5
|
+
import { edgeServedRemote } from "../lib/nginx-ingress";
|
|
6
|
+
import { summarizeStartupFailure } from "../lib/tunnel";
|
|
7
|
+
|
|
8
|
+
function portOf(server: ReturnType<typeof createServer>): number {
|
|
9
|
+
const addr = server.address();
|
|
10
|
+
return typeof addr === "object" && addr !== null ? addr.port : -1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("summarizeStartupFailure", () => {
|
|
14
|
+
test("names an nginx emerg line over generic log noise", () => {
|
|
15
|
+
const log = [
|
|
16
|
+
"Starting ngrok tunnel to localhost:7840...",
|
|
17
|
+
"nginx: [emerg] bind() to 0.0.0.0:7840 failed (98: Address already in use)",
|
|
18
|
+
].join("\n");
|
|
19
|
+
expect(summarizeStartupFailure(log)).toContain("[emerg]");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("names an ngrok error code", () => {
|
|
23
|
+
expect(
|
|
24
|
+
summarizeStartupFailure(
|
|
25
|
+
"agent started\nERR_NGROK_4018: Authentication failed",
|
|
26
|
+
),
|
|
27
|
+
).toContain("ERR_NGROK_4018");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the last matching line wins", () => {
|
|
31
|
+
const log = [
|
|
32
|
+
"nginx: [emerg] bind() to 0.0.0.0:7840 failed (98: Address already in use)",
|
|
33
|
+
"nginx: [emerg] bind() to 0.0.0.0:7841 failed (98: Address already in use)",
|
|
34
|
+
].join("\n");
|
|
35
|
+
expect(summarizeStartupFailure(log)).toContain(":7841");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("falls back to the last non-empty line when nothing error-shaped appears", () => {
|
|
39
|
+
expect(summarizeStartupFailure("first line\nsome plain output")).toBe(
|
|
40
|
+
"some plain output",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an empty log summarizes to null", () => {
|
|
45
|
+
expect(summarizeStartupFailure("")).toBeNull();
|
|
46
|
+
expect(summarizeStartupFailure("\n \n")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("long lines are capped", () => {
|
|
50
|
+
const long = `nginx: [emerg] ${"x".repeat(400)}`;
|
|
51
|
+
const summary = summarizeStartupFailure(long) ?? "";
|
|
52
|
+
expect(summary.length).toBeLessThanOrEqual(201);
|
|
53
|
+
expect(summary.endsWith("…")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("findAvailableIngressPort", () => {
|
|
58
|
+
test("skips a port a foreign listener already holds", async () => {
|
|
59
|
+
const blocker = createServer();
|
|
60
|
+
await new Promise<void>((resolve) => {
|
|
61
|
+
blocker.listen(0, "127.0.0.1", resolve);
|
|
62
|
+
});
|
|
63
|
+
const occupied = portOf(blocker);
|
|
64
|
+
try {
|
|
65
|
+
const port = await findAvailableIngressPort(occupied);
|
|
66
|
+
expect(port).toBe(occupied + 1);
|
|
67
|
+
} finally {
|
|
68
|
+
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns null when the whole band is busy", async () => {
|
|
73
|
+
const scout = createServer();
|
|
74
|
+
await new Promise<void>((resolve) => scout.listen(0, "127.0.0.1", resolve));
|
|
75
|
+
const base = portOf(scout);
|
|
76
|
+
await new Promise<void>((resolve) => scout.close(() => resolve()));
|
|
77
|
+
// Hold both ports of a 2-port band with fresh listeners.
|
|
78
|
+
const first = createServer();
|
|
79
|
+
const second = createServer();
|
|
80
|
+
await new Promise<void>((resolve) =>
|
|
81
|
+
first.listen(base, "127.0.0.1", resolve),
|
|
82
|
+
);
|
|
83
|
+
await new Promise<void>((resolve) =>
|
|
84
|
+
second.listen(base + 1, "127.0.0.1", resolve),
|
|
85
|
+
);
|
|
86
|
+
try {
|
|
87
|
+
expect(await findAvailableIngressPort(base, 2)).toBeNull();
|
|
88
|
+
} finally {
|
|
89
|
+
await new Promise<void>((resolve) => first.close(() => resolve()));
|
|
90
|
+
await new Promise<void>((resolve) => second.close(() => resolve()));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("edgeServedRemote", () => {
|
|
96
|
+
test("a docker cadet is stamped truthfully", () => {
|
|
97
|
+
expect(edgeServedRemote("docker")).toBe("docker");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a custom cadet keeps its kind", () => {
|
|
101
|
+
expect(edgeServedRemote("custom")).toBe("custom");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("local, paired, cloud, and unknown entries read as custom so the remote-gateway machinery always runs", () => {
|
|
105
|
+
expect(edgeServedRemote("local")).toBe("custom");
|
|
106
|
+
expect(edgeServedRemote("paired")).toBe("custom");
|
|
107
|
+
expect(edgeServedRemote("cloud")).toBe("custom");
|
|
108
|
+
expect(edgeServedRemote(undefined)).toBe("custom");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import * as processes from "../lib/process.js";
|
|
12
|
+
import {
|
|
13
|
+
TAILSCALE_PROVIDER,
|
|
14
|
+
stopTailscaleTunnel,
|
|
15
|
+
type TailscaleDeps,
|
|
16
|
+
} from "../lib/tunnels/tailscale.js";
|
|
17
|
+
import * as ngrok from "../lib/tunnels/ngrok.js";
|
|
18
|
+
import { tunnel } from "../commands/tunnel.js";
|
|
19
|
+
import {
|
|
20
|
+
readRootProcesses,
|
|
21
|
+
saveAssistantEntry,
|
|
22
|
+
stampRootProcess,
|
|
23
|
+
findAssistantByName,
|
|
24
|
+
} from "../lib/assistant-config.js";
|
|
25
|
+
|
|
26
|
+
const originalArgv = process.argv;
|
|
27
|
+
const originalLock = process.env.CADET_LOCKFILE_DIR;
|
|
28
|
+
const originalConfig = process.env.XDG_CONFIG_HOME;
|
|
29
|
+
let root = "";
|
|
30
|
+
let workspace = "";
|
|
31
|
+
let stop: ReturnType<typeof spyOn<typeof processes, "stopProcess">>;
|
|
32
|
+
let identity: ReturnType<
|
|
33
|
+
typeof spyOn<typeof processes, "readProcessCommandLine">
|
|
34
|
+
>;
|
|
35
|
+
let logs: ReturnType<typeof spyOn<typeof console, "log">>;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
root = mkdtempSync(join(tmpdir(), "cadet-tunnel-stop-"));
|
|
39
|
+
workspace = join(root, "instance", "workspace");
|
|
40
|
+
mkdirSync(workspace, { recursive: true });
|
|
41
|
+
process.env.CADET_LOCKFILE_DIR = root;
|
|
42
|
+
process.env.XDG_CONFIG_HOME = root;
|
|
43
|
+
saveAssistantEntry({
|
|
44
|
+
assistantId: "fixture",
|
|
45
|
+
name: "fixture",
|
|
46
|
+
cloud: "local",
|
|
47
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
48
|
+
ingressUrl: "https://old.ngrok.app",
|
|
49
|
+
resources: {
|
|
50
|
+
instanceDir: join(root, "instance"),
|
|
51
|
+
gatewayPort: 7830,
|
|
52
|
+
daemonPort: 7821,
|
|
53
|
+
cesPort: 8090,
|
|
54
|
+
qdrantPort: 6333,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(workspace, "config.json"),
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
ingress: {
|
|
61
|
+
publicBaseUrl: "https://old.ngrok.app",
|
|
62
|
+
lastTunnel: {
|
|
63
|
+
provider: "ngrok",
|
|
64
|
+
publicBaseUrl: "https://old.ngrok.app",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
stampRootProcess("tunnel", {
|
|
70
|
+
pid: 424242,
|
|
71
|
+
provider: "ngrok",
|
|
72
|
+
assistantId: "fixture",
|
|
73
|
+
startedAt: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
stampRootProcess("app", { pid: 424243, startedAt: new Date().toISOString() });
|
|
76
|
+
ngrok.saveNgrokDomain("old.ngrok.app");
|
|
77
|
+
stop = spyOn(processes, "stopProcess").mockResolvedValue(true);
|
|
78
|
+
identity = spyOn(processes, "readProcessCommandLine").mockReturnValue(
|
|
79
|
+
"ngrok http 7840",
|
|
80
|
+
);
|
|
81
|
+
logs = spyOn(console, "log").mockImplementation(() => {});
|
|
82
|
+
process.argv = ["bun", "cadet", "tunnel", "stop", "fixture"];
|
|
83
|
+
});
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
stop.mockRestore();
|
|
86
|
+
identity.mockRestore();
|
|
87
|
+
logs.mockRestore();
|
|
88
|
+
process.argv = originalArgv;
|
|
89
|
+
if (originalLock === undefined) delete process.env.CADET_LOCKFILE_DIR;
|
|
90
|
+
else process.env.CADET_LOCKFILE_DIR = originalLock;
|
|
91
|
+
if (originalConfig === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
92
|
+
else process.env.XDG_CONFIG_HOME = originalConfig;
|
|
93
|
+
rmSync(root, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("stop terminates the owned agent and clears ingress, retaining nginx and domain; repeated stop is harmless", async () => {
|
|
97
|
+
await tunnel();
|
|
98
|
+
expect(stop).toHaveBeenCalledWith(424242, "tunnel");
|
|
99
|
+
expect(readRootProcesses().tunnel).toBeUndefined();
|
|
100
|
+
expect(readRootProcesses().app?.pid).toBe(424243);
|
|
101
|
+
expect(findAssistantByName("fixture")?.ingressUrl).toBeUndefined();
|
|
102
|
+
expect(
|
|
103
|
+
JSON.parse(readFileSync(join(workspace, "config.json"), "utf8")).ingress
|
|
104
|
+
.publicBaseUrl,
|
|
105
|
+
).toBeUndefined();
|
|
106
|
+
expect(ngrok.loadNgrokDomain()).toBe("old.ngrok.app");
|
|
107
|
+
await tunnel();
|
|
108
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
test("a recycled PID is not signaled and its stale record is cleared", async () => {
|
|
111
|
+
identity.mockReturnValue("unrelated-process");
|
|
112
|
+
await tunnel();
|
|
113
|
+
expect(stop).not.toHaveBeenCalled();
|
|
114
|
+
expect(readRootProcesses().tunnel).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
test("another assistant's tunnel is left intact", async () => {
|
|
117
|
+
stampRootProcess("tunnel", {
|
|
118
|
+
pid: 424242,
|
|
119
|
+
provider: "ngrok",
|
|
120
|
+
assistantId: "another",
|
|
121
|
+
startedAt: new Date().toISOString(),
|
|
122
|
+
});
|
|
123
|
+
await expect(tunnel()).rejects.toThrow("belongs to 'another'");
|
|
124
|
+
expect(stop).not.toHaveBeenCalled();
|
|
125
|
+
expect(readRootProcesses().tunnel?.assistantId).toBe("another");
|
|
126
|
+
});
|
|
127
|
+
test("failure to stop retains process and ingress state", async () => {
|
|
128
|
+
stop.mockResolvedValue(false);
|
|
129
|
+
await expect(tunnel()).rejects.toThrow("Could not stop ngrok");
|
|
130
|
+
expect(readRootProcesses().tunnel?.pid).toBe(424242);
|
|
131
|
+
expect(findAssistantByName("fixture")?.ingressUrl).toBe(
|
|
132
|
+
"https://old.ngrok.app",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("Cloudflare dispatch stops cloudflared and clears its ingress", async () => {
|
|
137
|
+
stampRootProcess("tunnel", {
|
|
138
|
+
pid: 424242,
|
|
139
|
+
provider: "cloudflare",
|
|
140
|
+
assistantId: "fixture",
|
|
141
|
+
startedAt: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
identity.mockReturnValue(
|
|
144
|
+
"/usr/local/bin/cloudflared tunnel --url http://localhost:7840",
|
|
145
|
+
);
|
|
146
|
+
await tunnel();
|
|
147
|
+
expect(stop).toHaveBeenCalledWith(424242, "tunnel");
|
|
148
|
+
expect(readRootProcesses().tunnel).toBeUndefined();
|
|
149
|
+
expect(findAssistantByName("fixture")?.ingressUrl).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("Tailscale dispatch disables serve and preserves a separate webhook ingress", async () => {
|
|
153
|
+
stampRootProcess("tunnel", {
|
|
154
|
+
pid: 424242,
|
|
155
|
+
provider: "tailscale",
|
|
156
|
+
previous: {
|
|
157
|
+
pid: 424240,
|
|
158
|
+
provider: "ngrok",
|
|
159
|
+
assistantId: "fixture",
|
|
160
|
+
startedAt: new Date().toISOString(),
|
|
161
|
+
},
|
|
162
|
+
assistantId: "fixture",
|
|
163
|
+
startedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(workspace, "config.json"),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
ingress: {
|
|
169
|
+
publicBaseUrl: "https://old.ngrok.app",
|
|
170
|
+
pairingTunnel: { provider: "tailscale" },
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
const calls: string[][] = [];
|
|
175
|
+
const deps: TailscaleDeps = {
|
|
176
|
+
findBinary: async () => "tailscale",
|
|
177
|
+
run: (_bin, args) => {
|
|
178
|
+
calls.push(args);
|
|
179
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
const provider = spyOn(TAILSCALE_PROVIDER, "stop").mockImplementation(
|
|
183
|
+
(opts) => stopTailscaleTunnel(opts, deps),
|
|
184
|
+
);
|
|
185
|
+
try {
|
|
186
|
+
await tunnel();
|
|
187
|
+
expect(calls).toEqual([["serve", "--https=443", "off"]]);
|
|
188
|
+
expect(stop).not.toHaveBeenCalled();
|
|
189
|
+
expect(readRootProcesses().tunnel?.pid).toBe(424240);
|
|
190
|
+
expect(findAssistantByName("fixture")?.ingressUrl).toBe(
|
|
191
|
+
"https://old.ngrok.app",
|
|
192
|
+
);
|
|
193
|
+
const state = JSON.parse(
|
|
194
|
+
readFileSync(join(workspace, "config.json"), "utf8"),
|
|
195
|
+
);
|
|
196
|
+
expect(state.ingress.publicBaseUrl).toBe("https://old.ngrok.app");
|
|
197
|
+
expect(state.ingress.pairingTunnel).toBeUndefined();
|
|
198
|
+
} finally {
|
|
199
|
+
provider.mockRestore();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("Tailscale shutdown failure preserves the recorded tunnel and ingress", async () => {
|
|
204
|
+
stampRootProcess("tunnel", {
|
|
205
|
+
pid: 424242,
|
|
206
|
+
provider: "tailscale",
|
|
207
|
+
assistantId: "fixture",
|
|
208
|
+
startedAt: new Date().toISOString(),
|
|
209
|
+
});
|
|
210
|
+
const deps: TailscaleDeps = {
|
|
211
|
+
findBinary: async () => "tailscale",
|
|
212
|
+
run: () => ({ status: 1, stdout: "", stderr: "permission denied" }),
|
|
213
|
+
};
|
|
214
|
+
const provider = spyOn(TAILSCALE_PROVIDER, "stop").mockImplementation(
|
|
215
|
+
(opts) => stopTailscaleTunnel(opts, deps),
|
|
216
|
+
);
|
|
217
|
+
try {
|
|
218
|
+
await expect(tunnel()).rejects.toThrow("Could not stop tailscale serve");
|
|
219
|
+
expect(readRootProcesses().tunnel?.provider).toBe("tailscale");
|
|
220
|
+
expect(findAssistantByName("fixture")?.ingressUrl).toBe(
|
|
221
|
+
"https://old.ngrok.app",
|
|
222
|
+
);
|
|
223
|
+
} finally {
|
|
224
|
+
provider.mockRestore();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
@@ -53,6 +53,10 @@ const runCloudflareTunnelMock = mock<
|
|
|
53
53
|
mock.module("../lib/tunnels/cloudflare.js", () => ({
|
|
54
54
|
...realCloudflareTunnel,
|
|
55
55
|
runCloudflareTunnel: runCloudflareTunnelMock,
|
|
56
|
+
CLOUDFLARE_PROVIDER: {
|
|
57
|
+
...realCloudflareTunnel.CLOUDFLARE_PROVIDER,
|
|
58
|
+
run: runCloudflareTunnelMock,
|
|
59
|
+
},
|
|
56
60
|
}));
|
|
57
61
|
|
|
58
62
|
const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
|
|
@@ -61,6 +65,7 @@ const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
|
|
|
61
65
|
mock.module("../lib/tunnels/ngrok", () => ({
|
|
62
66
|
...realNgrok,
|
|
63
67
|
runNgrokTunnel: runNgrokTunnelMock,
|
|
68
|
+
NGROK_PROVIDER: { ...realNgrok.NGROK_PROVIDER, run: runNgrokTunnelMock },
|
|
64
69
|
}));
|
|
65
70
|
|
|
66
71
|
const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
|
|
@@ -69,6 +74,10 @@ const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
|
|
|
69
74
|
mock.module("../lib/tunnels/tailscale.js", () => ({
|
|
70
75
|
...realTailscaleTunnel,
|
|
71
76
|
runTailscaleTunnel: runTailscaleTunnelMock,
|
|
77
|
+
TAILSCALE_PROVIDER: {
|
|
78
|
+
...realTailscaleTunnel.TAILSCALE_PROVIDER,
|
|
79
|
+
run: runTailscaleTunnelMock,
|
|
80
|
+
},
|
|
72
81
|
}));
|
|
73
82
|
|
|
74
83
|
const notifyGatewayOfPublicUrlMock = mock<
|
|
@@ -123,7 +132,7 @@ function makeCloudEntry(assistantId = "cloud-1"): AssistantEntry {
|
|
|
123
132
|
return {
|
|
124
133
|
assistantId,
|
|
125
134
|
runtimeUrl: `https://runtime.example.com/${assistantId}`,
|
|
126
|
-
cloud: "
|
|
135
|
+
cloud: "cloud",
|
|
127
136
|
};
|
|
128
137
|
}
|
|
129
138
|
|
|
@@ -1705,6 +1714,7 @@ describe("tunnel background mode", () => {
|
|
|
1705
1714
|
mock.module("../lib/tunnels/ngrok", () => ({
|
|
1706
1715
|
...realNgrok,
|
|
1707
1716
|
runNgrokTunnel: runNgrokTunnelMock,
|
|
1717
|
+
NGROK_PROVIDER: { ...realNgrok.NGROK_PROVIDER, run: runNgrokTunnelMock },
|
|
1708
1718
|
}));
|
|
1709
1719
|
mock.module("../lib/nginx-ingress.js", () => ({
|
|
1710
1720
|
...realNginxIngress,
|
|
@@ -1780,7 +1790,7 @@ describe("tunnel background mode", () => {
|
|
|
1780
1790
|
|
|
1781
1791
|
expect(logs).toContain("Tunnel established: https://foo.ngrok.app");
|
|
1782
1792
|
expect(logs).toContain("Running in background (pid 9999)");
|
|
1783
|
-
expect(logs).toContain("Stop with:
|
|
1793
|
+
expect(logs).toContain("Stop with: cadet tunnel stop");
|
|
1784
1794
|
|
|
1785
1795
|
// The parent hands the actual tunnel workflow off to the child: it never
|
|
1786
1796
|
// runs the provider or edge setup itself.
|
|
@@ -1818,7 +1828,7 @@ describe("tunnel background mode", () => {
|
|
|
1818
1828
|
const { exited, errors } = await runTunnelExpectingExit1();
|
|
1819
1829
|
|
|
1820
1830
|
expect(exited).toBe(true);
|
|
1821
|
-
expect(errors).toContain("exited during startup
|
|
1831
|
+
expect(errors).toContain("Error (1): tunnel process exited during startup");
|
|
1822
1832
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
1823
1833
|
});
|
|
1824
1834
|
|
|
@@ -55,7 +55,7 @@ mock.module("../lib/assistant-config", () => ({
|
|
|
55
55
|
}));
|
|
56
56
|
|
|
57
57
|
const createBackupMock = mock<typeof backupOps.createBackup>(
|
|
58
|
-
async () => "/tmp/local-pre-upgrade.
|
|
58
|
+
async () => "/tmp/local-pre-upgrade.tar.gz",
|
|
59
59
|
);
|
|
60
60
|
const pruneOldBackupsMock = mock<typeof backupOps.pruneOldBackups>(() => {});
|
|
61
61
|
const restoreBackupMock = mock<typeof backupOps.restoreBackup>(
|
|
@@ -238,7 +238,7 @@ beforeEach(() => {
|
|
|
238
238
|
loadAllAssistantsMock.mockReturnValue([entry]);
|
|
239
239
|
saveAssistantEntryMock.mockReset();
|
|
240
240
|
createBackupMock.mockReset();
|
|
241
|
-
createBackupMock.mockResolvedValue("/tmp/local-pre-upgrade.
|
|
241
|
+
createBackupMock.mockResolvedValue("/tmp/local-pre-upgrade.tar.gz");
|
|
242
242
|
pruneOldBackupsMock.mockReset();
|
|
243
243
|
pruneOldBackupsMock.mockReturnValue(undefined);
|
|
244
244
|
restoreBackupMock.mockReset();
|
|
@@ -372,7 +372,7 @@ describe("cadet upgrade local", () => {
|
|
|
372
372
|
await upgrade();
|
|
373
373
|
|
|
374
374
|
expect(loopbackSafeFetchMock).toHaveBeenCalledWith(
|
|
375
|
-
"http://127.0.0.1:7830/healthz
|
|
375
|
+
"http://127.0.0.1:7830/healthz",
|
|
376
376
|
expect.any(Object),
|
|
377
377
|
);
|
|
378
378
|
expect(createBackupMock).toHaveBeenCalledWith(
|
|
@@ -382,14 +382,6 @@ describe("cadet upgrade local", () => {
|
|
|
382
382
|
prefix: "local-assistant-pre-upgrade",
|
|
383
383
|
}),
|
|
384
384
|
);
|
|
385
|
-
expect(saveAssistantEntryMock).toHaveBeenCalledWith(
|
|
386
|
-
expect.objectContaining({
|
|
387
|
-
previousVersion: "v0.8.11",
|
|
388
|
-
previousDbMigrationVersion: 12,
|
|
389
|
-
previousWorkspaceMigrationId: "011-test",
|
|
390
|
-
preUpgradeBackupPath: "/tmp/local-pre-upgrade.vbundle",
|
|
391
|
-
}),
|
|
392
|
-
);
|
|
393
385
|
expect(stopLocalProcessesMock).toHaveBeenCalledWith(
|
|
394
386
|
expect.objectContaining({
|
|
395
387
|
instanceDir: tempDir,
|
|
@@ -446,6 +438,24 @@ describe("cadet upgrade local", () => {
|
|
|
446
438
|
expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain("upgraded to");
|
|
447
439
|
});
|
|
448
440
|
|
|
441
|
+
test("failed safety backup aborts before runtime installation or service replacement", async () => {
|
|
442
|
+
createBackupMock.mockResolvedValue(null);
|
|
443
|
+
await expect(upgrade()).rejects.toThrow("process.exit(1)");
|
|
444
|
+
expect(ensureLocalRuntimeMock).not.toHaveBeenCalled();
|
|
445
|
+
expect(stopLocalProcessesMock).not.toHaveBeenCalled();
|
|
446
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("failed target startup restores prior runtime selection and all roots before restarting", async () => {
|
|
450
|
+
startLocalDaemonMock.mockRejectedValueOnce(new Error("target startup failed"));
|
|
451
|
+
await expect(upgrade()).rejects.toThrow("process.exit(1)");
|
|
452
|
+
expect(restoreBackupMock).toHaveBeenCalledWith("http://127.0.0.1:7830", "local-assistant", "/tmp/local-pre-upgrade.tar.gz", { resume: false });
|
|
453
|
+
expect(startLocalDaemonMock).toHaveBeenCalledTimes(2);
|
|
454
|
+
expect(startCesMock).toHaveBeenCalledTimes(2);
|
|
455
|
+
expect(stopLocalProcessesMock).toHaveBeenCalledTimes(2);
|
|
456
|
+
expect(waitForReadyMock).toHaveBeenCalled();
|
|
457
|
+
});
|
|
458
|
+
|
|
449
459
|
test("skips restart when the local assistant is already on the target version", async () => {
|
|
450
460
|
loopbackSafeFetchMock.mockResolvedValue({
|
|
451
461
|
ok: true,
|
|
@@ -156,10 +156,10 @@ describe("buildReplayState", () => {
|
|
|
156
156
|
expect(state.signingKey).toBe("sign-key");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
test("generates
|
|
159
|
+
test("generates a service token but preserves file-backed signing identity when env is absent", () => {
|
|
160
160
|
const state = buildReplayState({}, {});
|
|
161
161
|
expect(state.bootstrapSecret).toBeUndefined();
|
|
162
162
|
expect(state.cesServiceToken).toMatch(/^[0-9a-f]{64}$/);
|
|
163
|
-
expect(state.signingKey).
|
|
163
|
+
expect(state.signingKey).toBeUndefined();
|
|
164
164
|
});
|
|
165
165
|
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { afterAll, beforeEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import * as backups from "../lib/backup-ops.js";
|
|
3
|
+
import * as docker from "../lib/docker.js";
|
|
4
|
+
import * as lifecycle from "../lib/upgrade-lifecycle.js";
|
|
5
|
+
import * as config from "../lib/assistant-config.js";
|
|
6
|
+
import { mockCommands } from "./commands-mock.js";
|
|
7
|
+
import * as loopback from "../lib/loopback-fetch.js";
|
|
8
|
+
|
|
9
|
+
const capture = spyOn(backups, "createBackup");
|
|
10
|
+
const images = spyOn(docker, "captureImageRefs");
|
|
11
|
+
const pull = spyOn(docker, "pullImage");
|
|
12
|
+
const wake = spyOn(docker, "wakeContainers");
|
|
13
|
+
const save = spyOn(config, "saveAssistantEntry");
|
|
14
|
+
const restore = spyOn(backups, "restoreBackup");
|
|
15
|
+
const sleep = spyOn(docker, "sleepContainers");
|
|
16
|
+
const stop = spyOn(docker, "stopContainers");
|
|
17
|
+
const start = spyOn(docker, "startContainers");
|
|
18
|
+
const fetch = spyOn(loopback, "loopbackSafeFetch");
|
|
19
|
+
const res = docker.dockerResourceNames("recovery");
|
|
20
|
+
const containerOptions = {
|
|
21
|
+
gatewayPort: 7830,
|
|
22
|
+
assistantPort: 3001,
|
|
23
|
+
imageTags: {
|
|
24
|
+
assistant: "assistant:old",
|
|
25
|
+
gateway: "gateway:old",
|
|
26
|
+
"credential-executor": "ces:old",
|
|
27
|
+
},
|
|
28
|
+
instanceName: "recovery",
|
|
29
|
+
res,
|
|
30
|
+
};
|
|
31
|
+
const events: string[] = [];
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
events.length = 0;
|
|
34
|
+
capture.mockReset();
|
|
35
|
+
images.mockReset();
|
|
36
|
+
pull.mockReset();
|
|
37
|
+
wake.mockReset();
|
|
38
|
+
save.mockReset();
|
|
39
|
+
save.mockImplementation(() => {});
|
|
40
|
+
restore.mockReset();
|
|
41
|
+
sleep.mockReset();
|
|
42
|
+
stop.mockReset();
|
|
43
|
+
start.mockReset();
|
|
44
|
+
fetch.mockReset();
|
|
45
|
+
restore.mockImplementation(async () => {
|
|
46
|
+
events.push("restore-roots");
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
sleep.mockImplementation(async () => {
|
|
50
|
+
events.push("sleep");
|
|
51
|
+
});
|
|
52
|
+
stop.mockImplementation(async () => {
|
|
53
|
+
events.push("remove");
|
|
54
|
+
});
|
|
55
|
+
start.mockImplementation(async () => {
|
|
56
|
+
events.push("start-old-version");
|
|
57
|
+
});
|
|
58
|
+
fetch.mockImplementation(async () => {
|
|
59
|
+
events.push("check-ready");
|
|
60
|
+
return Response.json({ ready: true });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
for (const spy of [
|
|
65
|
+
restore,
|
|
66
|
+
sleep,
|
|
67
|
+
stop,
|
|
68
|
+
start,
|
|
69
|
+
fetch,
|
|
70
|
+
capture,
|
|
71
|
+
images,
|
|
72
|
+
pull,
|
|
73
|
+
wake,
|
|
74
|
+
save,
|
|
75
|
+
])
|
|
76
|
+
spy.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("failed-state recovery restores tar roots while stopped before restarting old services", async () => {
|
|
80
|
+
expect(
|
|
81
|
+
await lifecycle.attemptFailedStateRestore({
|
|
82
|
+
runtimeUrl: "http://127.0.0.1:1",
|
|
83
|
+
assistantId: "recovery",
|
|
84
|
+
backupPath: "pre-upgrade.tar.gz",
|
|
85
|
+
backupLabel: "pre-upgrade",
|
|
86
|
+
res,
|
|
87
|
+
containerOptions,
|
|
88
|
+
}),
|
|
89
|
+
).toEqual({ restored: true, ready: true });
|
|
90
|
+
expect(restore).toHaveBeenCalledWith(
|
|
91
|
+
"http://127.0.0.1:1",
|
|
92
|
+
"recovery",
|
|
93
|
+
"pre-upgrade.tar.gz",
|
|
94
|
+
{ resume: false },
|
|
95
|
+
);
|
|
96
|
+
expect(events).toEqual([
|
|
97
|
+
"restore-roots",
|
|
98
|
+
"sleep",
|
|
99
|
+
"remove",
|
|
100
|
+
"start-old-version",
|
|
101
|
+
"check-ready",
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("failed root restore does not restart a partial installation", async () => {
|
|
106
|
+
restore.mockResolvedValue(false);
|
|
107
|
+
expect(
|
|
108
|
+
await lifecycle.attemptFailedStateRestore({
|
|
109
|
+
runtimeUrl: "http://127.0.0.1:1",
|
|
110
|
+
assistantId: "recovery",
|
|
111
|
+
backupPath: "pre-upgrade.tar.gz",
|
|
112
|
+
backupLabel: "pre-upgrade",
|
|
113
|
+
res,
|
|
114
|
+
containerOptions,
|
|
115
|
+
}),
|
|
116
|
+
).toEqual({ restored: false, ready: false });
|
|
117
|
+
expect(start).not.toHaveBeenCalled();
|
|
118
|
+
expect(stop).not.toHaveBeenCalled();
|
|
119
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("versioned restore installs roots into stopped target containers before first startup", async () => {
|
|
123
|
+
const commands = mockCommands();
|
|
124
|
+
commands.on(
|
|
125
|
+
"docker",
|
|
126
|
+
(args) => args[0] === "inspect",
|
|
127
|
+
() => ({
|
|
128
|
+
stdout: JSON.stringify(["ACTOR_TOKEN_SIGNING_KEY=" + "01".repeat(32)]),
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const entry: config.AssistantEntry = {
|
|
132
|
+
assistantId: "recovery",
|
|
133
|
+
cloud: "docker",
|
|
134
|
+
runtimeUrl: "http://127.0.0.1:1",
|
|
135
|
+
};
|
|
136
|
+
capture.mockImplementation(async () => {
|
|
137
|
+
events.push("capture-safety");
|
|
138
|
+
return "/tmp/pre-rollback.tar.gz";
|
|
139
|
+
});
|
|
140
|
+
images.mockResolvedValue({
|
|
141
|
+
assistant: "assistant:current",
|
|
142
|
+
gateway: "gateway:current",
|
|
143
|
+
"credential-executor": "ces:current",
|
|
144
|
+
});
|
|
145
|
+
pull.mockResolvedValue(undefined);
|
|
146
|
+
start.mockImplementation(async (options) => {
|
|
147
|
+
expect(options.createOnly).toBe(true);
|
|
148
|
+
events.push("create-stopped-target");
|
|
149
|
+
});
|
|
150
|
+
wake.mockImplementation(async () => {
|
|
151
|
+
events.push("wake-restored-target");
|
|
152
|
+
});
|
|
153
|
+
fetch.mockImplementation(async (url) => {
|
|
154
|
+
if (String(url).includes("/healthz"))
|
|
155
|
+
return Response.json({ version: "0.12.11" });
|
|
156
|
+
if (String(url).includes("/readyz")) {
|
|
157
|
+
events.push("check-ready");
|
|
158
|
+
return Response.json({ ready: true });
|
|
159
|
+
}
|
|
160
|
+
return Response.json({});
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
await lifecycle.performDockerRollback(entry, {
|
|
164
|
+
targetVersion: "0.12.10",
|
|
165
|
+
backupPath: "/tmp/source.tar.gz",
|
|
166
|
+
});
|
|
167
|
+
expect(restore).toHaveBeenCalledWith(
|
|
168
|
+
entry.runtimeUrl,
|
|
169
|
+
entry.assistantId,
|
|
170
|
+
"/tmp/source.tar.gz",
|
|
171
|
+
{ resume: false },
|
|
172
|
+
);
|
|
173
|
+
expect(events).toEqual([
|
|
174
|
+
"capture-safety",
|
|
175
|
+
"sleep",
|
|
176
|
+
"remove",
|
|
177
|
+
"create-stopped-target",
|
|
178
|
+
"restore-roots",
|
|
179
|
+
"wake-restored-target",
|
|
180
|
+
"check-ready",
|
|
181
|
+
]);
|
|
182
|
+
} finally {
|
|
183
|
+
commands.restore();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
@@ -227,3 +227,28 @@ test("preserves PID tracking when process termination fails", async () => {
|
|
|
227
227
|
expect(isProcessAlive(pidFile)).toEqual({ alive: true, pid: process.pid });
|
|
228
228
|
rmSync(dir, { recursive: true, force: true });
|
|
229
229
|
});
|
|
230
|
+
|
|
231
|
+
test("graceful-only stop times out without SIGKILL or losing PID identity", async () => {
|
|
232
|
+
const originalKill = process.kill;
|
|
233
|
+
const calls: Array<NodeJS.Signals | number | undefined> = [];
|
|
234
|
+
process.kill = ((_pid: number, signal?: NodeJS.Signals | number) => {
|
|
235
|
+
calls.push(signal);
|
|
236
|
+
return true;
|
|
237
|
+
}) as typeof process.kill;
|
|
238
|
+
try {
|
|
239
|
+
expect(
|
|
240
|
+
await stopProcess(
|
|
241
|
+
4812,
|
|
242
|
+
"backup service",
|
|
243
|
+
0,
|
|
244
|
+
"linux",
|
|
245
|
+
undefined,
|
|
246
|
+
() => "cadet-daemon",
|
|
247
|
+
true,
|
|
248
|
+
),
|
|
249
|
+
).toBe(false);
|
|
250
|
+
expect(calls).toEqual([0, "SIGTERM", 0]);
|
|
251
|
+
} finally {
|
|
252
|
+
process.kill = originalKill;
|
|
253
|
+
}
|
|
254
|
+
});
|