@celilo/cli 2.1.0 → 2.2.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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
package/src/hooks/mount-set.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface MountEntry {
|
|
|
39
39
|
readonly mode: MountMode;
|
|
40
40
|
/** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
|
|
41
41
|
readonly reason: string;
|
|
42
|
+
/** What this row's absence from the host means. See `MountAbsence`. */
|
|
43
|
+
readonly absence: MountAbsence;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface MountSet {
|
|
@@ -154,8 +156,27 @@ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']
|
|
|
154
156
|
*/
|
|
155
157
|
const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
/**
|
|
160
|
+
* What a row's ABSENCE from the host means. Required, never defaulted: a default
|
|
161
|
+
* lets the next mount be added without deciding which kind it is, and that is
|
|
162
|
+
* exactly how the undifferentiated `skipped` list came to exist.
|
|
163
|
+
*
|
|
164
|
+
* bubblewrap fails the whole jail on a bind whose source is missing, so an
|
|
165
|
+
* absent row must be dropped. The question this answers is whether dropping it
|
|
166
|
+
* is fine, fatal, or nobody's business.
|
|
167
|
+
*/
|
|
168
|
+
export type MountAbsence =
|
|
169
|
+
/** Fatal. The hook cannot do what it was asked without this. */
|
|
170
|
+
| 'required'
|
|
171
|
+
/** Fatal only for a module that declared it needs the facility. */
|
|
172
|
+
| 'declared-only'
|
|
173
|
+
/** Not reported. A genuinely needed one fails the runtime, which is louder. */
|
|
174
|
+
| 'runtime'
|
|
175
|
+
/** Expected at some lifecycle points. Debug at most. */
|
|
176
|
+
| 'conditional';
|
|
177
|
+
|
|
178
|
+
function entry(path: string, mode: MountMode, reason: string, absence: MountAbsence): MountEntry {
|
|
179
|
+
return { path, mode, reason, absence };
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
/**
|
|
@@ -182,21 +203,25 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
182
203
|
// silently an empty tmpfs directory writes into it, returns success, and
|
|
183
204
|
// produces a backup containing NOTHING. It is found at restore. So the gate
|
|
184
205
|
// on this asserts the artifact is non-empty, never that the hook exited zero.
|
|
185
|
-
entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
|
|
206
|
+
entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run', 'runtime'));
|
|
186
207
|
|
|
187
208
|
// 2. The runtime. Without it nothing runs, so it is not really a policy row.
|
|
188
|
-
entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
|
|
189
|
-
entries.push(
|
|
209
|
+
entries.push(entry(request.runtimePath, 'ro', 'the interpreter', 'required'));
|
|
210
|
+
entries.push(
|
|
211
|
+
entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns', 'required'),
|
|
212
|
+
);
|
|
190
213
|
// See MountSetRequest.runtimeModulePaths. Without these the shim starts and
|
|
191
214
|
// immediately dies on `Cannot find module`.
|
|
192
215
|
for (const dir of request.runtimeModulePaths ?? []) {
|
|
193
|
-
entries.push(
|
|
216
|
+
entries.push(
|
|
217
|
+
entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports", 'required'),
|
|
218
|
+
);
|
|
194
219
|
}
|
|
195
220
|
for (const dir of RUNTIME_SUPPORT_DIRS) {
|
|
196
|
-
entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
|
|
221
|
+
entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime'));
|
|
197
222
|
}
|
|
198
223
|
for (const file of RESOLVER_FILES) {
|
|
199
|
-
entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES'));
|
|
224
|
+
entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime'));
|
|
200
225
|
}
|
|
201
226
|
// The fleet browser, read-only (task 4.10).
|
|
202
227
|
//
|
|
@@ -225,7 +250,9 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
225
250
|
// the browser rather than here. That interim landed 2026-08-31 (celilo#1215):
|
|
226
251
|
// the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag,
|
|
227
252
|
// and `jail-browser-launch-flags.test.ts` pins it there.
|
|
228
|
-
entries.push(
|
|
253
|
+
entries.push(
|
|
254
|
+
entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned', 'declared-only'),
|
|
255
|
+
);
|
|
229
256
|
|
|
230
257
|
// 3. The module's own tree, read-only, then its writable directories carved
|
|
231
258
|
// on top. bubblewrap resolves that in the right order, which is why the
|
|
@@ -234,19 +261,36 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
234
261
|
// D9 says "the module's own tree is bound read-only". These are the
|
|
235
262
|
// carved exceptions to that sentence, and there are three of them rather
|
|
236
263
|
// than the one D9's prose implies.
|
|
237
|
-
entries.push(entry(modulePath, 'ro', "the module's own tree"));
|
|
264
|
+
entries.push(entry(modulePath, 'ro', "the module's own tree", 'required'));
|
|
238
265
|
entries.push(
|
|
239
|
-
entry(
|
|
266
|
+
entry(
|
|
267
|
+
resolve(request.stateDir),
|
|
268
|
+
'rw',
|
|
269
|
+
'ctx.stateDir, the sanctioned writable directory',
|
|
270
|
+
'required',
|
|
271
|
+
),
|
|
240
272
|
);
|
|
241
273
|
entries.push(
|
|
242
|
-
entry(
|
|
274
|
+
entry(
|
|
275
|
+
join(modulePath, 'generated'),
|
|
276
|
+
'rw',
|
|
277
|
+
"celilo's generated output the hook may amend",
|
|
278
|
+
'conditional',
|
|
279
|
+
),
|
|
243
280
|
);
|
|
244
281
|
if (request.screenshotDir) {
|
|
245
|
-
entries.push(
|
|
282
|
+
entries.push(
|
|
283
|
+
entry(
|
|
284
|
+
resolve(request.screenshotDir),
|
|
285
|
+
'rw',
|
|
286
|
+
'ctx.screenshotDir, this run only',
|
|
287
|
+
'conditional',
|
|
288
|
+
),
|
|
289
|
+
);
|
|
246
290
|
}
|
|
247
291
|
|
|
248
292
|
// 4. The broker channel. Bound AFTER the tmpfs, per the note above.
|
|
249
|
-
entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
|
|
293
|
+
entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket', 'required'));
|
|
250
294
|
|
|
251
295
|
// 5. Contract-declared path inputs, at the access the contract declares.
|
|
252
296
|
// Never inferred from the name — see ContractField.path.
|
|
@@ -256,6 +300,10 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
256
300
|
resolve(input.value),
|
|
257
301
|
input.access === 'write' ? 'rw' : 'ro',
|
|
258
302
|
`contract input '${input.name}' (${input.access})`,
|
|
303
|
+
// Fatal, always. A hook whose declared WRITE path is dropped writes into
|
|
304
|
+
// the run's private tmpfs and reports success over a directory that is
|
|
305
|
+
// discarded when it exits. celilo#1248-adjacent; see jail.ts's note.
|
|
306
|
+
'required',
|
|
259
307
|
),
|
|
260
308
|
);
|
|
261
309
|
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { remoteAccessPolicy } from '../services/remote-access';
|
|
27
27
|
import { loadCapabilityFunctions } from './capability-loader';
|
|
28
28
|
import { invokeHook } from './executor';
|
|
29
|
+
import { createHookStores } from './hook-store';
|
|
29
30
|
import { loadHookConfigMap } from './load-hook-config';
|
|
30
31
|
import type { HookLogger, HookName, HookResult } from './types';
|
|
31
32
|
|
|
@@ -192,6 +193,7 @@ export async function runNamedHook(
|
|
|
192
193
|
requiredCapabilities,
|
|
193
194
|
systems: getModuleSystems(moduleId, db),
|
|
194
195
|
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
196
|
+
hookStores: () => createHookStores(db, moduleId),
|
|
195
197
|
timeoutMs: options.timeoutMs,
|
|
196
198
|
},
|
|
197
199
|
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: an on_restore hook in the celilo-mgmt staging shape, minus
|
|
3
|
+
* everything the real one owns but the staging itself. Copies the artifact's
|
|
4
|
+
* celilo.db into restore_dir/system/ so the Phase 4 wrapper's
|
|
5
|
+
* applyStagedSystemFiles has something to swap.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { defineHook } from '@celilo/capabilities';
|
|
11
|
+
|
|
12
|
+
export default defineHook({
|
|
13
|
+
hook: 'on_restore',
|
|
14
|
+
requires: [],
|
|
15
|
+
optional: [],
|
|
16
|
+
handler: async (ctx) => {
|
|
17
|
+
const restoreDir = (ctx as unknown as { restore_dir: string }).restore_dir;
|
|
18
|
+
const systemDir = join(restoreDir, 'system');
|
|
19
|
+
mkdirSync(systemDir, { recursive: true });
|
|
20
|
+
const artifactDb = join(restoreDir, 'celilo.db');
|
|
21
|
+
if (existsSync(artifactDb)) {
|
|
22
|
+
cpSync(artifactDb, join(systemDir, 'celilo.db'));
|
|
23
|
+
}
|
|
24
|
+
return { restored_items: 1 };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory hook-owned-state stores for tests that build a HookContext
|
|
5
|
+
* without a broker. The four methods mutate the record directly; the
|
|
6
|
+
* manifest validation a real store applies lives broker-side
|
|
7
|
+
* (hook-store.ts) and has its own tests.
|
|
8
|
+
*
|
|
9
|
+
* Methods are defined non-enumerably, so the result spreads, serializes and
|
|
10
|
+
* `toEqual`s as the plain record the test wrote — the same shape a real
|
|
11
|
+
* broker-side context carries when executeHookScript frames it.
|
|
12
|
+
*/
|
|
13
|
+
export function configStore(
|
|
14
|
+
values: Record<string, unknown> = {},
|
|
15
|
+
): Record<string, unknown> & HookStore {
|
|
16
|
+
return attach(values);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
|
|
20
|
+
// Downcast: the caller passed Record<string, string>; attach only adds
|
|
21
|
+
// methods and never writes, so the value type is unchanged.
|
|
22
|
+
return attach(values) as HookStoreBackedMap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
|
|
26
|
+
const store: HookStore = {
|
|
27
|
+
get: async (name) => values[name] as string | undefined,
|
|
28
|
+
set: async (name, value) => {
|
|
29
|
+
values[name] = value;
|
|
30
|
+
},
|
|
31
|
+
delete: async (name) => {
|
|
32
|
+
delete values[name];
|
|
33
|
+
},
|
|
34
|
+
transaction: async (fn) => {
|
|
35
|
+
await fn(store);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
for (const [name, method] of Object.entries(store)) {
|
|
39
|
+
Object.defineProperty(values, name, {
|
|
40
|
+
value: method,
|
|
41
|
+
enumerable: false,
|
|
42
|
+
writable: true,
|
|
43
|
+
configurable: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return Object.assign(values, store);
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that exercises the hook-owned-state accessor across
|
|
3
|
+
* the process boundary — set/get round trip on both stores, delete, a
|
|
4
|
+
* committed transaction, a discarded transaction, and the undeclared-name
|
|
5
|
+
* throw the whole design exists to produce.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineHook } from '@celilo/capabilities';
|
|
9
|
+
|
|
10
|
+
export default defineHook({
|
|
11
|
+
hook: 'on_install',
|
|
12
|
+
requires: [],
|
|
13
|
+
handler: async (ctx) => {
|
|
14
|
+
const outputs: Record<string, unknown> = {};
|
|
15
|
+
|
|
16
|
+
// Round trip on each store.
|
|
17
|
+
await ctx.secrets.set('bot_token', 'token-value');
|
|
18
|
+
outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
|
|
19
|
+
|
|
20
|
+
await ctx.config.set('public_ip', '203.0.113.7');
|
|
21
|
+
outputs.configRoundTrip = await ctx.config.get('public_ip');
|
|
22
|
+
|
|
23
|
+
// The map surface still answers from the values the context carried.
|
|
24
|
+
outputs.mapRead = ctx.config.mapOnlyValue;
|
|
25
|
+
|
|
26
|
+
// Delete removes; deleting a declared-but-never-written name is a no-op.
|
|
27
|
+
// (An UNDECLARED name on delete throws — that is 3.4, tested below.
|
|
28
|
+
// Declared-and-absent is the no-op case 3.7 pins.)
|
|
29
|
+
await ctx.secrets.delete('bot_token');
|
|
30
|
+
await ctx.secrets.set('api_key', 'to-delete');
|
|
31
|
+
await ctx.secrets.delete('api_key');
|
|
32
|
+
outputs.deletedIsGone = await ctx.secrets.get('api_key');
|
|
33
|
+
|
|
34
|
+
// Committed transaction: both writes land.
|
|
35
|
+
await ctx.secrets.transaction((s) => {
|
|
36
|
+
s.set('bot_token', 'committed-a');
|
|
37
|
+
s.set('api_key', 'committed-b');
|
|
38
|
+
});
|
|
39
|
+
outputs.transactionA = await ctx.secrets.get('bot_token');
|
|
40
|
+
outputs.transactionB = await ctx.secrets.get('api_key');
|
|
41
|
+
|
|
42
|
+
// Discarded transaction: nothing lands, including the overwrite of the
|
|
43
|
+
// committed value above.
|
|
44
|
+
try {
|
|
45
|
+
await ctx.secrets.transaction(async (s) => {
|
|
46
|
+
s.set('bot_token', 'discarded');
|
|
47
|
+
throw new Error('hook failed midway');
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
outputs.discarded = (error as Error).message;
|
|
51
|
+
}
|
|
52
|
+
outputs.afterDiscard = await ctx.secrets.get('bot_token');
|
|
53
|
+
|
|
54
|
+
// An undeclared name throws, naming the module and the declared set.
|
|
55
|
+
try {
|
|
56
|
+
await ctx.secrets.set('not_declared', 'x');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
outputs.undeclaredSecret = (error as Error).message;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return outputs;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -26,6 +26,7 @@ import { executeHookScript, hookChildEnv } from './executor';
|
|
|
26
26
|
import type { MountSetWire } from './hook-protocol';
|
|
27
27
|
import { createCapturingLogger } from './logger';
|
|
28
28
|
import { deriveMountSet } from './mount-set';
|
|
29
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
29
30
|
import type { HookContext } from './types';
|
|
30
31
|
import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
|
|
31
32
|
|
|
@@ -54,10 +55,25 @@ function scratchModule(script: string): string {
|
|
|
54
55
|
|
|
55
56
|
const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
|
|
56
57
|
entries: [
|
|
57
|
-
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
{ path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' },
|
|
59
|
+
{
|
|
60
|
+
path: '/srv/celilo/mod',
|
|
61
|
+
mode: 'ro',
|
|
62
|
+
reason: "the module's own tree",
|
|
63
|
+
absence: 'required',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: '/srv/celilo/mod/state',
|
|
67
|
+
mode: 'rw',
|
|
68
|
+
reason: 'the sanctioned writable directory',
|
|
69
|
+
absence: 'required',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
path: '/srv/staged',
|
|
73
|
+
mode: 'rw',
|
|
74
|
+
reason: "contract input 'backup_dir' (write)",
|
|
75
|
+
absence: 'required',
|
|
76
|
+
},
|
|
61
77
|
],
|
|
62
78
|
chdir: '/srv/celilo/mod',
|
|
63
79
|
...overrides,
|
|
@@ -159,8 +175,8 @@ describe('the lint inside a real hook run', () => {
|
|
|
159
175
|
mkdirSync(join(root, 'state'), { recursive: true });
|
|
160
176
|
const { logger, messages } = createCapturingLogger();
|
|
161
177
|
const context: HookContext = {
|
|
162
|
-
config:
|
|
163
|
-
secrets:
|
|
178
|
+
config: configStore(),
|
|
179
|
+
secrets: secretStore(),
|
|
164
180
|
systems: [],
|
|
165
181
|
logger,
|
|
166
182
|
debug: false,
|
package/src/manifest/schema.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
-
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { basename, join, relative } from 'node:path';
|
|
6
|
+
import { gunzipSync } from 'node:zlib';
|
|
6
7
|
import { create as tarCreate } from 'tar';
|
|
7
8
|
import { parse as parseYaml } from 'yaml';
|
|
8
9
|
import { log } from '../../cli/prompts';
|
|
@@ -120,6 +121,33 @@ export async function computeChecksums(sourceDir: string): Promise<ChecksumsData
|
|
|
120
121
|
};
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Ceiling on the final tar+gzip write of the .netapp. Every step before it
|
|
126
|
+
* (staging, the module's own build command) already carries a timeout, but the
|
|
127
|
+
* tar stream did not — and 2026-09-04 it stalled mid-write (all samples in
|
|
128
|
+
* kevent64, artifact truncated at 59 percent) and sat there for two hours with
|
|
129
|
+
* no error and no exit. A bounded wait turns that into a nameable failure.
|
|
130
|
+
*/
|
|
131
|
+
const PACKAGE_STREAM_TIMEOUT_MS = 300_000;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate that a produced .netapp is a complete gzip stream.
|
|
135
|
+
*
|
|
136
|
+
* A .netapp is a gzipped tar (tarCreate above runs with gzip: true), so a
|
|
137
|
+
* full gunzip pass is the whole check: a stream truncated mid-write fails
|
|
138
|
+
* with 'unexpected end of file' instead of surviving to the consumer, where
|
|
139
|
+
* the same zlib error reads as a truncated DOWNLOAD from an unrelated cause.
|
|
140
|
+
* Returns null when valid, otherwise the reason.
|
|
141
|
+
*/
|
|
142
|
+
export function verifyNetappIntegrity(packagePath: string): string | null {
|
|
143
|
+
try {
|
|
144
|
+
gunzipSync(readFileSync(packagePath));
|
|
145
|
+
return null;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return err instanceof Error ? err.message : String(err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
123
151
|
/**
|
|
124
152
|
* Build a module package (.netapp file)
|
|
125
153
|
*
|
|
@@ -321,7 +349,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
321
349
|
|
|
322
350
|
// Create tarball
|
|
323
351
|
const finalOutputPath = outputPath || join(process.cwd(), `${moduleId}.netapp`);
|
|
324
|
-
|
|
352
|
+
const tarPromise = tarCreate(
|
|
325
353
|
{
|
|
326
354
|
file: finalOutputPath,
|
|
327
355
|
cwd: buildDir,
|
|
@@ -329,6 +357,46 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
329
357
|
},
|
|
330
358
|
['checksums.json', 'signature.sig', ...Object.keys(checksumsData.files)],
|
|
331
359
|
);
|
|
360
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
361
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
362
|
+
timer = setTimeout(
|
|
363
|
+
() =>
|
|
364
|
+
reject(
|
|
365
|
+
new Error(
|
|
366
|
+
`packaging tar stream exceeded ${PACKAGE_STREAM_TIMEOUT_MS / 1000}s and was abandoned mid-write`,
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
PACKAGE_STREAM_TIMEOUT_MS,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
timer?.unref?.();
|
|
373
|
+
try {
|
|
374
|
+
await Promise.race([tarPromise, timeout]);
|
|
375
|
+
} catch (tarError) {
|
|
376
|
+
// The losing side may still hold the output file open: swallow its
|
|
377
|
+
// eventual rejection and remove the partial artifact so nothing
|
|
378
|
+
// downstream mistakes a truncated write for a package.
|
|
379
|
+
tarPromise.catch(() => {});
|
|
380
|
+
rmSync(finalOutputPath, { force: true });
|
|
381
|
+
return {
|
|
382
|
+
success: false,
|
|
383
|
+
error: `Failed to build module: ${
|
|
384
|
+
tarError instanceof Error ? tarError.message : 'Unknown error'
|
|
385
|
+
}`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Refuse to declare success on an artifact we cannot read back. The tar
|
|
390
|
+
// step resolved, so this only trips if the stream left the file truncated
|
|
391
|
+
// anyway — the exact corruption build-infra shipped on 2026-09-04.
|
|
392
|
+
const integrityError = verifyNetappIntegrity(finalOutputPath);
|
|
393
|
+
if (integrityError) {
|
|
394
|
+
rmSync(finalOutputPath, { force: true });
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: `Produced package failed gzip integrity check (${finalOutputPath}): ${integrityError}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
332
400
|
|
|
333
401
|
return {
|
|
334
402
|
success: true,
|
package/src/module/web-root.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
-
import { MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
17
|
+
import { MODULE_STATE_WEB_ROOT, MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
18
18
|
import { eq } from 'drizzle-orm';
|
|
19
19
|
import type { DbClient } from '../db/client';
|
|
20
20
|
import { modules } from '../db/schema';
|
|
@@ -33,3 +33,19 @@ export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | u
|
|
|
33
33
|
if (!module) return undefined;
|
|
34
34
|
return join(module.sourcePath, MODULE_WEB_ROOT);
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path to `<module root>/state/site` — the state web overlay
|
|
39
|
+
* (celilo#1265) — or undefined when no such module is installed.
|
|
40
|
+
*
|
|
41
|
+
* Beside `resolveModuleWebRoot` for the same reason that function lives here:
|
|
42
|
+
* ONE application of the convention, by the one party that can resolve it for
|
|
43
|
+
* a module that is not running. Does NOT check that the directory exists — a
|
|
44
|
+
* module with no generated site content has no overlay, and deciding what an
|
|
45
|
+
* absent directory means belongs to the caller (see `requireWebRoot`).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveModuleStateWebRoot(moduleId: string, db: DbClient): string | undefined {
|
|
48
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
49
|
+
if (!module) return undefined;
|
|
50
|
+
return join(module.sourcePath, MODULE_STATE_WEB_ROOT);
|
|
51
|
+
}
|