@celilo/e2e 0.7.17 → 0.9.0

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/src/cli/build.ts CHANGED
@@ -19,10 +19,11 @@
19
19
  */
20
20
 
21
21
  import { execSync, spawnSync } from 'node:child_process';
22
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
22
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
23
23
  import { basename, join, resolve } from 'node:path';
24
- import { ensureRegistryServerBundle } from '../registry-bundle';
24
+ import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
+ import { ensureRegistryServerBundle } from '../registry-bundle';
26
27
 
27
28
  /** Walk up from pkgDir to find the monorepo root (contains a `modules/` dir). */
28
29
  function findMonorepoRoot(pkgDir: string): string | null {
@@ -56,6 +57,13 @@ interface BuildOptions {
56
57
  moduleDirs: string[];
57
58
  save: boolean;
58
59
  skipModules: boolean;
60
+ /**
61
+ * Bake `management:latest` from the PUBLISHED @celilo/cli on real npm
62
+ * instead of the monorepo DEV cli served by the sim registry. Runs a
63
+ * standalone container on the default docker network (real internet) —
64
+ * the simulated topology has no route to npmjs.org.
65
+ */
66
+ published: boolean;
59
67
  }
60
68
 
61
69
  /**
@@ -75,7 +83,7 @@ interface BuildOptions {
75
83
  * keeps build-infra working in npm-installed @celilo/e2e, where these
76
84
  * staging dirs aren't relevant.
77
85
  */
78
- function stageSimulatorInputs(pkgDir: string): void {
86
+ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
79
87
  // Try CWD first — when cele2e is installed globally, `pkgDir` is
80
88
  // somewhere like `~/.bun/install/global/node_modules/@celilo/e2e`,
81
89
  // which doesn't have a `modules/` sibling. Walking up from CWD
@@ -84,7 +92,13 @@ function stageSimulatorInputs(pkgDir: string): void {
84
92
  // Mirrors the same CWD-then-pkgDir pattern used for module
85
93
  // discovery further down.
86
94
  const repoRoot = findMonorepoRoot(process.cwd()) ?? findMonorepoRoot(pkgDir);
87
- if (!repoRoot) return;
95
+ if (!repoRoot) {
96
+ // No monorepo (npm-installed @celilo/e2e) → fetch the same caches from
97
+ // the public celilo sources instead of building them from workspace
98
+ // source, so build-infra still works for a monorepo-free consumer (ce-i2i).
99
+ await stageFromPublic(pkgDir);
100
+ return;
101
+ }
88
102
 
89
103
  const websiteSrc = join(repoRoot, 'modules', 'celilo-website', 'site');
90
104
  const websiteCache = join(pkgDir, '.celilo-website-cache');
@@ -154,6 +168,201 @@ function stageSimulatorInputs(pkgDir: string): void {
154
168
  console.log('');
155
169
  }
156
170
 
171
+ /**
172
+ * The published @celilo/* packages the npm-registry-sim must serve so
173
+ * install.sh's `bun add -g @celilo/cli @celilo/event-bus @celilo/e2e`
174
+ * resolves its full transitive closure against the sim. Mirrors
175
+ * PACKAGES_TO_PACK in scripts/pack-celilo-packages.ts (the monorepo
176
+ * equivalent that packs from workspace source instead of fetching).
177
+ */
178
+ const PUBLISHED_CELILO_PACKAGES = [
179
+ '@celilo/cli',
180
+ '@celilo/capabilities',
181
+ '@celilo/cli-display',
182
+ '@celilo/e2e',
183
+ '@celilo/event-bus',
184
+ ] as const;
185
+
186
+ /**
187
+ * npm-consumer counterpart to the monorepo staging above (ce-i2i). With no
188
+ * `modules/` source tree to build from, fetch the sim-content caches from
189
+ * the public celilo sources so `cele2e build-infra` still produces working
190
+ * simulator images. Everything is pulled over the public internet, which
191
+ * build-infra already requires (docker base images, the caddy apt repo, ACME):
192
+ *
193
+ * .npm-registry-cache/ ← published @celilo/* tarballs (npm registry)
194
+ * .celilo-website-cache/ ← install.sh from https://celilo.computer (all the
195
+ * bake step's `curl celilo.computer/install.sh` needs)
196
+ * .apt-repo-cache/pool/ ← real celilo + celilo-bootstrap .debs fetched from
197
+ * apt.celilo.computer (only bootstrap-apt needs them).
198
+ *
199
+ * Sources are overridable via CELILO_E2E_NPM_REGISTRY / CELILO_E2E_WEBSITE_BASE
200
+ * / CELILO_E2E_APT_BASE (used by the monorepo-free CI smoke test, ce-1pe).
201
+ */
202
+ async function stageFromPublic(pkgDir: string): Promise<void> {
203
+ console.log(`${bold}Staging simulator inputs (npm-consumer mode)...${reset}\n`);
204
+
205
+ const registry = (process.env.CELILO_E2E_NPM_REGISTRY ?? 'https://registry.npmjs.org').replace(
206
+ /\/+$/,
207
+ '',
208
+ );
209
+ const websiteBase = (process.env.CELILO_E2E_WEBSITE_BASE ?? 'https://celilo.computer').replace(
210
+ /\/+$/,
211
+ '',
212
+ );
213
+
214
+ // (a) npm-registry-cache: the published @celilo/* tarballs.
215
+ const npmCache = join(pkgDir, '.npm-registry-cache');
216
+ rmSync(npmCache, { recursive: true, force: true });
217
+ mkdirSync(npmCache, { recursive: true });
218
+ process.stdout.write(` ${'npm-registry (fetch)'.padEnd(28)} `);
219
+ const t0 = Date.now();
220
+ for (const pkg of PUBLISHED_CELILO_PACKAGES) {
221
+ await fetchPublishedTarball(pkg, npmCache, registry);
222
+ }
223
+ console.log(
224
+ `${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s, ${PUBLISHED_CELILO_PACKAGES.length} tarball(s)${reset}`,
225
+ );
226
+
227
+ // (b) celilo-website-cache: the bake step's `curl celilo.computer/install.sh`
228
+ // only needs install.sh served, so fetch it (bootstrap.sh best-effort — it's
229
+ // referenced by install.sh's help text but not needed for the bake path).
230
+ const websiteCache = join(pkgDir, '.celilo-website-cache');
231
+ rmSync(websiteCache, { recursive: true, force: true });
232
+ mkdirSync(websiteCache, { recursive: true });
233
+ process.stdout.write(` ${'celilo-website (fetch)'.padEnd(28)} `);
234
+ const t1 = Date.now();
235
+ await fetchSiteFile(websiteBase, 'install.sh', websiteCache, { required: true });
236
+ await fetchSiteFile(websiteBase, 'bootstrap.sh', websiteCache, { required: false });
237
+ console.log(`${green}✔${reset} ${dim}${Math.round((Date.now() - t1) / 1000)}s${reset}`);
238
+
239
+ // (c) apt-repo-cache/pool: the real celilo + celilo-bootstrap .debs, fetched
240
+ // from apt.celilo.computer so the bootstrap-apt test's `apt install
241
+ // celilo-bootstrap` resolves. Only that test exercises this path; if the
242
+ // fetch fails we leave the pool empty (build-infra still succeeds for
243
+ // everyone) and bootstrap-apt fails loudly with the sim's "empty repo".
244
+ const aptBase = (process.env.CELILO_E2E_APT_BASE ?? 'https://apt.celilo.computer').replace(
245
+ /\/+$/,
246
+ '',
247
+ );
248
+ const pool = join(pkgDir, '.apt-repo-cache', 'pool');
249
+ rmSync(join(pkgDir, '.apt-repo-cache'), { recursive: true, force: true });
250
+ mkdirSync(pool, { recursive: true });
251
+ process.stdout.write(` ${'apt-repo (fetch)'.padEnd(28)} `);
252
+ const t2 = Date.now();
253
+ try {
254
+ const count = await fetchAptPool(aptBase, pool);
255
+ console.log(
256
+ `${green}✔${reset} ${dim}${Math.round((Date.now() - t2) / 1000)}s, ${count} deb(s)${reset}`,
257
+ );
258
+ } catch (err) {
259
+ console.log(
260
+ `${yellow}empty${reset} ${dim}(${err instanceof Error ? err.message : String(err)})${reset}`,
261
+ );
262
+ }
263
+
264
+ console.log('');
265
+ }
266
+
267
+ /**
268
+ * Fetch the celilo + celilo-bootstrap .debs from a real apt repo laid out like
269
+ * apt.celilo.computer (`deb <base>/ stable main`) into `pool`. Parses the
270
+ * per-arch Packages index for its `Filename: pool/...` entries and downloads
271
+ * each referenced .deb (deduped across arches, so the arch-all bootstrap deb is
272
+ * fetched once). Returns the number of debs staged; throws if none were found.
273
+ */
274
+ export async function fetchAptPool(base: string, pool: string): Promise<number> {
275
+ const filenames = new Set<string>();
276
+ for (const arch of ['amd64', 'arm64']) {
277
+ const dir = `${base}/dists/stable/main/binary-${arch}`;
278
+ const packages = await fetchPackagesIndex(dir);
279
+ for (const line of packages.split('\n')) {
280
+ const m = line.match(/^Filename:\s*(\S+)/);
281
+ if (m) filenames.add(m[1]);
282
+ }
283
+ }
284
+ if (filenames.size === 0) {
285
+ throw new Error(`no debs in ${base} stable/main — repo layout changed?`);
286
+ }
287
+ for (const filename of filenames) {
288
+ const url = `${base}/${filename}`;
289
+ const res = await fetch(url);
290
+ if (!res.ok) throw new Error(`fetching ${url}: HTTP ${res.status}`);
291
+ writeFileSync(join(pool, basename(filename)), Buffer.from(await res.arrayBuffer()));
292
+ }
293
+ return filenames.size;
294
+ }
295
+
296
+ /** Fetch a Packages index from an apt binary-<arch> dir, uncompressed or .gz. */
297
+ async function fetchPackagesIndex(dir: string): Promise<string> {
298
+ const plain = await fetch(`${dir}/Packages`);
299
+ if (plain.ok) return await plain.text();
300
+ const gz = await fetch(`${dir}/Packages.gz`);
301
+ if (!gz.ok) throw new Error(`fetching ${dir}/Packages(.gz): HTTP ${gz.status}`);
302
+ return gunzipSync(Buffer.from(await gz.arrayBuffer())).toString('utf8');
303
+ }
304
+
305
+ /**
306
+ * Download a package's `latest` published tarball from the npm registry into
307
+ * destDir. Uses the registry's own tarball filename so the npm-registry-sim
308
+ * (which indexes tarballs by their embedded package.json) serves it verbatim.
309
+ */
310
+ export async function fetchPublishedTarball(
311
+ pkg: string,
312
+ destDir: string,
313
+ registry: string,
314
+ ): Promise<void> {
315
+ const packumentUrl = `${registry}/${pkg}`;
316
+ const packumentRes = await fetch(packumentUrl);
317
+ if (!packumentRes.ok) {
318
+ throw new Error(
319
+ `Fetching packument for ${pkg} (${packumentUrl}): HTTP ${packumentRes.status}. ` +
320
+ `Is ${pkg} published to ${registry}?`,
321
+ );
322
+ }
323
+ const packument = (await packumentRes.json()) as {
324
+ 'dist-tags'?: Record<string, string>;
325
+ versions?: Record<string, { dist?: { tarball?: string } }>;
326
+ };
327
+ const latest = packument['dist-tags']?.latest;
328
+ const tarballUrl = latest ? packument.versions?.[latest]?.dist?.tarball : undefined;
329
+ if (!latest || !tarballUrl) {
330
+ throw new Error(`${pkg}: registry ${registry} has no dist-tags.latest tarball`);
331
+ }
332
+ const tgzRes = await fetch(tarballUrl);
333
+ if (!tgzRes.ok) {
334
+ throw new Error(`Fetching tarball for ${pkg}@${latest} (${tarballUrl}): HTTP ${tgzRes.status}`);
335
+ }
336
+ const filename =
337
+ tarballUrl.split('/').pop() ?? `${pkg.replace('@', '').replace('/', '-')}-${latest}.tgz`;
338
+ writeFileSync(join(destDir, filename), Buffer.from(await tgzRes.arrayBuffer()));
339
+ }
340
+
341
+ /**
342
+ * Download a single file from the published site into destDir. Throws on a
343
+ * missing required file; silently skips an absent optional one.
344
+ */
345
+ export async function fetchSiteFile(
346
+ base: string,
347
+ name: string,
348
+ destDir: string,
349
+ opts: { required: boolean },
350
+ ): Promise<void> {
351
+ const url = `${base}/${name}`;
352
+ let res: Response;
353
+ try {
354
+ res = await fetch(url);
355
+ } catch (err) {
356
+ if (opts.required) throw err;
357
+ return;
358
+ }
359
+ if (!res.ok) {
360
+ if (opts.required) throw new Error(`Fetching ${url}: HTTP ${res.status}`);
361
+ return;
362
+ }
363
+ writeFileSync(join(destDir, name), Buffer.from(await res.arrayBuffer()));
364
+ }
365
+
157
366
  function packageNetapp(moduleDir: string, netappsDir: string): void {
158
367
  const absDir = resolve(moduleDir);
159
368
  if (!existsSync(absDir)) {
@@ -201,6 +410,58 @@ function packageNetapp(moduleDir: string, netappsDir: string): void {
201
410
  console.log(`${green}✔${reset} ${dim}${elapsed}s ${size}${reset}`);
202
411
  }
203
412
 
413
+ /**
414
+ * Consumer (npm-installed) staging: fetch standard-module .netapps from the
415
+ * public celilo registry into `netappsDir`. In the monorepo we package these
416
+ * from source (packageNetapp); an npm consumer has no `modules/` source, so
417
+ * getRegistryVolumes() binds `./netapps` into the sim registry and we fill it
418
+ * here at build-infra time (which has network — the test RUN stays hermetic
419
+ * against the local sim). No version-pinning: the capability system governs
420
+ * compatibility at import time (validateCapabilityAccess), surfacing a
421
+ * mismatch as an actionable import error rather than a brittle lockfile.
422
+ */
423
+ export async function stageNetappsFromRegistry(netappsDir: string): Promise<void> {
424
+ const base = (process.env.CELILO_REGISTRY_URL ?? 'https://celilo.computer/registry').replace(
425
+ /\/+$/,
426
+ '',
427
+ );
428
+ console.log(`${bold}Fetching standard-module netapps from registry...${reset}`);
429
+ console.log(` ${dim}${base}${reset}\n`);
430
+
431
+ // ponytail: single page (per_page=100). Paginate if >100 modules ever ship.
432
+ const listUrl = `${base}/api/v1/modules?per_page=100`;
433
+ const listResp = await fetch(listUrl, { signal: AbortSignal.timeout(30_000) });
434
+ if (!listResp.ok) {
435
+ console.error(`${red}Registry list failed: HTTP ${listResp.status} ${listUrl}${reset}`);
436
+ process.exit(1);
437
+ }
438
+ const { modules } = (await listResp.json()) as {
439
+ modules: Array<{ name: string; max_version: string }>;
440
+ };
441
+ if (!modules || modules.length === 0) {
442
+ console.error(`${red}Registry returned no modules — nothing to stage${reset}`);
443
+ process.exit(1);
444
+ }
445
+
446
+ for (const { name, max_version } of modules) {
447
+ process.stdout.write(` ${name.padEnd(20)} `);
448
+ const t0 = Date.now();
449
+ const dlUrl = `${base}/api/v1/modules/${encodeURIComponent(name)}/${encodeURIComponent(max_version)}/download`;
450
+ const resp = await fetch(dlUrl, { signal: AbortSignal.timeout(60_000) });
451
+ if (!resp.ok) {
452
+ console.log(`${red}✗${reset}`);
453
+ console.error(`${red} download failed: HTTP ${resp.status} ${dlUrl}${reset}`);
454
+ process.exit(1);
455
+ }
456
+ const bytes = new Uint8Array(await resp.arrayBuffer());
457
+ writeFileSync(join(netappsDir, `${name}.netapp`), bytes);
458
+ console.log(
459
+ `${green}✔${reset} ${dim}${max_version} ${Math.round((Date.now() - t0) / 1000)}s ${(bytes.length / 1024).toFixed(0)}KB${reset}`,
460
+ );
461
+ }
462
+ console.log('');
463
+ }
464
+
204
465
  function buildDockerImages(pkgDir: string): void {
205
466
  // Refresh the bundled registry-server source before docker build —
206
467
  // Dockerfile.registry copies from <pkgDir>/registry-server, which is
@@ -295,7 +556,7 @@ function tagVanillaManagement(): void {
295
556
  * build-infra so the operator can't accidentally proceed with a
296
557
  * non-functional default management image.
297
558
  */
298
- function bakeManagement(pkgDir: string): void {
559
+ function bakeManagement(pkgDir: string, published: boolean): void {
299
560
  const bakeScript = join(pkgDir, 'bin', 'e2e-bake-management');
300
561
  if (!existsSync(bakeScript)) {
301
562
  console.error(
@@ -305,9 +566,14 @@ function bakeManagement(pkgDir: string): void {
305
566
  process.exit(1);
306
567
  }
307
568
 
308
- console.log(`${bold}Baking celilo into management:latest via install.sh...${reset}\n`);
569
+ console.log(
570
+ published
571
+ ? `${bold}Baking PUBLISHED @celilo/cli into management:latest from real npm...${reset}\n`
572
+ : `${bold}Baking celilo into management:latest via install.sh...${reset}\n`,
573
+ );
309
574
  const start = Date.now();
310
- const result = spawnSync('bun', ['run', bakeScript], { stdio: 'inherit' });
575
+ const bakeArgs = ['run', bakeScript, ...(published ? ['--published'] : [])];
576
+ const result = spawnSync('bun', bakeArgs, { stdio: 'inherit' });
311
577
  const elapsed = Math.round((Date.now() - start) / 1000);
312
578
 
313
579
  if (result.status !== 0) {
@@ -381,8 +647,8 @@ function saveImages(pkgDir: string): void {
381
647
  console.log('\nTo restore after colima restart:\n cele2e load');
382
648
  }
383
649
 
384
- export function runBuild(options: BuildOptions): void {
385
- const { pkgDir, moduleDirs, save, skipModules } = options;
650
+ export async function runBuild(options: BuildOptions): Promise<void> {
651
+ const { pkgDir, moduleDirs, save, skipModules, published } = options;
386
652
  const netappsDir = join(pkgDir, 'netapps');
387
653
  mkdirSync(netappsDir, { recursive: true });
388
654
 
@@ -390,7 +656,7 @@ export function runBuild(options: BuildOptions): void {
390
656
  // and npm-registry-sim COPY from the staged dirs at docker-build time,
391
657
  // so they have to exist (and reflect current workspace state) before
392
658
  // buildDockerImages() runs.
393
- stageSimulatorInputs(pkgDir);
659
+ await stageSimulatorInputs(pkgDir);
394
660
 
395
661
  if (!skipModules) {
396
662
  // Explicit dirs take precedence; fall back to auto-discovering all
@@ -412,6 +678,12 @@ export function runBuild(options: BuildOptions): void {
412
678
  packageNetapp(dir, netappsDir);
413
679
  }
414
680
  console.log('');
681
+ } else {
682
+ // npm consumer: no `modules/` source to package from — fetch the
683
+ // standard-module netapps from the registry so the sim registry
684
+ // (which binds ./netapps) has something to serve. Consumers need
685
+ // zero vendored module binaries.
686
+ await stageNetappsFromRegistry(netappsDir);
415
687
  }
416
688
  }
417
689
 
@@ -429,7 +701,7 @@ export function runBuild(options: BuildOptions): void {
429
701
  // simulated celilo.computer / npm registry. This is what makes the
430
702
  // default management image every test uses an actual product of
431
703
  // install.sh, rather than a parallel `bun add -g` pinning.
432
- bakeManagement(pkgDir);
704
+ bakeManagement(pkgDir, published);
433
705
 
434
706
  // Pre-seed heavy app images so app-zone deploys load them from the cache
435
707
  // instead of pulling GB through the sim (ISS-0154).
@@ -33,6 +33,9 @@ export const COMMANDS: CommandDef[] = [
33
33
  { name: '--reuse', description: 'Reuse existing network if running' },
34
34
  { name: '--live', description: 'Use live (non-simulated) internet' },
35
35
  { name: '--published', description: 'Use published .netapp packages' },
36
+ { name: '--notify', description: 'Desktop notification when the run finishes' },
37
+ { name: '--shuffle', description: 'Randomize test order to surface order-dependent bugs (seed is logged)' },
38
+ { name: '--seed=', description: 'Replay a specific --shuffle order (e.g. --seed=12345)' },
36
39
  ],
37
40
  },
38
41
  {
@@ -75,7 +78,12 @@ export const COMMANDS: CommandDef[] = [
75
78
  },
76
79
  {
77
80
  name: 'status',
78
- description: 'Show network status and DNS health',
81
+ description: 'Show run-lock holder + network status (exit 3 if busy)',
82
+ flags: [{ name: '--json', description: 'Emit a machine-readable lock snapshot for polling' }],
83
+ },
84
+ {
85
+ name: 'release',
86
+ description: 'Free a run-lock left by --keep/up (does not tear down)',
79
87
  },
80
88
  {
81
89
  name: 'load',
@@ -101,8 +109,8 @@ export const COMMANDS: CommandDef[] = [
101
109
 
102
110
  export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
103
111
 
104
- export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published'];
112
+ export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published', '--notify'];
105
113
 
106
114
  export const DOWN_FLAGS = ['--keep', '--all'];
107
115
 
108
- export const BUILD_INFRA_FLAGS = ['--save'];
116
+ export const BUILD_INFRA_FLAGS = ['--save', '--skip-modules', '--published'];
@@ -119,6 +119,7 @@ _cele2e() {
119
119
  '--reuse[Reuse existing network if running]' \\
120
120
  '--live[Use live (non-simulated) internet]' \\
121
121
  '--published[Use published .netapp packages]' \\
122
+ '--notify[Desktop notification when the run finishes]' \\
122
123
  '--ci[Plain log output: one line per step, no spinner]' \\
123
124
  '--no-ci[Force spinner even when a CI env var is set]' \\
124
125
  '*::test name or module path:_cele2e_run_args'
@@ -137,6 +138,8 @@ _cele2e() {
137
138
  build-infra)
138
139
  _arguments \\
139
140
  '--save[Save Docker images to tarball after build]' \\
141
+ '--skip-modules[Skip .netapp packaging (only rebuild Docker images)]' \\
142
+ '--published[Bake management:latest with the published @celilo/cli from real npm]' \\
140
143
  '*:module directory:_directories'
141
144
  ;;
142
145
  shell)
@@ -148,7 +151,10 @@ _cele2e() {
148
151
  completion)
149
152
  _arguments '1: :_cele2e_completion_shells'
150
153
  ;;
151
- status|load|clear-timing|list)
154
+ status)
155
+ _arguments '--json[Emit a machine-readable lock snapshot for polling]'
156
+ ;;
157
+ release|load|clear-timing)
152
158
  ;;
153
159
  esac
154
160
  ;;
@@ -164,7 +170,8 @@ _cele2e_commands() {
164
170
  'build-infra:Rebuild @celilo/e2e Docker images and standard module .netapps'
165
171
  'clear-timing:Clear saved timing history for test ETAs'
166
172
  'shell:Shell into a running container (default: management)'
167
- 'status:Show network status and DNS health'
173
+ 'status:Show run-lock holder + network status (exit 3 if busy)'
174
+ 'release:Free a run-lock left by --keep/up (does not tear down)'
168
175
  'load:Load cached Docker images from tarball'
169
176
  'scaffold:Generate a new test file from template'
170
177
  'version:Show cele2e version'
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ import { runBuild } from './build';
23
23
  import { runScaffold } from './scaffold';
24
24
  import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
25
25
  import { findAllModules, findModulesWithMatchingTest } from './module-discovery';
26
+ import { acquireRunLock, clearLock, E2eBusyError, formatBusy, lockStatus, markKept } from '../run-lock';
26
27
 
27
28
  const PKG_DIR = resolve(import.meta.dir, '../..');
28
29
  const BIN_DIR = join(PKG_DIR, 'bin');
@@ -127,7 +128,8 @@ Commands:
127
128
  clear-timing Clear saved timing history used for test ETAs
128
129
  version Show cele2e version
129
130
  shell [container] Shell into a running container (default: management)
130
- status Show network status and DNS health
131
+ status Show run-lock holder + network status (exit 3 if busy)
132
+ release Free a run-lock left by --keep/up (does not tear down)
131
133
  load Load cached Docker images from tarball
132
134
  scaffold <name> Generate a new test file from template
133
135
  completion <shell> Generate shell completion script (zsh|bash)
@@ -139,6 +141,7 @@ Options for \`run\`:
139
141
  --reuse Reuse existing network if running
140
142
  --live Use live (non-simulated) internet
141
143
  --published Use published .netapp packages
144
+ --notify Desktop notification when the run finishes (best-effort)
142
145
  --ci Plain log output: one ✔/✗ line per step, no spinner
143
146
  (auto-on when a CI env var is set; --no-ci forces off)
144
147
 
@@ -154,6 +157,8 @@ Options for \`down\`:
154
157
  Options for \`build-infra\`:
155
158
  --save Save Docker images to tarball after build
156
159
  --skip-modules Skip .netapp packaging (only rebuild Docker images)
160
+ --published Bake management:latest with the PUBLISHED @celilo/cli from
161
+ real npm (default: bake the monorepo DEV cli via the sim)
157
162
 
158
163
  Examples:
159
164
  cele2e run --all # FULL regression: every module suite + top-level e2e/tests/
@@ -297,16 +302,55 @@ switch (command) {
297
302
  process.exit(0);
298
303
  }
299
304
 
300
- case 'up':
305
+ case 'up': {
306
+ // `up` leaves an interactive network running, so it holds the run-lock in a
307
+ // kept state (cleared by `cele2e down`/`release`) to stop a concurrent run
308
+ // from wiping it.
309
+ try {
310
+ acquireRunLock({ test: 'up', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
311
+ markKept();
312
+ } catch (err) {
313
+ if (err instanceof E2eBusyError) {
314
+ console.error(err.message);
315
+ process.exit(3);
316
+ }
317
+ throw err;
318
+ }
301
319
  runScript('e2e-up', args);
320
+ }
302
321
 
303
322
  case 'down':
323
+ // Tearing the network down frees the (possibly kept) lock.
324
+ clearLock();
304
325
  runScript('e2e-down', args, { E2E_TEST_DIR: stateDir });
305
326
 
306
327
  case 'shell':
307
328
  runScript('e2e-shell', args);
308
329
 
330
+ case 'release': {
331
+ // Free a lock left by `--keep`/`up` (a `kept` stack) so the next run can
332
+ // proceed. Does not tear the stack down — use `cele2e down` for that.
333
+ const had = clearLock();
334
+ console.log(had ? 'Released the e2e run-lock.' : 'No e2e run-lock held.');
335
+ process.exit(0);
336
+ }
337
+
309
338
  case 'status': {
339
+ // Run-lock is the contention surface other sessions poll. Report it first;
340
+ // exit 0 = free, 3 = busy. `--json` emits a machine-readable snapshot.
341
+ const lock = lockStatus();
342
+ if (args.includes('--json')) {
343
+ console.log(JSON.stringify(lock));
344
+ process.exit(lock.free ? 0 : 3);
345
+ }
346
+ console.log('=== Run Lock ===');
347
+ if (lock.free) {
348
+ console.log(' free');
349
+ } else if (lock.holder) {
350
+ console.log(` BUSY — ${formatBusy(lock.holder)}`);
351
+ }
352
+ console.log('');
353
+
310
354
  const persistentFile = join(stateDir, '.e2e-persistent.json');
311
355
  let projectName: string | undefined;
312
356
  if (existsSync(persistentFile)) {
@@ -331,8 +375,20 @@ switch (command) {
331
375
  case 'build-infra': {
332
376
  const save = args.includes('--save');
333
377
  const skipModules = args.includes('--skip-modules');
378
+ const published = args.includes('--published');
334
379
  const moduleDirs = args.filter((a) => !a.startsWith('--'));
335
- runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
380
+ // build-infra mutates the shared Docker images — serialize against runs.
381
+ // Released via the lock's exit handler (runBuild may process.exit on failure).
382
+ try {
383
+ acquireRunLock({ test: 'build-infra', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
384
+ } catch (err) {
385
+ if (err instanceof E2eBusyError) {
386
+ console.error(err.message);
387
+ process.exit(3);
388
+ }
389
+ throw err;
390
+ }
391
+ await runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules, published });
336
392
  break;
337
393
  }
338
394
 
@@ -0,0 +1,22 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { checkCliVersion } from './cli-version-contract';
3
+
4
+ // ce-5qp: harness<->CLI version contract fails fast on a stale management image.
5
+
6
+ test('accepts a CLI at or above the minimum', () => {
7
+ expect(checkCliVersion('celilo 0.9.1', '0.9.0')).toBeNull();
8
+ expect(checkCliVersion('celilo 0.9.0', '0.9.0')).toBeNull();
9
+ expect(checkCliVersion('celilo 1.2.3', '0.9.0')).toBeNull();
10
+ });
11
+
12
+ test('rejects a stale CLI with an actionable message', () => {
13
+ const msg = checkCliVersion('celilo 0.5.0-alpha.5', '0.9.0');
14
+ expect(msg).toContain('celilo 0.5.0-alpha.5');
15
+ expect(msg).toContain('>=0.9.0');
16
+ expect(msg).toContain('build-infra');
17
+ });
18
+
19
+ test('reports unparseable version output instead of silently passing', () => {
20
+ expect(checkCliVersion('command not found', '0.9.0')).toContain('Could not parse');
21
+ expect(checkCliVersion('', '0.9.0')).toContain('Could not parse');
22
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Harness<->CLI version contract (ce-5qp). The management image ships a
3
+ * baked-in `celilo` CLI; a published harness run against a stale image
4
+ * (e.g. an old npm consumer's `cele2e build-infra`) used to hang ~90s in
5
+ * `system init` and surface only "canceled". Instead we check the baked CLI
6
+ * version up front and fail fast (<10s) with an actionable message.
7
+ *
8
+ * Bump MIN_CLI_VERSION whenever the harness starts relying on a CLI
9
+ * feature/flag that older CLIs lack. It's the MINIMUM the harness needs,
10
+ * not an exact pin.
11
+ */
12
+ export const MIN_CLI_VERSION = '0.9.0';
13
+
14
+ /**
15
+ * Pure core of the contract: given the raw `celilo --version` stdout, decide
16
+ * whether it satisfies `minVersion`. Returns an actionable error string when
17
+ * it doesn't, or `null` when OK. No I/O, so it's unit-testable without a
18
+ * running management container.
19
+ */
20
+ export function checkCliVersion(versionStdout: string, minVersion = MIN_CLI_VERSION): string | null {
21
+ // Output is `celilo <version>` (apps/celilo displayVersion). Grab the token.
22
+ const version = versionStdout.match(/celilo\s+(\S+)/)?.[1];
23
+ if (!version) {
24
+ return (
25
+ `Could not parse celilo version from "${versionStdout.trim()}" ` +
26
+ `(harness requires >=${minVersion}). Is the management image's celilo CLI intact?`
27
+ );
28
+ }
29
+ if (Bun.semver.order(version, minVersion) < 0) {
30
+ return (
31
+ `Management image has celilo ${version}, harness requires >=${minVersion}. ` +
32
+ `Rebuild the management image against a newer @celilo/cli ` +
33
+ `(e.g. \`cele2e build-infra\`, or \`--published\` after publishing).`
34
+ );
35
+ }
36
+ return null;
37
+ }