@peerbit/server 6.0.42 → 7.0.1
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/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +161 -352
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +1 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +2 -5
- package/dist/src/client.js.map +1 -1
- package/dist/src/docker.d.ts +70 -1
- package/dist/src/docker.d.ts.map +1 -1
- package/dist/src/docker.js +559 -14
- package/dist/src/docker.js.map +1 -1
- package/dist/src/domain-lease.d.ts +126 -0
- package/dist/src/domain-lease.d.ts.map +1 -0
- package/dist/src/domain-lease.js +800 -0
- package/dist/src/domain-lease.js.map +1 -0
- package/dist/src/domain.d.ts +50 -2
- package/dist/src/domain.d.ts.map +1 -1
- package/dist/src/domain.js +484 -67
- package/dist/src/domain.js.map +1 -1
- package/dist/src/hetzner.d.ts +4 -21
- package/dist/src/hetzner.d.ts.map +1 -1
- package/dist/src/hetzner.js +4 -116
- package/dist/src/hetzner.js.map +1 -1
- package/dist/src/nginx-template.conf +19 -1
- package/dist/src/remotes.browser.d.ts +4 -1
- package/dist/src/remotes.browser.d.ts.map +1 -1
- package/dist/src/remotes.browser.js +1 -2
- package/dist/src/remotes.browser.js.map +1 -1
- package/dist/src/remotes.d.ts +2 -1
- package/dist/src/remotes.d.ts.map +1 -1
- package/dist/src/remotes.js +1 -0
- package/dist/src/remotes.js.map +1 -1
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +3 -5
- package/dist/src/server.js.map +1 -1
- package/dist/test/api.spec.js +2 -1
- package/dist/test/api.spec.js.map +1 -1
- package/dist/test/cli.spec.js +48 -1
- package/dist/test/cli.spec.js.map +1 -1
- package/dist/test/docker.spec.d.ts +2 -0
- package/dist/test/docker.spec.d.ts.map +1 -0
- package/dist/test/docker.spec.js +822 -0
- package/dist/test/docker.spec.js.map +1 -0
- package/dist/test/domain-lease.spec.d.ts +2 -0
- package/dist/test/domain-lease.spec.d.ts.map +1 -0
- package/dist/test/domain-lease.spec.js +530 -0
- package/dist/test/domain-lease.spec.js.map +1 -0
- package/dist/test/domain.integration.spec.js +358 -75
- package/dist/test/domain.integration.spec.js.map +1 -1
- package/dist/test/remotes.spec.d.ts +2 -0
- package/dist/test/remotes.spec.d.ts.map +1 -0
- package/dist/test/remotes.spec.js +15 -0
- package/dist/test/remotes.spec.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/ui/assets/index-CjZwCewh.js +10 -0
- package/dist/ui/assets/index-e3g42MbC.js +9 -0
- package/dist/ui/index.html +1 -1
- package/package.json +14 -19
- package/src/cli.ts +204 -408
- package/src/client.ts +2 -6
- package/src/docker.ts +841 -20
- package/src/domain-lease.ts +1118 -0
- package/src/domain.ts +650 -90
- package/src/hetzner.ts +4 -184
- package/src/nginx-template.conf +19 -1
- package/src/remotes.browser.ts +7 -1
- package/src/remotes.ts +6 -1
- package/src/server.ts +3 -5
- package/dist/src/aws.browser.d.ts +0 -2
- package/dist/src/aws.browser.d.ts.map +0 -1
- package/dist/src/aws.browser.js +0 -3
- package/dist/src/aws.browser.js.map +0 -1
- package/dist/src/aws.d.ts +0 -30
- package/dist/src/aws.d.ts.map +0 -1
- package/dist/src/aws.js +0 -240
- package/dist/src/aws.js.map +0 -1
- package/dist/test/launch.spec.d.ts +0 -2
- package/dist/test/launch.spec.d.ts.map +0 -1
- package/dist/test/launch.spec.js +0 -23
- package/dist/test/launch.spec.js.map +0 -1
- package/dist/ui/assets/index-BBTKOM0z.js +0 -6
- package/dist/ui/assets/index-DVtj_GOf.js +0 -13
- package/src/aws.browser.ts +0 -1
- package/src/aws.ts +0 -327
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { DOCKER_REPLACEMENT_LABEL, DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL, DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL, replaceDockerContainer, withQuiescedDockerContainer, } from "../src/docker.js";
|
|
3
|
+
const createDocker = (initial) => {
|
|
4
|
+
const containers = new Map(Object.entries(initial));
|
|
5
|
+
const calls = [];
|
|
6
|
+
const newInspectionStates = [];
|
|
7
|
+
let nextId = 1;
|
|
8
|
+
let failingRuns = 0;
|
|
9
|
+
let failedRunForeignReplacements = 0;
|
|
10
|
+
let failingRemovalsAfterEffect = 0;
|
|
11
|
+
let failingRemovalsBeforeEffect = 0;
|
|
12
|
+
let failingStopsAfterEffect = 0;
|
|
13
|
+
const find = (reference) => {
|
|
14
|
+
const byName = containers.get(reference);
|
|
15
|
+
if (byName)
|
|
16
|
+
return { container: byName, name: reference };
|
|
17
|
+
for (const [name, container] of containers) {
|
|
18
|
+
if (container.id === reference)
|
|
19
|
+
return { container, name };
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
};
|
|
23
|
+
const missing = (reference) => {
|
|
24
|
+
const error = new Error("No such object");
|
|
25
|
+
error.stderr = `Error: No such object: ${reference}`;
|
|
26
|
+
return error;
|
|
27
|
+
};
|
|
28
|
+
const execute = async (args) => {
|
|
29
|
+
const call = [...args];
|
|
30
|
+
calls.push(call);
|
|
31
|
+
if (call[0] === "container" && call[1] === "inspect") {
|
|
32
|
+
const found = find(call[2]);
|
|
33
|
+
if (!found)
|
|
34
|
+
throw missing(call[2]);
|
|
35
|
+
if (found.container.id.startsWith("new-") &&
|
|
36
|
+
newInspectionStates.length > 0) {
|
|
37
|
+
Object.assign(found.container, newInspectionStates.shift());
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
stdout: JSON.stringify([
|
|
41
|
+
{
|
|
42
|
+
Config: { Labels: found.container.labels || {} },
|
|
43
|
+
Id: found.container.id,
|
|
44
|
+
Mounts: found.container.mounts,
|
|
45
|
+
Name: `/${found.name}`,
|
|
46
|
+
RestartCount: found.container.restartCount || 0,
|
|
47
|
+
State: {
|
|
48
|
+
Health: found.container.healthStatus
|
|
49
|
+
? { Status: found.container.healthStatus }
|
|
50
|
+
: undefined,
|
|
51
|
+
Restarting: found.container.restarting || false,
|
|
52
|
+
Running: found.container.running,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
]),
|
|
56
|
+
stderr: "",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (call[0] === "container" && call[1] === "rename") {
|
|
60
|
+
const found = find(call[2]);
|
|
61
|
+
if (!found)
|
|
62
|
+
throw missing(call[2]);
|
|
63
|
+
const destination = containers.get(call[3]);
|
|
64
|
+
if (destination && destination !== found.container) {
|
|
65
|
+
throw new Error(`Container name ${call[3]} is already in use`);
|
|
66
|
+
}
|
|
67
|
+
containers.delete(found.name);
|
|
68
|
+
containers.set(call[3], found.container);
|
|
69
|
+
}
|
|
70
|
+
else if (call[0] === "container" && call[1] === "stop") {
|
|
71
|
+
const found = find(call[2]);
|
|
72
|
+
if (!found)
|
|
73
|
+
throw missing(call[2]);
|
|
74
|
+
found.container.running = false;
|
|
75
|
+
if (failingStopsAfterEffect > 0) {
|
|
76
|
+
failingStopsAfterEffect -= 1;
|
|
77
|
+
throw new Error("simulated ambiguous docker stop failure");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (call[0] === "container" && call[1] === "start") {
|
|
81
|
+
const found = find(call[2]);
|
|
82
|
+
if (!found)
|
|
83
|
+
throw missing(call[2]);
|
|
84
|
+
found.container.running = true;
|
|
85
|
+
}
|
|
86
|
+
else if (call[0] === "container" && call[1] === "rm") {
|
|
87
|
+
const reference = call.at(-1);
|
|
88
|
+
const found = find(reference);
|
|
89
|
+
if (!found)
|
|
90
|
+
throw missing(reference);
|
|
91
|
+
if (failingRemovalsBeforeEffect > 0) {
|
|
92
|
+
failingRemovalsBeforeEffect -= 1;
|
|
93
|
+
throw new Error("simulated docker remove failure");
|
|
94
|
+
}
|
|
95
|
+
containers.delete(found.name);
|
|
96
|
+
if (failingRemovalsAfterEffect > 0) {
|
|
97
|
+
failingRemovalsAfterEffect -= 1;
|
|
98
|
+
throw new Error("simulated ambiguous docker remove failure");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (call[0] === "run") {
|
|
102
|
+
const imageIndex = call.findIndex((argument, index) => index > 0 &&
|
|
103
|
+
!argument.startsWith("-") &&
|
|
104
|
+
call[index - 1] !== "--label" &&
|
|
105
|
+
call[index - 1] !== "--name");
|
|
106
|
+
const nameIndex = call.findIndex((argument, index) => argument === "--name" && index < imageIndex);
|
|
107
|
+
const id = `new-${nextId++}`;
|
|
108
|
+
const name = nameIndex === -1 ? `anonymous-${id}` : call[nameIndex + 1];
|
|
109
|
+
if (containers.has(name)) {
|
|
110
|
+
throw new Error(`Container name ${name} is already in use`);
|
|
111
|
+
}
|
|
112
|
+
const labels = {};
|
|
113
|
+
for (let index = 0; index < call.length; index++) {
|
|
114
|
+
if (call[index] !== "--label")
|
|
115
|
+
continue;
|
|
116
|
+
const label = call[index + 1];
|
|
117
|
+
const separator = label.indexOf("=");
|
|
118
|
+
labels[label.slice(0, separator)] = label.slice(separator + 1);
|
|
119
|
+
}
|
|
120
|
+
const container = {
|
|
121
|
+
id,
|
|
122
|
+
labels,
|
|
123
|
+
running: true,
|
|
124
|
+
};
|
|
125
|
+
containers.set(name, container);
|
|
126
|
+
if (failedRunForeignReplacements > 0) {
|
|
127
|
+
failedRunForeignReplacements -= 1;
|
|
128
|
+
containers.set(name, {
|
|
129
|
+
id: "foreign-after-failure",
|
|
130
|
+
labels: {},
|
|
131
|
+
running: true,
|
|
132
|
+
});
|
|
133
|
+
throw new Error("simulated run failure with foreign replacement");
|
|
134
|
+
}
|
|
135
|
+
if (failingRuns > 0) {
|
|
136
|
+
failingRuns -= 1;
|
|
137
|
+
throw new Error("simulated docker run failure after create");
|
|
138
|
+
}
|
|
139
|
+
return { stdout: `${container.id}\n`, stderr: "" };
|
|
140
|
+
}
|
|
141
|
+
return { stdout: "", stderr: "" };
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
calls,
|
|
145
|
+
containers,
|
|
146
|
+
execute,
|
|
147
|
+
failNextRuns: (count) => {
|
|
148
|
+
failingRuns = count;
|
|
149
|
+
},
|
|
150
|
+
failNextRemovalsAfterEffect: (count) => {
|
|
151
|
+
failingRemovalsAfterEffect = count;
|
|
152
|
+
},
|
|
153
|
+
failNextRemovalsBeforeEffect: (count) => {
|
|
154
|
+
failingRemovalsBeforeEffect = count;
|
|
155
|
+
},
|
|
156
|
+
failNextStopsAfterEffect: (count) => {
|
|
157
|
+
failingStopsAfterEffect = count;
|
|
158
|
+
},
|
|
159
|
+
queueNewInspectionStates: (states) => {
|
|
160
|
+
newInspectionStates.push(...states);
|
|
161
|
+
},
|
|
162
|
+
replaceFailedRunWithForeign: (count) => {
|
|
163
|
+
failedRunForeignReplacements = count;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
describe("withQuiescedDockerContainer", () => {
|
|
168
|
+
const name = "nginx-certbot";
|
|
169
|
+
it("stops and restarts only the immutable recognized container ID", async () => {
|
|
170
|
+
const docker = createDocker({
|
|
171
|
+
[name]: { id: "managed-id", labels: { managed: "yes" }, running: true },
|
|
172
|
+
});
|
|
173
|
+
await expect(withQuiescedDockerContainer(name, (inspection) => inspection.Config?.Labels?.managed === "yes", async () => {
|
|
174
|
+
expect(docker.containers.get(name)?.running).to.be.false;
|
|
175
|
+
throw new Error("verification failed");
|
|
176
|
+
}, { execute: docker.execute })).rejectedWith("verification failed");
|
|
177
|
+
expect(docker.containers.get(name)?.running).to.be.true;
|
|
178
|
+
expect(docker.calls).to.deep.include(["container", "stop", "managed-id"]);
|
|
179
|
+
expect(docker.calls).to.deep.include(["container", "start", "managed-id"]);
|
|
180
|
+
});
|
|
181
|
+
it("refuses to stop an unrelated container", async () => {
|
|
182
|
+
const docker = createDocker({
|
|
183
|
+
[name]: { id: "foreign-id", running: true },
|
|
184
|
+
});
|
|
185
|
+
await expect(withQuiescedDockerContainer(name, () => false, async () => undefined, { execute: docker.execute })).rejectedWith("refusing to stop anything");
|
|
186
|
+
expect(docker.containers.get(name)?.running).to.be.true;
|
|
187
|
+
expect(docker.calls.some((call) => call[0] === "container" && call[1] === "stop")).to.be.false;
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe("replaceDockerContainer", () => {
|
|
191
|
+
const name = "nginx-certbot";
|
|
192
|
+
const backupName = "peerbit-certbot-backup-test";
|
|
193
|
+
const runningBackupName = `${backupName}-running`;
|
|
194
|
+
const stoppedBackupName = `${backupName}-stopped`;
|
|
195
|
+
const replacementToken = "replacement-test";
|
|
196
|
+
const runArgs = ["run", "--name", name, "image@sha256:digest"];
|
|
197
|
+
const options = (docker) => ({
|
|
198
|
+
backupName,
|
|
199
|
+
execute: docker.execute,
|
|
200
|
+
replacementToken,
|
|
201
|
+
retryDelayMs: 0,
|
|
202
|
+
startupProbeAttempts: 1,
|
|
203
|
+
startupProbeIntervalMs: 0,
|
|
204
|
+
});
|
|
205
|
+
it("keeps the old container through commit and addresses it by ID", async () => {
|
|
206
|
+
const docker = createDocker({
|
|
207
|
+
[name]: { id: "legacy", running: true },
|
|
208
|
+
});
|
|
209
|
+
let committed = false;
|
|
210
|
+
await replaceDockerContainer(name, runArgs, {
|
|
211
|
+
...options(docker),
|
|
212
|
+
expectedContainerId: "legacy",
|
|
213
|
+
onCommitted: () => {
|
|
214
|
+
committed = true;
|
|
215
|
+
expect(docker.containers.has(runningBackupName)).to.be.false;
|
|
216
|
+
},
|
|
217
|
+
onStarted: () => {
|
|
218
|
+
expect(docker.containers.get(runningBackupName)?.id).equal("legacy");
|
|
219
|
+
expect(docker.containers.get(runningBackupName)?.running).to.be.false;
|
|
220
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
expect(committed).to.be.true;
|
|
224
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
225
|
+
expect(docker.calls).to.deep.include([
|
|
226
|
+
"container",
|
|
227
|
+
"rename",
|
|
228
|
+
"legacy",
|
|
229
|
+
runningBackupName,
|
|
230
|
+
]);
|
|
231
|
+
expect(docker.calls).to.deep.include(["container", "stop", "legacy"]);
|
|
232
|
+
expect(docker.calls).to.deep.include([
|
|
233
|
+
"container",
|
|
234
|
+
"rm",
|
|
235
|
+
"--force",
|
|
236
|
+
"legacy",
|
|
237
|
+
]);
|
|
238
|
+
const run = docker.calls.find((call) => call[0] === "run");
|
|
239
|
+
expect(run).to.include(`${DOCKER_REPLACEMENT_LABEL}=${replacementToken}`);
|
|
240
|
+
expect(run).to.include(`${DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL}=legacy`);
|
|
241
|
+
expect(run).to.include(`${DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL}=true`);
|
|
242
|
+
});
|
|
243
|
+
it("restores a running previous container when both starts fail", async () => {
|
|
244
|
+
const docker = createDocker({
|
|
245
|
+
[name]: { id: "legacy", running: true },
|
|
246
|
+
});
|
|
247
|
+
docker.failNextRuns(2);
|
|
248
|
+
let error;
|
|
249
|
+
try {
|
|
250
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
251
|
+
}
|
|
252
|
+
catch (caught) {
|
|
253
|
+
error = caught;
|
|
254
|
+
}
|
|
255
|
+
expect(error?.message).to.include("previous container restored");
|
|
256
|
+
expect(docker.containers.get(name)).deep.equal({
|
|
257
|
+
id: "legacy",
|
|
258
|
+
running: true,
|
|
259
|
+
});
|
|
260
|
+
expect(docker.containers.has(runningBackupName)).to.be.false;
|
|
261
|
+
});
|
|
262
|
+
it("retries when a detached container exits after an initially good probe", async () => {
|
|
263
|
+
const docker = createDocker({
|
|
264
|
+
[name]: { id: "legacy", running: true },
|
|
265
|
+
});
|
|
266
|
+
docker.queueNewInspectionStates([{}, {}, { running: false }]);
|
|
267
|
+
await replaceDockerContainer(name, runArgs, {
|
|
268
|
+
...options(docker),
|
|
269
|
+
startupProbeAttempts: 2,
|
|
270
|
+
});
|
|
271
|
+
expect(docker.containers.get(name)?.id).equal("new-2");
|
|
272
|
+
expect(docker.calls.filter((call) => call[0] === "run")).to.have.length(2);
|
|
273
|
+
});
|
|
274
|
+
it("retries a replacement that enters a restart loop", async () => {
|
|
275
|
+
const docker = createDocker({
|
|
276
|
+
[name]: { id: "legacy", running: true },
|
|
277
|
+
});
|
|
278
|
+
docker.queueNewInspectionStates([{}, { restartCount: 1 }]);
|
|
279
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
280
|
+
expect(docker.containers.get(name)?.id).equal("new-2");
|
|
281
|
+
expect(docker.calls.filter((call) => call[0] === "run")).to.have.length(2);
|
|
282
|
+
});
|
|
283
|
+
it("rolls back when the atomic commit callback fails", async () => {
|
|
284
|
+
const docker = createDocker({
|
|
285
|
+
[name]: { id: "legacy", running: true },
|
|
286
|
+
});
|
|
287
|
+
let committed = false;
|
|
288
|
+
let rollbackInspected = false;
|
|
289
|
+
let error;
|
|
290
|
+
try {
|
|
291
|
+
await replaceDockerContainer(name, runArgs, {
|
|
292
|
+
...options(docker),
|
|
293
|
+
onCommit: () => {
|
|
294
|
+
throw new Error("activation failed");
|
|
295
|
+
},
|
|
296
|
+
onCommitted: () => {
|
|
297
|
+
committed = true;
|
|
298
|
+
},
|
|
299
|
+
onRollbackInspected: () => {
|
|
300
|
+
rollbackInspected = true;
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch (caught) {
|
|
305
|
+
error = caught;
|
|
306
|
+
}
|
|
307
|
+
expect(error?.message).to.include("previous container restored");
|
|
308
|
+
expect(committed).to.be.false;
|
|
309
|
+
expect(rollbackInspected).to.be.true;
|
|
310
|
+
expect(docker.containers.get(name)).deep.equal({
|
|
311
|
+
id: "legacy",
|
|
312
|
+
running: true,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
it("reports a replacement that survives a removal-before-effect rollback failure", async () => {
|
|
316
|
+
const generationRoot = "/state/generations/new";
|
|
317
|
+
const docker = createDocker({
|
|
318
|
+
[name]: { id: "legacy", running: true },
|
|
319
|
+
});
|
|
320
|
+
docker.queueNewInspectionStates([
|
|
321
|
+
{
|
|
322
|
+
mounts: [
|
|
323
|
+
{
|
|
324
|
+
Destination: "/etc/nginx/user_conf.d",
|
|
325
|
+
Source: `${generationRoot}/nginx`,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
docker.failNextRemovalsBeforeEffect(1);
|
|
331
|
+
let remainingIds = [];
|
|
332
|
+
let error;
|
|
333
|
+
try {
|
|
334
|
+
await replaceDockerContainer(name, runArgs, {
|
|
335
|
+
...options(docker),
|
|
336
|
+
onRollbackInspected: (remainingContainers) => {
|
|
337
|
+
remainingIds = remainingContainers.flatMap((container) => container.Id ? [container.Id] : []);
|
|
338
|
+
},
|
|
339
|
+
onStarted: () => {
|
|
340
|
+
throw new Error("readiness failed");
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (caught) {
|
|
345
|
+
error = caught;
|
|
346
|
+
}
|
|
347
|
+
expect(error?.message).to.include("replacement cleanup failed");
|
|
348
|
+
expect(error?.message).to.include("previous-container restore failed");
|
|
349
|
+
expect(remainingIds).to.include("new-1");
|
|
350
|
+
expect(docker.containers.get(name)?.mounts?.[0]?.Source).equal(`${generationRoot}/nginx`);
|
|
351
|
+
});
|
|
352
|
+
it("restores the previous container when it becomes unstable during activation", async () => {
|
|
353
|
+
const docker = createDocker({
|
|
354
|
+
[name]: { id: "legacy", running: true },
|
|
355
|
+
});
|
|
356
|
+
let commitCalled = false;
|
|
357
|
+
let committed = false;
|
|
358
|
+
let error;
|
|
359
|
+
try {
|
|
360
|
+
await replaceDockerContainer(name, runArgs, {
|
|
361
|
+
...options(docker),
|
|
362
|
+
onCommit: () => {
|
|
363
|
+
commitCalled = true;
|
|
364
|
+
},
|
|
365
|
+
onCommitted: () => {
|
|
366
|
+
committed = true;
|
|
367
|
+
},
|
|
368
|
+
onStarted: () => {
|
|
369
|
+
const replacement = docker.containers.get(name);
|
|
370
|
+
replacement.restartCount = 1;
|
|
371
|
+
replacement.restarting = true;
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (caught) {
|
|
376
|
+
error = caught;
|
|
377
|
+
}
|
|
378
|
+
expect(error?.message).to.include("did not remain stably running through activation");
|
|
379
|
+
expect(error?.message).to.include("previous container restored");
|
|
380
|
+
expect(commitCalled).to.be.false;
|
|
381
|
+
expect(committed).to.be.false;
|
|
382
|
+
expect(docker.containers.get(name)).deep.equal({
|
|
383
|
+
id: "legacy",
|
|
384
|
+
running: true,
|
|
385
|
+
});
|
|
386
|
+
expect(docker.containers.has(runningBackupName)).to.be.false;
|
|
387
|
+
});
|
|
388
|
+
it("refuses an unexpected initial container ID without mutating it", async () => {
|
|
389
|
+
const docker = createDocker({
|
|
390
|
+
[name]: { id: "unexpected", running: true },
|
|
391
|
+
});
|
|
392
|
+
let error;
|
|
393
|
+
try {
|
|
394
|
+
await replaceDockerContainer(name, runArgs, {
|
|
395
|
+
...options(docker),
|
|
396
|
+
expectedContainerId: "expected",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch (caught) {
|
|
400
|
+
error = caught;
|
|
401
|
+
}
|
|
402
|
+
expect(error?.message).to.include("changed during configuration");
|
|
403
|
+
expect(docker.containers.get(name)?.id).equal("unexpected");
|
|
404
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
405
|
+
});
|
|
406
|
+
it("restores an old container when stop takes effect but reports failure", async () => {
|
|
407
|
+
const docker = createDocker({
|
|
408
|
+
[name]: { id: "legacy", running: true },
|
|
409
|
+
});
|
|
410
|
+
docker.failNextStopsAfterEffect(1);
|
|
411
|
+
let error;
|
|
412
|
+
try {
|
|
413
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
414
|
+
}
|
|
415
|
+
catch (caught) {
|
|
416
|
+
error = caught;
|
|
417
|
+
}
|
|
418
|
+
expect(error?.message).to.include("previous container restored");
|
|
419
|
+
expect(docker.containers.get(name)).deep.equal({
|
|
420
|
+
id: "legacy",
|
|
421
|
+
running: true,
|
|
422
|
+
});
|
|
423
|
+
expect(docker.calls).to.deep.include(["container", "start", "legacy"]);
|
|
424
|
+
});
|
|
425
|
+
it("keeps an originally stopped container stopped after rollback", async () => {
|
|
426
|
+
const docker = createDocker({
|
|
427
|
+
[name]: { id: "legacy", running: false },
|
|
428
|
+
});
|
|
429
|
+
try {
|
|
430
|
+
await replaceDockerContainer(name, runArgs, {
|
|
431
|
+
...options(docker),
|
|
432
|
+
onStarted: () => {
|
|
433
|
+
throw new Error("activation failed");
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// Expected: the assertion below verifies rollback state.
|
|
439
|
+
}
|
|
440
|
+
expect(docker.containers.get(name)).deep.equal({
|
|
441
|
+
id: "legacy",
|
|
442
|
+
running: false,
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
it("serializes same-name replacements while the commit callback is pending", async () => {
|
|
446
|
+
const docker = createDocker({
|
|
447
|
+
[name]: { id: "legacy", running: true },
|
|
448
|
+
});
|
|
449
|
+
let enter;
|
|
450
|
+
const entered = new Promise((resolve) => (enter = resolve));
|
|
451
|
+
let release;
|
|
452
|
+
const barrier = new Promise((resolve) => (release = resolve));
|
|
453
|
+
const first = replaceDockerContainer(name, runArgs, {
|
|
454
|
+
...options(docker),
|
|
455
|
+
onStarted: async () => {
|
|
456
|
+
enter();
|
|
457
|
+
await barrier;
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
await entered;
|
|
461
|
+
const callCount = docker.calls.length;
|
|
462
|
+
let error;
|
|
463
|
+
let callsWhileLocked = 0;
|
|
464
|
+
try {
|
|
465
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
466
|
+
}
|
|
467
|
+
catch (caught) {
|
|
468
|
+
error = caught;
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
callsWhileLocked = docker.calls.length;
|
|
472
|
+
release();
|
|
473
|
+
}
|
|
474
|
+
await first;
|
|
475
|
+
expect(error?.message).to.include("already replacing");
|
|
476
|
+
expect(callsWhileLocked).equal(callCount);
|
|
477
|
+
});
|
|
478
|
+
it("does not remove a foreign container that appears after a failed run", async () => {
|
|
479
|
+
const docker = createDocker({});
|
|
480
|
+
docker.replaceFailedRunWithForeign(1);
|
|
481
|
+
let error;
|
|
482
|
+
try {
|
|
483
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
484
|
+
}
|
|
485
|
+
catch (caught) {
|
|
486
|
+
error = caught;
|
|
487
|
+
}
|
|
488
|
+
expect(error).to.be.instanceOf(Error);
|
|
489
|
+
expect(docker.containers.get(name)?.id).equal("foreign-after-failure");
|
|
490
|
+
expect(docker.calls.some((call) => call[1] === "rm" && call.at(-1) === "foreign-after-failure")).to.be.false;
|
|
491
|
+
});
|
|
492
|
+
it("recovers an originally running interrupted backup before replacing it", async () => {
|
|
493
|
+
const docker = createDocker({
|
|
494
|
+
[runningBackupName]: { id: "legacy", running: false },
|
|
495
|
+
});
|
|
496
|
+
await replaceDockerContainer(name, runArgs, {
|
|
497
|
+
...options(docker),
|
|
498
|
+
validateExisting: () => true,
|
|
499
|
+
});
|
|
500
|
+
expect(docker.calls).to.deep.include([
|
|
501
|
+
"container",
|
|
502
|
+
"rename",
|
|
503
|
+
"legacy",
|
|
504
|
+
name,
|
|
505
|
+
]);
|
|
506
|
+
expect(docker.calls).to.deep.include(["container", "start", "legacy"]);
|
|
507
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
508
|
+
});
|
|
509
|
+
it("keeps an originally stopped interrupted backup stopped", async () => {
|
|
510
|
+
const docker = createDocker({
|
|
511
|
+
[stoppedBackupName]: { id: "legacy", running: false },
|
|
512
|
+
});
|
|
513
|
+
await replaceDockerContainer(name, runArgs, {
|
|
514
|
+
...options(docker),
|
|
515
|
+
validateExisting: () => true,
|
|
516
|
+
});
|
|
517
|
+
expect(docker.calls.some((call) => call[1] === "start" && call.at(-1) === "legacy")).to.be.false;
|
|
518
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
519
|
+
});
|
|
520
|
+
it("refuses an unsuffixed legacy backup without mutating it", async () => {
|
|
521
|
+
const docker = createDocker({
|
|
522
|
+
[backupName]: { id: "legacy", running: false },
|
|
523
|
+
});
|
|
524
|
+
let error;
|
|
525
|
+
try {
|
|
526
|
+
await replaceDockerContainer(name, runArgs, {
|
|
527
|
+
...options(docker),
|
|
528
|
+
validateExisting: () => true,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (caught) {
|
|
532
|
+
error = caught;
|
|
533
|
+
}
|
|
534
|
+
expect(error?.message).to.include("predates crash-safe state metadata");
|
|
535
|
+
expect(docker.containers.get(backupName)).deep.equal({
|
|
536
|
+
id: "legacy",
|
|
537
|
+
running: false,
|
|
538
|
+
});
|
|
539
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
540
|
+
});
|
|
541
|
+
const interruptedPair = (running = true) => ({
|
|
542
|
+
[name]: {
|
|
543
|
+
id: "replacement",
|
|
544
|
+
labels: {
|
|
545
|
+
[DOCKER_REPLACEMENT_LABEL]: "interrupted-token",
|
|
546
|
+
[DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL]: "legacy",
|
|
547
|
+
[DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL]: String(running),
|
|
548
|
+
},
|
|
549
|
+
running: true,
|
|
550
|
+
},
|
|
551
|
+
[running ? runningBackupName : stoppedBackupName]: {
|
|
552
|
+
id: "legacy",
|
|
553
|
+
running: false,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
it("restores a recognized pre-commit pair before replacing it", async () => {
|
|
557
|
+
const docker = createDocker(interruptedPair());
|
|
558
|
+
await replaceDockerContainer(name, runArgs, {
|
|
559
|
+
...options(docker),
|
|
560
|
+
resolveInterruptedPair: () => "restore-backup",
|
|
561
|
+
validateExisting: () => true,
|
|
562
|
+
});
|
|
563
|
+
expect(docker.calls).to.deep.include([
|
|
564
|
+
"container",
|
|
565
|
+
"rm",
|
|
566
|
+
"--force",
|
|
567
|
+
"replacement",
|
|
568
|
+
]);
|
|
569
|
+
expect(docker.calls).to.deep.include(["container", "start", "legacy"]);
|
|
570
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
571
|
+
});
|
|
572
|
+
it("finishes a recognized committed pair before replacing it", async () => {
|
|
573
|
+
const docker = createDocker(interruptedPair());
|
|
574
|
+
await replaceDockerContainer(name, runArgs, {
|
|
575
|
+
...options(docker),
|
|
576
|
+
resolveInterruptedPair: () => "keep-current",
|
|
577
|
+
validateExisting: () => true,
|
|
578
|
+
});
|
|
579
|
+
const removeBackup = docker.calls.findIndex((call) => call[1] === "rm" && call.at(-1) === "legacy");
|
|
580
|
+
const renameCurrent = docker.calls.findIndex((call) => call[1] === "rename" && call[2] === "replacement");
|
|
581
|
+
expect(removeBackup).to.be.greaterThan(-1);
|
|
582
|
+
expect(renameCurrent).to.be.greaterThan(removeBackup);
|
|
583
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
584
|
+
});
|
|
585
|
+
it("leaves an ambiguous interrupted pair unchanged", async () => {
|
|
586
|
+
const initial = interruptedPair();
|
|
587
|
+
const docker = createDocker(initial);
|
|
588
|
+
let error;
|
|
589
|
+
try {
|
|
590
|
+
await replaceDockerContainer(name, runArgs, {
|
|
591
|
+
...options(docker),
|
|
592
|
+
validateExisting: () => true,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
catch (caught) {
|
|
596
|
+
error = caught;
|
|
597
|
+
}
|
|
598
|
+
expect(error?.message).to.include("unambiguous");
|
|
599
|
+
expect(docker.containers.get(name)).deep.equal(initial[name]);
|
|
600
|
+
expect(docker.containers.get(runningBackupName)).deep.equal(initial[runningBackupName]);
|
|
601
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
602
|
+
});
|
|
603
|
+
it("leaves a metadata-mismatched interrupted pair unchanged", async () => {
|
|
604
|
+
const initial = interruptedPair();
|
|
605
|
+
initial[name].labels[DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL] = "other";
|
|
606
|
+
const docker = createDocker(initial);
|
|
607
|
+
let error;
|
|
608
|
+
try {
|
|
609
|
+
await replaceDockerContainer(name, runArgs, {
|
|
610
|
+
...options(docker),
|
|
611
|
+
resolveInterruptedPair: () => "restore-backup",
|
|
612
|
+
validateExisting: () => true,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (caught) {
|
|
616
|
+
error = caught;
|
|
617
|
+
}
|
|
618
|
+
expect(error?.message).to.include("do not form a recognized");
|
|
619
|
+
expect(docker.containers.get(name)).deep.equal(initial[name]);
|
|
620
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
621
|
+
});
|
|
622
|
+
it("refuses multiple backup candidates without mutating them", async () => {
|
|
623
|
+
const docker = createDocker({
|
|
624
|
+
[runningBackupName]: { id: "running-backup", running: false },
|
|
625
|
+
[stoppedBackupName]: { id: "stopped-backup", running: false },
|
|
626
|
+
});
|
|
627
|
+
let error;
|
|
628
|
+
try {
|
|
629
|
+
await replaceDockerContainer(name, runArgs, {
|
|
630
|
+
...options(docker),
|
|
631
|
+
validateExisting: () => true,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
catch (caught) {
|
|
635
|
+
error = caught;
|
|
636
|
+
}
|
|
637
|
+
expect(error?.message).to.include("Multiple Docker backups");
|
|
638
|
+
expect(docker.containers.size).equal(2);
|
|
639
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
640
|
+
});
|
|
641
|
+
it("does not stop current when committed-backup cleanup fails", async () => {
|
|
642
|
+
const docker = createDocker(interruptedPair());
|
|
643
|
+
docker.failNextRemovalsBeforeEffect(1);
|
|
644
|
+
let error;
|
|
645
|
+
try {
|
|
646
|
+
await replaceDockerContainer(name, runArgs, {
|
|
647
|
+
...options(docker),
|
|
648
|
+
resolveInterruptedPair: () => "keep-current",
|
|
649
|
+
validateExisting: () => true,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
catch (caught) {
|
|
653
|
+
error = caught;
|
|
654
|
+
}
|
|
655
|
+
expect(error).to.be.instanceOf(Error);
|
|
656
|
+
expect(docker.containers.get(name)?.running).to.be.true;
|
|
657
|
+
expect(docker.containers.get(runningBackupName)?.id).equal("legacy");
|
|
658
|
+
});
|
|
659
|
+
it("restores the backup when current removal takes effect but fails", async () => {
|
|
660
|
+
const docker = createDocker(interruptedPair());
|
|
661
|
+
docker.failNextRemovalsAfterEffect(1);
|
|
662
|
+
let error;
|
|
663
|
+
try {
|
|
664
|
+
await replaceDockerContainer(name, runArgs, {
|
|
665
|
+
...options(docker),
|
|
666
|
+
resolveInterruptedPair: () => "restore-backup",
|
|
667
|
+
validateExisting: () => true,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (caught) {
|
|
671
|
+
error = caught;
|
|
672
|
+
}
|
|
673
|
+
expect(error?.message).to.include("previous container restored");
|
|
674
|
+
expect(docker.containers.get(name)).deep.equal({
|
|
675
|
+
id: "legacy",
|
|
676
|
+
running: true,
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
it("rejects reserved replacement labels before mutating", async () => {
|
|
680
|
+
const docker = createDocker({
|
|
681
|
+
[name]: { id: "legacy", running: true },
|
|
682
|
+
});
|
|
683
|
+
let error;
|
|
684
|
+
try {
|
|
685
|
+
await replaceDockerContainer(name, [
|
|
686
|
+
"run",
|
|
687
|
+
"--label",
|
|
688
|
+
`${DOCKER_REPLACEMENT_LABEL}=caller-value`,
|
|
689
|
+
"--name",
|
|
690
|
+
name,
|
|
691
|
+
"image",
|
|
692
|
+
], options(docker));
|
|
693
|
+
}
|
|
694
|
+
catch (caught) {
|
|
695
|
+
error = caught;
|
|
696
|
+
}
|
|
697
|
+
expect(error?.message).to.include("reserved label");
|
|
698
|
+
expect(docker.containers.get(name)?.running).to.be.true;
|
|
699
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
700
|
+
});
|
|
701
|
+
it("rejects compact reserved-label shorthand before mutating", async () => {
|
|
702
|
+
const docker = createDocker({
|
|
703
|
+
[name]: { id: "legacy", running: true },
|
|
704
|
+
});
|
|
705
|
+
let error;
|
|
706
|
+
try {
|
|
707
|
+
await replaceDockerContainer(name, [
|
|
708
|
+
"run",
|
|
709
|
+
`-l${DOCKER_REPLACEMENT_LABEL}=caller-value`,
|
|
710
|
+
"--name",
|
|
711
|
+
name,
|
|
712
|
+
"image",
|
|
713
|
+
], options(docker));
|
|
714
|
+
}
|
|
715
|
+
catch (caught) {
|
|
716
|
+
error = caught;
|
|
717
|
+
}
|
|
718
|
+
expect(error?.message).to.include("reserved label");
|
|
719
|
+
expect(docker.containers.get(name)?.running).to.be.true;
|
|
720
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
721
|
+
});
|
|
722
|
+
it("requires the replacement to use the canonical name", async () => {
|
|
723
|
+
const docker = createDocker({
|
|
724
|
+
[name]: { id: "legacy", running: true },
|
|
725
|
+
});
|
|
726
|
+
let error;
|
|
727
|
+
try {
|
|
728
|
+
await replaceDockerContainer(name, ["run", "image"], options(docker));
|
|
729
|
+
}
|
|
730
|
+
catch (caught) {
|
|
731
|
+
error = caught;
|
|
732
|
+
}
|
|
733
|
+
expect(error?.message).to.include("canonical --name");
|
|
734
|
+
expect(docker.containers.get(name)?.running).to.be.true;
|
|
735
|
+
expect(docker.calls.some((call) => ["rename", "rm", "start", "stop"].includes(call[1]))).to.be.false;
|
|
736
|
+
});
|
|
737
|
+
it("rejects --name after the image without mutating containers", async () => {
|
|
738
|
+
const docker = createDocker({
|
|
739
|
+
[name]: { id: "legacy", running: true },
|
|
740
|
+
});
|
|
741
|
+
let commitCalled = false;
|
|
742
|
+
let error;
|
|
743
|
+
try {
|
|
744
|
+
await replaceDockerContainer(name, ["run", "image", "--name", name], {
|
|
745
|
+
...options(docker),
|
|
746
|
+
onCommit: () => {
|
|
747
|
+
commitCalled = true;
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
catch (caught) {
|
|
752
|
+
error = caught;
|
|
753
|
+
}
|
|
754
|
+
expect(error?.message).to.include("immediately after 'run'");
|
|
755
|
+
expect(commitCalled).to.be.false;
|
|
756
|
+
expect(docker.containers).deep.equal(new Map([[name, { id: "legacy", running: true }]]));
|
|
757
|
+
});
|
|
758
|
+
it("records an absent original explicitly", async () => {
|
|
759
|
+
const docker = createDocker({});
|
|
760
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
761
|
+
expect(docker.containers.get(name)?.labels).to.include({
|
|
762
|
+
[DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL]: "none",
|
|
763
|
+
[DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL]: "false",
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
it("reclaims a stale non-Linux process lock after a hard exit", async function () {
|
|
767
|
+
if (process.platform === "linux" || process.platform === "win32") {
|
|
768
|
+
this.skip();
|
|
769
|
+
}
|
|
770
|
+
const { createHash } = await import("crypto");
|
|
771
|
+
const fs = await import("fs");
|
|
772
|
+
const path = await import("path");
|
|
773
|
+
const { pathToFileURL } = await import("url");
|
|
774
|
+
const { spawn } = await import("child_process");
|
|
775
|
+
const moduleUrl = pathToFileURL(path.join(process.cwd(), "dist", "src", "docker.js")).href;
|
|
776
|
+
const child = spawn(process.execPath, [
|
|
777
|
+
"--input-type=module",
|
|
778
|
+
"--eval",
|
|
779
|
+
`import { replaceDockerContainer } from ${JSON.stringify(moduleUrl)};
|
|
780
|
+
await replaceDockerContainer(${JSON.stringify(name)}, ["run"], {
|
|
781
|
+
execute: async () => {
|
|
782
|
+
process.stdout.write("locked\\n");
|
|
783
|
+
setInterval(() => {}, 1_000);
|
|
784
|
+
await new Promise(() => {});
|
|
785
|
+
return { stdout: "", stderr: "" };
|
|
786
|
+
}
|
|
787
|
+
});`,
|
|
788
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
789
|
+
const digest = createHash("sha256").update(name).digest("hex").slice(0, 32);
|
|
790
|
+
const lockPath = path.join("/tmp", `peerbit-docker-replace-${digest}.sock.lock`);
|
|
791
|
+
try {
|
|
792
|
+
await new Promise((resolve, reject) => {
|
|
793
|
+
const onData = (chunk) => {
|
|
794
|
+
if (chunk.toString().includes("locked"))
|
|
795
|
+
resolve();
|
|
796
|
+
};
|
|
797
|
+
child.stdout.on("data", onData);
|
|
798
|
+
child.once("error", reject);
|
|
799
|
+
child.stderr.on("data", (chunk) => {
|
|
800
|
+
const message = chunk.toString();
|
|
801
|
+
if (message)
|
|
802
|
+
reject(new Error(message));
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
const exited = new Promise((resolve) => child.once("exit", () => resolve()));
|
|
806
|
+
child.kill("SIGKILL");
|
|
807
|
+
await exited;
|
|
808
|
+
expect(fs.existsSync(lockPath)).to.be.true;
|
|
809
|
+
const stale = new Date(Date.now() - 20_000);
|
|
810
|
+
fs.utimesSync(lockPath, stale, stale);
|
|
811
|
+
const docker = createDocker({});
|
|
812
|
+
await replaceDockerContainer(name, runArgs, options(docker));
|
|
813
|
+
expect(docker.containers.get(name)?.id).equal("new-1");
|
|
814
|
+
}
|
|
815
|
+
finally {
|
|
816
|
+
if (!child.killed)
|
|
817
|
+
child.kill("SIGKILL");
|
|
818
|
+
fs.rmSync(lockPath, { force: true, recursive: true });
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
//# sourceMappingURL=docker.spec.js.map
|