@crouton-kit/crouter 0.3.38 → 0.3.39
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/core/hearth/provider.d.ts +8 -0
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.d.ts +1 -0
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +254 -0
- package/dist/core/hearth/providers/blaxel-home.d.ts +39 -0
- package/dist/core/hearth/providers/blaxel-home.js +271 -21
- package/dist/core/hearth/providers/blaxel.d.ts +5 -0
- package/dist/core/hearth/providers/blaxel.js +86 -4
- package/dist/core/hearth/providers/types.d.ts +16 -0
- package/dist/hearth/control-plane/__tests__/error-serialization.test.d.ts +1 -0
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +29 -0
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +1 -0
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +44 -0
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +1 -0
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +230 -0
- package/dist/hearth/control-plane/db.js +27 -0
- package/dist/hearth/control-plane/hearth-target.d.ts +23 -0
- package/dist/hearth/control-plane/hearth-target.js +68 -0
- package/dist/hearth/control-plane/registry.d.ts +6 -1
- package/dist/hearth/control-plane/registry.js +7 -0
- package/dist/hearth/control-plane/relay.d.ts +4 -0
- package/dist/hearth/control-plane/relay.js +72 -3
- package/dist/hearth/control-plane/secrets.d.ts +14 -0
- package/dist/hearth/control-plane/secrets.js +21 -0
- package/dist/hearth/control-plane/server.js +140 -4
- package/dist/hearth/control-plane/serving.d.ts +15 -0
- package/dist/hearth/control-plane/serving.js +106 -0
- package/dist/hearth/control-plane/types.d.ts +20 -0
- package/dist/hearth/control-plane/wake.d.ts +8 -2
- package/dist/hearth/control-plane/wake.js +241 -3
- package/package.json +1 -1
|
@@ -11,6 +11,14 @@ export interface HomeBackend {
|
|
|
11
11
|
* and return refreshed provider pointers for the CP registry to persist.
|
|
12
12
|
*/
|
|
13
13
|
wake(home: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
|
|
14
|
+
/**
|
|
15
|
+
* Targeted destroy→recreate of a home onto a SPECIFIC image, reattaching the same durable
|
|
16
|
+
* volume (auto-upgrade roll and rollback, design §4/§5). Force-destroys the sandbox first so
|
|
17
|
+
* the recreate genuinely lands on `imageRef` (the volume is never touched), reboots the guest,
|
|
18
|
+
* and reports `templateVersion` for the CP to record. The caller independently asserts the
|
|
19
|
+
* running `crtr sys version` in-guest before trusting the flip.
|
|
20
|
+
*/
|
|
21
|
+
recreateOnImage(home: HomeProviderDescriptor, imageRef: string, templateVersion: string): Promise<HomeProviderRefresh>;
|
|
14
22
|
/** Pause the home and return refreshed provider pointers for the CP registry to persist. */
|
|
15
23
|
suspend(home: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
|
|
16
24
|
/** Machine resources (cpu/mem) for status, or null when unavailable. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { BlaxelHomeBackend } from '../blaxel-home.js';
|
|
4
|
+
import { isMissingResourceError } from '../blaxel.js';
|
|
5
|
+
// Regression for the Run-3 orphan gap (orphan-cleanup-fix.md §1/§3.3): cleanup used a
|
|
6
|
+
// point-in-time `attachedTo` read to find "the sandbox holding the volume", but Blaxel
|
|
7
|
+
// autonomously drops that claim on idle→STANDBY, so a failed roll-forward generation that had
|
|
8
|
+
// already been detached was invisible to cleanup and lived on as a billed, route-registered,
|
|
9
|
+
// volume-claiming orphan. The fix destroys the entire generation NAME FAMILY via `listMachines`
|
|
10
|
+
// with ZERO dependence on attachment state — so a family member NOT holding the volume must
|
|
11
|
+
// still be destroyed, and a same-prefixed foreign name must NOT.
|
|
12
|
+
const VOLUME_ID = 'hearth-zt-test-1';
|
|
13
|
+
function makeConfig() {
|
|
14
|
+
return {
|
|
15
|
+
providerName: 'blaxel',
|
|
16
|
+
guestUser: 'agent',
|
|
17
|
+
guestHome: '/home/agent',
|
|
18
|
+
guestCodePath: '/home/agent/code',
|
|
19
|
+
piPackages: [],
|
|
20
|
+
webPort: 8080,
|
|
21
|
+
nodeKind: 'developer',
|
|
22
|
+
imageRef: 'crouter-hearth-home-0-3-35',
|
|
23
|
+
templateVersion: '0.3.35',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeDescriptor() {
|
|
27
|
+
return {
|
|
28
|
+
homeId: 'home-1',
|
|
29
|
+
tenantId: 'tenant-1',
|
|
30
|
+
providerName: 'blaxel',
|
|
31
|
+
providerSandboxId: `${VOLUME_ID}-gold`,
|
|
32
|
+
providerVolumeId: VOLUME_ID,
|
|
33
|
+
homeAgentTarget: 'guest-node-1',
|
|
34
|
+
relayToken: 'relay-token',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** A fake provider whose `listMachines` HONORS the `externalId` filter over a tagged inventory:
|
|
38
|
+
* the row-pointed generation, a DETACHED/standby family member NOT holding the volume (the Run-3
|
|
39
|
+
* miss), a FAILED family record (a create that 500'd but left a row, e.g. Blaxel's `gmr…azqr`),
|
|
40
|
+
* and the bare base name — all tagged `externalId = VOLUME_ID`. Plus survivors: a same-prefixed
|
|
41
|
+
* FOREIGN home (`hearth-zt-test-10-…`, numeric-adjacent, spared by the `-g` anchor too); the
|
|
42
|
+
* suffix-extension collision `<base>-gfoo` that IS in the name family but belongs to a DIFFERENT
|
|
43
|
+
* home (tagged with its own volume id) — destroyed by the old name-only sweep, spared now by the
|
|
44
|
+
* tag filter; and a same-name-family-but-UNTAGGED record (a legacy pre-backfill member, no
|
|
45
|
+
* externalId) — excluded by the filter, so it survives as the alerted self-heals orphan class,
|
|
46
|
+
* NOT a collateral destroy. Records every destroy target. */
|
|
47
|
+
function makeFakeProvider(destroyed) {
|
|
48
|
+
const inventory = [
|
|
49
|
+
{ ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gold`, state: 'running' }, externalId: VOLUME_ID }, // row pointer
|
|
50
|
+
{ ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gdetached`, state: 'running' }, externalId: VOLUME_ID }, // detached standby orphan — the miss
|
|
51
|
+
{ ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gfailed`, state: 'terminated' }, externalId: VOLUME_ID }, // FAILED leftover record
|
|
52
|
+
{ ref: { providerName: 'blaxel', machineId: VOLUME_ID, state: 'running' }, externalId: VOLUME_ID }, // bare base name
|
|
53
|
+
{ ref: { providerName: 'blaxel', machineId: 'hearth-zt-test-10-gxyz', state: 'running' }, externalId: 'hearth-zt-test-10' }, // FOREIGN numeric-adjacent home, must survive
|
|
54
|
+
{ ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gfoo`, state: 'running' }, externalId: `${VOLUME_ID}-gfoo` }, // suffix-extension collision, DIFFERENT home — must survive
|
|
55
|
+
{ ref: { providerName: 'blaxel', machineId: `${VOLUME_ID}-gstale`, state: 'running' } }, // same-name-family but UNTAGGED (legacy) — must survive
|
|
56
|
+
{ ref: { providerName: 'blaxel', machineId: 'unrelated-sandbox', state: 'running' } }, // unrelated, must survive
|
|
57
|
+
];
|
|
58
|
+
const fake = {
|
|
59
|
+
providerName: 'blaxel',
|
|
60
|
+
imageRef: 'crouter-hearth-home-0-3-35',
|
|
61
|
+
templateVersion: '0.3.35',
|
|
62
|
+
volumeSizeGb: 4,
|
|
63
|
+
async listMachines(filter) {
|
|
64
|
+
return inventory.filter((e) => filter?.externalId === undefined || e.externalId === filter.externalId).map((e) => e.ref);
|
|
65
|
+
},
|
|
66
|
+
async destroy(machineId) {
|
|
67
|
+
destroyed.push(machineId);
|
|
68
|
+
return { kind: 'destroyed' };
|
|
69
|
+
},
|
|
70
|
+
// Volume already free (Blaxel detached it) — the poll passes on the first read.
|
|
71
|
+
async getVolumeAttachment() {
|
|
72
|
+
return null;
|
|
73
|
+
},
|
|
74
|
+
async createMachine(input) {
|
|
75
|
+
return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
|
|
76
|
+
},
|
|
77
|
+
async exec(_id, _input) {
|
|
78
|
+
return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
|
|
79
|
+
},
|
|
80
|
+
async waitForPort() { },
|
|
81
|
+
async publishHttpPort(machineId, input) {
|
|
82
|
+
return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
|
|
83
|
+
},
|
|
84
|
+
async getMachine(machineId) {
|
|
85
|
+
return { providerName: 'blaxel', machineId, state: 'running' };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return fake;
|
|
89
|
+
}
|
|
90
|
+
// Regression for the recovery-wake live re-proof (SHA 567cd4f, backfill-recovery-evidence.md §3):
|
|
91
|
+
// the Blaxel SDK (@blaxel/core 0.2.94) rejects a not-found lookup with a PLAIN OBJECT
|
|
92
|
+
// (`{code:404,error:"Sandbox not found"}`), not an `Error` instance. `String()` on that object
|
|
93
|
+
// yields the literal "[object Object]", which matched none of the message regex, so the
|
|
94
|
+
// predicate returned `false` for a genuine 404 — breaking the `getMachine` terminated-machine
|
|
95
|
+
// fallback (the sweep's entry point) AND the `destroy`/`destroyVolume` not_found tolerance the
|
|
96
|
+
// sweep's per-name destroy loop depends on.
|
|
97
|
+
test('isMissingResourceError recognizes the Blaxel SDK plain-object 404 shape', () => {
|
|
98
|
+
assert.equal(isMissingResourceError({ code: 404, error: 'Sandbox not found' }), true, 'plain-object 404 (the exact live shape) must be recognized');
|
|
99
|
+
assert.equal(isMissingResourceError({ code: 500, error: 'boom' }), false, 'an unrelated plain-object error must not be mistaken for not-found');
|
|
100
|
+
});
|
|
101
|
+
test('isMissingResourceError preserves existing Error-message-regex behavior', () => {
|
|
102
|
+
assert.equal(isMissingResourceError(new Error('sandbox hearth-zt-test-1-gxyz not found')), true, 'an Error whose message matches the regex must still be recognized');
|
|
103
|
+
assert.equal(isMissingResourceError(new Error('boom')), false, 'an unrelated Error must not be mistaken for not-found');
|
|
104
|
+
});
|
|
105
|
+
test('sweepAndReleaseVolume (via recreateOnImage) destroys EVERY name-family member — including a detached one — and spares foreign homes', async () => {
|
|
106
|
+
const destroyed = [];
|
|
107
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makeFakeProvider(destroyed));
|
|
108
|
+
const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
|
|
109
|
+
// Both the row-pointed generation AND the detached standby orphan (the exact Run-3 miss) are
|
|
110
|
+
// destroyed, plus the FAILED leftover and the bare base name.
|
|
111
|
+
assert.ok(destroyed.includes(`${VOLUME_ID}-gold`), 'row-pointed generation must be destroyed');
|
|
112
|
+
assert.ok(destroyed.includes(`${VOLUME_ID}-gdetached`), 'DETACHED standby orphan (Run-3 miss) must be destroyed');
|
|
113
|
+
assert.ok(destroyed.includes(`${VOLUME_ID}-gfailed`), 'FAILED leftover record must be destroyed');
|
|
114
|
+
assert.ok(destroyed.includes(VOLUME_ID), 'bare base-name generation must be destroyed');
|
|
115
|
+
// The `-g` anchor keeps a same-prefixed foreign home and unrelated sandboxes safe.
|
|
116
|
+
assert.ok(!destroyed.includes('hearth-zt-test-10-gxyz'), 'foreign home hearth-zt-test-10 must NOT be swept (-g anchor)');
|
|
117
|
+
assert.ok(!destroyed.includes('unrelated-sandbox'), 'unrelated sandbox must NOT be swept');
|
|
118
|
+
// MAJOR-1: the suffix-extension collision (`<base>-gfoo`, a DIFFERENT home) IS in the name
|
|
119
|
+
// family but carries a different externalId, so the tag-scoped list never surfaces it — the
|
|
120
|
+
// old name-only sweep would have collateral-destroyed it. And a same-name-family but UNTAGGED
|
|
121
|
+
// legacy record is likewise excluded by the filter, surviving as the self-heals orphan class.
|
|
122
|
+
assert.ok(!destroyed.includes(`${VOLUME_ID}-gfoo`), 'suffix-extension foreign home <base>-gfoo must NOT be swept (untagged — MAJOR-1)');
|
|
123
|
+
assert.ok(!destroyed.includes(`${VOLUME_ID}-gstale`), 'same-name-family but UNTAGGED record must NOT be swept (tag filter excludes it)');
|
|
124
|
+
// The four tagged family members are destroyed before the fresh generation is created.
|
|
125
|
+
assert.equal(destroyed.length, 4, 'exactly the four tagged family members are destroyed');
|
|
126
|
+
// A fresh `-g` generation is minted and reported on the target version.
|
|
127
|
+
assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
|
|
128
|
+
assert.notEqual(refresh.providerSandboxId, `${VOLUME_ID}-gold`);
|
|
129
|
+
assert.equal(refresh.templateVersion, '0.3.36');
|
|
130
|
+
});
|
|
131
|
+
/** A fake modeling a provider that assembles its filtered result from MULTIPLE cursor pages
|
|
132
|
+
* before returning (MINOR-1). The tagged family spans 3 pages; `listMachines` walks all pages,
|
|
133
|
+
* concatenates, and honors the externalId filter — the same total the real paginated
|
|
134
|
+
* `listSandboxes` loop assembles. Asserts the sweep destroys every tagged member across every
|
|
135
|
+
* page, so a family member beyond page 1 can never survive as an orphan. */
|
|
136
|
+
function makePaginatedFakeProvider(familyIds, destroyed) {
|
|
137
|
+
// Split the tagged family across three pages; sprinkle in a foreign untagged record per page.
|
|
138
|
+
const pages = [[], [], []];
|
|
139
|
+
familyIds.forEach((id, i) => pages[i % 3].push({ ref: { providerName: 'blaxel', machineId: id, state: 'running' }, externalId: VOLUME_ID }));
|
|
140
|
+
pages[0].push({ ref: { providerName: 'blaxel', machineId: 'foreign-a', state: 'running' }, externalId: 'other-home' });
|
|
141
|
+
pages[2].push({ ref: { providerName: 'blaxel', machineId: 'foreign-b', state: 'running' } });
|
|
142
|
+
const fake = {
|
|
143
|
+
providerName: 'blaxel',
|
|
144
|
+
imageRef: 'crouter-hearth-home-0-3-35',
|
|
145
|
+
templateVersion: '0.3.35',
|
|
146
|
+
volumeSizeGb: 4,
|
|
147
|
+
async listMachines(filter) {
|
|
148
|
+
// Assemble across all pages (what the real cursor loop does) then apply the filter.
|
|
149
|
+
return pages.flat().filter((e) => filter?.externalId === undefined || e.externalId === filter.externalId).map((e) => e.ref);
|
|
150
|
+
},
|
|
151
|
+
async destroy(machineId) {
|
|
152
|
+
destroyed.push(machineId);
|
|
153
|
+
return { kind: 'destroyed' };
|
|
154
|
+
},
|
|
155
|
+
async getVolumeAttachment() {
|
|
156
|
+
return null;
|
|
157
|
+
},
|
|
158
|
+
async createMachine(input) {
|
|
159
|
+
return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
|
|
160
|
+
},
|
|
161
|
+
async exec() {
|
|
162
|
+
return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
|
|
163
|
+
},
|
|
164
|
+
async waitForPort() { },
|
|
165
|
+
async publishHttpPort(machineId, input) {
|
|
166
|
+
return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
|
|
167
|
+
},
|
|
168
|
+
async getMachine(machineId) {
|
|
169
|
+
return { providerName: 'blaxel', machineId, state: 'running' };
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return fake;
|
|
173
|
+
}
|
|
174
|
+
test('MINOR-1 pagination: a family served across multiple pages is fully found+destroyed by the sweep', async () => {
|
|
175
|
+
const destroyed = [];
|
|
176
|
+
// A family that would straddle page boundaries: the base plus several `-g` generations.
|
|
177
|
+
const familyIds = [VOLUME_ID, `${VOLUME_ID}-ga`, `${VOLUME_ID}-gb`, `${VOLUME_ID}-gc`, `${VOLUME_ID}-gd`, `${VOLUME_ID}-ge`, `${VOLUME_ID}-gf`];
|
|
178
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makePaginatedFakeProvider(familyIds, destroyed));
|
|
179
|
+
await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
|
|
180
|
+
// Every tagged family member across every page is destroyed — none survives beyond page 1.
|
|
181
|
+
for (const id of familyIds)
|
|
182
|
+
assert.ok(destroyed.includes(id), `family member ${id} (across pages) must be destroyed`);
|
|
183
|
+
assert.equal(destroyed.length, familyIds.length, 'exactly the tagged family is destroyed — foreign records on the same pages survive');
|
|
184
|
+
assert.ok(!destroyed.includes('foreign-a'), 'foreign tagged record must survive');
|
|
185
|
+
assert.ok(!destroyed.includes('foreign-b'), 'foreign untagged record must survive');
|
|
186
|
+
});
|
|
187
|
+
/** A fake whose `createMachine` runs a scripted sequence of outcomes ('ok' | a rejection value)
|
|
188
|
+
* per call, with an empty family (isolating the §4 retry logic from the sweep). Records every
|
|
189
|
+
* destroy so self-cleanup and the discarded generation are observable. */
|
|
190
|
+
function makeCreateScriptedProvider(script, destroyed) {
|
|
191
|
+
let call = 0;
|
|
192
|
+
const fake = {
|
|
193
|
+
providerName: 'blaxel',
|
|
194
|
+
imageRef: 'crouter-hearth-home-0-3-35',
|
|
195
|
+
templateVersion: '0.3.35',
|
|
196
|
+
volumeSizeGb: 4,
|
|
197
|
+
async listMachines() {
|
|
198
|
+
return [];
|
|
199
|
+
},
|
|
200
|
+
async destroy(machineId) {
|
|
201
|
+
destroyed.push(machineId);
|
|
202
|
+
return { kind: 'destroyed' };
|
|
203
|
+
},
|
|
204
|
+
async getVolumeAttachment() {
|
|
205
|
+
return null;
|
|
206
|
+
},
|
|
207
|
+
async createMachine(input) {
|
|
208
|
+
const outcome = script[call++];
|
|
209
|
+
if (outcome !== 'ok')
|
|
210
|
+
throw outcome;
|
|
211
|
+
return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
|
|
212
|
+
},
|
|
213
|
+
async exec() {
|
|
214
|
+
return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
|
|
215
|
+
},
|
|
216
|
+
async waitForPort() { },
|
|
217
|
+
async publishHttpPort(machineId, input) {
|
|
218
|
+
return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
|
|
219
|
+
},
|
|
220
|
+
async getMachine(machineId) {
|
|
221
|
+
return { providerName: 'blaxel', machineId, state: 'running' };
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
return fake;
|
|
225
|
+
}
|
|
226
|
+
test('§4 retry: a genuine 5xx DEPLOYMENT_FAILED create is self-cleaned and retried ONCE onto a fresh name', async () => {
|
|
227
|
+
const destroyed = [];
|
|
228
|
+
// First create fails with a server-side deployment transient; the bounded retry then succeeds.
|
|
229
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makeCreateScriptedProvider([{ status_code: 500, code: 'DEPLOYMENT_FAILED', message: 'deployment failed, please try again' }, 'ok'], destroyed));
|
|
230
|
+
const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
|
|
231
|
+
// The failed first generation is self-cleaned (destroyed) — no orphan — and exactly one
|
|
232
|
+
// discarded generation was destroyed (empty family means the only destroy is the self-cleanup).
|
|
233
|
+
assert.equal(destroyed.length, 1, 'exactly the failed first generation is self-cleaned');
|
|
234
|
+
// The returned generation is the SECOND minted name, not the discarded one.
|
|
235
|
+
assert.notEqual(refresh.providerSandboxId, destroyed[0]);
|
|
236
|
+
assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
|
|
237
|
+
assert.equal(refresh.templateVersion, '0.3.36');
|
|
238
|
+
});
|
|
239
|
+
test('§4 retry: a 4xx create is NEVER retried — it self-cleans and rethrows the original error', async () => {
|
|
240
|
+
const destroyed = [];
|
|
241
|
+
const boom = { status_code: 409, code: 'SANDBOX_ALREADY_EXISTS', message: 'conflict' };
|
|
242
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makeCreateScriptedProvider([boom, 'ok'], destroyed));
|
|
243
|
+
await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => err === boom, 'a 4xx must rethrow the ORIGINAL rejection, never be absorbed by a retry');
|
|
244
|
+
// Self-cleanup destroyed the one failed generation, and the retry path never ran a second create.
|
|
245
|
+
assert.equal(destroyed.length, 1, 'the failed 4xx generation is still self-cleaned exactly once');
|
|
246
|
+
});
|
|
247
|
+
test('§4 retry: a second consecutive retryable failure throws (bounded to one retry) and both generations are self-cleaned', async () => {
|
|
248
|
+
const destroyed = [];
|
|
249
|
+
const boom = { status_code: 503, code: 'DEPLOYMENT_FAILED', message: 'still failing' };
|
|
250
|
+
const backend = new BlaxelHomeBackend(makeConfig(), makeCreateScriptedProvider([boom, boom], destroyed));
|
|
251
|
+
await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => err === boom);
|
|
252
|
+
// Both the first and the retry generation are self-cleaned — no orphan survives a double failure.
|
|
253
|
+
assert.equal(destroyed.length, 2, 'both failed generations are self-cleaned');
|
|
254
|
+
});
|
|
@@ -6,6 +6,45 @@ export declare class BlaxelHomeBackend {
|
|
|
6
6
|
private readonly provider;
|
|
7
7
|
constructor(config: M0Config, provider?: BlaxelMachineProvider);
|
|
8
8
|
wake(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
|
|
9
|
+
/** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
|
|
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). Then
|
|
12
|
+
* poll until the platform reports the volume detached. Safe because at sweep time the
|
|
13
|
+
* replacement does not exist yet (its name is minted after) and any live family member is by
|
|
14
|
+
* construction either the generation being replaced or an uncommitted leftover — the home's
|
|
15
|
+
* durable state is the volume, never a sandbox; one wake per home runs at a time. */
|
|
16
|
+
private sweepAndReleaseVolume;
|
|
17
|
+
/** Poll the volume until the platform reports it detached — the create-precondition gate
|
|
18
|
+
* (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
|
|
19
|
+
* whenever it actually had to wait, so the re-proof can quantify headroom against the budget. */
|
|
20
|
+
private pollVolumeDetached;
|
|
21
|
+
/** Create one fresh generation and bring it to a serving state (create → bootstrap exec →
|
|
22
|
+
* waitForPort → publish → getMachine), with provider self-cleanup and a single bounded
|
|
23
|
+
* retry (orphan-cleanup-fix.md §3.4/§4). Each attempt destroys the generation IT created if
|
|
24
|
+
* anything from `createMachine` through the final `getMachine` fails — no code path exits
|
|
25
|
+
* leaving a live generation it created and did not return — then rethrows the ORIGINAL error
|
|
26
|
+
* (cleanup never masks it). On the first failure, if the error is a genuine server-side
|
|
27
|
+
* deployment transient, pause, re-run ONLY the volume-free poll (the sweep already ran once),
|
|
28
|
+
* mint a NEW name, and retry exactly once; a second failure throws. The sweep is the caller's
|
|
29
|
+
* responsibility and runs before the first attempt only. */
|
|
30
|
+
private createGeneration;
|
|
31
|
+
/** Targeted destroy→recreate onto a SPECIFIC image (auto-upgrade roll and
|
|
32
|
+
* rollback, design §4/§5). Unlike `wake()`'s standby-resume path, this is the ONE path
|
|
33
|
+
* that force-destroys the home's sandboxes before recreating. Cleanup is a deterministic
|
|
34
|
+
* name-family sweep (orphan-cleanup-fix.md §3.3): every generation of this home is destroyed
|
|
35
|
+
* before the fresh one is minted, so a failed roll-forward generation can never survive as a
|
|
36
|
+
* live orphan even after Blaxel autonomously detaches it from the volume. The recreate lands
|
|
37
|
+
* on a FRESH sandbox name every call — a name that has never existed cannot 409 against a
|
|
38
|
+
* lingering same-name delete. The durable volume is a separate resource and is NEVER touched
|
|
39
|
+
* (flow-D durability) — user state is byte-identical across the flip, which is exactly what
|
|
40
|
+
* makes both the upgrade and the rollback lossless. The new `providerSandboxId` is returned on
|
|
41
|
+
* the refresh; callers persist it (`refreshProviderPointers` treats it as a refreshable
|
|
42
|
+
* pointer). `templateVersion` is baked into the bootstrap's fail-fast version assert (the
|
|
43
|
+
* baked crtr must match) AND reported back so the CP records the target version — the caller
|
|
44
|
+
* still independently asserts `crtr sys version` in-guest before trusting it (wake.ts
|
|
45
|
+
* `rollHome`/`rollbackHome`, against `refresh.providerSandboxId`, never a stale captured
|
|
46
|
+
* name). */
|
|
47
|
+
recreateOnImage(homeInput: HomeProviderDescriptor, imageRef: string, templateVersion: string): Promise<HomeProviderRefresh>;
|
|
9
48
|
suspend(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
|
|
10
49
|
getMachine(machineId: string): Promise<MachineRef | null>;
|
|
11
50
|
/** P2.6 stdin-capable exec seam, passed through verbatim — the `HomeBackend` interface is the
|
|
@@ -1,10 +1,93 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
1
2
|
import { general } from '../../errors.js';
|
|
2
3
|
import { nowIso } from '../../fs-utils.js';
|
|
3
4
|
import { guestEnv } from '../guest-env.js';
|
|
4
5
|
import { buildBootstrapScript } from './blaxel-bootstrap.js';
|
|
5
6
|
import { BlaxelMachineProvider } from './blaxel.js';
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// recreate-race-fix.md §3.2 — a volume GET is one cheap API call; 1s resolution is plenty
|
|
8
|
+
// against an expected seconds-scale detach.
|
|
9
|
+
const VOLUME_DETACH_POLL_MS = 1_000;
|
|
10
|
+
// Expected wait is seconds (observed live); 120s is ~20-60x headroom over that, so this still
|
|
11
|
+
// fails deterministically inside one wake's lifetime rather than hanging forever on a genuine
|
|
12
|
+
// platform-side stuck detach.
|
|
13
|
+
const VOLUME_DETACH_TIMEOUT_MS = 120_000;
|
|
14
|
+
// orphan-cleanup-fix.md §4 — pause before the single bounded retry of a create that failed on a
|
|
15
|
+
// server-side deployment transient, to let the platform-side condition clear.
|
|
16
|
+
const CREATE_RETRY_DELAY_MS = 5_000;
|
|
17
|
+
function stableRouteName(sandboxId) {
|
|
18
|
+
return `${sandboxId}-web`;
|
|
19
|
+
}
|
|
20
|
+
/** Mint a fresh per-generation sandbox name. `providerVolumeId` is the home's stable base
|
|
21
|
+
* (never suffixed), so names stay bounded: `hearth-zt-test-1-gmchq3x2a`. Sequential recreates
|
|
22
|
+
* are seconds apart, so a ms timestamp cannot collide for one home (recreate-race-fix.md §3.2). */
|
|
23
|
+
function mintSandboxName(providerVolumeId) {
|
|
24
|
+
return `${providerVolumeId}-g${Date.now().toString(36)}`;
|
|
25
|
+
}
|
|
26
|
+
/** Escape a volumeId for safe use inside a RegExp so the generation-family anchor is exact —
|
|
27
|
+
* a home base name is user-influenced, so a literal `.` or `+` in it must not become a
|
|
28
|
+
* wildcard (orphan-cleanup-fix.md §3.1). */
|
|
29
|
+
function escapeRegExp(value) {
|
|
30
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
31
|
+
}
|
|
32
|
+
/** Predicate matching every generation of a home's name family: the stable base itself, or a
|
|
33
|
+
* `<base>-g<suffix>` generation. The `-g` anchor is load-bearing — it prevents `<base>0-g…`
|
|
34
|
+
* (e.g. `hearth-zt-test-10-g…`) from matching base `hearth-zt-test-1` (orphan-cleanup-fix.md
|
|
35
|
+
* §3.1). Suffix chars are base36 (lowercase alnum), matching `mintSandboxName`. */
|
|
36
|
+
function generationFamilyMatcher(volumeId) {
|
|
37
|
+
const gen = new RegExp(`^${escapeRegExp(volumeId)}-g[0-9a-z]+$`);
|
|
38
|
+
return (name) => name === volumeId || gen.test(name);
|
|
39
|
+
}
|
|
40
|
+
/** Serialize an arbitrary rejection to a non-empty marker string. `JSON.stringify` RETURNS
|
|
41
|
+
* `undefined` (does not throw) for `undefined`, a function, or a symbol — falling through to
|
|
42
|
+
* that would omit the `error` field from the marker's JSON line entirely, so fall back to
|
|
43
|
+
* `String(...)` whenever it yields `undefined` (matches wake.ts/relay.ts `serializeError`). */
|
|
44
|
+
function serialize(error) {
|
|
45
|
+
if (error instanceof Error)
|
|
46
|
+
return error.message;
|
|
47
|
+
try {
|
|
48
|
+
const json = JSON.stringify(error);
|
|
49
|
+
return json === undefined ? String(error) : json;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return String(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Emit one structured stderr line for a recreate-lifecycle marker (sweep / volume_wait /
|
|
56
|
+
* cleanup_failed / create_retry). Single writer for all four so the JSON shape stays uniform
|
|
57
|
+
* and ops log-alerting keys on `marker` (orphan-cleanup-fix.md §3.3/§3.4/§4). */
|
|
58
|
+
function emitMarker(marker, fields, level = 'info') {
|
|
59
|
+
process.stderr.write(`${JSON.stringify({ level, marker, ...fields })}\n`);
|
|
60
|
+
}
|
|
61
|
+
/** Classify a failed create+deploy as a retryable server-side deployment transient
|
|
62
|
+
* (orphan-cleanup-fix.md §4). Retryable IFF `status_code >= 500` OR `code === 'DEPLOYMENT_FAILED'`
|
|
63
|
+
* on the rejection object, with a message-substring fallback for Error-wrapped variants whose
|
|
64
|
+
* structured fields were flattened into `.message`. NEVER on 4xx — a 409 here is a real name
|
|
65
|
+
* conflict the design must surface, not absorb, so an explicit 4xx status vetoes the retry even
|
|
66
|
+
* if a `DEPLOYMENT_FAILED` code is also present. */
|
|
67
|
+
function isRetryableDeploymentError(error) {
|
|
68
|
+
if (error !== null && typeof error === 'object') {
|
|
69
|
+
// Only the canonical HTTP-status keys — NOT a bare `status`, which is an overloaded field
|
|
70
|
+
// name some envelopes use for a non-HTTP state enum/count (MINOR-2).
|
|
71
|
+
const obj = error;
|
|
72
|
+
const status = [obj.status_code, obj.statusCode].find((v) => typeof v === 'number');
|
|
73
|
+
if (typeof status === 'number') {
|
|
74
|
+
if (status >= 400 && status < 500)
|
|
75
|
+
return false; // 4xx: real conflict, never absorb
|
|
76
|
+
if (status >= 500)
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (obj.code === 'DEPLOYMENT_FAILED')
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
// Fallback for Error-wrapped variants: only POSITIVE server-side signals, never inferring a 4xx.
|
|
83
|
+
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
|
84
|
+
if (message !== '') {
|
|
85
|
+
if (/DEPLOYMENT_FAILED/.test(message))
|
|
86
|
+
return true;
|
|
87
|
+
if (/\bstatus[_ ]?code\b\D{0,8}5\d\d/i.test(message))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
8
91
|
}
|
|
9
92
|
function ensureReadyHome(input) {
|
|
10
93
|
if (input.providerName !== 'blaxel') {
|
|
@@ -30,10 +113,10 @@ function isRunningMachine(machine) {
|
|
|
30
113
|
function previewEndpoint(route, fallback) {
|
|
31
114
|
return route.concreteUrl ?? route.url ?? fallback ?? null;
|
|
32
115
|
}
|
|
33
|
-
function refreshedPointers(home, provider, route, status, machine) {
|
|
116
|
+
function refreshedPointers(home, provider, route, status, sandboxId, machine) {
|
|
34
117
|
return {
|
|
35
118
|
providerName: 'blaxel',
|
|
36
|
-
providerSandboxId:
|
|
119
|
+
providerSandboxId: sandboxId,
|
|
37
120
|
providerVolumeId: home.providerVolumeId,
|
|
38
121
|
previewEndpoint: route === null ? (home.previewEndpoint ?? null) : previewEndpoint(route, home.previewEndpoint),
|
|
39
122
|
route,
|
|
@@ -80,25 +163,192 @@ export class BlaxelHomeBackend {
|
|
|
80
163
|
await this.provider.exec(current.providerSandboxId, { command: bootstrapScript, background: true, cwd: this.config.guestHome });
|
|
81
164
|
await this.provider.waitForPort(current.providerSandboxId, this.config.webPort, { maxWaitMs: 30_000 });
|
|
82
165
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
envs: guestEnv(this.config),
|
|
89
|
-
volumeMount: { volumeName: current.providerVolumeId, path: this.config.guestHome },
|
|
90
|
-
ports: [{ target: this.config.webPort, protocol: 'HTTP' }],
|
|
166
|
+
const route = await this.provider.publishHttpPort(current.providerSandboxId, {
|
|
167
|
+
machineId: current.providerSandboxId,
|
|
168
|
+
targetPort: this.config.webPort,
|
|
169
|
+
routeName: stableRouteName(current.providerSandboxId),
|
|
170
|
+
routeId: stableRouteName(current.providerSandboxId),
|
|
91
171
|
});
|
|
92
|
-
|
|
93
|
-
|
|
172
|
+
return refreshedPointers(current, this.provider, route, 'running', current.providerSandboxId, await this.getMachine(current.providerSandboxId));
|
|
173
|
+
}
|
|
174
|
+
// Terminated-machine branch (recreate-race-fix.md §3.4, orphan-cleanup-fix.md §3.3/§3.4):
|
|
175
|
+
// the crash-recovery path for a sandbox deleted recently out-of-band OR by a crashed/failed
|
|
176
|
+
// roll (a Fly redeploy restarting the CP mid-roll lands here on the next wake).
|
|
177
|
+
// `sweepAndReleaseVolume` destroys EVERY generation in this home's name family — including
|
|
178
|
+
// an unpointed leftover a crashed roll left holding the volume, which the old point-in-time
|
|
179
|
+
// attachedTo read could miss once Blaxel autonomously detached it on idle→STANDBY — then
|
|
180
|
+
// polls until the volume is confirmed free before minting a fresh name and creating.
|
|
181
|
+
await this.sweepAndReleaseVolume(current.providerVolumeId, current.homeId);
|
|
182
|
+
const { sandboxName, route, machine: created } = await this.createGeneration({
|
|
183
|
+
homeId: current.homeId,
|
|
184
|
+
tenantId: current.tenantId,
|
|
185
|
+
volumeId: current.providerVolumeId,
|
|
186
|
+
bootstrapScript,
|
|
187
|
+
});
|
|
188
|
+
return refreshedPointers(current, this.provider, route, 'running', sandboxName, created);
|
|
189
|
+
}
|
|
190
|
+
/** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
|
|
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). Then
|
|
193
|
+
* poll until the platform reports the volume detached. Safe because at sweep time the
|
|
194
|
+
* replacement does not exist yet (its name is minted after) and any live family member is by
|
|
195
|
+
* construction either the generation being replaced or an uncommitted leftover — the home's
|
|
196
|
+
* durable state is the volume, never a sandbox; one wake per home runs at a time. */
|
|
197
|
+
async sweepAndReleaseVolume(volumeId, homeId) {
|
|
198
|
+
// Scope the list to sandboxes Hearth tagged for THIS home (externalId = volumeId), then keep
|
|
199
|
+
// the name-family matcher as an AND-intersection on top (orphan-cleanup-fix MAJOR-1). The
|
|
200
|
+
// destroy set requires BOTH keys to agree — the tag says "Hearth created this for home X",
|
|
201
|
+
// the name says "it is in X's generation family" — so a single fault (a mistagged create, a
|
|
202
|
+
// future API silently ignoring the filter param) cannot widen the blast radius back onto a
|
|
203
|
+
// foreign home. Fail-safe defense-in-depth on a destructive op, NOT a lenient fallback.
|
|
204
|
+
const inFamily = generationFamilyMatcher(volumeId);
|
|
205
|
+
const doomed = (await this.provider.listMachines({ externalId: volumeId })).map((m) => m.machineId).filter(inFamily);
|
|
206
|
+
for (const name of doomed)
|
|
207
|
+
await this.provider.destroy(name); // sequential; destroy tolerates not_found
|
|
208
|
+
if (doomed.length > 0)
|
|
209
|
+
emitMarker('hearth.recreate.sweep', { home_id: homeId, volume_id: volumeId, destroyed: doomed });
|
|
210
|
+
await this.pollVolumeDetached(volumeId, homeId);
|
|
211
|
+
}
|
|
212
|
+
/** Poll the volume until the platform reports it detached — the create-precondition gate
|
|
213
|
+
* (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
|
|
214
|
+
* whenever it actually had to wait, so the re-proof can quantify headroom against the budget. */
|
|
215
|
+
async pollVolumeDetached(volumeId, homeId) {
|
|
216
|
+
const pollStartedAt = Date.now();
|
|
217
|
+
const deadline = pollStartedAt + VOLUME_DETACH_TIMEOUT_MS;
|
|
218
|
+
let waited = false;
|
|
219
|
+
for (;;) {
|
|
220
|
+
const attached = await this.provider.getVolumeAttachment(volumeId);
|
|
221
|
+
if (attached === null)
|
|
222
|
+
break;
|
|
223
|
+
waited = true;
|
|
224
|
+
if (Date.now() >= deadline) {
|
|
225
|
+
throw general(`volume ${volumeId} still attached to ${attached} after ${VOLUME_DETACH_TIMEOUT_MS}ms — cannot recreate home ${homeId}`, {
|
|
226
|
+
homeId,
|
|
227
|
+
volumeId,
|
|
228
|
+
attached,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
await delay(VOLUME_DETACH_POLL_MS);
|
|
232
|
+
}
|
|
233
|
+
if (waited) {
|
|
234
|
+
emitMarker('hearth.recreate.volume_wait', { home_id: homeId, volume_id: volumeId, waited_ms: Date.now() - pollStartedAt });
|
|
94
235
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
236
|
+
}
|
|
237
|
+
/** Create one fresh generation and bring it to a serving state (create → bootstrap exec →
|
|
238
|
+
* waitForPort → publish → getMachine), with provider self-cleanup and a single bounded
|
|
239
|
+
* retry (orphan-cleanup-fix.md §3.4/§4). Each attempt destroys the generation IT created if
|
|
240
|
+
* anything from `createMachine` through the final `getMachine` fails — no code path exits
|
|
241
|
+
* leaving a live generation it created and did not return — then rethrows the ORIGINAL error
|
|
242
|
+
* (cleanup never masks it). On the first failure, if the error is a genuine server-side
|
|
243
|
+
* deployment transient, pause, re-run ONLY the volume-free poll (the sweep already ran once),
|
|
244
|
+
* mint a NEW name, and retry exactly once; a second failure throws. The sweep is the caller's
|
|
245
|
+
* responsibility and runs before the first attempt only. */
|
|
246
|
+
async createGeneration(args) {
|
|
247
|
+
const attempt = async (sandboxName) => {
|
|
248
|
+
try {
|
|
249
|
+
await this.provider.createMachine({
|
|
250
|
+
tenantId: args.tenantId,
|
|
251
|
+
name: sandboxName,
|
|
252
|
+
// Tag EVERY generation (first-name and retry-name alike) with the home's stable volume
|
|
253
|
+
// id so the sweep's filtered list only ever sees this home's family (MAJOR-1).
|
|
254
|
+
externalId: args.volumeId,
|
|
255
|
+
...(args.image === undefined ? {} : { image: args.image }),
|
|
256
|
+
envs: guestEnv(this.config),
|
|
257
|
+
volumeMount: { volumeName: args.volumeId, path: this.config.guestHome },
|
|
258
|
+
ports: [{ target: this.config.webPort, protocol: 'HTTP' }],
|
|
259
|
+
});
|
|
260
|
+
await this.provider.exec(sandboxName, { command: args.bootstrapScript, background: true, cwd: this.config.guestHome });
|
|
261
|
+
await this.provider.waitForPort(sandboxName, this.config.webPort, { maxWaitMs: 60_000 });
|
|
262
|
+
const route = await this.provider.publishHttpPort(sandboxName, {
|
|
263
|
+
machineId: sandboxName,
|
|
264
|
+
targetPort: this.config.webPort,
|
|
265
|
+
routeName: stableRouteName(sandboxName),
|
|
266
|
+
routeId: stableRouteName(sandboxName),
|
|
267
|
+
});
|
|
268
|
+
return { route, machine: await this.getMachine(sandboxName) };
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
// §3.4 self-cleanup: destroy the generation this attempt created; never mask the original.
|
|
272
|
+
await this.provider
|
|
273
|
+
.destroy(sandboxName)
|
|
274
|
+
.catch((cleanupErr) => emitMarker('hearth.recreate.cleanup_failed', { home_id: args.homeId, sandbox: sandboxName, error: serialize(cleanupErr) }, 'warn'));
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const firstName = mintSandboxName(args.volumeId);
|
|
279
|
+
try {
|
|
280
|
+
return { sandboxName: firstName, ...(await attempt(firstName)) };
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (!isRetryableDeploymentError(error))
|
|
284
|
+
throw error;
|
|
285
|
+
emitMarker('hearth.recreate.create_retry', { home_id: args.homeId, discarded_generation: firstName, error: serialize(error) }, 'warn');
|
|
286
|
+
await delay(CREATE_RETRY_DELAY_MS);
|
|
287
|
+
// Re-run ONLY the poll — NOT a re-sweep. This sits outside attempt()'s try/catch, so a
|
|
288
|
+
// poll-timeout here would otherwise mask the ORIGINAL deployment error (MINOR-3). The
|
|
289
|
+
// original is what the caller must see (it is already recorded in the create_retry
|
|
290
|
+
// marker above); on a poll failure during the retry path, rethrow it, not the timeout.
|
|
291
|
+
try {
|
|
292
|
+
await this.pollVolumeDetached(args.volumeId, args.homeId);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const secondName = mintSandboxName(args.volumeId);
|
|
298
|
+
return { sandboxName: secondName, ...(await attempt(secondName)) }; // second failure throws (self-cleaned)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Targeted destroy→recreate onto a SPECIFIC image (auto-upgrade roll and
|
|
302
|
+
* rollback, design §4/§5). Unlike `wake()`'s standby-resume path, this is the ONE path
|
|
303
|
+
* that force-destroys the home's sandboxes before recreating. Cleanup is a deterministic
|
|
304
|
+
* name-family sweep (orphan-cleanup-fix.md §3.3): every generation of this home is destroyed
|
|
305
|
+
* before the fresh one is minted, so a failed roll-forward generation can never survive as a
|
|
306
|
+
* live orphan even after Blaxel autonomously detaches it from the volume. The recreate lands
|
|
307
|
+
* on a FRESH sandbox name every call — a name that has never existed cannot 409 against a
|
|
308
|
+
* lingering same-name delete. The durable volume is a separate resource and is NEVER touched
|
|
309
|
+
* (flow-D durability) — user state is byte-identical across the flip, which is exactly what
|
|
310
|
+
* makes both the upgrade and the rollback lossless. The new `providerSandboxId` is returned on
|
|
311
|
+
* the refresh; callers persist it (`refreshProviderPointers` treats it as a refreshable
|
|
312
|
+
* pointer). `templateVersion` is baked into the bootstrap's fail-fast version assert (the
|
|
313
|
+
* baked crtr must match) AND reported back so the CP records the target version — the caller
|
|
314
|
+
* still independently asserts `crtr sys version` in-guest before trusting it (wake.ts
|
|
315
|
+
* `rollHome`/`rollbackHome`, against `refresh.providerSandboxId`, never a stale captured
|
|
316
|
+
* name). */
|
|
317
|
+
async recreateOnImage(homeInput, imageRef, templateVersion) {
|
|
318
|
+
const current = ensureReadyHome(homeInput);
|
|
319
|
+
if (imageRef.trim() === '') {
|
|
320
|
+
throw general(`recreateOnImage for home ${current.homeId} requires a non-empty image ref`, { homeId: current.homeId });
|
|
321
|
+
}
|
|
322
|
+
if (templateVersion.trim() === '') {
|
|
323
|
+
throw general(`recreateOnImage for home ${current.homeId} requires a non-empty template version`, { homeId: current.homeId });
|
|
324
|
+
}
|
|
325
|
+
// Sweep the whole name family (destroys the failed roll-forward generation whether or not it
|
|
326
|
+
// still holds the volume — the exact Run-3 orphan gap) then wait until the volume is confirmed
|
|
327
|
+
// detached before minting a fresh name and creating (orphan-cleanup-fix.md §3.3).
|
|
328
|
+
await this.sweepAndReleaseVolume(current.providerVolumeId, current.homeId);
|
|
329
|
+
const bootstrapScript = buildBootstrapScript({
|
|
330
|
+
guestHome: this.config.guestHome,
|
|
331
|
+
crtrVersion: templateVersion,
|
|
332
|
+
relayToken: current.relayToken,
|
|
333
|
+
webPort: this.config.webPort,
|
|
334
|
+
nodeId: current.homeAgentTarget,
|
|
335
|
+
piAuthJson: this.config.piAuthJson,
|
|
336
|
+
piPackages: this.config.piPackages,
|
|
337
|
+
isFirstProvision: false,
|
|
338
|
+
});
|
|
339
|
+
const { sandboxName, route, machine } = await this.createGeneration({
|
|
340
|
+
homeId: current.homeId,
|
|
341
|
+
tenantId: current.tenantId,
|
|
342
|
+
volumeId: current.providerVolumeId,
|
|
343
|
+
bootstrapScript,
|
|
344
|
+
image: imageRef,
|
|
100
345
|
});
|
|
101
|
-
|
|
346
|
+
// Report the TARGET version, not the provider's construction-time value:
|
|
347
|
+
// the sandbox was recreated on `imageRef`, whose baked crtr is `templateVersion`.
|
|
348
|
+
return {
|
|
349
|
+
...refreshedPointers(current, this.provider, route, 'running', sandboxName, machine),
|
|
350
|
+
templateVersion,
|
|
351
|
+
};
|
|
102
352
|
}
|
|
103
353
|
async suspend(homeInput) {
|
|
104
354
|
const current = ensureReadyHome(homeInput);
|
|
@@ -121,7 +371,7 @@ export class BlaxelHomeBackend {
|
|
|
121
371
|
providerSandboxId: current.providerSandboxId,
|
|
122
372
|
});
|
|
123
373
|
}
|
|
124
|
-
return refreshedPointers(current, this.provider, null, machine.state, machine);
|
|
374
|
+
return refreshedPointers(current, this.provider, null, machine.state, current.providerSandboxId, machine);
|
|
125
375
|
}
|
|
126
376
|
async getMachine(machineId) {
|
|
127
377
|
const machine = await this.provider.getMachine(machineId);
|