@celilo/e2e 0.19.0 → 0.19.2
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/package.json +3 -3
- package/registry-server/src/bootstrap.ts +5 -0
- package/registry-server/src/server.test.ts +221 -1
- package/registry-server/src/server.ts +92 -0
- package/registry-server/src/storage.ts +95 -2
- package/registry-server/src/sweep.test.ts +326 -0
- package/registry-server/src/sweep.ts +289 -0
- package/scripts/consumer-paths-changed.ts +3 -0
- package/src/address-plan.test.ts +21 -1
- package/src/container-manager.ts +29 -15
- package/src/docker-compose-generator.ts +66 -22
- package/src/shared-infra.ts +6 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { RegistryStorage } from './storage';
|
|
6
|
+
import { DEFAULT_KEEP_BUILD_REVISIONS, parseRevision, planModuleSweep, sweep } from './sweep';
|
|
7
|
+
|
|
8
|
+
let dataDir: string;
|
|
9
|
+
let storage: RegistryStorage;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
dataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-sweep-'));
|
|
13
|
+
storage = new RegistryStorage(dataDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Publish a version through the same two calls the real publish path uses. */
|
|
21
|
+
function publish(name: string, vers: string, sizeBytes = 32): void {
|
|
22
|
+
storage.storePackage(name, vers, Buffer.alloc(sizeBytes, 7));
|
|
23
|
+
storage.appendIndex({ name, vers, deps: [], cksum: `sha256:${vers}`, yanked: false });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* THE invariant. A sweep that trims payloads without trimming index lines
|
|
28
|
+
* leaves the index advertising a version whose file is gone — search lists it
|
|
29
|
+
* and the download 404s. The reverse leaves unreachable bytes on the disk the
|
|
30
|
+
* sweep exists to reclaim. Both directions have to hold.
|
|
31
|
+
*/
|
|
32
|
+
function expectIndexAndPayloadsAgree(name: string): void {
|
|
33
|
+
const indexed = storage.readIndex(name).map((e) => e.vers);
|
|
34
|
+
const stored = storage.storedVersions(name).sort();
|
|
35
|
+
for (const vers of indexed) {
|
|
36
|
+
expect(storage.readPackage(name, vers)).not.toBeNull();
|
|
37
|
+
}
|
|
38
|
+
expect(stored).toEqual([...indexed].sort());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('parseRevision', () => {
|
|
42
|
+
test('splits a release from its build revision', () => {
|
|
43
|
+
expect(parseRevision('1.0.3+7')).toEqual({ release: '1.0.3', revision: 7 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('keeps a Debian epoch with the release', () => {
|
|
47
|
+
expect(parseRevision('2:1.0.0+1')).toEqual({ release: '2:1.0.0', revision: 1 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('returns null for a version with no build revision', () => {
|
|
51
|
+
expect(parseRevision('1.0.0')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('planModuleSweep', () => {
|
|
56
|
+
const entry = (vers: string, yanked = false) => ({
|
|
57
|
+
name: 'm',
|
|
58
|
+
vers,
|
|
59
|
+
deps: [],
|
|
60
|
+
cksum: 'sha256:x',
|
|
61
|
+
yanked,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Plan against a store where every indexed version HAS its payload — the
|
|
66
|
+
* ordinary case. Passing an empty stored set instead would trip the
|
|
67
|
+
* unreadable guard, which is a different scenario with its own tests below.
|
|
68
|
+
*/
|
|
69
|
+
const planWithPayloads = (entries: ReturnType<typeof entry>[], keepBuildRevisions: number) =>
|
|
70
|
+
planModuleSweep(
|
|
71
|
+
'm',
|
|
72
|
+
entries,
|
|
73
|
+
entries.map((e) => e.vers),
|
|
74
|
+
{ keepBuildRevisions },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
test('keeps the newest build revision of every release', () => {
|
|
78
|
+
const entries = [
|
|
79
|
+
entry('1.0.0+1'),
|
|
80
|
+
entry('1.0.0+2'),
|
|
81
|
+
entry('1.0.2+8'),
|
|
82
|
+
entry('1.0.2+9'),
|
|
83
|
+
entry('1.0.3+5'),
|
|
84
|
+
entry('1.0.3+6'),
|
|
85
|
+
entry('1.0.3+7'),
|
|
86
|
+
];
|
|
87
|
+
const plan = planWithPayloads(entries, 1);
|
|
88
|
+
expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.0+2', '1.0.2+9', '1.0.3+7']);
|
|
89
|
+
expect(plan.remove).toEqual(['1.0.0+1', '1.0.2+8', '1.0.3+5', '1.0.3+6']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('never removes the only revision of a release', () => {
|
|
93
|
+
const entries = [entry('0.9.0+2'), entry('1.0.0+1')];
|
|
94
|
+
const plan = planWithPayloads(entries, 1);
|
|
95
|
+
expect(plan.remove).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a module can never be swept out of existence', () => {
|
|
99
|
+
const entries = [entry('1.0.0+1')];
|
|
100
|
+
const plan = planWithPayloads(entries, 1);
|
|
101
|
+
expect(plan.keep).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('keeps more revisions per release when asked', () => {
|
|
105
|
+
const entries = [entry('1.0.3+5'), entry('1.0.3+6'), entry('1.0.3+7')];
|
|
106
|
+
const plan = planWithPayloads(entries, 2);
|
|
107
|
+
expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.3+6', '1.0.3+7']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('keepBuildRevisions below 1 is clamped — no policy deletes a whole release', () => {
|
|
111
|
+
// Needs MORE THAN ONE release to be a real check. With a single release
|
|
112
|
+
// the latest-line retention below masks a missing clamp; with two, an
|
|
113
|
+
// unclamped 0 wipes out every release except the last.
|
|
114
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.3+5'), entry('1.0.3+6')];
|
|
115
|
+
const plan = planWithPayloads(entries, 0);
|
|
116
|
+
expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.0+2', '1.0.3+6']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('retains what latestVersion resolves to even when the index is out of order', () => {
|
|
120
|
+
// Last line wins for RegistryClient.latestVersion regardless of ordering,
|
|
121
|
+
// so a disordered index must not lose the version clients actually fetch.
|
|
122
|
+
const entries = [entry('1.0.3+7'), entry('1.0.3+6'), entry('1.0.3+5')];
|
|
123
|
+
const plan = planWithPayloads(entries, 1);
|
|
124
|
+
expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+5');
|
|
125
|
+
expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+7');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('retains the last NON-yanked line, which is what clients resolve to', () => {
|
|
129
|
+
const entries = [entry('1.0.3+5'), entry('1.0.3+6'), entry('1.0.3+7', true)];
|
|
130
|
+
const plan = planWithPayloads(entries, 1);
|
|
131
|
+
expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+6');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('keeps a version it cannot parse rather than deleting it', () => {
|
|
135
|
+
const entries = [entry('nonsense'), entry('1.0.0+1'), entry('1.0.0+2')];
|
|
136
|
+
const plan = planWithPayloads(entries, 1);
|
|
137
|
+
expect(plan.remove).toEqual(['1.0.0+1']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('reports a payload with no index line as an orphan', () => {
|
|
141
|
+
const plan = planModuleSweep('m', [entry('1.0.0+2')], ['1.0.0+1', '1.0.0+2'], {
|
|
142
|
+
keepBuildRevisions: 1,
|
|
143
|
+
});
|
|
144
|
+
expect(plan.orphans).toEqual(['1.0.0+1']);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('sweep over a fixture store', () => {
|
|
149
|
+
test('index and payloads agree afterwards, and every release survives', () => {
|
|
150
|
+
for (const rev of [1, 2, 3, 4, 5]) publish('celilo-registry', `1.0.3+${rev}`);
|
|
151
|
+
for (const rev of [1, 2]) publish('celilo-registry', `1.0.2+${rev}`);
|
|
152
|
+
publish('forgejo', '2.1.0+1');
|
|
153
|
+
|
|
154
|
+
const report = sweep(storage, { keepBuildRevisions: DEFAULT_KEEP_BUILD_REVISIONS });
|
|
155
|
+
|
|
156
|
+
expect(report.removedCount).toBe(5);
|
|
157
|
+
expectIndexAndPayloadsAgree('celilo-registry');
|
|
158
|
+
expectIndexAndPayloadsAgree('forgejo');
|
|
159
|
+
// Original index order is preserved: the fixture published 1.0.3 before
|
|
160
|
+
// 1.0.2, and the sweep filters lines rather than re-sorting them.
|
|
161
|
+
expect(storage.readIndex('celilo-registry').map((e) => e.vers)).toEqual(['1.0.3+5', '1.0.2+2']);
|
|
162
|
+
expect(storage.readIndex('forgejo').map((e) => e.vers)).toEqual(['2.1.0+1']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('reclaims the bytes it says it reclaims', () => {
|
|
166
|
+
publish('m', '1.0.0+1', 4096);
|
|
167
|
+
publish('m', '1.0.0+2', 4096);
|
|
168
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
169
|
+
expect(report.reclaimedBytes).toBe(4096);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('a dry run changes nothing', () => {
|
|
173
|
+
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
174
|
+
const report = sweep(storage, { keepBuildRevisions: 1 }, true);
|
|
175
|
+
|
|
176
|
+
expect(report.removedCount).toBe(2);
|
|
177
|
+
expect(storage.readIndex('m')).toHaveLength(3);
|
|
178
|
+
expect(storage.storedVersions('m').sort()).toEqual(['1.0.0+1', '1.0.0+2', '1.0.0+3']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('is idempotent — a second sweep finds nothing to do', () => {
|
|
182
|
+
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
183
|
+
sweep(storage, { keepBuildRevisions: 1 });
|
|
184
|
+
const second = sweep(storage, { keepBuildRevisions: 1 });
|
|
185
|
+
|
|
186
|
+
expect(second.removedCount).toBe(0);
|
|
187
|
+
expect(second.orphanCount).toBe(0);
|
|
188
|
+
expect(second.reclaimedBytes).toBe(0);
|
|
189
|
+
expectIndexAndPayloadsAgree('m');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a sweep interrupted after the index rewrite self-heals on the next run', () => {
|
|
193
|
+
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
194
|
+
// The crash window: index trimmed, payloads not yet unlinked. The version
|
|
195
|
+
// is unlisted but still on disk — wasted bytes, not a broken registry.
|
|
196
|
+
storage.updateIndex('m', storage.readIndex('m').slice(-1));
|
|
197
|
+
expect(storage.storedVersions('m')).toHaveLength(3);
|
|
198
|
+
|
|
199
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
200
|
+
|
|
201
|
+
expect(report.orphanCount).toBe(2);
|
|
202
|
+
expectIndexAndPayloadsAgree('m');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('a version left unlisted stays downloadable until its payload goes', () => {
|
|
206
|
+
// Why index-first is the safe order: the download route reads the payload
|
|
207
|
+
// directly and never consults the index, so the window an interrupted
|
|
208
|
+
// sweep opens serves stale-but-valid bytes rather than 404ing.
|
|
209
|
+
publish('m', '1.0.0+1');
|
|
210
|
+
storage.updateIndex('m', []);
|
|
211
|
+
expect(storage.readPackage('m', '1.0.0+1')).not.toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('reclaims a half-written publish, which is what makes the version publishable again', () => {
|
|
215
|
+
// Both states below exist on the production registry, created 2026-08-22 by
|
|
216
|
+
// real failures: an ENOSPC that killed a run after storePackage, and an
|
|
217
|
+
// EACCES on appendIndex. handlePublish has no rollback between the two.
|
|
218
|
+
//
|
|
219
|
+
// The consequence is worse than wasted bytes: `packageExists` reads the
|
|
220
|
+
// FILESYSTEM, so the orphan makes its own version permanently
|
|
221
|
+
// unpublishable — "versions are immutable". Removing the orphan is the
|
|
222
|
+
// repair, and nothing else in the product performs it.
|
|
223
|
+
storage.storePackage('celilo-mgmt', '0.6.2+2', Buffer.alloc(2048, 1));
|
|
224
|
+
publish('celilo-mgmt', '0.6.1+1');
|
|
225
|
+
expect(storage.packageExists('celilo-mgmt', '0.6.2+2')).toBe(true);
|
|
226
|
+
|
|
227
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
228
|
+
|
|
229
|
+
expect(report.orphanCount).toBe(1);
|
|
230
|
+
expect(storage.packageExists('celilo-mgmt', '0.6.2+2')).toBe(false);
|
|
231
|
+
expectIndexAndPayloadsAgree('celilo-mgmt');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('REPORTS an index line with no payload and does not remove it', () => {
|
|
235
|
+
// The dangerous direction. Removing the line would actually repair the
|
|
236
|
+
// module — a dangling entry already 404s — but a missing payload is
|
|
237
|
+
// indistinguishable from an unreadable store, so it is reported only.
|
|
238
|
+
publish('m', '1.0.0+1');
|
|
239
|
+
publish('m', '1.0.0+2');
|
|
240
|
+
storage.removePackage('m', '1.0.0+1');
|
|
241
|
+
|
|
242
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
243
|
+
|
|
244
|
+
expect(report.danglingCount).toBe(1);
|
|
245
|
+
expect(report.modules[0]?.dangling).toEqual(['1.0.0+1']);
|
|
246
|
+
// Still listed: the sweep reported it rather than acting on it.
|
|
247
|
+
expect(storage.readIndex('m').map((e) => e.vers)).toContain('1.0.0+1');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('touches nothing when a module has index lines and no payloads at all', () => {
|
|
251
|
+
// An unmounted volume or a wrong DATA_DIR looks exactly like this. Acting
|
|
252
|
+
// on it would trim the index of a module whose files are merely
|
|
253
|
+
// unreachable.
|
|
254
|
+
for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
|
|
255
|
+
for (const rev of [1, 2, 3]) storage.removePackage('m', `1.0.0+${rev}`);
|
|
256
|
+
|
|
257
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
258
|
+
|
|
259
|
+
expect(report.unreadable).toEqual(['m']);
|
|
260
|
+
expect(report.removedCount).toBe(0);
|
|
261
|
+
expect(storage.readIndex('m')).toHaveLength(3);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('a dangling entry does not stop the rest of the sweep', () => {
|
|
265
|
+
publish('m', '1.0.0+1');
|
|
266
|
+
publish('m', '1.0.0+2');
|
|
267
|
+
publish('m', '1.0.0+3');
|
|
268
|
+
storage.removePackage('m', '1.0.0+1');
|
|
269
|
+
|
|
270
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
271
|
+
|
|
272
|
+
expect(report.danglingCount).toBe(1);
|
|
273
|
+
// 1.0.0+2 is superseded and still had its payload, so it is swept normally.
|
|
274
|
+
expect(report.removedCount).toBe(1);
|
|
275
|
+
expect(storage.readIndex('m').map((e) => e.vers)).toEqual(['1.0.0+1', '1.0.0+3']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('never sweeps the last SERVABLE version because a newer line is dangling', () => {
|
|
279
|
+
// The failure this guards: retention computed over every index line lets a
|
|
280
|
+
// dangling NEWEST entry satisfy both the per-release rule and the
|
|
281
|
+
// latest-line rule, so every servable older version reads as superseded.
|
|
282
|
+
// The module ends up listed, with one index line, and undownloadable.
|
|
283
|
+
publish('m', '1.0.0+1');
|
|
284
|
+
publish('m', '1.0.0+2');
|
|
285
|
+
publish('m', '1.0.0+3');
|
|
286
|
+
storage.removePackage('m', '1.0.0+3'); // the newest line is now dangling
|
|
287
|
+
|
|
288
|
+
sweep(storage, { keepBuildRevisions: 1 });
|
|
289
|
+
|
|
290
|
+
// Something is still downloadable, which is the whole point.
|
|
291
|
+
const servable = storage
|
|
292
|
+
.readIndex('m')
|
|
293
|
+
.map((e) => e.vers)
|
|
294
|
+
.filter((v) => storage.packageExists('m', v));
|
|
295
|
+
expect(servable.length).toBeGreaterThan(0);
|
|
296
|
+
expect(servable).toContain('1.0.0+2');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('an empty store sweeps cleanly', () => {
|
|
300
|
+
const report = sweep(storage, { keepBuildRevisions: 1 });
|
|
301
|
+
expect(report.removedCount).toBe(0);
|
|
302
|
+
expect(report.modules).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('updateIndex atomicity', () => {
|
|
307
|
+
test('leaves no temp file behind', () => {
|
|
308
|
+
publish('m', '1.0.0+1');
|
|
309
|
+
storage.updateIndex('m', storage.readIndex('m'));
|
|
310
|
+
expect(existsSync(`${storage.indexPath('m')}.tmp`)).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('a write that cannot land leaves the original index intact', () => {
|
|
314
|
+
publish('m', '1.0.0+1');
|
|
315
|
+
publish('m', '1.0.0+2');
|
|
316
|
+
const before = storage.readIndex('m');
|
|
317
|
+
// Block the temp path so the new content cannot be written. A truncating
|
|
318
|
+
// in-place write ignores the obstruction and replaces the index anyway;
|
|
319
|
+
// the rename path fails with the original still on disk. This stands in
|
|
320
|
+
// for the failure that actually matters — ENOSPC on the full disk this
|
|
321
|
+
// sweep exists to relieve — which needs an fs seam to reproduce directly.
|
|
322
|
+
mkdirSync(`${storage.indexPath('m')}.tmp`, { recursive: true });
|
|
323
|
+
expect(() => storage.updateIndex('m', before.slice(0, 1))).toThrow();
|
|
324
|
+
expect(storage.readIndex('m')).toEqual(before);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reclaiming disk from superseded build revisions.
|
|
3
|
+
*
|
|
4
|
+
* The registry had no delete path at all: `yank` sets a boolean in the index
|
|
5
|
+
* and frees nothing, so every revision ever published was retained forever.
|
|
6
|
+
* A `+N` build revision is auto-assigned on every publish, so the store grew
|
|
7
|
+
* with every release until the disk filled and the release pipeline stopped.
|
|
8
|
+
*
|
|
9
|
+
* ── What the policy keeps, and why it cannot break a fetch ──
|
|
10
|
+
*
|
|
11
|
+
* A version is `X.Y.Z+N`: a released semver plus a build revision. The bloat
|
|
12
|
+
* is the rebuilds, not the releases — a module embedding compiled binaries
|
|
13
|
+
* republished twenty times carries twenty ~73 MB payloads of one release.
|
|
14
|
+
*
|
|
15
|
+
* So the sweep keeps the newest `keepBuildRevisions` of EVERY release and
|
|
16
|
+
* drops the rest. No release ever disappears, which is what makes the safety
|
|
17
|
+
* argument checkable rather than a judgement call:
|
|
18
|
+
*
|
|
19
|
+
* - The only version a celilo client can ask for is the one
|
|
20
|
+
* `RegistryClient.latestVersion` picks — the last non-yanked line in the
|
|
21
|
+
* index. There is no `--version` flag, no lockfile, and `IndexEntry.deps`
|
|
22
|
+
* is always `[]`, so nothing pins a specific revision.
|
|
23
|
+
* - {@link planModuleSweep} retains that line explicitly, on top of the
|
|
24
|
+
* per-release rule, so the property holds even for an index whose lines
|
|
25
|
+
* are out of publish order.
|
|
26
|
+
* - Every release keeps a downloadable payload, so an operator rolling back
|
|
27
|
+
* to an older release still can.
|
|
28
|
+
*
|
|
29
|
+
* ── Ordering, and why it is index-first ──
|
|
30
|
+
*
|
|
31
|
+
* The download route reads the payload file directly and never consults the
|
|
32
|
+
* index. That asymmetry decides the order:
|
|
33
|
+
*
|
|
34
|
+
* - index line first, then payload — the window is "unlisted but still
|
|
35
|
+
* served". Nothing 404s. A crash here leaves an orphan payload, which
|
|
36
|
+
* wastes the disk we are reclaiming but is otherwise correct.
|
|
37
|
+
* - payload first, then index — the window is "listed but 404s", an
|
|
38
|
+
* actively wrong registry. A crash here leaves it that way.
|
|
39
|
+
*
|
|
40
|
+
* So: index first. The orphan a crash leaves behind is then reclaimed by the
|
|
41
|
+
* NEXT run, which is what makes the sweep idempotent and safe to interrupt.
|
|
42
|
+
* The orphan pass runs FIRST for a second reason: it is pure unlink and needs
|
|
43
|
+
* no free space, so it can still make progress on a disk with nothing left to
|
|
44
|
+
* write with — the state that produced this code.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { IndexEntry, RegistryStorage } from './storage';
|
|
48
|
+
|
|
49
|
+
export interface SweepPolicy {
|
|
50
|
+
/**
|
|
51
|
+
* Build revisions to keep per released semver. Must be >= 1 — keeping zero
|
|
52
|
+
* would delete a whole release, which this policy never does.
|
|
53
|
+
*/
|
|
54
|
+
keepBuildRevisions: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_KEEP_BUILD_REVISIONS = 1;
|
|
58
|
+
|
|
59
|
+
export interface ModuleSweepPlan {
|
|
60
|
+
name: string;
|
|
61
|
+
/** Index entries to retain, in their original order. */
|
|
62
|
+
keep: IndexEntry[];
|
|
63
|
+
/** Versions whose index line and payload both go. */
|
|
64
|
+
remove: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Payloads on disk with no index line. Unreachable by search, and the
|
|
67
|
+
* publisher's immutability check still sees them — `packageExists` reads the
|
|
68
|
+
* FILESYSTEM, so an orphan makes its version unpublishable forever with no
|
|
69
|
+
* repair path anywhere else in the product. Safe to delete, and deleting it
|
|
70
|
+
* IS the repair.
|
|
71
|
+
*/
|
|
72
|
+
orphans: string[];
|
|
73
|
+
/**
|
|
74
|
+
* Index lines whose payload is missing. REPORTED, NEVER REMOVED.
|
|
75
|
+
*
|
|
76
|
+
* This is the direction the sweep must never create, and the reason it takes
|
|
77
|
+
* the index line before the payload. Removing the line would in fact repair
|
|
78
|
+
* the module — a dangling entry already 404s on download, and dropping it
|
|
79
|
+
* makes `latestVersion` resolve to a version that can actually be served.
|
|
80
|
+
* It is left alone anyway, because a missing payload and an unreadable store
|
|
81
|
+
* are indistinguishable from here: an unmounted volume or a wrong DATA_DIR
|
|
82
|
+
* makes every payload look absent, and an auto-remove would then delete the
|
|
83
|
+
* whole index. Reporting costs nothing and cannot destroy anything.
|
|
84
|
+
*/
|
|
85
|
+
dangling: string[];
|
|
86
|
+
/**
|
|
87
|
+
* The module's index lists versions and NOT ONE has a payload on disk. That
|
|
88
|
+
* is a broken or unmounted store rather than N independent losses, so the
|
|
89
|
+
* sweep does nothing at all to this module — see {@link ModuleSweepPlan.dangling}.
|
|
90
|
+
*/
|
|
91
|
+
unreadable: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SweepReport {
|
|
95
|
+
modules: Array<{
|
|
96
|
+
name: string;
|
|
97
|
+
removed: string[];
|
|
98
|
+
orphans: string[];
|
|
99
|
+
dangling: string[];
|
|
100
|
+
bytes: number;
|
|
101
|
+
}>;
|
|
102
|
+
removedCount: number;
|
|
103
|
+
orphanCount: number;
|
|
104
|
+
/** Index lines with no payload. Reported for a human; never acted on. */
|
|
105
|
+
danglingCount: number;
|
|
106
|
+
/** Modules skipped entirely because their whole payload set is missing. */
|
|
107
|
+
unreadable: string[];
|
|
108
|
+
reclaimedBytes: number;
|
|
109
|
+
dryRun: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Split `X.Y.Z+N` (or `E:X.Y.Z+N`) into the release it belongs to and its
|
|
114
|
+
* build revision. Returns null for anything that does not parse — the caller
|
|
115
|
+
* treats that as "keep", because a version the sweep cannot reason about is
|
|
116
|
+
* not one it should delete.
|
|
117
|
+
*/
|
|
118
|
+
export function parseRevision(vers: string): { release: string; revision: number } | null {
|
|
119
|
+
const plus = vers.lastIndexOf('+');
|
|
120
|
+
if (plus <= 0) return null;
|
|
121
|
+
const release = vers.slice(0, plus);
|
|
122
|
+
const revision = Number(vers.slice(plus + 1));
|
|
123
|
+
if (!Number.isInteger(revision) || revision < 0) return null;
|
|
124
|
+
return { release, revision };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decide what one module keeps. Pure — no filesystem, no storage.
|
|
129
|
+
*
|
|
130
|
+
* `storedVersions` is what is on DISK, which is deliberately not the same set
|
|
131
|
+
* as `entries`: a previous interrupted run can leave a payload whose index
|
|
132
|
+
* line is already gone, and reclaiming it is the whole reason this takes both.
|
|
133
|
+
*/
|
|
134
|
+
export function planModuleSweep(
|
|
135
|
+
name: string,
|
|
136
|
+
entries: IndexEntry[],
|
|
137
|
+
storedVersions: string[],
|
|
138
|
+
policy: SweepPolicy,
|
|
139
|
+
): ModuleSweepPlan {
|
|
140
|
+
const keepBuildRevisions = Math.max(1, Math.trunc(policy.keepBuildRevisions));
|
|
141
|
+
|
|
142
|
+
const stored = new Set(storedVersions);
|
|
143
|
+
const dangling = entries.filter((e) => !stored.has(e.vers)).map((e) => e.vers);
|
|
144
|
+
|
|
145
|
+
// Every listed version missing its payload is one broken store, not N
|
|
146
|
+
// independent losses. Touch nothing.
|
|
147
|
+
if (entries.length > 0 && dangling.length === entries.length) {
|
|
148
|
+
return { name, keep: entries, remove: [], orphans: [], dangling, unreadable: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Retention is computed over the SERVABLE entries only — the ones whose
|
|
153
|
+
* payload is actually there.
|
|
154
|
+
*
|
|
155
|
+
* Doing it over every index line is subtly wrong in a way that bites exactly
|
|
156
|
+
* when the store is already damaged. If a module's NEWEST line is dangling,
|
|
157
|
+
* that line satisfies both the per-release rule and the latest-line rule, so
|
|
158
|
+
* every older version — the servable ones — becomes "superseded" and is
|
|
159
|
+
* swept. The module is then listed, has one index line, and cannot be
|
|
160
|
+
* downloaded at all. The sweep would not have created the dangling entry,
|
|
161
|
+
* but it would have removed the last thing that still worked.
|
|
162
|
+
*/
|
|
163
|
+
const servable = entries.filter((e) => stored.has(e.vers));
|
|
164
|
+
|
|
165
|
+
const retained = new Set<string>();
|
|
166
|
+
|
|
167
|
+
// The newest `keepBuildRevisions` of each release.
|
|
168
|
+
const byRelease = new Map<string, Array<{ vers: string; revision: number }>>();
|
|
169
|
+
for (const entry of servable) {
|
|
170
|
+
const parsed = parseRevision(entry.vers);
|
|
171
|
+
if (!parsed) {
|
|
172
|
+
// Unparseable — never a publish this server accepted, so it is hand-
|
|
173
|
+
// written state. Keep it and move on.
|
|
174
|
+
retained.add(entry.vers);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const group = byRelease.get(parsed.release) ?? [];
|
|
178
|
+
group.push({ vers: entry.vers, revision: parsed.revision });
|
|
179
|
+
byRelease.set(parsed.release, group);
|
|
180
|
+
}
|
|
181
|
+
for (const group of byRelease.values()) {
|
|
182
|
+
group.sort((a, b) => b.revision - a.revision);
|
|
183
|
+
for (const { vers } of group.slice(0, keepBuildRevisions)) retained.add(vers);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// What every client actually resolves to, retained explicitly rather than
|
|
187
|
+
// inferred from the rule above. The rule already covers it for an index in
|
|
188
|
+
// publish order; stating it means a disordered or hand-edited index cannot
|
|
189
|
+
// turn into an unfetchable module.
|
|
190
|
+
const lastServable = servable.at(-1);
|
|
191
|
+
if (lastServable) retained.add(lastServable.vers);
|
|
192
|
+
const lastUnyanked = [...servable].reverse().find((e) => !e.yanked);
|
|
193
|
+
if (lastUnyanked) retained.add(lastUnyanked.vers);
|
|
194
|
+
|
|
195
|
+
// Only ever removes something it can see. A dangling line is left in the
|
|
196
|
+
// index untouched and reported instead.
|
|
197
|
+
const remove = servable.filter((e) => !retained.has(e.vers)).map((e) => e.vers);
|
|
198
|
+
const removing = new Set(remove);
|
|
199
|
+
const indexed = new Set(entries.map((e) => e.vers));
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
name,
|
|
203
|
+
keep: entries.filter((e) => !removing.has(e.vers)),
|
|
204
|
+
remove,
|
|
205
|
+
orphans: storedVersions.filter((v) => !indexed.has(v)),
|
|
206
|
+
dangling,
|
|
207
|
+
unreadable: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Apply one module's plan. Orphans first (needs no free space), then the
|
|
213
|
+
* index rewrite, then the payloads the rewrite just unlisted.
|
|
214
|
+
*/
|
|
215
|
+
function applyModuleSweep(
|
|
216
|
+
storage: RegistryStorage,
|
|
217
|
+
plan: ModuleSweepPlan,
|
|
218
|
+
dryRun: boolean,
|
|
219
|
+
): number {
|
|
220
|
+
let bytes = 0;
|
|
221
|
+
|
|
222
|
+
for (const version of plan.orphans) {
|
|
223
|
+
bytes += storage.packageSize(plan.name, version);
|
|
224
|
+
if (!dryRun) storage.removePackage(plan.name, version);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const version of plan.remove) {
|
|
228
|
+
bytes += storage.packageSize(plan.name, version);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (plan.remove.length > 0 && !dryRun) {
|
|
232
|
+
storage.updateIndex(plan.name, plan.keep);
|
|
233
|
+
for (const version of plan.remove) storage.removePackage(plan.name, version);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return bytes;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Sweep every module in the store. */
|
|
240
|
+
export function sweep(storage: RegistryStorage, policy: SweepPolicy, dryRun = false): SweepReport {
|
|
241
|
+
const modules: SweepReport['modules'] = [];
|
|
242
|
+
let removedCount = 0;
|
|
243
|
+
let orphanCount = 0;
|
|
244
|
+
let reclaimedBytes = 0;
|
|
245
|
+
|
|
246
|
+
const unreadable: string[] = [];
|
|
247
|
+
let danglingCount = 0;
|
|
248
|
+
|
|
249
|
+
for (const name of storage.storedNames()) {
|
|
250
|
+
const plan = planModuleSweep(
|
|
251
|
+
name,
|
|
252
|
+
storage.readIndex(name),
|
|
253
|
+
storage.storedVersions(name),
|
|
254
|
+
policy,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (plan.unreadable) {
|
|
258
|
+
unreadable.push(name);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
danglingCount += plan.dangling.length;
|
|
263
|
+
if (plan.remove.length === 0 && plan.orphans.length === 0 && plan.dangling.length === 0) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const bytes = applyModuleSweep(storage, plan, dryRun);
|
|
268
|
+
modules.push({
|
|
269
|
+
name,
|
|
270
|
+
removed: plan.remove,
|
|
271
|
+
orphans: plan.orphans,
|
|
272
|
+
dangling: plan.dangling,
|
|
273
|
+
bytes,
|
|
274
|
+
});
|
|
275
|
+
removedCount += plan.remove.length;
|
|
276
|
+
orphanCount += plan.orphans.length;
|
|
277
|
+
reclaimedBytes += bytes;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
modules,
|
|
282
|
+
removedCount,
|
|
283
|
+
orphanCount,
|
|
284
|
+
danglingCount,
|
|
285
|
+
unreadable,
|
|
286
|
+
reclaimedBytes,
|
|
287
|
+
dryRun,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
export const CONSUMER_PATHS = [
|
|
23
23
|
'packages/e2e/**',
|
|
24
24
|
'packages/registry-server/**',
|
|
25
|
+
// The smoke test IS this gate's body — the only place publishModule and
|
|
26
|
+
// friends run in the consumer install shape (celilo#1142).
|
|
27
|
+
'e2e/tests/smoke.test.ts',
|
|
25
28
|
// The publish driver (moved from the dead apps/celilo/scripts/publish.ts).
|
|
26
29
|
'apps/celilo/src/cli/commands/publish/**',
|
|
27
30
|
'scripts/publish.ts',
|
package/src/address-plan.test.ts
CHANGED
|
@@ -50,6 +50,25 @@ const SCANNED_PATHS = ['packages/e2e', 'e2e/tests', 'modules/*/e2e'];
|
|
|
50
50
|
*/
|
|
51
51
|
const EXCLUDED = [/^packages\/e2e\/CHANGELOG\.md$/, /address-plan\.test\.ts$/];
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A single LINE may exempt itself with `#539-ok: <reason>`.
|
|
55
|
+
*
|
|
56
|
+
* There is one legitimate reason to write a banned prefix: enumerating an
|
|
57
|
+
* RFC 1918 block as a CLASS, which any correct private-address predicate must
|
|
58
|
+
* do. `192.168.` is both the operator's LAN (banned) and 192.168.0.0/16
|
|
59
|
+
* (unavoidable), and RETIRED_PREFIXES cannot tell them apart. The tell that
|
|
60
|
+
* this is the prefix list's blind spot rather than a real judgement: `10.` sits
|
|
61
|
+
* beside `192.168.` in exactly such a predicate and escapes only because `10.`
|
|
62
|
+
* was never added to the list.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately per-line and not per-file. Excluding a file would blind the gate
|
|
65
|
+
* to every OTHER address in it, which is how a suppression becomes the next
|
|
66
|
+
* leak. The reason is required — a marker with nothing after the colon does not
|
|
67
|
+
* match, because an exemption that names only a line number is the next
|
|
68
|
+
* reader's mystery.
|
|
69
|
+
*/
|
|
70
|
+
const INLINE_EXEMPTION = /#539-ok:\s*\S/;
|
|
71
|
+
|
|
53
72
|
function grepRetired(): string[] {
|
|
54
73
|
const pattern = RETIRED_PREFIXES.map(toPattern).join('|');
|
|
55
74
|
let out = '';
|
|
@@ -66,7 +85,8 @@ function grepRetired(): string[] {
|
|
|
66
85
|
return out
|
|
67
86
|
.split('\n')
|
|
68
87
|
.filter(Boolean)
|
|
69
|
-
.filter((line) => !EXCLUDED.some((re) => re.test(line.split(':')[0])))
|
|
88
|
+
.filter((line) => !EXCLUDED.some((re) => re.test(line.split(':')[0] as string)))
|
|
89
|
+
.filter((line) => !INLINE_EXEMPTION.test(line));
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
describe('simulated address plan (#539)', () => {
|