@celilo/cli 0.14.0 → 0.14.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.
@@ -39,7 +39,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
39
39
  - **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations, self-registers). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
40
40
  - **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
41
41
  - **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
42
- - **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public). Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** nothing — a transport that depended on the proxy, registrar or firewall could not tell you those were broken. See `openspec/changes/add-alerting/`.
42
+ - **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public). Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
43
43
  - **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
44
44
 
45
45
  ## Git forge & CI pipeline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,10 +19,20 @@
19
19
  * instead. Modules placed here inherit that reach, so placement is a privilege
20
20
  * decision. celilo-mgmt may equally run in `internal`.
21
21
  *
22
+ * Semi-trusted LAN:
23
+ * - internal: Behind the firewall (NAT outbound, port-forward inbound), shielded
24
+ * from the uncontrolled outside internet. Not firewall-segmented.
25
+ *
22
26
  * External Zone (Cloud/VPS):
23
27
  * - external: Services hosted outside home network (no VLAN, e.g., VPS on internet)
28
+ *
29
+ * Re-exported from db/schema rather than redeclared: this file used to carry its
30
+ * own hand-written copy of the union, and a second hand-maintained copy is what
31
+ * let `secure-mgmt` go missing in other places (see NETWORK_ZONES' comment).
24
32
  */
25
- export type NetworkZone = 'internal' | 'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'external';
33
+ import type { NetworkZone } from '../db/schema';
34
+
35
+ export type { NetworkZone };
26
36
 
27
37
  export interface WellKnownCapability {
28
38
  canonical_hostname: string;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../../db/client';
8
- import { modules } from '../../db/schema';
8
+ import { type NetworkZone, modules } from '../../db/schema';
9
9
  import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
10
10
  import { buildResolutionContext } from '../../variables/context';
11
11
  import { getArg, validateRequiredArgs } from '../parser';
@@ -161,14 +161,22 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
161
161
  };
162
162
  }
163
163
 
164
- const zoneDescriptions: Record<string, string> = {
164
+ // Keyed by NetworkZone, not string: a new zone added to NETWORK_ZONES becomes a
165
+ // compile error here rather than silently rendering as "Unknown zone". Both
166
+ // `internal` and `secure-mgmt` were missing from the previous string-keyed map.
167
+ const zoneDescriptions: Record<NetworkZone, string> = {
168
+ internal: 'Internal (Semi-trusted network behind the firewall)',
165
169
  dmz: 'DMZ (Public-facing services)',
166
170
  app: 'Application (Internal services)',
167
171
  secure: 'Secure (Authentication/Database)',
172
+ 'secure-mgmt': "Secure-Mgmt (celilo's own control plane)",
168
173
  external: 'External (VPS/Cloud)',
169
174
  };
170
175
 
171
- const description = zoneDescriptions[zone] || 'Unknown zone';
176
+ // Cast at the lookup, not the declaration: `zone` comes from config and may be
177
+ // any string, so the runtime fallback stays — but the map above still has to
178
+ // cover every NetworkZone.
179
+ const description = zoneDescriptions[zone as NetworkZone] ?? 'Unknown zone';
172
180
 
173
181
  const lines = [`Module: ${moduleId}`, `Zone: ${zone} - ${description}`, ''];
174
182
 
package/src/db/schema.ts CHANGED
@@ -198,7 +198,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
198
198
  .references(() => modules.id, { onDelete: 'cascade' }),
199
199
  vmid: integer('vmid').notNull().unique(),
200
200
  containerIp: text('container_ip').notNull().unique(), // CIDR format (e.g., "10.0.10.10/24")
201
- zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'>().notNull(),
201
+ zone: text('zone').$type<AllocatableZone>().notNull(),
202
202
  allocatedAt: integer('allocated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
203
203
  });
204
204
 
