@celilo/cli 1.12.0 → 1.13.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.
Files changed (47) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +17 -2
  3. package/package.json +3 -3
  4. package/src/cli/commands/alerts-list.ts +16 -1
  5. package/src/cli/commands/backup-list.test.ts +82 -1
  6. package/src/cli/commands/backup-list.ts +113 -4
  7. package/src/cli/commands/console.ts +122 -0
  8. package/src/cli/commands/module-list.ts +3 -41
  9. package/src/cli/completion.ts +5 -0
  10. package/src/cli/index.ts +25 -1
  11. package/src/console/closure.test.ts +246 -0
  12. package/src/console/closure.ts +208 -0
  13. package/src/console/control-plane-boundary.test.ts +75 -0
  14. package/src/console/projection.test.ts +231 -0
  15. package/src/console/projection.ts +327 -0
  16. package/src/db/schema.ts +19 -14
  17. package/src/hooks/broker.test.ts +4 -6
  18. package/src/hooks/executor.test.ts +85 -4
  19. package/src/hooks/executor.ts +164 -9
  20. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  21. package/src/hooks/hook-state-dir.test.ts +14 -2
  22. package/src/hooks/hook-timeout.test.ts +2 -4
  23. package/src/hooks/hook-trespass.test.ts +50 -5
  24. package/src/hooks/jail.test.ts +370 -0
  25. package/src/hooks/jail.ts +491 -0
  26. package/src/hooks/mount-set.ts +24 -0
  27. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  28. package/src/manifest/schema.ts +35 -0
  29. package/src/manifest/validate.test.ts +142 -0
  30. package/src/manifest/validate.ts +101 -0
  31. package/src/module/import.test.ts +116 -0
  32. package/src/module/import.ts +73 -1
  33. package/src/module/packaging/audit.ts +103 -1
  34. package/src/module/packaging/classify-module-path.test.ts +36 -0
  35. package/src/module/packaging/package-rules.ts +18 -0
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/module-business-baseline.ts +12 -0
  38. package/src/services/alerting/observed-health.ts +71 -0
  39. package/src/services/api-principal-enrolment.test.ts +179 -0
  40. package/src/services/api-principal-enrolment.ts +103 -0
  41. package/src/services/audit/backups.ts +10 -1
  42. package/src/services/backup-metadata.ts +19 -11
  43. package/src/services/consumer-cleanup.ts +31 -5
  44. package/src/services/instance-ops.test.ts +302 -0
  45. package/src/services/instance-ops.ts +292 -0
  46. package/src/services/module-instances.test.ts +428 -42
  47. package/src/services/module-instances.ts +219 -26
@@ -1,22 +1,35 @@
1
1
  /**
2
- * Instance identity (openspec/changes/submodules, D2 and D4).
2
+ * Instance identity and the on-disk shape of an instance
3
+ * (openspec/changes/submodules, D2 and D4).
3
4
  *
4
- * A parent module addresses the instances it owns by an opaque key it chose.
5
+ * Two jobs, both small and both load-bearing.
6
+ *
7
+ * **Identity.** A parent addresses its instances by an opaque key it chose.
5
8
  * celilo addresses them by a `modules.id`, because an instance IS a module row,
6
9
  * and that is what lets every reader keyed on `moduleId` keep working. This
7
10
  * file is the single place those two names meet, so the mapping cannot drift.
8
11
  *
9
- * The rest of the submodules mechanism (the symlink farm an instance's install
10
- * is, the deploy-worker capability, the lifecycle) is not here. This is the
11
- * identity and the ownership question, landed ahead of it because
12
- * `hook-process-boundary` stage 3 needs `ownedSystemModuleIds` and nothing else
13
- * from that change.
12
+ * **Layout.** An instance directory is real and private, holding only
13
+ * `generated/`, with the submodule's authored subtrees symlinked in. That keeps
14
+ * `join(sourcePath, 'generated')` true (the invariant nine call sites already
15
+ * depend on) while giving each instance somewhere of its own to be written to.
16
+ *
17
+ * The alternative, pointing `sourcePath` at the shared submodule directory,
18
+ * looks cleaner and is not. Hooks run with the module directory as their root
19
+ * and WRITE into it (`browser.ts` puts screenshots there, `module build` uses it
20
+ * as cwd), so N instances would land on top of each other. Copying instead is
21
+ * out on size: a module is about 20MB, nearly all of it the bundled hook closure
22
+ * that celilo#173 makes load-bearing, against about 72KB of authored source.
14
23
  */
