@celilo/e2e 0.18.0 → 0.19.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/config/routing/fw-isp-routes.sh +13 -3
- package/docker/Dockerfile.management +6 -0
- package/package.json +3 -3
- package/registry-server/src/bootstrap.ts +5 -0
- package/registry-server/src/server.test.ts +183 -1
- package/registry-server/src/server.ts +75 -0
- package/registry-server/src/storage.ts +76 -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 +23 -9
- package/src/docker-compose-generator.ts +107 -24
- package/src/network-builder.ts +17 -0
- package/src/shared-infra.ts +6 -0
- package/src/types.ts +13 -0
|
@@ -24,9 +24,19 @@ iptables -P FORWARD ACCEPT
|
|
|
24
24
|
# DNS
|
|
25
25
|
echo "nameserver 203.0.113.1" > /etc/resolv.conf
|
|
26
26
|
|
|
27
|
-
# Start dnsmasq DHCP server on internal network
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# Start dnsmasq DHCP server on internal network.
|
|
28
|
+
#
|
|
29
|
+
# Skippable, because celilo can serve DHCP itself (`modules/dnsmasq-dhcp`) and
|
|
30
|
+
# two DHCP servers on one broadcast domain race: whichever answers a DISCOVER
|
|
31
|
+
# first wins, and a test asserting which one served a lease would be asserting
|
|
32
|
+
# a coin toss. An operator moving DHCP to celilo turns the router's off, so a
|
|
33
|
+
# suite that tests celilo's DHCP models that by setting ROUTER_DHCP=off.
|
|
34
|
+
if [ "${ROUTER_DHCP:-on}" = "off" ]; then
|
|
35
|
+
echo "dnsmasq DHCP server NOT started (ROUTER_DHCP=off; celilo serves DHCP here)"
|
|
36
|
+
else
|
|
37
|
+
dnsmasq --conf-dir=/etc/dnsmasq.d --keep-in-foreground --log-facility=- &
|
|
38
|
+
echo "dnsmasq DHCP server started"
|
|
39
|
+
fi
|
|
30
40
|
|
|
31
41
|
# Start greenwave simulator
|
|
32
42
|
if [ -f /simulator/server.ts ]; then
|
|
@@ -35,6 +35,12 @@ RUN apt-get update && apt-get install -y \
|
|
|
35
35
|
procps \
|
|
36
36
|
zsh \
|
|
37
37
|
gnupg \
|
|
38
|
+
# The hook jail (openspec/changes/hook-process-boundary, stage 2) runs every
|
|
39
|
+
# hook under bubblewrap. Without it the management box runs hooks unjailed,
|
|
40
|
+
# which is a valid mode but not the one the e2e suite is there to exercise.
|
|
41
|
+
# The container needs security_opt relaxations to let it build a namespace —
|
|
42
|
+
# see the measured ladder in docker-compose-generator.ts.
|
|
43
|
+
bubblewrap \
|
|
38
44
|
&& rm -rf /var/lib/apt/lists/*
|
|
39
45
|
|
|
40
46
|
# --- Terraform, plus an OFFLINE provider mirror -------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"celilo-e2e-infra": "./bin/e2e-infra"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "bun test --timeout 30000 tests/",
|
|
21
|
+
"test": "bun test --timeout 30000 ./tests/ ./src/ ./npm-registry-server/ ./simulators/",
|
|
22
22
|
"test:completion": "bun test tests/completion",
|
|
23
23
|
"test:integration": "bun test tests-integration/"
|
|
24
24
|
},
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@celilo/capabilities": "^3.
|
|
40
|
+
"@celilo/capabilities": "^3.3.0",
|
|
41
41
|
"@celilo/cli-display": "^0.2.0",
|
|
42
42
|
"@celilo/event-bus": "^0.6.0",
|
|
43
43
|
"@celilo/terraform-fake": "^0.3.1",
|
|
@@ -33,6 +33,8 @@ export interface BootstrapEntry {
|
|
|
33
33
|
* apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
|
|
34
34
|
*/
|
|
35
35
|
description?: string;
|
|
36
|
+
/** Manifest's `icon` field, when present. Served like `description`. */
|
|
37
|
+
icon?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -60,11 +62,14 @@ export function scanBootstrapDir(bootstrapDir: string): Map<string, BootstrapEnt
|
|
|
60
62
|
const name = idMatch?.[1]?.trim() ?? entry.name;
|
|
61
63
|
const version = versionMatch?.[1]?.trim() ?? '0.0.0';
|
|
62
64
|
const description = descriptionMatch?.[1]?.trim().replace(/^['"]|['"]$/g, '');
|
|
65
|
+
const iconMatch = yaml.match(/^icon:\s*(.+?)\s*$/m);
|
|
66
|
+
const icon = iconMatch?.[1]?.trim().replace(/^['"]|['"]$/g, '');
|
|
63
67
|
entries.set(name, {
|
|
64
68
|
name,
|
|
65
69
|
version,
|
|
66
70
|
sourceDir: join(bootstrapDir, entry.name),
|
|
67
71
|
description,
|
|
72
|
+
icon,
|
|
68
73
|
});
|
|
69
74
|
} catch {
|
|
70
75
|
// skip malformed manifests — matches e2e server behavior
|
|
@@ -217,6 +217,64 @@ describe('GET /api/v1/modules (search)', () => {
|
|
|
217
217
|
expect(found?.description).toBe('');
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
test('icon survives publish, the sparse index and a search read', async () => {
|
|
221
|
+
// openspec/changes/module-icons D4 — the icon rides the same path
|
|
222
|
+
// `description` does: publish metadata → index entry → browse endpoints.
|
|
223
|
+
expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
|
|
224
|
+
200,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
|
|
228
|
+
expect(index.status).toBe(200);
|
|
229
|
+
const line = JSON.parse((await index.text()).trim()) as { icon?: string };
|
|
230
|
+
expect(line.icon).toBe('\u22c8');
|
|
231
|
+
|
|
232
|
+
const res = await fetch(`${baseUrl}/api/v1/modules`);
|
|
233
|
+
const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
|
|
234
|
+
expect(body.modules.find((m) => m.name === 'homebridge')?.icon).toBe('\u22c8');
|
|
235
|
+
|
|
236
|
+
const detail = await fetch(`${baseUrl}/api/v1/modules/homebridge`);
|
|
237
|
+
expect(((await detail.json()) as { icon?: string }).icon).toBe('\u22c8');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('an entry published without an icon reads back as undefined, not a throw', async () => {
|
|
241
|
+
// The old-entry case D4 depends on: index lines written before the field
|
|
242
|
+
// existed carry no `icon`, and a consumer falls back rather than failing.
|
|
243
|
+
//
|
|
244
|
+
// This test passes with or without the icon feature, so it is NOT a gate on
|
|
245
|
+
// it — verified by running it against the pre-#1147 tree. It is here to
|
|
246
|
+
// catch a future change that makes a missing `icon` throw or coerce to '',
|
|
247
|
+
// which is what D4's fallback chain would break on. Do not count it as
|
|
248
|
+
// coverage of the capture path; that is the test above.
|
|
249
|
+
expect((await publish('homebridge', '1.0.0+1', 'valid-token')).status).toBe(200);
|
|
250
|
+
|
|
251
|
+
const res = await fetch(`${baseUrl}/api/v1/modules`);
|
|
252
|
+
const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
|
|
253
|
+
const found = body.modules.find((m) => m.name === 'homebridge');
|
|
254
|
+
expect(found).toBeTruthy();
|
|
255
|
+
expect(found?.icon).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a multi-character icon is dropped while a valid one beside it is kept', async () => {
|
|
259
|
+
// Publish metadata is arbitrary client JSON. The CLI refines the value at
|
|
260
|
+
// manifest-parse time; the server keeps the index from carrying a string
|
|
261
|
+
// that would blow out a fixed-width slot on the browse page.
|
|
262
|
+
//
|
|
263
|
+
// Both halves are asserted deliberately. Checking only that the bad value
|
|
264
|
+
// is absent would pass just as well if the capture path were deleted
|
|
265
|
+
// outright — the guard and the capture would cancel out and the test would
|
|
266
|
+
// stay green having lost the feature. The kept glyph is what stops that.
|
|
267
|
+
expect(
|
|
268
|
+
(await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', 'not-a-glyph')).status,
|
|
269
|
+
).toBe(200);
|
|
270
|
+
expect((await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u25cd')).status).toBe(200);
|
|
271
|
+
|
|
272
|
+
const res = await fetch(`${baseUrl}/api/v1/modules`);
|
|
273
|
+
const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
|
|
274
|
+
expect(body.modules.find((m) => m.name === 'homebridge')?.icon).toBeUndefined();
|
|
275
|
+
expect(body.modules.find((m) => m.name === 'caddy')?.icon).toBe('\u25cd');
|
|
276
|
+
});
|
|
277
|
+
|
|
220
278
|
test('total_downloads reflects actual download count, sort=downloads orders by it', async () => {
|
|
221
279
|
// Publish two modules, hit one's download endpoint several times.
|
|
222
280
|
// The search response should show those counts and sort=downloads
|
|
@@ -490,9 +548,15 @@ async function publish(
|
|
|
490
548
|
vers: string,
|
|
491
549
|
token: string,
|
|
492
550
|
description?: string,
|
|
551
|
+
icon?: string,
|
|
493
552
|
): Promise<Response> {
|
|
494
553
|
const meta = Buffer.from(
|
|
495
|
-
JSON.stringify({
|
|
554
|
+
JSON.stringify({
|
|
555
|
+
name,
|
|
556
|
+
vers,
|
|
557
|
+
...(description ? { description } : {}),
|
|
558
|
+
...(icon ? { icon } : {}),
|
|
559
|
+
}),
|
|
496
560
|
'utf-8',
|
|
497
561
|
);
|
|
498
562
|
const file = Buffer.from('fake netapp bytes');
|
|
@@ -769,3 +833,121 @@ describe('module-owner authorization (ce-1ch)', () => {
|
|
|
769
833
|
expect(res.status).toBe(200);
|
|
770
834
|
});
|
|
771
835
|
});
|
|
836
|
+
|
|
837
|
+
// ── sweep (reclaiming disk from superseded build revisions) ──────────────────
|
|
838
|
+
|
|
839
|
+
describe('POST /api/v1/modules/sweep', () => {
|
|
840
|
+
function requestSweep(token: string, body: unknown = {}): Promise<Response> {
|
|
841
|
+
return fetch(`${baseUrl}/api/v1/modules/sweep`, {
|
|
842
|
+
method: 'POST',
|
|
843
|
+
headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
|
|
844
|
+
body: JSON.stringify(body),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
test('no token is 401 — a sweep deletes, so it is admin-only', async () => {
|
|
849
|
+
const res = await requestSweep('');
|
|
850
|
+
expect(res.status).toBe(401);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test('a scoped (non-admin) token is 401', async () => {
|
|
854
|
+
const mint = await fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
|
|
855
|
+
method: 'POST',
|
|
856
|
+
headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
|
|
857
|
+
body: JSON.stringify({ repo: 'celilo/homebridge', scope: 'homebridge' }),
|
|
858
|
+
});
|
|
859
|
+
const { token } = (await mint.json()) as { token: string };
|
|
860
|
+
|
|
861
|
+
const res = await requestSweep(token);
|
|
862
|
+
expect(res.status).toBe(401);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('removes superseded revisions and leaves the download path working', async () => {
|
|
866
|
+
for (const rev of [1, 2, 3]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
|
|
867
|
+
|
|
868
|
+
const res = await requestSweep('valid-token');
|
|
869
|
+
expect(res.status).toBe(200);
|
|
870
|
+
const body = (await res.json()) as { ok: boolean; removedCount: number };
|
|
871
|
+
expect(body.ok).toBe(true);
|
|
872
|
+
expect(body.removedCount).toBe(2);
|
|
873
|
+
|
|
874
|
+
// The surviving revision downloads; a swept one is gone from BOTH the
|
|
875
|
+
// index and the store, so nothing advertises a 404.
|
|
876
|
+
expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+3/download`)).status).toBe(200);
|
|
877
|
+
expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(404);
|
|
878
|
+
|
|
879
|
+
const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
|
|
880
|
+
const versions = (await index.text())
|
|
881
|
+
.split('\n')
|
|
882
|
+
.filter(Boolean)
|
|
883
|
+
.map((line) => (JSON.parse(line) as { vers: string }).vers);
|
|
884
|
+
expect(versions).toEqual(['1.0.0+3']);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test('dry_run reports the plan without deleting anything', async () => {
|
|
888
|
+
for (const rev of [1, 2]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
|
|
889
|
+
|
|
890
|
+
const res = await requestSweep('valid-token', { dry_run: true });
|
|
891
|
+
const body = (await res.json()) as { removedCount: number; dryRun: boolean };
|
|
892
|
+
expect(body.removedCount).toBe(1);
|
|
893
|
+
expect(body.dryRun).toBe(true);
|
|
894
|
+
expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(200);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test('rejects a keep_build_revisions that would delete a whole release', async () => {
|
|
898
|
+
const res = await requestSweep('valid-token', { keep_build_revisions: 0 });
|
|
899
|
+
expect(res.status).toBe(400);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test('honours a larger keep_build_revisions', async () => {
|
|
903
|
+
for (const rev of [1, 2, 3, 4]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
|
|
904
|
+
const res = await requestSweep('valid-token', { keep_build_revisions: 2 });
|
|
905
|
+
const body = (await res.json()) as { removedCount: number };
|
|
906
|
+
expect(body.removedCount).toBe(2);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test('coexists with a module actually named "sweep"', async () => {
|
|
910
|
+
await publish('sweep', '1.0.0+1', 'valid-token');
|
|
911
|
+
const res = await requestSweep('valid-token');
|
|
912
|
+
expect(res.status).toBe(200);
|
|
913
|
+
// …and the module is still reachable by its own GET route.
|
|
914
|
+
expect((await fetch(`${baseUrl}/api/v1/modules/sweep`)).status).toBe(200);
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
describe('POST /api/v1/modules/sweep — a damaged store', () => {
|
|
919
|
+
function requestSweep(token: string, body: unknown = {}): Promise<Response> {
|
|
920
|
+
return fetch(`${baseUrl}/api/v1/modules/sweep`, {
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
923
|
+
body: JSON.stringify(body),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
test('reclaims a half-written publish, freeing the version to be published again', async () => {
|
|
928
|
+
// handlePublish stores the package and appends the index with no rollback
|
|
929
|
+
// between them, so an ENOSPC or EACCES in the gap leaves an orphan. The
|
|
930
|
+
// immutability check reads the filesystem, so that version then cannot be
|
|
931
|
+
// published at all — this is the only repair path in the product.
|
|
932
|
+
await publish('homebridge', '1.0.0+1', 'valid-token');
|
|
933
|
+
const rmIndex = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
|
|
934
|
+
method: 'DELETE',
|
|
935
|
+
headers: { Authorization: 'valid-token' },
|
|
936
|
+
});
|
|
937
|
+
expect(rmIndex.status).toBe(200);
|
|
938
|
+
|
|
939
|
+
// Simulate the crash: index line gone, payload left behind.
|
|
940
|
+
rmSync(join(dataDir, 'index'), { recursive: true, force: true });
|
|
941
|
+
|
|
942
|
+
const blocked = await publish('homebridge', '1.0.0+1', 'valid-token');
|
|
943
|
+
expect(blocked.status).toBe(409);
|
|
944
|
+
|
|
945
|
+
const swept = await requestSweep('valid-token');
|
|
946
|
+
const body = (await swept.json()) as { orphanCount: number };
|
|
947
|
+
expect(body.orphanCount).toBe(1);
|
|
948
|
+
|
|
949
|
+
// The version is publishable again.
|
|
950
|
+
const retry = await publish('homebridge', '1.0.0+1', 'valid-token');
|
|
951
|
+
expect(retry.status).toBe(200);
|
|
952
|
+
});
|
|
953
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* PUT /api/v1/modules/new → publish (token auth)
|
|
11
11
|
* DELETE /api/v1/modules/{name}/{ver}/yank → yank
|
|
12
12
|
* PUT /api/v1/modules/{name}/{ver}/unyank → unyank
|
|
13
|
+
* POST /api/v1/modules/sweep → reclaim old revisions (admin)
|
|
13
14
|
*
|
|
14
15
|
* All routes can be prefixed with PATH_PREFIX (e.g. "/registry").
|
|
15
16
|
*
|
|
@@ -34,6 +35,7 @@ import { ModuleOwnerStore, fileModuleOwnerPersistence } from './module-owner-sto
|
|
|
34
35
|
import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
|
|
35
36
|
import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
|
|
36
37
|
import { type IndexEntry, RegistryStorage } from './storage';
|
|
38
|
+
import { DEFAULT_KEEP_BUILD_REVISIONS, sweep } from './sweep';
|
|
37
39
|
import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
|
|
38
40
|
|
|
39
41
|
/** Max total publish body size. Real modules are a few MB; 100MB is generous. */
|
|
@@ -313,6 +315,12 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
313
315
|
// not provided we leave it undefined; the search endpoint falls
|
|
314
316
|
// back to bootstrap data or empty.
|
|
315
317
|
const description = typeof meta.description === 'string' ? meta.description : undefined;
|
|
318
|
+
// Icon likewise, but capped at one Unicode scalar. The CLI already refines
|
|
319
|
+
// it at manifest-parse time; this second check is here because the publish
|
|
320
|
+
// metadata is arbitrary client JSON and the value lands in a fixed-width
|
|
321
|
+
// slot on the browse page.
|
|
322
|
+
const icon =
|
|
323
|
+
typeof meta.icon === 'string' && [...meta.icon].length === 1 ? meta.icon : undefined;
|
|
316
324
|
|
|
317
325
|
const validation = validateNameAndVersion(name, vers);
|
|
318
326
|
if (!validation.ok) return err(validation.message);
|
|
@@ -339,12 +347,63 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
339
347
|
cksum: `sha256:${cksum}`,
|
|
340
348
|
yanked: false,
|
|
341
349
|
description,
|
|
350
|
+
icon,
|
|
342
351
|
});
|
|
343
352
|
|
|
344
353
|
console.log(`[registry] published ${name}@${vers} (${fileLen} bytes, sha256:${cksum})`);
|
|
345
354
|
return Response.json({ ok: true, name, vers });
|
|
346
355
|
}
|
|
347
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Reclaim disk from superseded build revisions (admin-only).
|
|
359
|
+
*
|
|
360
|
+
* The counterpart yank never was: yanking flips a boolean and frees nothing,
|
|
361
|
+
* so before this endpoint existed every revision ever published was retained
|
|
362
|
+
* forever and the store grew without bound until the disk filled.
|
|
363
|
+
*
|
|
364
|
+
* Runs IN the process that owns the store, so it cannot race a concurrent
|
|
365
|
+
* publish's index append. `dry_run` reports the same plan without touching
|
|
366
|
+
* anything — the safe way to see what a policy would do on a live store.
|
|
367
|
+
*/
|
|
368
|
+
async function handleSweep(req: Request): Promise<Response> {
|
|
369
|
+
if (!(await authorizeAdminReq(req))) return unauthorized();
|
|
370
|
+
|
|
371
|
+
let body: { keep_build_revisions?: unknown; dry_run?: unknown } = {};
|
|
372
|
+
try {
|
|
373
|
+
const text = await req.text();
|
|
374
|
+
if (text.trim()) body = JSON.parse(text) as typeof body;
|
|
375
|
+
} catch {
|
|
376
|
+
return err('Invalid JSON body');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const requested = body.keep_build_revisions;
|
|
380
|
+
if (requested !== undefined && (!Number.isInteger(requested) || (requested as number) < 1)) {
|
|
381
|
+
return err('keep_build_revisions must be an integer >= 1');
|
|
382
|
+
}
|
|
383
|
+
const keepBuildRevisions = (requested as number | undefined) ?? DEFAULT_KEEP_BUILD_REVISIONS;
|
|
384
|
+
const dryRun = body.dry_run === true;
|
|
385
|
+
|
|
386
|
+
const report = sweep(storage, { keepBuildRevisions }, dryRun);
|
|
387
|
+
console.log(
|
|
388
|
+
`[registry] sweep${dryRun ? ' (dry run)' : ''} keep=${keepBuildRevisions}: ` +
|
|
389
|
+
`${report.removedCount} superseded + ${report.orphanCount} orphaned revision(s), ` +
|
|
390
|
+
`${Math.round(report.reclaimedBytes / 1024 / 1024)} MB`,
|
|
391
|
+
);
|
|
392
|
+
// Both of these mean the store is damaged, not merely untidy, and the sweep
|
|
393
|
+
// deliberately did not act on either. Say so where an operator will see it.
|
|
394
|
+
if (report.danglingCount > 0) {
|
|
395
|
+
console.warn(
|
|
396
|
+
`[registry] ${report.danglingCount} index line(s) have no package file — left in place; download 404s until republished`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (report.unreadable.length > 0) {
|
|
400
|
+
console.warn(
|
|
401
|
+
`[registry] skipped entirely (no package files at all — check the mount and DATA_DIR): ${report.unreadable.join(', ')}`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
return Response.json({ ok: true, ...report });
|
|
405
|
+
}
|
|
406
|
+
|
|
348
407
|
function setYanked(name: string, version: string, yanked: boolean): Response {
|
|
349
408
|
const entries = storage.readIndex(name);
|
|
350
409
|
const entry = entries.find((e) => e.vers === version);
|
|
@@ -505,10 +564,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
505
564
|
// description capture and haven't been republished).
|
|
506
565
|
const bootstrapEntry = bootstrap.get(name);
|
|
507
566
|
const description = latest?.description ?? bootstrapEntry?.description ?? '';
|
|
567
|
+
// Icon follows the same order, but has no empty-string tier: absent
|
|
568
|
+
// means "this module declared none", and the consumer resolves it
|
|
569
|
+
// from its own table or a placeholder (module-icons D5).
|
|
570
|
+
const icon = latest?.icon ?? bootstrapEntry?.icon;
|
|
508
571
|
return {
|
|
509
572
|
name,
|
|
510
573
|
max_version: latest?.vers ?? '0.0.0',
|
|
511
574
|
description,
|
|
575
|
+
icon,
|
|
512
576
|
total_downloads: storage.getDownloads(name),
|
|
513
577
|
};
|
|
514
578
|
});
|
|
@@ -551,6 +615,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
551
615
|
return handleRevokeToken(req);
|
|
552
616
|
}
|
|
553
617
|
|
|
618
|
+
// Reclaim disk from superseded build revisions (admin-only), grouped
|
|
619
|
+
// with the other admin endpoints. A module NAMED "sweep" is fine here:
|
|
620
|
+
// its metadata route is a GET, so the two never compete.
|
|
621
|
+
if (method === 'POST' && suffix === `${apiBase}/sweep`) {
|
|
622
|
+
const rl = rateLimitOrNull(req, srv);
|
|
623
|
+
if (rl) return rl;
|
|
624
|
+
return handleSweep(req);
|
|
625
|
+
}
|
|
626
|
+
|
|
554
627
|
// Module-owner table management (admin-only — ce-1ch). Matched before the
|
|
555
628
|
// generic `${apiBase}/{name}` handlers so a name of "owners" can't shadow
|
|
556
629
|
// them.
|
|
@@ -588,9 +661,11 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
588
661
|
const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
|
|
589
662
|
const bootstrapEntry = resolveBootstrap().get(name);
|
|
590
663
|
const description = latest?.description ?? bootstrapEntry?.description ?? '';
|
|
664
|
+
const icon = latest?.icon ?? bootstrapEntry?.icon;
|
|
591
665
|
return Response.json({
|
|
592
666
|
name,
|
|
593
667
|
description,
|
|
668
|
+
icon,
|
|
594
669
|
total_downloads: storage.getDownloads(name),
|
|
595
670
|
versions: entries.map((v) => ({
|
|
596
671
|
num: v.vers,
|
|
@@ -8,7 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
12
21
|
import { dirname, join } from 'node:path';
|
|
13
22
|
|
|
14
23
|
export interface IndexEntry {
|
|
@@ -25,6 +34,14 @@ export interface IndexEntry {
|
|
|
25
34
|
* step 0). Old index files without this field still parse cleanly.
|
|
26
35
|
*/
|
|
27
36
|
description?: string;
|
|
37
|
+
/**
|
|
38
|
+
* One monochrome glyph from the module's `manifest.yml#icon`, captured at
|
|
39
|
+
* publish time. Optional for the same reason `description` is: entries
|
|
40
|
+
* written before the field existed have none, and a consumer reading one
|
|
41
|
+
* without it falls back to its own table rather than failing
|
|
42
|
+
* (openspec/changes/module-icons, D4).
|
|
43
|
+
*/
|
|
44
|
+
icon?: string;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
export interface ModuleVersion {
|
|
@@ -79,10 +96,28 @@ export class RegistryStorage {
|
|
|
79
96
|
writeFileSync(path, `${JSON.stringify(entry)}\n`, { flag: 'a' });
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Replace a module's whole index file.
|
|
101
|
+
*
|
|
102
|
+
* Writes a sibling temp file and renames it over the original, which is
|
|
103
|
+
* atomic within a directory. A plain truncating write has two failure modes
|
|
104
|
+
* this avoids, and the sweep meets both: a crash mid-write leaves a half-
|
|
105
|
+
* index, and an ENOSPC leaves an EMPTY one — losing every version of the
|
|
106
|
+
* module. The sweep exists precisely because the disk filled up, so "the
|
|
107
|
+
* write fails for want of space" is its expected environment, not a corner.
|
|
108
|
+
* On failure the original file is untouched.
|
|
109
|
+
*/
|
|
82
110
|
updateIndex(name: string, entries: IndexEntry[]): void {
|
|
83
111
|
const path = this.indexPath(name);
|
|
84
112
|
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
-
|
|
113
|
+
const tmp = `${path}.tmp`;
|
|
114
|
+
try {
|
|
115
|
+
writeFileSync(tmp, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`);
|
|
116
|
+
renameSync(tmp, path);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
rmSync(tmp, { force: true, recursive: true });
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
storePackage(name: string, version: string, data: Buffer): string {
|
|
@@ -96,6 +131,45 @@ export class RegistryStorage {
|
|
|
96
131
|
return existsSync(this.packagePath(name, version));
|
|
97
132
|
}
|
|
98
133
|
|
|
134
|
+
/** Bytes on disk for one version's .netapp, or 0 when it is already gone. */
|
|
135
|
+
packageSize(name: string, version: string): number {
|
|
136
|
+
const path = this.packagePath(name, version);
|
|
137
|
+
if (!existsSync(path)) return 0;
|
|
138
|
+
return statSync(path).size;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Delete one version's payload directory (`modules/{name}/{version}/`).
|
|
143
|
+
* Idempotent: removing a version that is already gone is a no-op, so an
|
|
144
|
+
* interrupted sweep is safe to re-run.
|
|
145
|
+
*/
|
|
146
|
+
removePackage(name: string, version: string): void {
|
|
147
|
+
rmSync(dirname(this.packagePath(name, version)), { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every version with a payload directory on disk, whether or not the index
|
|
152
|
+
* still lists it. The sweep needs the on-DISK set, not the indexed one:
|
|
153
|
+
* removing an index line is what makes a version unreachable, and the file
|
|
154
|
+
* it leaves behind is exactly what has to be reclaimed afterwards.
|
|
155
|
+
*/
|
|
156
|
+
storedVersions(name: string): string[] {
|
|
157
|
+
const dir = join(this.dataDir, 'modules', name);
|
|
158
|
+
if (!existsSync(dir)) return [];
|
|
159
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
160
|
+
.filter((d) => d.isDirectory())
|
|
161
|
+
.map((d) => d.name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Module names with a payload directory, including any absent from the index. */
|
|
165
|
+
storedNames(): string[] {
|
|
166
|
+
const dir = join(this.dataDir, 'modules');
|
|
167
|
+
if (!existsSync(dir)) return [];
|
|
168
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
169
|
+
.filter((d) => d.isDirectory())
|
|
170
|
+
.map((d) => d.name);
|
|
171
|
+
}
|
|
172
|
+
|
|
99
173
|
readPackage(name: string, version: string): Buffer | null {
|
|
100
174
|
const path = this.packagePath(name, version);
|
|
101
175
|
return existsSync(path) ? readFileSync(path) : null;
|