@celilo/cli 0.5.0-alpha.7 → 0.5.0-alpha.8

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": "0.5.0-alpha.7",
3
+ "version": "0.5.0-alpha.8",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
55
  "@celilo/capabilities": "^0.4.2",
56
56
  "@celilo/cli-display": "^0.1.9",
57
- "@celilo/event-bus": "^0.1.6",
57
+ "@celilo/event-bus": "^0.1.7",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -15,14 +15,14 @@
15
15
  */
16
16
 
17
17
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
- import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
  import { type DbClient, getDb } from '../db/client';
22
22
  import { modules, secrets } from '../db/schema';
23
23
  import type { ModuleManifest } from '../manifest/schema';
24
24
  import { findMissingSecrets } from './config-interview';
25
- import { findMissingRequiredVariables } from './deploy-validation';
25
+ import { findMissingBuildArtifacts, findMissingRequiredVariables } from './deploy-validation';
26
26
 
27
27
  function manifestWithStringMapSecret(): ModuleManifest {
28
28
  return {
@@ -293,3 +293,53 @@ describe('findMissingSecrets (shared)', () => {
293
293
  expect(missing[0].value_pattern_message).toBe('min 8 chars');
294
294
  });
295
295
  });
296
+
297
+ /**
298
+ * The deploy build-gate: the management server does NOT build modules from
299
+ * source. A module's declared artifacts must already be present on disk
300
+ * (shipped in the .netapp at `module package` / `module publish` time). Deploy
301
+ * verifies them; a missing artifact is a hard error, never a from-source
302
+ * rebuild on the management box (ISS-0131).
303
+ */
304
+ describe('findMissingBuildArtifacts (deploy build-gate)', () => {
305
+ let dir: string;
306
+
307
+ beforeEach(() => {
308
+ dir = mkdtempSync(join(tmpdir(), 'build-gate-'));
309
+ });
310
+
311
+ afterEach(() => {
312
+ rmSync(dir, { recursive: true, force: true });
313
+ });
314
+
315
+ function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
316
+ return {
317
+ celilo_contract: '1.0',
318
+ id: 'buildmod',
319
+ name: 'Build Module',
320
+ version: '1.0.0',
321
+ description: 'fixture',
322
+ build: { command: 'true', artifacts },
323
+ } as unknown as ModuleManifest;
324
+ }
325
+
326
+ test('returns [] when the module declares no build section', () => {
327
+ const manifest = { celilo_contract: '1.0', id: 'm', version: '1.0.0' } as ModuleManifest;
328
+ expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]);
329
+ });
330
+
331
+ test('returns [] when all declared artifacts exist on disk (prebuilt .netapp)', () => {
332
+ mkdirSync(join(dir, 'dist'), { recursive: true });
333
+ writeFileSync(join(dir, 'dist', 'server'), 'binary');
334
+ writeFileSync(join(dir, 'dist', 'index.html'), '<html>');
335
+ const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html']);
336
+ expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]);
337
+ });
338
+
339
+ test('returns the missing artifacts when some are absent (no mgmt-server rebuild)', () => {
340
+ mkdirSync(join(dir, 'dist'), { recursive: true });
341
+ writeFileSync(join(dir, 'dist', 'server'), 'binary');
342
+ const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html', 'dist/db.sqlite']);
343
+ expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']);
344
+ });
345
+ });
@@ -14,14 +14,12 @@ import type { ModuleManifest } from '../manifest/schema';
14
14
  import { isPrivilegedCapability } from '../manifest/validate';
15
15
  import { generateTemplates } from '../templates/generator';
16
16
  import { findMissingSecrets } from './config-interview';
17
- import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
18
17
 