@@ -211,7 +211,7 @@ export const ipReservations = sqliteTable('ip_reservations', {
211
211
  id: integer('id').primaryKey({ autoIncrement: true }),
212
212
  ipStart: text('ip_start').notNull(), // Single IP or range start
213
213
  ipEnd: text('ip_end'), // NULL for single IP, end IP for range
214
- zone: text('zone').$type<'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'>().notNull(),
214
+ zone: text('zone').$type<AllocatableZone>().notNull(),
215
215
  reason: text('reason').notNull(),
216
216
  reservedAt: integer('reserved_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
217
217
  });
@@ -275,6 +275,17 @@ export const NETWORK_ZONES = [
275
275
  */
276
276
  export type NetworkZone = (typeof NETWORK_ZONES)[number];
277
277
 
278
+ /**
279
+ * Zones an IP allocation or reservation can name: every NetworkZone except
280
+ * `external`, whose systems are addressed by the provider, not by our IPAM.
281
+ *
282
+ * Derived rather than hand-written for the same reason as NetworkZone above —
283
+ * the previous hand-written union was copied into two column definitions and a
284
+ * cast in machine-pool.ts, and the cast had already drifted (it was missing
285
+ * `secure-mgmt`, and its comment claimed the only difference was `external`).
286
+ */
287
+ export type AllocatableZone = Exclude<NetworkZone, 'external'>;
288
+
278
289
  /**
279
290
  * Container services table
280
291
  * Stores container service providers (Proxmox, Digital Ocean, etc.)
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { NETWORK_ZONES } from '../db/schema';
2
3
  import { SUPPORTED_CONTRACT_VERSIONS } from './contracts';
3
4
 
4
5
  /**
@@ -309,9 +310,19 @@ export function parseIntervalMinutes(value: string): number | null {
309
310
  * operator creates a monitor by hand.
310
311
  */
311
312
  export const HealthCheckHookSchema = LifecycleHookSchema.extend({
313
+ // `.regex` duplicates the well-formedness half of the `superRefine` below on
314
+ // purpose: only `.regex` survives the export to JSON Schema, and that export is
315
+ // what validates `modules/*/manifest.yml` in the editor. The 5-minute floor
316
+ // cannot be expressed in JSON Schema at all, so it stays a refinement — which
317
+ // is why both exist rather than one. Keeping them in sync is the point of
318
+ // sharing DURATION_PATTERN.
312
319
  interval: z
313
320
  .string()
321
+ .regex(DURATION_PATTERN)
314
322
  .optional()
323
+ .describe(
324
+ 'Suggested monitoring cadence, e.g. "15m", "1h", "1d". Must be 5m or longer — the monitor sweep runs on a 5-minute grid.',
325
+ )
315
326
  .superRefine((value, ctx) => {
316
327
  if (value === undefined) return;
317
328
  const minutes = parseIntervalMinutes(value);
@@ -379,9 +390,10 @@ export const SystemResourceSchema = z.object({
379
390
  'Proxmox provisioning type: lxc (default) or vm (qemu, for Docker / kernel-module workloads). ' +
380
391
  'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.',
381
392
  ),
382
- zone: z
383
- .enum(['internal', 'dmz', 'app', 'secure', 'secure-mgmt', 'external'])
384
- .describe('Required security zone for this module'),
393
+ // Derived from NETWORK_ZONES, not re-listed: a hand-written copy here would be
394
+ // a fourth place a new zone has to be remembered, and the copies in
395
+ // module-show and machine-pool had both already drifted.
396
+ zone: z.enum(NETWORK_ZONES).describe('Required security zone for this module'),
385
397
  });
386
398
 
387
399
  /**
@@ -594,7 +606,9 @@ export const ModuleManifestSchema = z
594
606
  container_created: LifecycleHookSchema.optional(),
595
607
  on_install: LifecycleHookSchema.optional(),
596
608
  on_uninstall: LifecycleHookSchema.optional(),
597
- health_check: HealthCheckHookSchema.optional(),
609
+ health_check: HealthCheckHookSchema.optional().describe(
610
+ "Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
611
+ ),
598
612
  validate_config: LifecycleHookSchema.optional(),
599
613
  on_backup: LifecycleHookSchema.optional(),
600
614
  on_backup_analyze: LifecycleHookSchema.optional(),
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { and, eq, inArray } from 'drizzle-orm';
3
3
  import { getDb } from '../db/client';
4
4
  import {
5
+ type AllocatableZone,
5
6
  type NetworkZone,
6
7
  containerServices,
7
8
  ipAllocations,
@@ -355,7 +356,7 @@ export async function getContainerSystemsByZone(
355
356
  eq(moduleInfrastructure.infrastructureType, 'container_service'),
356
357
  // ip_allocations.zone is narrower than NetworkZone (no 'external');
357
358
  // safe to cast — any 'external' input would just match zero rows.
358
- inArray(ipAllocations.zone, zones as Array<'dmz' | 'app' | 'secure' | 'internal'>),
359
+ inArray(ipAllocations.zone, zones as AllocatableZone[]),
359
360
  ),
360
361
  );
361
362