@celilo/cli 5.2.0 → 5.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "5.2.0",
3
+ "version": "5.2.2",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { inspectArtifactFile, verifyBuildArtifacts } from './artifact-bytes';
6
+
7
+ const ELF = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
8
+
9
+ function dir(): string {
10
+ return mkdtempSync(join(tmpdir(), 'artifact-bytes-'));
11
+ }
12
+
13
+ function write(base: string, rel: string, body: Buffer, exec = false): string {
14
+ const full = join(base, rel);
15
+ mkdirSync(join(full, '..'), { recursive: true });
16
+ writeFileSync(full, body);
17
+ if (exec) chmodSync(full, 0o755);
18
+ return full;
19
+ }
20
+
21
+ describe('inspectArtifactFile', () => {
22
+ test('rejects a right-sized file of pure NUL — the celilo#1463 shape', () => {
23
+ const d = dir();
24
+ const p = write(d, 'server-linux-x86_64', Buffer.alloc(4096), true);
25
+ expect(inspectArtifactFile(p)).toMatch(/4096 bytes of pure NUL/);
26
+ });
27
+
28
+ test('rejects an empty file', () => {
29
+ const d = dir();
30
+ expect(inspectArtifactFile(write(d, 'bin', Buffer.alloc(0), true))).toMatch(/empty/);
31
+ });
32
+
33
+ test('accepts a real ELF', () => {
34
+ const d = dir();
35
+ const p = write(d, 'bin', Buffer.concat([ELF, Buffer.alloc(1024, 0x41)]), true);
36
+ expect(inspectArtifactFile(p)).toBeNull();
37
+ });
38
+
39
+ test('rejects an executable carrying no executable magic', () => {
40
+ const d = dir();
41
+ const p = write(d, 'bin', Buffer.from('error: build failed\n'), true);
42
+ expect(inspectArtifactFile(p)).toMatch(/no known executable magic/);
43
+ });
44
+
45
+ test('does not apply the magic check to non-executable artifacts', () => {
46
+ // Modules declare .xz tarballs, .sha256 sidecars and index.html as
47
+ // artifacts. None is a program and none may be rejected as one.
48
+ const d = dir();
49
+ expect(inspectArtifactFile(write(d, 'forgejo.xz.sha256', Buffer.from('abc f\n')))).toBeNull();
50
+ expect(inspectArtifactFile(write(d, 'index.html', Buffer.from('<!doctype html>')))).toBeNull();
51
+ });
52
+
53
+ test('a NUL file is rejected even when it is not executable', () => {
54
+ const d = dir();
55
+ expect(inspectArtifactFile(write(d, 'forgejo.xz', Buffer.alloc(64)))).toMatch(/pure NUL/);
56
+ });
57
+
58
+ test('a file whose NUL run ends late is still accepted', () => {
59
+ // Guards the streaming scan: content past the first chunk must be seen.
60
+ const d = dir();
61
+ const body = Buffer.alloc((1 << 20) + 16);
62
+ body[body.length - 1] = 0x41;
63
+ expect(inspectArtifactFile(write(d, 'blob', body))).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('verifyBuildArtifacts', () => {
68
+ test('names the module, the artifact and its size', () => {
69
+ const d = dir();
70
+ write(d, 'ansible/roles/r/files/srv-linux-x86_64', Buffer.alloc(106129743 % 8192), true);
71
+ const reason = verifyBuildArtifacts(d, ['ansible/roles/r/files/srv-linux-x86_64'], 'dj-live');
72
+ expect(reason).toContain('dj-live');
73
+ expect(reason).toContain('ansible/roles/r/files/srv-linux-x86_64');
74
+ expect(reason).toMatch(/bytes of pure NUL/);
75
+ });
76
+
77
+ test('passes a sound set', () => {
78
+ const d = dir();
79
+ write(d, 'files/a', Buffer.concat([ELF, Buffer.alloc(16, 1)]), true);
80
+ write(d, 'files/b.sha256', Buffer.from('deadbeef\n'));
81
+ expect(verifyBuildArtifacts(d, ['files/a', 'files/b.sha256'], 'm')).toBeNull();
82
+ });
83
+
84
+ test('walks a declared directory artifact', () => {
85
+ const d = dir();
86
+ write(d, 'spa/index.html', Buffer.from('<!doctype html>'));
87
+ write(d, 'spa/assets/app.js', Buffer.alloc(32));
88
+ const reason = verifyBuildArtifacts(d, ['spa'], 'console');
89
+ expect(reason).toContain('assets/app.js');
90
+ });
91
+ });
@@ -0,0 +1,135 @@
1
+ import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ /**
5
+ * Executable magics, for a file the build marked executable.
6
+ *
7
+ * Only consulted when the artifact carries an exec bit, because
8
+ * `build.artifacts` is not a list of programs — modules declare `.xz`
9
+ * tarballs, a `.sha256` sidecar, an `index.html` and a whole `spa/`
10
+ * directory. A magic check applied to all of them would fail every module in
11
+ * the fleet; applied to the files the build itself chmod'd +x it is exactly
12
+ * the "is this runnable" question.
13
+ */
14
+ const EXEC_MAGICS: readonly (readonly number[])[] = [
15
+ [0x7f, 0x45, 0x4c, 0x46], // ELF
16
+ [0xfe, 0xed, 0xfa, 0xce], // Mach-O 32
17
+ [0xfe, 0xed, 0xfa, 0xcf], // Mach-O 64
18
+ [0xce, 0xfa, 0xed, 0xfe], // Mach-O 32, byte-swapped
19
+ [0xcf, 0xfa, 0xed, 0xfe], // Mach-O 64, byte-swapped
20
+ [0xca, 0xfe, 0xba, 0xbe], // Mach-O universal
21
+ [0x4d, 0x5a], // PE/COFF "MZ"
22
+ [0x23, 0x21], // "#!" script
23
+ ];
24
+
25
+ const CHUNK = 1 << 20;
26
+
27
+ /** Read up to `n` leading bytes without loading the file. */
28
+ function head(path: string, n: number): Buffer {
29
+ const fd = openSync(path, 'r');
30
+ try {
31
+ const buf = Buffer.alloc(n);
32
+ const read = readSync(fd, buf, 0, n, 0);
33
+ return buf.subarray(0, read);
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * True when every byte of the file is NUL.
41
+ *
42
+ * Streams, and stops at the first non-zero byte — so a healthy artifact costs
43
+ * one 1 MiB read (its first byte is non-zero) and only a genuinely corrupt one
44
+ * is read through.
45
+ */
46
+ function isAllNul(path: string, size: number): boolean {
47
+ const fd = openSync(path, 'r');
48
+ try {
49
+ const buf = Buffer.alloc(CHUNK);
50
+ for (let off = 0; off < size; ) {
51
+ const read = readSync(fd, buf, 0, CHUNK, off);
52
+ if (read === 0) break;
53
+ for (let i = 0; i < read; i++) {
54
+ if (buf[i] !== 0) return false;
55
+ }
56
+ off += read;
57
+ }
58
+ return true;
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Inspect one artifact file's own bytes. Returns a reason, or null when sound.
66
+ *
67
+ * Takes the verdict from the bytes rather than from the exit code of whatever
68
+ * wrote them. celilo#1463: two 100 MiB module binaries arrived as right-sized
69
+ * files of pure NUL, and every stage downstream reported success — because
70
+ * every one of those stages hashes, signs and verifies the artifact it is
71
+ * given. A checksum computed over corruption certifies corruption. The first
72
+ * thing that noticed was systemd, on a production host, with `Exec format
73
+ * error` and a restart counter.
74
+ */
75
+ export function inspectArtifactFile(path: string): string | null {
76
+ const size = statSync(path).size;
77
+ if (size === 0) return 'file is empty (0 bytes)';
78
+ if (isAllNul(path, size)) return `file is ${size} bytes of pure NUL — nothing was written`;
79
+
80
+ // Only a file the build marked runnable has to look runnable.
81
+ const mode = statSync(path).mode;
82
+ if (!(mode & 0o111)) return null;
83
+
84
+ const magic = head(path, 4);
85
+ const known = EXEC_MAGICS.some((m) => m.every((b, i) => magic[i] === b));
86
+ if (!known) {
87
+ return `file is executable but carries no known executable magic (starts ${magic.toString('hex')}) — not a runnable program`;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Gate every declared build artifact on its own bytes.
94
+ *
95
+ * Returns a human-readable failure naming module, artifact and size, or null
96
+ * when all artifacts are sound. A declared directory is walked, because a
97
+ * module can declare a built tree (`spa/`) and a NUL file inside one is as
98
+ * invisible as a NUL file beside it.
99
+ */
100
+ export function verifyBuildArtifacts(
101
+ buildDir: string,
102
+ artifacts: readonly string[],
103
+ moduleId: string,
104
+ ): string | null {
105
+ const failures: string[] = [];
106
+
107
+ const check = (fullPath: string, label: string): void => {
108
+ const reason = inspectArtifactFile(fullPath);
109
+ if (reason) failures.push(` ${label}: ${reason}`);
110
+ };
111
+
112
+ // `root` stays the artifact's own directory so a nested file reports its
113
+ // whole path under the artifact, not just its basename.
114
+ const walk = (dir: string, root: string, artifact: string): void => {
115
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
116
+ const full = join(dir, entry.name);
117
+ if (entry.isDirectory()) walk(full, root, artifact);
118
+ else if (entry.isFile()) check(full, join(artifact, relative(root, full)));
119
+ }
120
+ };
121
+
122
+ for (const artifact of artifacts) {
123
+ const full = join(buildDir, artifact);
124
+ if (statSync(full).isDirectory()) walk(full, full, artifact);
125
+ else check(full, artifact);
126
+ }
127
+
128
+ if (failures.length === 0) return null;
129
+ return [
130
+ `Refusing to package ${moduleId}: the build reported success but produced an unusable artifact.`,
131
+ ...failures,
132
+ '',
133
+ "The verdict is taken from the artifact's own bytes, not from the build command's exit code. Every check downstream of here — checksums, signature, the registry sha256 — would hash this faithfully and certify it (celilo#1463).",
134
+ ].join('\n');
135
+ }
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
9
9
  import { log } from '../../cli/prompts';
10
10
  import { formatViolations, scanModuleDirectory } from '../../policy/module-script-scan';
11
11
  import { validateModuleDirectory } from '../import';
12
+ import { verifyBuildArtifacts } from './artifact-bytes';
12
13
  import { resolveBuildCommandPaths } from './build-paths';
13
14
  import { computeFileChecksum } from './checksum';
14
15
  import { classifyModulePath, includeNodeModulesPath } from './package-rules';
@@ -385,7 +386,14 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
385
386
  return { success: false, error: `Auto-build failed: Build exited with code ${msg}` };
386
387
  }
387
388
 
388
- // Verify artifacts exist
389
+ // Verify artifacts exist, and that they are actually usable.
390
+ //
391
+ // Existence was the whole check, and existence is exactly what a
392
+ // right-sized file of pure NUL has. celilo#1463: two ~100 MiB module
393
+ // binaries were packaged, signed, published, downloaded, verified and
394
+ // installed as all-zero files, and every stage reported success, because
395
+ // this is the last point in the pipeline that sees the artifact before it
396
+ // becomes the thing all those checks are computed OVER.
389
397
  if (manifest.build.artifacts) {
390
398
  const missing = manifest.build.artifacts.filter(
391
399
  (a: string) => !existsSync(join(buildDir, a)),
@@ -396,6 +404,10 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
396
404
  error: `Build succeeded but artifacts missing: ${missing.join(', ')}`,
397
405
  };
398
406
  }
407
+ const unusable = verifyBuildArtifacts(buildDir, manifest.build.artifacts, moduleId);
408
+ if (unusable) {
409
+ return { success: false, error: unusable };
410
+ }
399
411
  }
400
412
  }
401
413
 
@@ -345,12 +345,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
345
345
  count: 1,
346
346
  why: 'S13 — ZONE_REQUIREMENTS, a second hand-maintained copy of well-known.ts; Phase 2 (#937)',
347
347
  },
348
- {
349
- file: 'apps/celilo/src/templates/generator.ts',
350
- capability: 'dns_internal',
351
- count: 1,
352
- why: 'X4 — declaration-driven and the model for the rest; only the error string names the capability (#945)',
353
- },
354
348
  {
355
349
  file: 'apps/celilo/src/variables/context.ts',
356
350
  capability: 'dns_internal',
@@ -21,9 +21,9 @@ import { tmpdir } from 'node:os';
21
21
  import { join } from 'node:path';
22
22
  import { copyAnsibleRoleFilesDirs } from './generator';
23
23
 
24
- function moduleWithRoleFile(binary: string): string {
24
+ function moduleWithRoleFile(binary: string, layout = 'ansible'): string {
25
25
  const root = mkdtempSync(join(tmpdir(), 'celilo-rolefiles-'));
26
- const filesDir = join(root, 'ansible', 'roles', 'demo', 'files');
26
+ const filesDir = join(root, layout, 'roles', 'demo', 'files');
27
27
  mkdirSync(filesDir, { recursive: true });
28
28
  writeFileSync(join(filesDir, 'demo-linux-x86_64'), binary);
29
29
  return root;
@@ -59,6 +59,24 @@ describe('copyAnsibleRoleFilesDirs', () => {
59
59
  expect(generatedBinary(outputPath)).toBe('v2');
60
60
  });
61
61
 
62
+ /**
63
+ * celilo#1461. The template pipeline accepts BOTH `ansible/` and
64
+ * `celilo/ansible/`; this copy accepted one, so a celilo/-layout module got
65
+ * its role's tasks/ and templates/ and never its files/. `existsSync` was
66
+ * false, the function returned, and nothing anywhere said so.
67
+ *
68
+ * Generated output is the standard layout either way — the source layout is
69
+ * what varies.
70
+ */
71
+ test('finds role files/ under the celilo/ layout too', async () => {
72
+ const modulePath = moduleWithRoleFile('v1', 'celilo/ansible');
73
+ const outputPath = mkdtempSync(join(tmpdir(), 'celilo-out-'));
74
+
75
+ await copyAnsibleRoleFilesDirs(modulePath, outputPath);
76
+
77
+ expect(generatedBinary(outputPath)).toBe('v1');
78
+ });
79
+
62
80
  test('a module with no role files/ directory is not an error', async () => {
63
81
  const root = mkdtempSync(join(tmpdir(), 'celilo-norole-'));
64
82
  mkdirSync(join(root, 'ansible', 'roles', 'demo', 'tasks'), { recursive: true });
@@ -60,9 +60,18 @@ const COPY_AS_IS_EXTENSIONS = ['.yml', '.yaml', '.tf'];
60
60
  /**
61
61
  * Template directories to process
62
62
  */
63
+ // A module may keep its deployment tree at the root or under `celilo/`.
64
+ // This is THE list of supported layouts — everything that has to find a
65
+ // module's ansible/ or terraform/ derives it from here. celilo#1461 was two
66
+ // functions deciding that independently and disagreeing.
67
+ const LAYOUT_ROOTS = ['', 'celilo'];
68
+
69
+ const layoutDirs = (name: string): string[] =>
70
+ LAYOUT_ROOTS.map((root) => (root ? `${root}/${name}` : name));
71
+
63
72
  // Directories to scan for template files. Supports both root-level and
64
73
  // celilo/ subdirectory layouts (e.g., ansible/ or celilo/ansible/).
65
- const TEMPLATE_DIRS = ['terraform', 'ansible', 'celilo/terraform', 'celilo/ansible'];
74
+ const TEMPLATE_DIRS = [...layoutDirs('terraform'), ...layoutDirs('ansible')];
66
75
 
67
76
  /**
68
77
  * Check if file is a template that needs variable resolution
@@ -513,8 +522,9 @@ export async function readTemplateFiles(
513
522
  * @param files - Generated files to write
514
523
  */
515
524
  /**
516
- * Copy each `ansible/roles/<role>/files/` directory from the module source
517
- * to the generated output, verbatim (preserving binary content + mode bits).
525
+ * Copy each `<layout>/ansible/roles/<role>/files/` directory from the module
526
+ * source to the generated output, verbatim (preserving binary content + mode
527
+ * bits). Both supported layouts are scanned — see LAYOUT_ROOTS.
518
528
  *
519
529
  * These hold Ansible role-local static assets — they may be binaries and
520
530
  * don't need template variable resolution, so the standard template
@@ -524,38 +534,49 @@ export async function copyAnsibleRoleFilesDirs(
524
534
  modulePath: string,
525
535
  outputPath: string,
526
536
  ): Promise<void> {
527
- const rolesDir = join(modulePath, 'ansible', 'roles');
528
- if (!existsSync(rolesDir)) return;
529
- const roles = await readdir(rolesDir, { withFileTypes: true });
530
- for (const role of roles) {
531
- if (!role.isDirectory()) continue;
532
- const srcFilesDir = join(rolesDir, role.name, 'files');
533
- if (!existsSync(srcFilesDir)) continue;
534
- const destFilesDir = join(outputPath, 'ansible', 'roles', role.name, 'files');
535
- await mkdir(dirname(destFilesDir), { recursive: true });
536
- // `force: true` is LOAD-BEARING on bun, and its absence was celilo#925.
537
- //
538
- // Node defaults `force` to true, so this looked correct and is correct
539
- // under Node. Bun 1.3.3 does not, on this path specifically — measured,
540
- // copying "NEW" over an existing "OLD":
541
- //
542
- // recursive only -> NEW
543
- // recursive + force -> NEW
544
- // recursive + preserveTimestamps -> OLD <- what this was
545
- // recursive + force + preserveTimestamps -> NEW
546
- //
547
- // celilo runs on bun. So a module's built binary landed in `generated/`
548
- // exactly once, at first generate, and no later version ever replaced it —
549
- // silently, because `cp` reports no error, so the caller's try/catch has
550
- // nothing to catch. Ansible then copies that first binary forever and
551
- // reports `ok`, unchanged, while the module's version field advances.
552
- //
553
- // The sibling call in `storage-set-path.ts:153` already passes `force`.
554
- await cp(srcFilesDir, destFilesDir, {
555
- recursive: true,
556
- force: true,
557
- preserveTimestamps: true,
558
- });
537
+ let copied = 0;
538
+ for (const ansibleDir of layoutDirs('ansible')) {
539
+ const rolesDir = join(modulePath, ansibleDir, 'roles');
540
+ if (!existsSync(rolesDir)) continue;
541
+ const roles = await readdir(rolesDir, { withFileTypes: true });
542
+ for (const role of roles) {
543
+ if (!role.isDirectory()) continue;
544
+ const srcFilesDir = join(rolesDir, role.name, 'files');
545
+ if (!existsSync(srcFilesDir)) continue;
546
+ const destFilesDir = join(outputPath, 'ansible', 'roles', role.name, 'files');
547
+ await mkdir(dirname(destFilesDir), { recursive: true });
548
+ // `force: true` is LOAD-BEARING on bun, and its absence was celilo#925.
549
+ //
550
+ // Node defaults `force` to true, so this looked correct and is correct
551
+ // under Node. Bun 1.3.3 does not, on this path specifically — measured,
552
+ // copying "NEW" over an existing "OLD":
553
+ //
554
+ // recursive only -> NEW
555
+ // recursive + force -> NEW
556
+ // recursive + preserveTimestamps -> OLD <- what this was
557
+ // recursive + force + preserveTimestamps -> NEW
558
+ //
559
+ // celilo runs on bun. So a module's built binary landed in `generated/`
560
+ // exactly once, at first generate, and no later version ever replaced it —
561
+ // silently, because `cp` reports no error, so the caller's try/catch has
562
+ // nothing to catch. Ansible then copies that first binary forever and
563
+ // reports `ok`, unchanged, while the module's version field advances.
564
+ //
565
+ // The sibling call in `storage-set-path.ts:153` already passes `force`.
566
+ await cp(srcFilesDir, destFilesDir, {
567
+ recursive: true,
568
+ force: true,
569
+ preserveTimestamps: true,
570
+ });
571
+ copied++;
572
+ }
573
+ }
574
+
575
+ // "I looked and found nothing" must not read the same as "I copied
576
+ // everything". A module with no role files/ is legitimate; a module that has
577
+ // one somewhere this function cannot see is celilo#1461, and it was silent.
578
+ if (copied === 0) {
579
+ log.info(`No Ansible role files/ directories found under ${modulePath}`);
559
580
  }
560
581
  }
561
582