19
18
  export interface ValidationResult {
20
19
  success: boolean;
21
20
  error?: string;
22
21
  warnings?: string[];
23
22
  autoGenerated?: boolean;
24
- autoBuilt?: boolean;
25
23
  missingVariables?: Array<{
26
24
  name: string;
27
25
  source: 'user' | 'secret' | 'capability' | 'system';
@@ -41,9 +39,27 @@ export interface ValidationResult {
41
39
  }>;
42
40
  }
43
41
 
42
+ /**
43
+ * Declared build artifacts that are missing on disk.
44
+ *
45
+ * Modules are built when their package is created: `module package` /
46
+ * `module publish` runs `manifest.build.command` and bakes the declared
47
+ * artifacts into the .netapp (cross-arch binaries and all). By deploy time —
48
+ * from the registry in production, or via `celilo package` in e2e — those
49
+ * artifacts are already on disk, so deploy only VERIFIES them. An empty result
50
+ * means the module is built; a non-empty result is a hard deploy error, because
51
+ * the management server does NOT build from source (ISS-0131: a from-source
52
+ * rebuild on the 3.7 GB management box deadlocked at `bun install`, then
53
+ * OOM-crashed nx workers, despite the .netapp shipping the binaries).
54
+ */
55
+ export function findMissingBuildArtifacts(manifest: ModuleManifest, sourcePath: string): string[] {
56
+ const declared = manifest.build?.artifacts ?? [];
57
+ return declared.filter((rel) => !existsSync(join(sourcePath, rel)));
58
+ }
59
+
44
60
  /**
45
61
  * Validate module is ready for deployment and auto-prepare if needed
46
- * Policy + Execution function - checks prerequisites and runs generate/build if needed
62
+ * Policy + Execution function - checks prerequisites and runs generate if needed
47
63
  *
48
64
  * @param moduleId - Module identifier
49
65
  * @param db - Database connection
@@ -54,7 +70,6 @@ export async function validateAndPrepareDeployment(
54
70
  db: DbClient,
55
71
  ): Promise<ValidationResult> {
56
72
  let autoGenerated = false;
57
- let autoBuilt = false;
58
73
 
59
74
  // Check module exists
60
75
  const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
@@ -107,7 +122,6 @@ export async function validateAndPrepareDeployment(
107
122
  return {
108
123
  success: true,
109
124
  autoGenerated: false,
110
- autoBuilt: false,
111
125
  missingVariables,
112
126
  };
113
127
  }
@@ -131,42 +145,19 @@ export async function validateAndPrepareDeployment(
131
145
 
132
146
  autoGenerated = true;
133
147
 
134
- // Auto-build if required and not built
135
- if (manifest.build) {
136
- const buildStatus = await getModuleBuildStatus(moduleId, db);
137
- // Cross-check the manifest's declared artifacts against the actual
138
- // filesystem in addition to whatever's in the build record. A "success"
139
- // record with an empty artifact list (e.g. from a build that exited 0
140
- // but produced nothing, or a synthetic record from a partial .netapp
141
- // import) would otherwise pass `verifyArtifactsExist([])` trivially and
142
- // let the deploy run on without binaries — exactly how Ansible ends up
143
- // failing on a missing `src:` file.
144
- const declaredArtifacts = manifest.build.artifacts ?? [];
145
- const declaredArtifactsOk = declaredArtifacts.every((rel) =>
146
- existsSync(join(module.sourcePath, rel)),
147
- );
148
- const needsBuild =
149
- !buildStatus ||
150
- buildStatus.status !== 'success' ||
151
- !verifyArtifactsExist(buildStatus.artifacts) ||
152
- (declaredArtifacts.length > 0 && !declaredArtifactsOk);
153
-
154
- if (needsBuild) {
155
- const buildResult = await buildModuleFromSource(moduleId, db);
156
- if (!buildResult.success) {
157
- return {
158
- success: false,
159
- error: `Auto-build failed: ${buildResult.error || 'Build failed'}`,
160
- };
161
- }
162
- autoBuilt = true;
163
- }
148
+ // Verify build artifacts are present the management server does NOT build
149
+ // modules from source (see findMissingBuildArtifacts / ISS-0131).
150
+ const missingArtifacts = findMissingBuildArtifacts(manifest, module.sourcePath);
151
+ if (missingArtifacts.length > 0) {
152
+ return {
153
+ success: false,
154
+ error: `Module '${moduleId}' is missing build artifacts that must ship in its package:\n${missingArtifacts.map((m) => ` - ${m}`).join('\n')}\n\nThe management server does not build modules from source. Build where the module is authored, then republish and update:\n celilo module publish <path> (or: celilo module build ${moduleId})\n celilo module update`,
155
+ };
164
156
  }
165
157
 
166
158
  return {
167
159
  success: true,
168
160
  autoGenerated,
169
- autoBuilt,
170
161
  };
171
162
  }
172
163
 
@@ -378,6 +378,19 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
378
378
  expect(f.remediation).toContain('natIp');
379
379
  });
380
380
 
381
+ it('SKIPS `.infra.<zone>` system-identity records (intentionally container-IP)', async () => {
382
+ seedFirewall('iptables', NAT_IP);
383
+ insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
384
+ insertModule('forgejo', baseManifest({ id: 'forgejo', name: 'Forgejo' }));
385
+ seedSystem('forgejo', '10.0.20.14', 'dmz');
386
+ // The on-system-event handler registers <host>.infra.<domain> at the
387
+ // system's own container IP on purpose — a zone-side identity name, not a
388
+ // LAN-reachability record. The check must NOT flag it.
389
+ seedRecord('technitium', 'forgejo', 'forgejo.infra.celilo.computer', '10.0.20.14');
390
+ const f = await checkServiceDns(db);
391
+ expect(f.status).toBe('ok');
392
+ });
393
+
381
394
  it('is ok when a record points at an internal-zone (LAN) system IP', async () => {
382
395
  seedFirewall('iptables', NAT_IP);
383
396
  insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
@@ -40,6 +40,17 @@ import { resolveSubscription } from './module-subscriptions';
40
40
  */
41
41
  const LAN_REACHABLE_ZONE = 'internal';
42
42
 
43
+ /**
44
+ * Records under the dedicated `.infra.<zone>` label are system-IDENTITY names,
45
+ * registered by modules/technitium/scripts/on-system-event.ts at the system's
46
+ * own container IP ON PURPOSE — the zone-side name for in-zone / VPN access,
47
+ * deliberately kept separate from the natIp records LAN devices use (so a
48
+ * container-IP identity record doesn't clobber the natIp record public_web
49
+ * needs). They are NOT LAN-reachability records, so the natIp rule doesn't
50
+ * apply to them — the service-DNS check skips them.
51
+ */
52
+ const SYSTEM_IDENTITY_HOST = /\.infra\./;
53
+
43
54
  export type FleetFindingStatus = 'ok' | 'warn' | 'fail';
44
55
 
45
56
  /**
@@ -600,6 +611,10 @@ export async function checkServiceDns(db: DbClient): Promise<FleetFinding> {
600
611
  const atContainer: string[] = [];
601
612
  const atOther: string[] = [];
602
613
  for (const r of records) {
614
+ // `.infra.<zone>` system-identity records are intentionally container-IP
615
+ // (zone-side names, not LAN-reachability records) — not subject to the
616
+ // natIp rule.
617
+ if (SYSTEM_IDENTITY_HOST.test(r.host)) continue;
603
618
  if (r.ip === natIp) continue;
604
619
  const owner = ipZone.get(r.ip);
605
620
  if (owner && owner.zone !== LAN_REACHABLE_ZONE) {
@@ -48,7 +48,6 @@ export interface DeployResult {
48
48
  phases: {
49
49
  validation?: boolean;
50
50
  autoGenerated?: boolean;
51
- autoBuilt?: boolean;
52
51
  planning?: boolean;
53
52
  terraformInit?: boolean;
54
53
  terraformPlan?: boolean;
@@ -353,7 +352,6 @@ async function deployModuleImpl(
353
352
  const validation = await validateAndPrepareDeployment(moduleId, db);
354
353
  phases.validation = validation.success;
355
354
  phases.autoGenerated = validation.autoGenerated;
356
- phases.autoBuilt = validation.autoBuilt;
357
355
  if (!validation.success) {
358
356
  return {
359
357
  success: false,
@@ -532,9 +530,7 @@ async function deployModuleImpl(
532
530
  }
533
531
  }
534
532
 
535
- log.success(
536
- validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
537
- );
533
+ log.success('Templates generated');
538
534
 
539
535
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
540
536
  if (manifest.hooks?.validate_config) {