@mycadet/cli 0.12.9-dev.202610142154.883240 → 0.12.9-dev.202610151822.dbac7cb
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/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/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/ingress.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/package.json +1 -1
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup.test.ts +1 -1
- package/src/__tests__/debug.test.ts +17 -91
- 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 +103 -5
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/ps-cloud-status.test.ts +6 -6
- package/src/__tests__/root-backup.test.ts +37 -0
- package/src/__tests__/root-restore-command.test.ts +26 -25
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +3 -3
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel.test.ts +1 -1
- package/src/commands/debug.ts +75 -131
- package/src/commands/restore.ts +16 -91
- package/src/commands/teleport.ts +47 -1559
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/docker.ts +39 -17
- package/src/lib/hatch-local.ts +17 -1
- package/src/lib/host-teleport.ts +505 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +1 -1
- package/src/lib/nginx-ingress.ts +120 -4
- package/src/lib/root-backup.ts +70 -40
- 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/upgrade-lifecycle.ts +35 -12
- package/src/__tests__/bundle-staging.test.ts +0 -228
- 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
|
@@ -28,10 +28,10 @@ describe("tunnelHostForCloudHost", () => {
|
|
|
28
28
|
|
|
29
29
|
test("maps env-prefixed platform hosts to their env tunnel", () => {
|
|
30
30
|
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
|
-
"tunnel
|
|
31
|
+
"staging-tunnel.mycadet.ai",
|
|
32
32
|
);
|
|
33
33
|
expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
|
|
34
|
-
"tunnel
|
|
34
|
+
"dev-tunnel.mycadet.ai",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Map a Cadet Cloud host onto its environment's tunnel host, following the
|
|
3
3
|
* deployment naming convention (`cloud.mycadet.ai` → `tunnel.mycadet.ai`,
|
|
4
|
-
* `{env}-cloud.mycadet.ai` → `
|
|
4
|
+
* `{env}-cloud.mycadet.ai` → `{env}-tunnel.mycadet.ai`). Returns null for
|
|
5
5
|
* hosts outside that convention (localhost, custom domains). Shared by the
|
|
6
6
|
* gateway speech relay and the web live-voice client so the two ends of the
|
|
7
7
|
* cloud-speech transport can't drift.
|
|
@@ -11,7 +11,7 @@ export function tunnelHostForCloudHost(host: string): string | null {
|
|
|
11
11
|
return "tunnel.mycadet.ai";
|
|
12
12
|
}
|
|
13
13
|
const match = /^([a-z0-9-]+)-cloud\.mycadet\.ai$/.exec(host);
|
|
14
|
-
return match ?
|
|
14
|
+
return match ? `${match[1]}-tunnel.mycadet.ai` : null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
@@ -28,10 +28,10 @@ describe("tunnelHostForCloudHost", () => {
|
|
|
28
28
|
|
|
29
29
|
test("maps env-prefixed platform hosts to their env tunnel", () => {
|
|
30
30
|
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
|
-
"tunnel
|
|
31
|
+
"staging-tunnel.mycadet.ai",
|
|
32
32
|
);
|
|
33
33
|
expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
|
|
34
|
-
"tunnel
|
|
34
|
+
"dev-tunnel.mycadet.ai",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Map a Cadet Cloud host onto its environment's tunnel host, following the
|
|
3
3
|
* deployment naming convention (`cloud.mycadet.ai` → `tunnel.mycadet.ai`,
|
|
4
|
-
* `{env}-cloud.mycadet.ai` → `
|
|
4
|
+
* `{env}-cloud.mycadet.ai` → `{env}-tunnel.mycadet.ai`). Returns null for
|
|
5
5
|
* hosts outside that convention (localhost, custom domains). Shared by the
|
|
6
6
|
* gateway speech relay and the web live-voice client so the two ends of the
|
|
7
7
|
* cloud-speech transport can't drift.
|
|
@@ -11,7 +11,7 @@ export function tunnelHostForCloudHost(host: string): string | null {
|
|
|
11
11
|
return "tunnel.mycadet.ai";
|
|
12
12
|
}
|
|
13
13
|
const match = /^([a-z0-9-]+)-cloud\.mycadet\.ai$/.exec(host);
|
|
14
|
-
return match ?
|
|
14
|
+
return match ? `${match[1]}-tunnel.mycadet.ai` : null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
@@ -28,10 +28,10 @@ describe("tunnelHostForCloudHost", () => {
|
|
|
28
28
|
|
|
29
29
|
test("maps env-prefixed platform hosts to their env tunnel", () => {
|
|
30
30
|
expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
|
|
31
|
-
"tunnel
|
|
31
|
+
"staging-tunnel.mycadet.ai",
|
|
32
32
|
);
|
|
33
33
|
expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
|
|
34
|
-
"tunnel
|
|
34
|
+
"dev-tunnel.mycadet.ai",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Map a Cadet Cloud host onto its environment's tunnel host, following the
|
|
3
3
|
* deployment naming convention (`cloud.mycadet.ai` → `tunnel.mycadet.ai`,
|
|
4
|
-
* `{env}-cloud.mycadet.ai` → `
|
|
4
|
+
* `{env}-cloud.mycadet.ai` → `{env}-tunnel.mycadet.ai`). Returns null for
|
|
5
5
|
* hosts outside that convention (localhost, custom domains). Shared by the
|
|
6
6
|
* gateway speech relay and the web live-voice client so the two ends of the
|
|
7
7
|
* cloud-speech transport can't drift.
|
|
@@ -11,7 +11,7 @@ export function tunnelHostForCloudHost(host: string): string | null {
|
|
|
11
11
|
return "tunnel.mycadet.ai";
|
|
12
12
|
}
|
|
13
13
|
const match = /^([a-z0-9-]+)-cloud\.mycadet\.ai$/.exec(host);
|
|
14
|
-
return match ?
|
|
14
|
+
return match ? `${match[1]}-tunnel.mycadet.ai` : null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for AssistantClient's cloud-managed (`cloud: "
|
|
2
|
+
* Tests for AssistantClient's cloud-managed (`cloud: "cloud"`) auth path.
|
|
3
3
|
* These entries have no guardian token on disk, so the client must authenticate
|
|
4
4
|
* with the stored platform credential instead of sending an anonymous request
|
|
5
5
|
* (which the cloud answers with a bare 403).
|
|
@@ -38,7 +38,7 @@ function seedCloud(token?: string): void {
|
|
|
38
38
|
assistantId: ASSISTANT_ID,
|
|
39
39
|
name: "Credence",
|
|
40
40
|
runtimeUrl: PLATFORM_URL,
|
|
41
|
-
cloud: "
|
|
41
|
+
cloud: "cloud",
|
|
42
42
|
});
|
|
43
43
|
if (token) {
|
|
44
44
|
saveCloudToken(token);
|
|
@@ -79,7 +79,7 @@ for (const topology of ["local", "docker"] as const) {
|
|
|
79
79
|
test("cloud backup calls the cloud backup API with session authentication", async () => {
|
|
80
80
|
const entry: config.AssistantEntry = {
|
|
81
81
|
assistantId: "cloud-test",
|
|
82
|
-
cloud: "
|
|
82
|
+
cloud: "cloud",
|
|
83
83
|
runtimeUrl: "https://cloud.mycadet.ai",
|
|
84
84
|
};
|
|
85
85
|
lookup.mockReturnValue(entry);
|
|
@@ -18,7 +18,6 @@ process.env.CADET_LOCKFILE_DIR = testDir;
|
|
|
18
18
|
|
|
19
19
|
import * as assistantConfig from "../lib/assistant-config.js";
|
|
20
20
|
import * as guardianToken from "../lib/guardian-token.js";
|
|
21
|
-
import * as localRuntimeClient from "../lib/local-runtime-client.js";
|
|
22
21
|
import * as cloudClient from "../lib/cloud-client.js";
|
|
23
22
|
import { withPreconnect } from "./helpers/http.js";
|
|
24
23
|
import { makeGuardianTokenData } from "./helpers/guardian-token.js";
|
|
@@ -29,26 +28,9 @@ const resolveTargetMock = spyOn(
|
|
|
29
28
|
).mockImplementation(() => {
|
|
30
29
|
throw new Error("process.exit:1");
|
|
31
30
|
});
|
|
32
|
-
const readCloudTokenMock = spyOn(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
).mockReturnValue("platform-token");
|
|
36
|
-
const exportMock = spyOn(
|
|
37
|
-
localRuntimeClient,
|
|
38
|
-
"localRuntimeExportToGcs",
|
|
39
|
-
).mockResolvedValue({ jobId: "job-1" });
|
|
40
|
-
const identityMock = spyOn(
|
|
41
|
-
localRuntimeClient,
|
|
42
|
-
"localRuntimeIdentity",
|
|
43
|
-
).mockResolvedValue({ version: "0.12.3" });
|
|
44
|
-
const pollMock = spyOn(
|
|
45
|
-
localRuntimeClient,
|
|
46
|
-
"localRuntimePollJobStatus",
|
|
47
|
-
).mockResolvedValue({
|
|
48
|
-
jobId: "job-1",
|
|
49
|
-
type: "export",
|
|
50
|
-
status: "complete",
|
|
51
|
-
});
|
|
31
|
+
const readCloudTokenMock = spyOn(cloudClient, "readCloudToken").mockReturnValue(
|
|
32
|
+
"platform-token",
|
|
33
|
+
);
|
|
52
34
|
const loadGuardianTokenSpy = spyOn(
|
|
53
35
|
guardianToken,
|
|
54
36
|
"loadGuardianToken",
|
|
@@ -109,13 +91,9 @@ beforeEach(() => {
|
|
|
109
91
|
);
|
|
110
92
|
resolveTargetMock.mockReset();
|
|
111
93
|
resolveTargetMock.mockReturnValue(LOCAL_ENTRY);
|
|
112
|
-
identityMock.mockReset();
|
|
113
|
-
identityMock.mockResolvedValue({ version: "0.12.3" });
|
|
114
94
|
leaseGuardianTokenSpy.mockClear();
|
|
115
95
|
readCloudTokenMock.mockReset();
|
|
116
96
|
readCloudTokenMock.mockReturnValue("platform-token");
|
|
117
|
-
exportMock.mockClear();
|
|
118
|
-
pollMock.mockClear();
|
|
119
97
|
});
|
|
120
98
|
|
|
121
99
|
afterEach(() => {
|
|
@@ -125,40 +103,29 @@ afterEach(() => {
|
|
|
125
103
|
|
|
126
104
|
afterAll(() => {
|
|
127
105
|
resolveTargetMock.mockRestore();
|
|
128
|
-
identityMock.mockRestore();
|
|
129
106
|
leaseGuardianTokenSpy.mockRestore();
|
|
130
107
|
readCloudTokenMock.mockRestore();
|
|
131
|
-
exportMock.mockRestore();
|
|
132
|
-
pollMock.mockRestore();
|
|
133
108
|
loadGuardianTokenSpy.mockRestore();
|
|
134
109
|
rmSync(testDir, { recursive: true, force: true });
|
|
135
110
|
});
|
|
136
111
|
|
|
137
112
|
describe("cadet debug", () => {
|
|
138
|
-
test("
|
|
113
|
+
test("requests sanitized logs and uploads them without runtime export jobs", async () => {
|
|
139
114
|
await debug();
|
|
140
|
-
|
|
141
|
-
expect(fetchCalls).toHaveLength(1);
|
|
142
|
-
expect(fetchCalls[0].url).toBe(
|
|
115
|
+
expect(fetchCalls.map((call) => call.url)).toEqual([
|
|
143
116
|
"https://cloud.mycadet.ai/v1/assistants/11111111-2222-3333-4444-555555555555/debug-bundle-upload-url/",
|
|
117
|
+
"http://127.0.0.1:7821/v1/logs/export",
|
|
118
|
+
"https://storage.googleapis.com/b/signed",
|
|
119
|
+
]);
|
|
120
|
+
expect(fetchCalls[1].init?.method).toBe("POST");
|
|
121
|
+
expect(new Headers(fetchCalls[1].init?.headers).get("Authorization")).toBe(
|
|
122
|
+
"Bearer local-token",
|
|
144
123
|
);
|
|
145
|
-
expect(fetchCalls[
|
|
146
|
-
expect(
|
|
147
|
-
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
expect(exportMock).toHaveBeenCalledTimes(1);
|
|
151
|
-
const [entry, token, params] = exportMock.mock.calls[0];
|
|
152
|
-
expect(entry).toBe(LOCAL_ENTRY);
|
|
153
|
-
expect(token).toBe("local-token");
|
|
154
|
-
expect(params).toEqual({
|
|
155
|
-
uploadUrl: "https://storage.googleapis.com/b/signed",
|
|
156
|
-
description: "debug bundle for Cadet staff",
|
|
157
|
-
profile: "debug",
|
|
158
|
-
});
|
|
159
|
-
expect(pollMock).toHaveBeenCalled();
|
|
160
|
-
// The daemon was checked for the debug profile before the cloud call.
|
|
161
|
-
expect(identityMock).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(fetchCalls[2].init?.method).toBe("PUT");
|
|
125
|
+
expect(new Headers(fetchCalls[2].init?.headers).has("Authorization")).toBe(
|
|
126
|
+
false,
|
|
127
|
+
);
|
|
128
|
+
expect(fetchCalls[2].init?.body).toBeInstanceOf(Blob);
|
|
162
129
|
});
|
|
163
130
|
|
|
164
131
|
test("passes an unquoted multi-word display name through to the shared resolver", async () => {
|
|
@@ -167,46 +134,6 @@ describe("cadet debug", () => {
|
|
|
167
134
|
expect(resolveTargetMock).toHaveBeenCalledWith("Support Bot");
|
|
168
135
|
});
|
|
169
136
|
|
|
170
|
-
test("refuses a daemon older than the debug profile before any URL is minted", async () => {
|
|
171
|
-
// Such a daemon strips `profile` and would upload the owner's
|
|
172
|
-
// credentials to the staff bucket.
|
|
173
|
-
identityMock.mockResolvedValue({ version: "0.12.2" });
|
|
174
|
-
const errors: string[] = [];
|
|
175
|
-
const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
|
|
176
|
-
errors.push(String(msg));
|
|
177
|
-
});
|
|
178
|
-
try {
|
|
179
|
-
await expect(debug()).rejects.toThrow("process.exit:1");
|
|
180
|
-
} finally {
|
|
181
|
-
errorSpy.mockRestore();
|
|
182
|
-
}
|
|
183
|
-
expect(errors.join("\n")).toContain("0.12.3");
|
|
184
|
-
expect(fetchCalls).toHaveLength(0);
|
|
185
|
-
expect(exportMock).not.toHaveBeenCalled();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("re-leases the guardian token when the daemon answers 401 mid-poll", async () => {
|
|
189
|
-
pollMock.mockReset();
|
|
190
|
-
let polls = 0;
|
|
191
|
-
pollMock.mockImplementation(async (_entry, token) => {
|
|
192
|
-
polls += 1;
|
|
193
|
-
if (polls === 1) {
|
|
194
|
-
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
195
|
-
}
|
|
196
|
-
expect(token).toBe("leased-token");
|
|
197
|
-
return {
|
|
198
|
-
jobId: "job-1",
|
|
199
|
-
type: "export",
|
|
200
|
-
status: "complete",
|
|
201
|
-
};
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
await debug();
|
|
205
|
-
|
|
206
|
-
expect(leaseGuardianTokenSpy).toHaveBeenCalledTimes(1);
|
|
207
|
-
expect(polls).toBe(2);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
137
|
test("explains what to turn on when the cloud refuses", async () => {
|
|
211
138
|
uploadUrlStatus = 403;
|
|
212
139
|
const errors: string[] = [];
|
|
@@ -219,13 +146,12 @@ describe("cadet debug", () => {
|
|
|
219
146
|
errorSpy.mockRestore();
|
|
220
147
|
}
|
|
221
148
|
expect(errors.join("\n")).toContain("Allow Staff Access");
|
|
222
|
-
expect(exportMock).not.toHaveBeenCalled();
|
|
223
149
|
});
|
|
224
150
|
|
|
225
151
|
test("refuses a Cadet-hosted assistant, which needs no bundle", async () => {
|
|
226
152
|
resolveTargetMock.mockReturnValue({
|
|
227
153
|
...LOCAL_ENTRY,
|
|
228
|
-
cloud: "
|
|
154
|
+
cloud: "cloud",
|
|
229
155
|
});
|
|
230
156
|
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
231
157
|
try {
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import * as tar from "tar";
|
|
8
|
+
import {
|
|
9
|
+
restoreHostTeleportTarget,
|
|
10
|
+
TeleportRecoveryError,
|
|
11
|
+
} from "../lib/host-teleport.js";
|
|
12
|
+
import { dockerResourceNames } from "../lib/docker.js";
|
|
13
|
+
import { DOCKER_STATEFUL_SET_SPEC } from "../lib/statefulset.js";
|
|
14
|
+
import { packRoot, STATE_ROOTS } from "../lib/root-backup.js";
|
|
15
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
16
|
+
import { mockCommands } from "./commands-mock.js";
|
|
17
|
+
|
|
18
|
+
const entry: AssistantEntry = {
|
|
19
|
+
assistantId: "destination",
|
|
20
|
+
cloud: "docker",
|
|
21
|
+
runtimeUrl: "http://localhost:7830",
|
|
22
|
+
containerInfo: {
|
|
23
|
+
assistantImage: "image-assistant",
|
|
24
|
+
gatewayImage: "image-gateway",
|
|
25
|
+
cesImage: "image-ces",
|
|
26
|
+
assistantPort: 7821,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const names = dockerResourceNames(entry.assistantId);
|
|
30
|
+
const containers = {
|
|
31
|
+
assistant: names.assistantContainer,
|
|
32
|
+
gateway: names.gatewayContainer,
|
|
33
|
+
"credential-executor": names.cesContainer,
|
|
34
|
+
};
|
|
35
|
+
const services: Array<keyof typeof containers> = ["assistant", "gateway", "credential-executor"];
|
|
36
|
+
const rootContainer = {
|
|
37
|
+
workspace: names.assistantContainer,
|
|
38
|
+
"gateway-security": names.gatewayContainer,
|
|
39
|
+
"ces-security": names.cesContainer,
|
|
40
|
+
"ces-data": names.cesContainer,
|
|
41
|
+
};
|
|
42
|
+
let directory: string, archive: string, recovery: string;
|
|
43
|
+
let commands: ReturnType<typeof mockCommands>;
|
|
44
|
+
let log: ReturnType<typeof spyOn>;
|
|
45
|
+
let failReadiness: boolean, failEveryWrite: boolean, writes: number;
|
|
46
|
+
let states: Map<string, ReturnType<typeof inspection>>;
|
|
47
|
+
function inspection(service: keyof typeof containers) {
|
|
48
|
+
const spec = DOCKER_STATEFUL_SET_SPEC.containers.find(
|
|
49
|
+
(item) => item.internalName === service,
|
|
50
|
+
)!;
|
|
51
|
+
return {
|
|
52
|
+
Id: `${service}-id`,
|
|
53
|
+
Image: `image-${service}`,
|
|
54
|
+
State: { Status: "exited", Running: false },
|
|
55
|
+
Config: {
|
|
56
|
+
User: "1001:1001",
|
|
57
|
+
Env: [
|
|
58
|
+
"ACTOR_TOKEN_SIGNING_KEY=" + "07".repeat(32),
|
|
59
|
+
"CES_SERVICE_TOKEN=internal-test-token",
|
|
60
|
+
"RUNTIME_HTTP_PORT=7821",
|
|
61
|
+
"GATEWAY_PORT=7830",
|
|
62
|
+
"GUARDIAN_BOOTSTRAP_SECRET=bootstrap-test-secret",
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
HostConfig: {
|
|
66
|
+
NetworkMode:
|
|
67
|
+
service === "assistant" ? names.network : "container:assistant-id",
|
|
68
|
+
},
|
|
69
|
+
Mounts: spec.volumeMounts.map((mount) => ({
|
|
70
|
+
Type: "volume",
|
|
71
|
+
Name: DOCKER_STATEFUL_SET_SPEC.volumeClaimTemplates
|
|
72
|
+
.find((volume) => volume.name === mount.volumeName)!
|
|
73
|
+
.dockerVolume(entry.assistantId),
|
|
74
|
+
Source: "/remote/volume",
|
|
75
|
+
Destination: mount.mountPath,
|
|
76
|
+
RW: !mount.readOnly,
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const previousDataHome = process.env.XDG_DATA_HOME;
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
directory = await mkdtemp(join(tmpdir(), "teleport-docker-"));
|
|
83
|
+
process.env.XDG_DATA_HOME = directory;
|
|
84
|
+
archive = join(directory, "source.tar.gz");
|
|
85
|
+
recovery = join(directory, "recovery.tar.gz");
|
|
86
|
+
for (const flavor of ["source", "original", "volumes"]) {
|
|
87
|
+
for (const root of STATE_ROOTS) {
|
|
88
|
+
await mkdir(join(directory, flavor, root), { recursive: true });
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(directory, flavor, root, "value"),
|
|
91
|
+
flavor === "volumes" ? "original" : flavor,
|
|
92
|
+
{ mode: 0o600 },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
await writeFile(
|
|
96
|
+
join(directory, flavor, "gateway-security", "actor-token-signing-key"),
|
|
97
|
+
Buffer.alloc(32, flavor === "source" ? 9 : 7),
|
|
98
|
+
);
|
|
99
|
+
await writeFile(
|
|
100
|
+
join(directory, flavor, "ces-security", "keys.enc"),
|
|
101
|
+
flavor === "source" ? "source ciphertext" : "original ciphertext",
|
|
102
|
+
{ mode: 0o600 },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
await packRoot(join(directory, "source"), archive);
|
|
106
|
+
await packRoot(join(directory, "original"), recovery);
|
|
107
|
+
states = new Map(
|
|
108
|
+
services.map((service) => [
|
|
109
|
+
containers[service],
|
|
110
|
+
inspection(service),
|
|
111
|
+
]),
|
|
112
|
+
);
|
|
113
|
+
failReadiness = false;
|
|
114
|
+
failEveryWrite = false;
|
|
115
|
+
writes = 0;
|
|
116
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
117
|
+
commands = mockCommands();
|
|
118
|
+
commands.on(
|
|
119
|
+
"docker",
|
|
120
|
+
(args) => args[0] === "inspect",
|
|
121
|
+
({ args }) => {
|
|
122
|
+
const state = states.get(args.at(-1)!);
|
|
123
|
+
if (!state) {
|
|
124
|
+
return { code: 1, stderr: "No such container" };
|
|
125
|
+
}
|
|
126
|
+
if (args.includes("--format")) {
|
|
127
|
+
return {
|
|
128
|
+
stdout:
|
|
129
|
+
args[2] === "{{.State.Status}}"
|
|
130
|
+
? state.State.Status
|
|
131
|
+
: String(state.State.Running),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { stdout: JSON.stringify([state]) };
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
commands.on(
|
|
138
|
+
"docker",
|
|
139
|
+
(args) => args[0] === "rm",
|
|
140
|
+
({ args }) => {
|
|
141
|
+
const state = states.get(args[1]!);
|
|
142
|
+
if (!state) {
|
|
143
|
+
return { code: 1, stderr: "No such container" };
|
|
144
|
+
}
|
|
145
|
+
expect(state.State.Running).toBe(false);
|
|
146
|
+
states.delete(args[1]!);
|
|
147
|
+
return {};
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
commands.on(
|
|
151
|
+
"docker",
|
|
152
|
+
(args) => args[0] === "create",
|
|
153
|
+
({ args }) => {
|
|
154
|
+
const container = args[args.indexOf("--name") + 1]!;
|
|
155
|
+
const service = services.find((service) => containers[service] === container)!;
|
|
156
|
+
const state = inspection(service);
|
|
157
|
+
state.Config.Env = args.flatMap((value, index) =>
|
|
158
|
+
value === "-e" ? [args[index + 1]!] : [],
|
|
159
|
+
);
|
|
160
|
+
states.set(container, state);
|
|
161
|
+
return {};
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
commands.on(
|
|
165
|
+
"docker",
|
|
166
|
+
(args) => args[0] === "start",
|
|
167
|
+
({ args }) => {
|
|
168
|
+
const state = states.get(args[1]!)!;
|
|
169
|
+
state.State.Running = true;
|
|
170
|
+
state.State.Status = "running";
|
|
171
|
+
return {};
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
commands.on(
|
|
175
|
+
"docker",
|
|
176
|
+
(args) => args[0] === "kill",
|
|
177
|
+
({ args }) => {
|
|
178
|
+
expect(args[1]).toBe("--signal=SIGTERM");
|
|
179
|
+
const state = states.get(args.at(-1)!)!;
|
|
180
|
+
state.State.Running = false;
|
|
181
|
+
state.State.Status = "exited";
|
|
182
|
+
return {};
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
commands.on(
|
|
186
|
+
"docker",
|
|
187
|
+
(args) => args[0] === "cp",
|
|
188
|
+
async ({ args }) => {
|
|
189
|
+
const root = STATE_ROOTS.find((root) => args[1]!.includes(`:/${root}/`))!;
|
|
190
|
+
expect(
|
|
191
|
+
states.get(rootContainer[root])!.State
|
|
192
|
+
.Running,
|
|
193
|
+
).toBe(false);
|
|
194
|
+
await cp(join(directory, "volumes", root), args[2]!, { recursive: true });
|
|
195
|
+
return {};
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
commands.on(
|
|
199
|
+
"docker",
|
|
200
|
+
(args) => args[0] === "run" && args.includes("-i"),
|
|
201
|
+
async ({ args, input }) => {
|
|
202
|
+
const mount = args[args.indexOf("--mount") + 1]!;
|
|
203
|
+
const root = STATE_ROOTS.find(
|
|
204
|
+
(root) =>
|
|
205
|
+
states
|
|
206
|
+
.get(rootContainer[root])!
|
|
207
|
+
.Mounts.find((item) => item.Destination === `/${root}`)?.Name ===
|
|
208
|
+
mount.split("src=")[1]!.split(",")[0]!,
|
|
209
|
+
)!;
|
|
210
|
+
expect([...states.values()].every((state) => !state.State.Running)).toBe(
|
|
211
|
+
true,
|
|
212
|
+
);
|
|
213
|
+
expect(args).toContain("--network=none");
|
|
214
|
+
expect(args).toContain("--user=0");
|
|
215
|
+
expect(args).toContain("1001:1001");
|
|
216
|
+
expect(args[args.indexOf("-ec") + 1]).toContain('chown -R "$1" /restore');
|
|
217
|
+
writes++;
|
|
218
|
+
if (failEveryWrite) {
|
|
219
|
+
return { code: 1, stderr: "injected volume write failure" };
|
|
220
|
+
}
|
|
221
|
+
const rootDir = join(directory, "volumes", root);
|
|
222
|
+
await rm(rootDir, { recursive: true });
|
|
223
|
+
await mkdir(rootDir);
|
|
224
|
+
await pipeline(
|
|
225
|
+
Readable.from([input]),
|
|
226
|
+
tar.x({ cwd: rootDir, preserveOwner: false }),
|
|
227
|
+
);
|
|
228
|
+
return {};
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
commands.on(
|
|
232
|
+
"docker",
|
|
233
|
+
(args) => args[0] === "exec",
|
|
234
|
+
() =>
|
|
235
|
+
failReadiness
|
|
236
|
+
? { code: 2, stdout: "migration_failed", stderr: "migration_failed" }
|
|
237
|
+
: { stdout: "ready" },
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
afterEach(async () => {
|
|
241
|
+
if (previousDataHome === undefined) {
|
|
242
|
+
delete process.env.XDG_DATA_HOME;
|
|
243
|
+
} else {
|
|
244
|
+
process.env.XDG_DATA_HOME = previousDataHome;
|
|
245
|
+
}
|
|
246
|
+
commands.restore();
|
|
247
|
+
log.mockRestore();
|
|
248
|
+
await rm(directory, { recursive: true, force: true });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("recreates immutable identity overrides and imports all roots before first start", async () => {
|
|
252
|
+
await restoreHostTeleportTarget(entry, archive, recovery);
|
|
253
|
+
for (const root of STATE_ROOTS) {
|
|
254
|
+
expect(
|
|
255
|
+
await readFile(join(directory, "volumes", root, "value"), "utf8"),
|
|
256
|
+
).toBe("source");
|
|
257
|
+
}
|
|
258
|
+
expect(
|
|
259
|
+
await readFile(
|
|
260
|
+
join(directory, "volumes", "ces-security", "keys.enc"),
|
|
261
|
+
"utf8",
|
|
262
|
+
),
|
|
263
|
+
).toBe("source ciphertext");
|
|
264
|
+
expect(
|
|
265
|
+
await readFile(
|
|
266
|
+
join(directory, "volumes", "gateway-security", "actor-token-signing-key"),
|
|
267
|
+
),
|
|
268
|
+
).toEqual(Buffer.alloc(32, 9));
|
|
269
|
+
expect(
|
|
270
|
+
[...states.values()].every(
|
|
271
|
+
(state) =>
|
|
272
|
+
!state.Config.Env.some((value) =>
|
|
273
|
+
value.startsWith("ACTOR_TOKEN_SIGNING_KEY="),
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
).toBe(true);
|
|
277
|
+
const firstStart = commands.calls.findIndex(
|
|
278
|
+
(request) => request.args[0] === "start",
|
|
279
|
+
);
|
|
280
|
+
const installs = commands.calls
|
|
281
|
+
.map((request, index) => ({ request, index }))
|
|
282
|
+
.filter(
|
|
283
|
+
({ request }) => request.args[0] === "run" && request.args.includes("-i"),
|
|
284
|
+
);
|
|
285
|
+
expect(installs).toHaveLength(4);
|
|
286
|
+
expect(installs.every(({ index }) => index < firstStart)).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
test("readiness failure restores original ciphertext, gateway identity, and stopped containers", async () => {
|
|
289
|
+
failReadiness = true;
|
|
290
|
+
await expect(
|
|
291
|
+
restoreHostTeleportTarget(entry, archive, recovery),
|
|
292
|
+
).rejects.toThrow("did not become ready");
|
|
293
|
+
for (const root of STATE_ROOTS) {
|
|
294
|
+
expect(
|
|
295
|
+
await readFile(join(directory, "volumes", root, "value"), "utf8"),
|
|
296
|
+
).toBe("original");
|
|
297
|
+
}
|
|
298
|
+
expect(
|
|
299
|
+
await readFile(
|
|
300
|
+
join(directory, "volumes", "ces-security", "keys.enc"),
|
|
301
|
+
"utf8",
|
|
302
|
+
),
|
|
303
|
+
).toBe("original ciphertext");
|
|
304
|
+
expect([...states.values()].every((state) => !state.State.Running)).toBe(
|
|
305
|
+
true,
|
|
306
|
+
);
|
|
307
|
+
expect(states.get(names.gatewayContainer)!.Config.Env).toContain(
|
|
308
|
+
"ACTOR_TOKEN_SIGNING_KEY=" + "07".repeat(32),
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
test("unrecoverable writes leave services stopped and report the retained recovery tar", async () => {
|
|
312
|
+
failEveryWrite = true;
|
|
313
|
+
await expect(
|
|
314
|
+
restoreHostTeleportTarget(entry, archive, recovery),
|
|
315
|
+
).rejects.toBeInstanceOf(TeleportRecoveryError);
|
|
316
|
+
expect([...states.values()].every((state) => !state.State.Running)).toBe(
|
|
317
|
+
true,
|
|
318
|
+
);
|
|
319
|
+
expect(writes).toBeGreaterThan(1);
|
|
320
|
+
});
|
|
321
|
+
test("customized persistent mounts are rejected before container removal or volume writes", async () => {
|
|
322
|
+
states.get(names.cesContainer)!.Mounts[0]!.Name = "custom-security";
|
|
323
|
+
await expect(
|
|
324
|
+
restoreHostTeleportTarget(entry, archive, recovery),
|
|
325
|
+
).rejects.toThrow("customized Docker mounts");
|
|
326
|
+
expect(commands.calls.some((request) => request.args[0] === "rm")).toBe(
|
|
327
|
+
false,
|
|
328
|
+
);
|
|
329
|
+
expect(writes).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
test("legacy vbundle is rejected before touching destination containers", async () => {
|
|
332
|
+
await expect(
|
|
333
|
+
restoreHostTeleportTarget(
|
|
334
|
+
entry,
|
|
335
|
+
join(directory, "legacy.vbundle"),
|
|
336
|
+
recovery,
|
|
337
|
+
),
|
|
338
|
+
).rejects.toThrow("vellum-to-cadet");
|
|
339
|
+
expect(commands.calls).toHaveLength(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("reads the remote Docker runtime version through in-container health instead of host ports", async () => {
|
|
343
|
+
const { readHostRuntimeVersion } = await import("../lib/host-teleport.js");
|
|
344
|
+
commands.on(
|
|
345
|
+
"docker",
|
|
346
|
+
(args) => args[0] === "exec" && args.at(-1)?.endsWith("/healthz") === true,
|
|
347
|
+
({ args }) => {
|
|
348
|
+
expect(args[1]).toBe(names.gatewayContainer);
|
|
349
|
+
expect(args.at(-1)).toBe("http://127.0.0.1:7830/healthz");
|
|
350
|
+
return { stdout: JSON.stringify({ version: "0.12.9" }) };
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
expect(await readHostRuntimeVersion(entry)).toBe("0.12.9");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("sleeping remote Docker runtime uses its recorded image tag for the version guard", async () => {
|
|
357
|
+
const { readHostRuntimeVersion } = await import("../lib/host-teleport.js");
|
|
358
|
+
commands.on("docker", (args) => args[0] === "exec", {
|
|
359
|
+
code: 1,
|
|
360
|
+
stderr: "container is stopped",
|
|
361
|
+
});
|
|
362
|
+
expect(
|
|
363
|
+
await readHostRuntimeVersion({
|
|
364
|
+
...entry,
|
|
365
|
+
containerInfo: {
|
|
366
|
+
...entry.containerInfo!,
|
|
367
|
+
gatewayImage: "mycadet/gateway:0.12.8",
|
|
368
|
+
},
|
|
369
|
+
}),
|
|
370
|
+
).toBe("0.12.8");
|
|
371
|
+
});
|