15
24
 
16
25
  import { createHash } from 'node:crypto';
26
+ import { existsSync } from 'node:fs';
27
+ import { lstat, mkdir, readdir, readlink, rm, symlink } from 'node:fs/promises';
28
+ import { join, relative, resolve } from 'node:path';
17
29
  import { eq } from 'drizzle-orm';
18
30
  import type { DbClient } from '../db/client';
19
- import { moduleInstances } from '../db/schema';
31
+ import { type InstanceState, moduleInstances } from '../db/schema';
32
+ import { SUBMODULES_DIR } from '../manifest/validate';
20
33
 
21
34
  /**
22
35
  * Bits of hash in a derived instance id, as hex characters.
@@ -28,14 +41,19 @@ import { moduleInstances } from '../db/schema';
28
41
  */
29
42
  const INSTANCE_ID_HASH_CHARS = 12;
30
43
 
44
+ /** Entries an instance never links, because it owns its own copy or none. */
45
+ const NOT_SYMLINKED = new Set(['generated', 'screenshots', 'cookies.json', 'checksums.json']);
46
+
47
+ /** The one subdirectory of an instance that is real rather than a link. */
48
+ export const INSTANCE_GENERATED_DIR = 'generated';
49
+
31
50
  /**
32
51
  * The `modules.id` an instance runs as.
33
52
  *
34
53
  * Derived rather than supplied, for two reasons. The parent's key is opaque and
35
54
  * may be anything (D2 forbids celilo interpreting it), while `modules.id` must
36
- * be kebab-case. And identity is the TRIPLE of parent, submodule and key, so
37
- * the first two have to be inside the hash, or two parents using the same key
38
- * would collide.
55
+ * be kebab-case. And identity is the TRIPLE, so the parent and submodule have to
56
+ * be inside the hash, or two parents using the same key would collide.
39
57
  *
40
58
  * Deterministic: the same triple always yields the same id, which is what makes
41
59
  * an instantiate safe to retry.
@@ -56,6 +74,191 @@ export function deriveInstanceModuleId(
56
74
  return `${parentId}-${submodule}-${digest}`;
57
75
  }
58
76
 
77
+ /** Where a parent's shared submodule source lives inside its own install. */
78
+ export function submoduleSourcePath(parentSourcePath: string, submodule: string): string {
79
+ return join(parentSourcePath, SUBMODULES_DIR, submodule);
80
+ }
81
+
82
+ /**
83
+ * Which entries of a submodule's source get symlinked into an instance.
84
+ *
85
+ * Pure, so the decision is testable without a filesystem. `generated/` is
86
+ * excluded because the instance owns its own. The derived hook outputs
87
+ * (`screenshots/`, `cookies.json`) are excluded because linking them would put
88
+ * every instance's writes back into one shared place, which is the failure this
89
+ * layout exists to prevent.
90
+ */
91
+ export function planInstanceLinks(submoduleEntries: string[]): string[] {
92
+ return submoduleEntries.filter((entry) => !NOT_SYMLINKED.has(entry)).sort();
93
+ }
94
+
95
+ /**
96
+ * Create or repair an instance's symlink farm.
97
+ *
98
+ * Idempotent, and deliberately a full converge rather than a create. A submodule
99
+ * that gains a top-level directory in a later version would otherwise leave
100
+ * every existing instance stale, so this runs on instantiate AND on parent
101
+ * update. A link pointing somewhere else is replaced rather than left, because a
102
+ * repointed link is the one integrity property unique to instances.
103
+ *
104
+ * Returns the entries linked, so a caller can report what changed.
105
+ */
106
+ export async function buildInstanceLinkFarm(opts: {
107
+ instancePath: string;
108
+ submodulePath: string;
109
+ }): Promise<string[]> {
110
+ const { instancePath, submodulePath } = opts;
111
+
112
+ if (!existsSync(submodulePath)) {
113
+ throw new Error(
114
+ `Cannot build an instance at ${instancePath}: its submodule source ${submodulePath} does not exist. The parent's install is incomplete.`,
115
+ );
116
+ }
117
+
118
+ await mkdir(instancePath, { recursive: true });
119
+ await mkdir(join(instancePath, INSTANCE_GENERATED_DIR), { recursive: true });
120
+
121
+ const wanted = planInstanceLinks(await readdir(submodulePath));
122
+
123
+ for (const entry of wanted) {
124
+ const linkPath = join(instancePath, entry);
125
+ // Relative, so the farm survives the whole data directory being moved or
126
+ // restored somewhere else, which `celilo restore` does.
127
+ const target = relative(instancePath, join(submodulePath, entry));
128
+
129
+ const existing = await readExistingLink(linkPath);
130
+ if (existing === target) continue;
131
+ if (existing !== null) await rm(linkPath, { recursive: true, force: true });
132
+
133
+ await symlink(target, linkPath);
134
+ }
135
+
136
+ // Links for entries the submodule no longer has. Left behind they dangle, and
137
+ // a dangling link reads as a broken install rather than as a stale one.
138
+ for (const entry of await readdir(instancePath)) {
139
+ if (entry === INSTANCE_GENERATED_DIR || wanted.includes(entry)) continue;
140
+ const stale = join(instancePath, entry);
141
+ if ((await lstat(stale)).isSymbolicLink()) await rm(stale, { force: true });
142
+ }
143
+
144
+ return wanted;
145
+ }
146
+
147
+ /**
148
+ * What `linkPath` currently points at, or null if it is absent or not a link.
149
+ *
150
+ * A real file or directory where a link belongs returns null so the caller
151
+ * replaces it. That is the right answer: an instance directory holds nothing of
152
+ * its own except `generated/`, so anything else there is debris.
153
+ */
154
+ async function readExistingLink(linkPath: string): Promise<string | null> {
155
+ if (!existsSync(linkPath)) {
156
+ // existsSync FOLLOWS links, so a dangling link reports absent. lstat is what
157
+ // distinguishes "nothing here" from "a link to nowhere", and only the second
158
+ // needs removing before symlink() will succeed.
159
+ try {
160
+ await lstat(linkPath);
161
+ } catch {
162
+ return null;
163
+ }
164
+ await rm(linkPath, { force: true });
165
+ return null;
166
+ }
167
+ try {
168
+ return (await lstat(linkPath)).isSymbolicLink() ? await readlink(linkPath) : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /** One link that does not point where it should. */
175
+ export interface LinkViolation {
176
+ entry: string;
177
+ expected: string;
178
+ actual: string | null;
179
+ }
180
+
181
+ /**
182
+ * Verify an instance's farm points where it claims.
183
+ *
184
+ * The integrity property unique to instances (D4). An instance carries no
185
+ * authored bytes of its own, so `module verify` has nothing of its own to
186
+ * checksum: its bytes are the submodule's, which are covered by the parent's
187
+ * baseline. What IS worth checking, and what nothing else checks, is that the
188
+ * links still resolve inside the declaring parent and have not been repointed at
189
+ * somebody else's source.
190
+ */
191
+ export async function verifyInstanceLinks(opts: {
192
+ instancePath: string;
193
+ submodulePath: string;
194
+ }): Promise<LinkViolation[]> {
195
+ const { instancePath, submodulePath } = opts;
196
+ const violations: LinkViolation[] = [];
197
+
198
+ for (const entry of planInstanceLinks(await readdir(submodulePath))) {
199
+ const linkPath = join(instancePath, entry);
200
+ const expected = resolve(submodulePath, entry);
201
+ const actual = await readExistingLink(linkPath);
202
+ const resolved = actual === null ? null : resolve(instancePath, actual);
203
+
204
+ if (resolved !== expected) {
205
+ violations.push({ entry, expected, actual: resolved });
206
+ }
207
+ }
208
+
209
+ return violations;
210
+ }
211
+
212
+ /** An instance row, joined to what the caller needs to reach its source. */
213
+ export interface InstanceRecord {
214
+ moduleId: string;
215
+ parentId: string;
216
+ submodule: string;
217
+ instanceKey: string;
218
+ label: string | null;
219
+ state: InstanceState;
220
+ }
221
+
222
+ /**
223
+ * The instance row for `moduleId`, or null if it names an ordinary module.
224
+ *
225
+ * The one question every caller that must treat an instance differently has to
226
+ * ask, so it lives here rather than being a join each of them writes.
227
+ */
228
+ export function loadInstance(moduleId: string, db: DbClient): InstanceRecord | null {
229
+ const row = db
230
+ .select({
231
+ moduleId: moduleInstances.moduleId,
232
+ parentId: moduleInstances.parentId,
233
+ submodule: moduleInstances.submodule,
234
+ instanceKey: moduleInstances.instanceKey,
235
+ label: moduleInstances.label,
236
+ state: moduleInstances.state,
237
+ })
238
+ .from(moduleInstances)
239
+ .where(eq(moduleInstances.moduleId, moduleId))
240
+ .get();
241
+
242
+ return row ?? null;
243
+ }
244
+
245
+ /** Every instance a parent owns, oldest first so listings are stable. */
246
+ export function loadInstancesForParent(parentId: string, db: DbClient): InstanceRecord[] {
247
+ return db
248
+ .select({
249
+ moduleId: moduleInstances.moduleId,
250
+ parentId: moduleInstances.parentId,
251
+ submodule: moduleInstances.submodule,
252
+ instanceKey: moduleInstances.instanceKey,
253
+ label: moduleInstances.label,
254
+ state: moduleInstances.state,
255
+ })
256
+ .from(moduleInstances)
257
+ .where(eq(moduleInstances.parentId, parentId))
258
+ .orderBy(moduleInstances.createdAt)
259
+ .all();
260
+ }
261
+
59
262
  /**
60
263
  * Every module id whose systems `moduleId` transitively owns: itself, plus each
61
264
  * of its instances.
@@ -66,24 +269,14 @@ export function deriveInstanceModuleId(
66
269
  * `IN (...)` set.
67
270
  *
68
271
  * NOT RECURSIVE, and that is a property of the model rather than a shortcut.
69
- * Nesting is refused when a parent is imported: a submodule may not declare
70
- * submodules of its own, so ownership is exactly one level deep by
71
- * construction. A parent owns instances, and an instance owns nothing. So this
72
- * is one indexed lookup on `module_instances_parent_idx` rather than a walk,
73
- * which is what makes it cheap enough to run at hook-invocation time.
74
- *
75
- * A recursive walk that happens to terminate and one that cannot recurse are
76
- * different things, and only the second is safe to call per invocation without
77
- * reasoning about depth.
272
+ * Nesting is refused at parent import (`validateSubmoduleManifest`), so
273
+ * ownership is exactly one level deep by construction: a parent owns instances,
274
+ * and an instance owns nothing. So this is one indexed lookup on
275
+ * `module_instances_parent_idx` rather than a walk, which is what makes it
276
+ * cheap enough to run at hook-invocation time.
78
277
  *
79
278
  * An instance asking gets only itself, which is correct: an instance provisions
80
279
  * its own systems and owns nobody else's.
81
- *
82
- * Correct with no instances in existence, which is the state of every fleet
83
- * until submodules ship: the table is empty, so every module gets `[itself]`,
84
- * and a caller reaches only the systems it provisioned. That is the answer the
85
- * allow-list wants today, and it picks up submodule behaviour later with no
86
- * second edit.
87
280
  */
88
281
  export function ownedSystemModuleIds(moduleId: string, db: DbClient): string[] {
89
282
  const instances = db