@crouton-kit/crouter 0.3.39 → 0.3.40
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.
|
@@ -252,3 +252,111 @@ test('§4 retry: a second consecutive retryable failure throws (bounded to one r
|
|
|
252
252
|
// Both the first and the retry generation are self-cleaned — no orphan survives a double failure.
|
|
253
253
|
assert.equal(destroyed.length, 2, 'both failed generations are self-cleaned');
|
|
254
254
|
});
|
|
255
|
+
// Contract: when the volume is held by a sandbox outside the tag-scoped destroy set — an
|
|
256
|
+
// untagged holder the sweep cannot reach — the guard must detect it immediately and throw an
|
|
257
|
+
// actionable error, never spin `pollVolumeDetached` to its VOLUME_DETACH_TIMEOUT_MS deadline.
|
|
258
|
+
/** A fake whose tag-scoped `listMachines` returns `[]` (nothing tagged for this home) while
|
|
259
|
+
* `getVolumeAttachment` reports the volume held by an untagged sandbox outside the destroy set.
|
|
260
|
+
* Counts `getVolumeAttachment` calls so the test can assert the guard short-circuits on the
|
|
261
|
+
* first read rather than looping into `pollVolumeDetached`. */
|
|
262
|
+
function makeUntaggedHolderProvider(holderName, destroyed, attachmentCalls) {
|
|
263
|
+
const fake = {
|
|
264
|
+
providerName: 'blaxel',
|
|
265
|
+
imageRef: 'crouter-hearth-home-0-3-35',
|
|
266
|
+
templateVersion: '0.3.35',
|
|
267
|
+
volumeSizeGb: 4,
|
|
268
|
+
async listMachines() {
|
|
269
|
+
return []; // tag-scoped list finds nothing — the holder carries no externalId tag
|
|
270
|
+
},
|
|
271
|
+
async destroy(machineId) {
|
|
272
|
+
destroyed.push(machineId);
|
|
273
|
+
return { kind: 'destroyed' };
|
|
274
|
+
},
|
|
275
|
+
async getVolumeAttachment() {
|
|
276
|
+
attachmentCalls.count += 1;
|
|
277
|
+
return `sandbox:${holderName}`;
|
|
278
|
+
},
|
|
279
|
+
async createMachine(input) {
|
|
280
|
+
return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
|
|
281
|
+
},
|
|
282
|
+
async exec() {
|
|
283
|
+
return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
|
|
284
|
+
},
|
|
285
|
+
async waitForPort() { },
|
|
286
|
+
async publishHttpPort(machineId, input) {
|
|
287
|
+
return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
|
|
288
|
+
},
|
|
289
|
+
async getMachine(machineId) {
|
|
290
|
+
return { providerName: 'blaxel', machineId, state: 'running' };
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
return fake;
|
|
294
|
+
}
|
|
295
|
+
test('loud guard: an untagged legacy sandbox holding the volume fails FAST with an actionable error, never the 120s poll', async () => {
|
|
296
|
+
const destroyed = [];
|
|
297
|
+
const attachmentCalls = { count: 0 };
|
|
298
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makeUntaggedHolderProvider('hearth-prod-1', destroyed, attachmentCalls));
|
|
299
|
+
await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => {
|
|
300
|
+
assert.ok(err instanceof Error, 'must throw an Error');
|
|
301
|
+
assert.match(err.message, /hearth-prod-1/, 'error must name the untagged holder sandbox');
|
|
302
|
+
assert.match(err.message, /migrate/i, 'error must instruct the operator to migrate the home');
|
|
303
|
+
return true;
|
|
304
|
+
});
|
|
305
|
+
// The tag-scoped sweep destroyed nothing (empty family) and the guard fired on the FIRST
|
|
306
|
+
// attachment read — it never looped into `pollVolumeDetached`'s wait/retry cycle.
|
|
307
|
+
assert.equal(destroyed.length, 0, 'nothing was destroyed — the holder was never tag-scoped');
|
|
308
|
+
assert.equal(attachmentCalls.count, 1, 'getVolumeAttachment must be read exactly once before failing fast, never polled to deadline');
|
|
309
|
+
});
|
|
310
|
+
/** A fake modeling the normal path: the row-pointed generation is tag-scoped and destroyed, and
|
|
311
|
+
* the volume reports still attached to THAT generation on the guard's read, then detached (null)
|
|
312
|
+
* on the next read inside `pollVolumeDetached` — a real destroy+detach seconds apart. Confirms
|
|
313
|
+
* the guard does NOT fire for a holder within the destroy set, and the poll still runs. */
|
|
314
|
+
function makeDestroyedHolderProvider(destroyed, attachmentCalls) {
|
|
315
|
+
const doomedName = `${VOLUME_ID}-gold`;
|
|
316
|
+
const fake = {
|
|
317
|
+
providerName: 'blaxel',
|
|
318
|
+
imageRef: 'crouter-hearth-home-0-3-35',
|
|
319
|
+
templateVersion: '0.3.35',
|
|
320
|
+
volumeSizeGb: 4,
|
|
321
|
+
async listMachines(filter) {
|
|
322
|
+
if (filter?.externalId !== VOLUME_ID)
|
|
323
|
+
return [];
|
|
324
|
+
return [{ providerName: 'blaxel', machineId: doomedName, state: 'running' }];
|
|
325
|
+
},
|
|
326
|
+
async destroy(machineId) {
|
|
327
|
+
destroyed.push(machineId);
|
|
328
|
+
return { kind: 'destroyed' };
|
|
329
|
+
},
|
|
330
|
+
async getVolumeAttachment() {
|
|
331
|
+
attachmentCalls.count += 1;
|
|
332
|
+
// First read (the guard, right after destroy): still shows the just-destroyed generation
|
|
333
|
+
// as the holder — a real detach is not instantaneous. Every subsequent read (inside
|
|
334
|
+
// `pollVolumeDetached`): the platform has confirmed detach.
|
|
335
|
+
return attachmentCalls.count === 1 ? `sandbox:${doomedName}` : null;
|
|
336
|
+
},
|
|
337
|
+
async createMachine(input) {
|
|
338
|
+
return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
|
|
339
|
+
},
|
|
340
|
+
async exec() {
|
|
341
|
+
return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
|
|
342
|
+
},
|
|
343
|
+
async waitForPort() { },
|
|
344
|
+
async publishHttpPort(machineId, input) {
|
|
345
|
+
return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
|
|
346
|
+
},
|
|
347
|
+
async getMachine(machineId) {
|
|
348
|
+
return { providerName: 'blaxel', machineId, state: 'running' };
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
return fake;
|
|
352
|
+
}
|
|
353
|
+
test('loud guard: does NOT fire when the holder is a sandbox the tag-scoped sweep just destroyed — normal recreate still completes', async () => {
|
|
354
|
+
const destroyed = [];
|
|
355
|
+
const attachmentCalls = { count: 0 };
|
|
356
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makeDestroyedHolderProvider(destroyed, attachmentCalls));
|
|
357
|
+
const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
|
|
358
|
+
assert.ok(destroyed.includes(`${VOLUME_ID}-gold`), 'the tag-scoped generation must still be destroyed');
|
|
359
|
+
assert.ok(attachmentCalls.count >= 2, 'the guard read plus at least one poll read must both occur');
|
|
360
|
+
assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
|
|
361
|
+
assert.equal(refresh.templateVersion, '0.3.36');
|
|
362
|
+
});
|
|
@@ -8,11 +8,17 @@ export declare class BlaxelHomeBackend {
|
|
|
8
8
|
wake(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
|
|
9
9
|
/** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
|
|
10
10
|
* point-in-time `attachedTo` read, which Blaxel's autonomous idle→STANDBY detach can race
|
|
11
|
-
* ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). The
|
|
12
|
+
* destroy set is intentionally TAG-SCOPED (externalId = volumeId) and is never widened by a
|
|
13
|
+
* name-family fallback (MAJOR-1) — so after destroying it, read the volume's current holder
|
|
14
|
+
* ONCE: if the volume is still attached to a sandbox that is NOT one we just destroyed, that
|
|
15
|
+
* holder is outside the strict sweep's reach (an untagged holder, or a foreign claim) and will
|
|
16
|
+
* never detach on its own, so fail fast with an actionable error instead of silently spinning
|
|
17
|
+
* the detach poll to its timeout. Otherwise poll
|
|
18
|
+
* until the platform reports the volume detached. Safe because at sweep time the replacement
|
|
19
|
+
* does not exist yet (its name is minted after) and any live family member is by construction
|
|
20
|
+
* either the generation being replaced or an uncommitted leftover — the home's durable state
|
|
21
|
+
* is the volume, never a sandbox; one wake per home runs at a time. */
|
|
16
22
|
private sweepAndReleaseVolume;
|
|
17
23
|
/** Poll the volume until the platform reports it detached — the create-precondition gate
|
|
18
24
|
* (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
|
|
@@ -189,11 +189,17 @@ export class BlaxelHomeBackend {
|
|
|
189
189
|
}
|
|
190
190
|
/** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
|
|
191
191
|
* point-in-time `attachedTo` read, which Blaxel's autonomous idle→STANDBY detach can race
|
|
192
|
-
* ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3).
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
192
|
+
* ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). The
|
|
193
|
+
* destroy set is intentionally TAG-SCOPED (externalId = volumeId) and is never widened by a
|
|
194
|
+
* name-family fallback (MAJOR-1) — so after destroying it, read the volume's current holder
|
|
195
|
+
* ONCE: if the volume is still attached to a sandbox that is NOT one we just destroyed, that
|
|
196
|
+
* holder is outside the strict sweep's reach (an untagged holder, or a foreign claim) and will
|
|
197
|
+
* never detach on its own, so fail fast with an actionable error instead of silently spinning
|
|
198
|
+
* the detach poll to its timeout. Otherwise poll
|
|
199
|
+
* until the platform reports the volume detached. Safe because at sweep time the replacement
|
|
200
|
+
* does not exist yet (its name is minted after) and any live family member is by construction
|
|
201
|
+
* either the generation being replaced or an uncommitted leftover — the home's durable state
|
|
202
|
+
* is the volume, never a sandbox; one wake per home runs at a time. */
|
|
197
203
|
async sweepAndReleaseVolume(volumeId, homeId) {
|
|
198
204
|
// Scope the list to sandboxes Hearth tagged for THIS home (externalId = volumeId), then keep
|
|
199
205
|
// the name-family matcher as an AND-intersection on top (orphan-cleanup-fix MAJOR-1). The
|
|
@@ -207,6 +213,19 @@ export class BlaxelHomeBackend {
|
|
|
207
213
|
await this.provider.destroy(name); // sequential; destroy tolerates not_found
|
|
208
214
|
if (doomed.length > 0)
|
|
209
215
|
emitMarker('hearth.recreate.sweep', { home_id: homeId, volume_id: volumeId, destroyed: doomed });
|
|
216
|
+
// Loud guard: the sweep above is strictly tag-scoped, so it can only ever destroy a sandbox
|
|
217
|
+
// that was created with `externalId = volumeId`. Read the volume's current holder ONCE,
|
|
218
|
+
// before the detach poll, to catch the case that sweep is structurally unable to fix — a
|
|
219
|
+
// holder that is NOT one of the sandboxes just destroyed (`doomed`) will never detach, and
|
|
220
|
+
// the poll below would spin the full VOLUME_DETACH_TIMEOUT_MS only to throw the same thing
|
|
221
|
+
// less clearly. Fail fast instead, naming the holder so an operator can act on it directly.
|
|
222
|
+
const holder = await this.provider.getVolumeAttachment(volumeId);
|
|
223
|
+
if (holder !== null) {
|
|
224
|
+
const holderName = holder.startsWith('sandbox:') ? holder.slice('sandbox:'.length) : holder;
|
|
225
|
+
if (!doomed.includes(holderName)) {
|
|
226
|
+
throw general(`legacy untagged sandbox ${holderName} holds volume ${volumeId} for home ${homeId} — the tag-scoped sweep cannot reclaim it (no externalId tag); migrate this home (delete the sandbox so a plain wake recreates it tagged) before rolling`, { homeId, volumeId, holder: holderName });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
210
229
|
await this.pollVolumeDetached(volumeId, homeId);
|
|
211
230
|
}
|
|
212
231
|
/** Poll the volume until the platform reports it detached — the create-precondition gate
|