@celilo/cli 2.3.0 → 3.1.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/CELILO_CORE_MODULES.md +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +16 -3
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader.ts +49 -1
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +19 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +69 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-business-baseline.ts +17 -0
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +106 -28
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/provider-converge.test.ts +209 -0
- package/src/services/provider-converge.ts +241 -0
- package/src/services/static-content-converge.test.ts +39 -0
- package/src/services/static-content-converge.ts +26 -5
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -3,25 +3,32 @@
|
|
|
3
3
|
* the process boundary — set/get round trip on both stores, delete, a
|
|
4
4
|
* committed transaction, a discarded transaction, and the undeclared-name
|
|
5
5
|
* throw the whole design exists to produce.
|
|
6
|
+
*
|
|
7
|
+
* Reports through a JSON file in its state directory: hook return values
|
|
8
|
+
* are no longer carried anywhere (hook-owned-state D5), and the state
|
|
9
|
+
* directory is the sanctioned place a hook hands structured data to its
|
|
10
|
+
* caller.
|
|
6
11
|
*/
|
|
7
12
|
|
|
13
|
+
import { writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
8
15
|
import { defineHook } from '@celilo/capabilities';
|
|
9
16
|
|
|
10
17
|
export default defineHook({
|
|
11
18
|
hook: 'on_install',
|
|
12
19
|
requires: [],
|
|
13
20
|
handler: async (ctx) => {
|
|
14
|
-
const
|
|
21
|
+
const report: Record<string, unknown> = {};
|
|
15
22
|
|
|
16
23
|
// Round trip on each store.
|
|
17
24
|
await ctx.secrets.set('bot_token', 'token-value');
|
|
18
|
-
|
|
25
|
+
report.secretRoundTrip = await ctx.secrets.get('bot_token');
|
|
19
26
|
|
|
20
27
|
await ctx.config.set('public_ip', '203.0.113.7');
|
|
21
|
-
|
|
28
|
+
report.configRoundTrip = await ctx.config.get('public_ip');
|
|
22
29
|
|
|
23
30
|
// The map surface still answers from the values the context carried.
|
|
24
|
-
|
|
31
|
+
report.mapRead = ctx.config.mapOnlyValue;
|
|
25
32
|
|
|
26
33
|
// Delete removes; deleting a declared-but-never-written name is a no-op.
|
|
27
34
|
// (An UNDECLARED name on delete throws — that is 3.4, tested below.
|
|
@@ -29,15 +36,15 @@ export default defineHook({
|
|
|
29
36
|
await ctx.secrets.delete('bot_token');
|
|
30
37
|
await ctx.secrets.set('api_key', 'to-delete');
|
|
31
38
|
await ctx.secrets.delete('api_key');
|
|
32
|
-
|
|
39
|
+
report.deletedIsGone = await ctx.secrets.get('api_key');
|
|
33
40
|
|
|
34
41
|
// Committed transaction: both writes land.
|
|
35
42
|
await ctx.secrets.transaction((s) => {
|
|
36
43
|
s.set('bot_token', 'committed-a');
|
|
37
44
|
s.set('api_key', 'committed-b');
|
|
38
45
|
});
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
report.transactionA = await ctx.secrets.get('bot_token');
|
|
47
|
+
report.transactionB = await ctx.secrets.get('api_key');
|
|
41
48
|
|
|
42
49
|
// Discarded transaction: nothing lands, including the overwrite of the
|
|
43
50
|
// committed value above.
|
|
@@ -47,17 +54,19 @@ export default defineHook({
|
|
|
47
54
|
throw new Error('hook failed midway');
|
|
48
55
|
});
|
|
49
56
|
} catch (error) {
|
|
50
|
-
|
|
57
|
+
report.discarded = (error as Error).message;
|
|
51
58
|
}
|
|
52
|
-
|
|
59
|
+
report.afterDiscard = await ctx.secrets.get('bot_token');
|
|
53
60
|
|
|
54
61
|
// An undeclared name throws, naming the module and the declared set.
|
|
55
62
|
try {
|
|
56
63
|
await ctx.secrets.set('not_declared', 'x');
|
|
57
64
|
} catch (error) {
|
|
58
|
-
|
|
65
|
+
report.undeclaredSecret = (error as Error).message;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
// Hand the report to the caller through the state directory, the one
|
|
69
|
+
// channel a hook still has.
|
|
70
|
+
writeFileSync(join(ctx.stateDir as string, 'report.json'), JSON.stringify(report));
|
|
62
71
|
},
|
|
63
72
|
});
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Test fixture: successful hook script. Migrated to defineHook in
|
|
3
3
|
* HOOK_API_V2 Phase 8 — the executor now requires the brand.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Declares `hook: 'container_created'` and returns void: hook return
|
|
6
|
+
* values are no longer persisted or consumed (hook-owned-state D5), so a
|
|
7
|
+
* hook reports through the logger and, where a test needs structured data
|
|
8
|
+
* back, through a file written to its state directory.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { defineHook } from '@celilo/capabilities';
|
|
@@ -15,6 +16,5 @@ export default defineHook({
|
|
|
15
16
|
handler: async (ctx) => {
|
|
16
17
|
ctx.logger.info('Starting test hook');
|
|
17
18
|
ctx.logger.success('Test hook completed');
|
|
18
|
-
return { api_key: `test-key-for-${(ctx.vps_ip as string | undefined) ?? 'unknown'}` };
|
|
19
19
|
},
|
|
20
20
|
});
|
|
@@ -71,15 +71,8 @@ export interface ContractHookSignature {
|
|
|
71
71
|
* a name added to `HOOK_NAMES` without an entry here is a type error, and an
|
|
72
72
|
* entry here for a name that is not a hook is one too. It was `Record<string,
|
|
73
73
|
* …>`, which is why the drift went unnoticed for months.
|
|
74
|
-
*
|
|
75
|
-
* `on_upstream_publish` is named explicitly because it is NOT a `HookName` — an
|
|
76
|
-
* array of build-bus match rules rather than an invokable hook. It has a
|
|
77
|
-
* signature here because the executor passes its payload as env vars and the
|
|
78
|
-
* contract is where that is written down.
|
|
79
74
|
*/
|
|
80
|
-
export type ContractHooks = Record<HookName, ContractHookSignature
|
|
81
|
-
on_upstream_publish: ContractHookSignature;
|
|
82
|
-
};
|
|
75
|
+
export type ContractHooks = Record<HookName, ContractHookSignature>;
|
|
83
76
|
|
|
84
77
|
/**
|
|
85
78
|
* Contract v1.0 — current canonical hook signatures.
|
|
@@ -370,14 +363,26 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
370
363
|
outputs: {},
|
|
371
364
|
},
|
|
372
365
|
/**
|
|
373
|
-
* Build-bus upstream publish hook
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
366
|
+
* Build-bus upstream publish hook (module-orchestrator-primitives slice 7).
|
|
367
|
+
* An ordinary hook: the build-bus receiver daemon dispatches it through the
|
|
368
|
+
* executor, jailed, with the verified PublishEvent as the `event` input in
|
|
369
|
+
* the hook's typed context. It used to be a bash script reached by env vars
|
|
370
|
+
* (`CELILO_EVENT_*`) on a second, unjailed execution path.
|
|
371
|
+
*
|
|
372
|
+
* `event` is a required input, which by the contract's own rule would be a
|
|
373
|
+
* breaking change needing a v2.0 contract. No v2 is minted because no module
|
|
374
|
+
* declares this hook as an invokable script: the only in-tree consumer was
|
|
375
|
+
* celilo-mgmt's bash entry, deleted in the same slice, and the dispatcher
|
|
376
|
+
* that invoked the old form never went through this table at all. The v1
|
|
377
|
+
* entry is being corrected to describe the shape the executor now actually
|
|
378
|
+
* passes, in the same @celilo/cli major that changes the manifest field's
|
|
379
|
+
* shape (tasks.md 7.6).
|
|
378
380
|
*/
|
|
379
381
|
on_upstream_publish: {
|
|
380
|
-
inputs: {
|
|
382
|
+
inputs: {
|
|
383
|
+
/** The verified PublishEvent, as received from the build bus. */
|
|
384
|
+
event: { required: true },
|
|
385
|
+
},
|
|
381
386
|
outputs: {},
|
|
382
387
|
},
|
|
383
388
|
};
|
|
@@ -45,7 +45,7 @@ function findActiveManifests(): string[] {
|
|
|
45
45
|
const manifests: string[] = [];
|
|
46
46
|
|
|
47
47
|
for (const entry of entries) {
|
|
48
|
-
if (entry === '
|
|
48
|
+
if (entry === '__archive__' || entry.startsWith('.')) continue;
|
|
49
49
|
const dir = join(MODULES_DIR, entry);
|
|
50
50
|
if (!statSync(dir).isDirectory()) continue;
|
|
51
51
|
const manifestPath = join(dir, 'manifest.yml');
|
package/src/manifest/schema.ts
CHANGED
|
@@ -105,6 +105,25 @@ export const SecretGenerateSchema = z.object({
|
|
|
105
105
|
identity: z.string().optional(),
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A recorded decision that a module needs no health check
|
|
110
|
+
* (openspec/changes/health-waiver-mechanism, D2). All three fields are
|
|
111
|
+
* required: an anonymous or undated waiver is a check that disappeared.
|
|
112
|
+
* Strict — an unknown field must fail manifest load, not silently vanish.
|
|
113
|
+
*/
|
|
114
|
+
export const HealthWaiverSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
/** The human's words, recorded verbatim. */
|
|
117
|
+
reason: z.string().min(1),
|
|
118
|
+
/** Who made the decision. */
|
|
119
|
+
by: z.string().min(1),
|
|
120
|
+
/** When — an ISO date. */
|
|
121
|
+
at: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
|
|
122
|
+
message: 'health_waiver.at must be an ISO date string',
|
|
123
|
+
}),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
|
|
108
127
|
export const SecretDeclareSchema = z.object({
|
|
109
128
|
name: z.string().min(1),
|
|
110
129
|
// `string-map` is `Record<string, string>` (e.g. domain → password). It's
|
|
@@ -304,14 +323,17 @@ export const HealthCheckHookSchema = LifecycleHookSchema.extend({
|
|
|
304
323
|
|
|
305
324
|
/**
|
|
306
325
|
* Build-bus upstream-publish hook. Fires when a publish event lands
|
|
307
|
-
* on the local event bus from the receiver daemon
|
|
308
|
-
*
|
|
309
|
-
*
|
|
326
|
+
* on the local event bus from the receiver daemon. Each module can declare
|
|
327
|
+
* multiple entries, each with its own match rule, so one manifest can react
|
|
328
|
+
* to two different packages with two different scripts (celilo-mgmt did
|
|
329
|
+
* exactly that) without the hook itself having to filter.
|
|
310
330
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* `
|
|
314
|
-
*
|
|
331
|
+
* As of module-orchestrator-primitives slice 7 the script is an ordinary
|
|
332
|
+
* TypeScript `defineHook` module dispatched through the executor, jailed,
|
|
333
|
+
* with the verified PublishEvent as the `event` input in the hook's typed
|
|
334
|
+
* context. It used to be a bash path spawned detached on a second execution
|
|
335
|
+
* path with celilo's whole `process.env` (design D6). That path is gone; the
|
|
336
|
+
* match rules survive as receiver-side trigger filtering.
|
|
315
337
|
*/
|
|
316
338
|
export const UpstreamPublishHookSchema = z.object({
|
|
317
339
|
/** Optional display label for logs / `celilo subscribers status`. */
|
|
@@ -328,9 +350,13 @@ export const UpstreamPublishHookSchema = z.object({
|
|
|
328
350
|
package_pattern: z.string().optional(),
|
|
329
351
|
})
|
|
330
352
|
.default({}),
|
|
331
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* Path to the hook script, relative to the module directory. A TypeScript
|
|
355
|
+
* `defineHook` module; the hook receives the verified PublishEvent as its
|
|
356
|
+
* `event` input. A shell script is not a hook language.
|
|
357
|
+
*/
|
|
332
358
|
script: z.string().min(1),
|
|
333
|
-
/**
|
|
359
|
+
/** Milliseconds before the hook is killed. Default: 600000. */
|
|
334
360
|
timeout: z.number().positive().optional(),
|
|
335
361
|
});
|
|
336
362
|
|
|
@@ -368,19 +394,23 @@ const LIFECYCLE_HOOK_SCHEMAS = {
|
|
|
368
394
|
list_peers: LifecycleHookSchema.optional(),
|
|
369
395
|
reconcile_peers: LifecycleHookSchema.optional(),
|
|
370
396
|
sweep_revisions: LifecycleHookSchema.optional(),
|
|
397
|
+
/**
|
|
398
|
+
* The one HookName whose schema entry is an ARRAY (see
|
|
399
|
+
* UpstreamPublishHookSchema): multiple match-rule entries per manifest,
|
|
400
|
+
* dispatched per event by the build-bus receiver daemon through the
|
|
401
|
+
* ordinary executor. Deliberately not a LifecycleHookSchema.
|
|
402
|
+
*/
|
|
403
|
+
on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
|
|
371
404
|
} satisfies Record<HookName, z.ZodTypeAny>;
|
|
372
405
|
|
|
373
406
|
/**
|
|
374
|
-
* What `manifest.hooks` accepts: every lifecycle hook,
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* `LIFECYCLE_HOOK_SCHEMAS` so the `satisfies` above stays exact.
|
|
379
|
-
* See [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
|
|
407
|
+
* What `manifest.hooks` accepts: every lifecycle hook, each derived from the
|
|
408
|
+
* one list (celilo#821). `on_upstream_publish` lives in
|
|
409
|
+
* `LIFECYCLE_HOOK_SCHEMAS` with the rest now that it is a HookName; its array
|
|
410
|
+
* entry is commented there.
|
|
380
411
|
*/
|
|
381
412
|
const HOOK_SCHEMAS = {
|
|
382
413
|
...LIFECYCLE_HOOK_SCHEMAS,
|
|
383
|
-
on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
|
|
384
414
|
};
|
|
385
415
|
|
|
386
416
|
/**
|
|
@@ -794,6 +824,25 @@ export const ModuleManifestSchema = z
|
|
|
794
824
|
.strict()
|
|
795
825
|
.optional(),
|
|
796
826
|
|
|
827
|
+
/**
|
|
828
|
+
* A recorded, first-class decision that this module needs no health
|
|
829
|
+
* check (openspec/changes/health-waiver-mechanism, D1-D4).
|
|
830
|
+
*
|
|
831
|
+
* A waiver is NOT `skip`. `skip` says a check could not run this
|
|
832
|
+
* time. A waiver says a human decided this module needs no check,
|
|
833
|
+
* and records WHO and WHY. It annotates the module's `unmeasured`
|
|
834
|
+
* audit finding; the severity stays `unmeasured` and the fleet
|
|
835
|
+
* verdict stays UNKNOWN. It never re-greens the board.
|
|
836
|
+
*
|
|
837
|
+
* It lives here rather than in operator state because it must
|
|
838
|
+
* survive reinstall, reprovision and registry publish, and because
|
|
839
|
+
* `git diff` should show the decision changing. There is no expiry
|
|
840
|
+
* by design: a lapsed waiver would silently re-green, which is the
|
|
841
|
+
* failure being guarded. Revisiting the decision is a new manifest
|
|
842
|
+
* edit, not a timer.
|
|
843
|
+
*/
|
|
844
|
+
health_waiver: HealthWaiverSchema.optional(),
|
|
845
|
+
|
|
797
846
|
build: z
|
|
798
847
|
.object({
|
|
799
848
|
/** Inline shell command to build the module (run via bash -c). Mutually exclusive with script. */
|
|
@@ -1003,6 +1052,7 @@ export type VariableType = z.infer<typeof VariableTypeSchema>;
|
|
|
1003
1052
|
export type AnsibleCollection = z.infer<typeof AnsibleCollectionSchema>;
|
|
1004
1053
|
export type SystemResource = z.infer<typeof SystemResourceSchema>;
|
|
1005
1054
|
export type SystemDeclaration = z.infer<typeof SystemDeclarationSchema>;
|
|
1055
|
+
export type HealthWaiver = z.infer<typeof HealthWaiverSchema>;
|
|
1006
1056
|
export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>;
|
|
1007
1057
|
export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
|
|
1008
1058
|
|
|
@@ -1673,3 +1673,50 @@ requires:
|
|
|
1673
1673
|
});
|
|
1674
1674
|
});
|
|
1675
1675
|
});
|
|
1676
|
+
|
|
1677
|
+
describe('health_waiver (openspec/changes/health-waiver-mechanism)', () => {
|
|
1678
|
+
const base = `\n${CONTRACT_LINE}\nid: namecheap\nname: Namecheap\nversion: 1.0.0\n`;
|
|
1679
|
+
|
|
1680
|
+
test('accepts a well-formed waiver and keeps all three fields', () => {
|
|
1681
|
+
const result = validateManifest(
|
|
1682
|
+
`${base}\nhealth_waiver:\n reason: "API-only: a real check is hard and may not be worth forcing"\n by: peba\n at: "2026-09-09"\n`,
|
|
1683
|
+
);
|
|
1684
|
+
expect(result.success).toBe(true);
|
|
1685
|
+
if (result.success) {
|
|
1686
|
+
expect(result.data.health_waiver).toEqual({
|
|
1687
|
+
reason: 'API-only: a real check is hard and may not be worth forcing',
|
|
1688
|
+
by: 'peba',
|
|
1689
|
+
at: '2026-09-09',
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
test('rejects an unknown field inside the waiver (strict)', () => {
|
|
1695
|
+
const result = validateManifest(
|
|
1696
|
+
`${base}\nhealth_waiver:\n reason: r\n by: peba\n at: "2026-09-09"\n expires: "2027-01-01"\n`,
|
|
1697
|
+
);
|
|
1698
|
+
expect(result.success).toBe(false);
|
|
1699
|
+
if (!result.success) {
|
|
1700
|
+
expect(result.errors.some((e) => e.message.toLowerCase().includes('unrecognized'))).toBe(
|
|
1701
|
+
true,
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test('rejects a waiver with no reason', () => {
|
|
1707
|
+
const result = validateManifest(`${base}\nhealth_waiver:\n by: peba\n at: "2026-09-09"\n`);
|
|
1708
|
+
expect(result.success).toBe(false);
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
test('rejects a waiver with no by', () => {
|
|
1712
|
+
const result = validateManifest(`${base}\nhealth_waiver:\n reason: r\n at: "2026-09-09"\n`);
|
|
1713
|
+
expect(result.success).toBe(false);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
test('rejects a waiver whose at is not a date', () => {
|
|
1717
|
+
const result = validateManifest(
|
|
1718
|
+
`${base}\nhealth_waiver:\n reason: r\n by: peba\n at: not-a-date\n`,
|
|
1719
|
+
);
|
|
1720
|
+
expect(result.success).toBe(false);
|
|
1721
|
+
});
|
|
1722
|
+
});
|
|
@@ -28,77 +28,125 @@
|
|
|
28
28
|
export interface CapabilityShape {
|
|
29
29
|
/** The contract version this hash was recorded at. */
|
|
30
30
|
readonly version: string;
|
|
31
|
-
/** sha256 of the printed interface
|
|
31
|
+
/** sha256 of the printed interface, the table declaration, and the request types. */
|
|
32
32
|
readonly hash: string;
|
|
33
|
+
/**
|
|
34
|
+
* Canonical request/result type member text, so the regenerate script can
|
|
35
|
+
* classify a request-contract change as breaking (removed member, optional
|
|
36
|
+
* → required, type change → MAJOR) or additive (optional addition → minor)
|
|
37
|
+
* before it writes. Without this, a regenerate could absorb a request break
|
|
38
|
+
* into a patch, which is exactly how celilo#1361 shipped.
|
|
39
|
+
*/
|
|
40
|
+
readonly requests: string;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>> = {
|
|
36
44
|
public_web: {
|
|
37
|
-
version: '
|
|
38
|
-
hash: '
|
|
45
|
+
version: '4.0.0',
|
|
46
|
+
hash: 'fa79b68744eea9506db431b9eb51bb0472736864ddeea7524af9945f4bf4f1b0',
|
|
47
|
+
requests:
|
|
48
|
+
'[{kind:"alias",members:[{name:"_self",optional:false,type:"Record<string, string | number | boolean>"}],name:"ClientConfig"},{kind:"interface",members:[{name:"defaultHostname",optional:false,type:"string"},{name:"getServerIp",optional:false,type:"Promise<string>"},{name:"publishStaticSite",optional:false,type:"Promise<PublishStaticSiteResult>"},{name:"register_route",optional:false,type:"Promise<RegisterRouteResult>"},{name:"registerReverseProxy",optional:false,type:"Promise<RegisterReverseProxyResult>"},{name:"upload_static_assets",optional:false,type:"Promise<UploadStaticAssetsResult>"}],name:"PublicWebCapability"},{kind:"interface",members:[{name:"clientConfig",optional:true,type:"ClientConfig"},{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishStaticSiteRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishStaticSiteResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string"},{name:"targetPort",optional:false,type:"number"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterReverseProxyRequest"},{kind:"interface",members:[{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterReverseProxyResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:true,type:"string"},{name:"targetPort",optional:true,type:"number"},{name:"type",optional:false,type:"\'static\' | \'reverse_proxy\'"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterRouteRequest"},{kind:"interface",members:[{name:"caddyPath",optional:true,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterRouteResult"},{kind:"interface",members:[{name:"path",optional:false,type:"string"}],name:"UploadStaticAssetsRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"success",optional:false,type:"boolean"}],name:"UploadStaticAssetsResult"}]',
|
|
39
49
|
},
|
|
40
50
|
idp: {
|
|
41
51
|
version: '1.2.0',
|
|
42
|
-
hash: '
|
|
52
|
+
hash: 'dcb5758d18f18abb8f705c37b894dcdc49c93c34b939d74c4cb32b2818b009c9',
|
|
53
|
+
requests:
|
|
54
|
+
'[{kind:"interface",members:[{name:"client_name",optional:false,type:"string"},{name:"client_type",optional:true,type:"\'public\' | \'confidential\'"},{name:"groups",optional:true,type:"string[]"},{name:"redirect_uris",optional:false,type:"string[]"},{name:"scopes",optional:true,type:"string[]"}],name:"CreateOidcClientRequest"},{kind:"interface",members:[{name:"application_slug",optional:false,type:"string"},{name:"client_id",optional:false,type:"string"},{name:"client_secret",optional:false,type:"string"},{name:"provider_id",optional:false,type:"number"}],name:"CreateOidcClientResult"},{kind:"interface",members:[{name:"description",optional:true,type:"string"},{name:"identifier",optional:false,type:"string"},{name:"username",optional:false,type:"string"}],name:"CreateTokenRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"},{name:"token",optional:false,type:"string"}],name:"CreateTokenResult"},{kind:"interface",members:[{name:"email",optional:false,type:"string"},{name:"groups",optional:true,type:"string[]"},{name:"password",optional:false,type:"string"},{name:"username",optional:false,type:"string"}],name:"CreateUserRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"},{name:"user_id",optional:false,type:"number"}],name:"CreateUserResult"},{kind:"interface",members:[{name:"create_oidc_client",optional:false,type:"Promise<CreateOidcClientResult>"},{name:"create_token",optional:false,type:"Promise<CreateTokenResult>"},{name:"create_user",optional:false,type:"Promise<CreateUserResult>"},{name:"list_tokens",optional:false,type:"Promise<TokenInfo[]>"},{name:"revoke_oidc_client",optional:false,type:"Promise<void>"},{name:"revoke_token",optional:false,type:"Promise<RevokeTokenResult>"}],name:"IdpCapability"},{kind:"interface",members:[{name:"username",optional:false,type:"string"}],name:"ListTokensRequest"},{kind:"interface",members:[{name:"client_id",optional:false,type:"string"}],name:"RevokeOidcClientRequest"},{kind:"interface",members:[{name:"identifier",optional:false,type:"string"}],name:"RevokeTokenRequest"},{kind:"interface",members:[{name:"revoked",optional:false,type:"boolean"}],name:"RevokeTokenResult"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"expires",optional:false,type:"string | null"},{name:"expiring",optional:false,type:"boolean"},{name:"identifier",optional:false,type:"string"}],name:"TokenInfo"}]',
|
|
43
55
|
},
|
|
44
56
|
dns_registrar: {
|
|
45
57
|
version: '4.0.0',
|
|
46
|
-
hash: '
|
|
58
|
+
hash: '5108a4dc2df0b4c78862c922df596156a58f04c64d81a65947724720e732b50c',
|
|
59
|
+
requests:
|
|
60
|
+
'[{kind:"interface",members:[{name:"registerHost",optional:false,type:"Promise<HookResult>"}],name:"DnsRegistrarCapability"},{kind:"interface",members:[{name:"artifactPaths",optional:true,type:"string[]"},{name:"duration",optional:false,type:"number"},{name:"error",optional:true,type:"string"},{name:"outputs",optional:false,type:"Record<string, unknown>"},{name:"success",optional:false,type:"boolean"}],name:"HookResult"},{kind:"interface",members:[{name:"fqdn",optional:false,type:"string"}],name:"RegisterHostRequest"}]',
|
|
47
61
|
},
|
|
48
62
|
firewall: {
|
|
49
63
|
version: '1.1.0',
|
|
50
|
-
hash: '
|
|
64
|
+
hash: 'f8c1c43b7d53193ae5b1e7cb7420a91efdb5e02f5bcd9add094aad66c0ec4104',
|
|
65
|
+
requests:
|
|
66
|
+
'[{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"externalPort",optional:false,type:"number"},{name:"internalIp",optional:false,type:"string"},{name:"internalPort",optional:false,type:"number"},{name:"protocol",optional:false,type:"string"}],name:"ExposedService"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"ingressIp",optional:true,type:"string"},{name:"internalIp",optional:false,type:"string"},{name:"ports",optional:false,type:"PortOrRange[]"},{name:"protocol",optional:true,type:"Protocol"}],name:"ExposeServiceRequest"},{kind:"interface",members:[{name:"externalIp",optional:false,type:"string"},{name:"natIp",optional:false,type:"string"}],name:"ExposeServiceResult"},{kind:"interface",members:[{name:"exposeService",optional:false,type:"Promise<ExposeServiceResult>"},{name:"listExposedServices",optional:false,type:"Promise<ExposedService[]>"},{name:"listTrustedSources",optional:true,type:"Promise<TrustedSource[]>"},{name:"providerModuleId",optional:true,type:"string"},{name:"registerTrustedSource",optional:true,type:"Promise<void>"}],name:"FirewallCapability"},{kind:"alias",members:[{name:"_self",optional:false,type:"number | {\\n start: number;\\n end: number;\\n}"}],name:"PortOrRange"},{kind:"alias",members:[{name:"_self",optional:false,type:"\'TCP\' | \'UDP\'"}],name:"Protocol"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"subnets",optional:false,type:"string[]"}],name:"RegisterTrustedSourceRequest"},{kind:"interface",members:[{name:"description",optional:false,type:"string"},{name:"registeredBy",optional:false,type:"string"},{name:"subnet",optional:false,type:"string"}],name:"TrustedSource"}]',
|
|
51
67
|
},
|
|
52
68
|
source_forge: {
|
|
53
69
|
version: '1.0.0',
|
|
54
|
-
hash: '
|
|
70
|
+
hash: 'cb005fe21e69f0984b03b862b2739ef07f868d363fd3a925feadf8e865620916',
|
|
71
|
+
requests:
|
|
72
|
+
'[{kind:"interface",members:[{name:"owner",optional:true,type:"string"},{name:"repo",optional:true,type:"string"},{name:"runnerName",optional:false,type:"string"},{name:"scope",optional:true,type:"SourceForgeScope"}],name:"IssueRunnerTokenRequest"},{kind:"interface",members:[{name:"token",optional:false,type:"string"}],name:"IssueRunnerTokenResult"},{kind:"interface",members:[{name:"defaultBranch",optional:true,type:"string"},{name:"publishToken",optional:false,type:"string"},{name:"repo",optional:false,type:"string"},{name:"requiredChecks",optional:true,type:"string[]"}],name:"RegisterRepoRequest"},{kind:"interface",members:[{name:"protectedBranch",optional:false,type:"string"},{name:"repo",optional:false,type:"string"}],name:"RegisterRepoResult"},{kind:"interface",members:[{name:"issue_runner_token",optional:false,type:"Promise<IssueRunnerTokenResult>"},{name:"registerRepo",optional:false,type:"Promise<RegisterRepoResult>"}],name:"SourceForgeCapability"},{kind:"alias",members:[{name:"_self",optional:false,type:"\'instance\' | \'org\' | \'user\' | \'repo\'"}],name:"SourceForgeScope"}]',
|
|
55
73
|
},
|
|
56
74
|
registry_publish: {
|
|
57
75
|
version: '1.0.0',
|
|
58
|
-
hash: '
|
|
76
|
+
hash: 'fd8a9fd097ab03fc6e4989099a8bf8ad011d2f0e641ecb9d7c8152504de3ce7a',
|
|
77
|
+
requests:
|
|
78
|
+
'[{kind:"interface",members:[{name:"repo",optional:false,type:"string"},{name:"scope",optional:false,type:"string"}],name:"MintScopedTokenRequest"},{kind:"interface",members:[{name:"token",optional:false,type:"string"}],name:"MintScopedTokenResult"},{kind:"interface",members:[{name:"mintScopedToken",optional:false,type:"Promise<MintScopedTokenResult>"},{name:"revokeRepo",optional:false,type:"Promise<void>"}],name:"RegistryPublishCapability"},{kind:"interface",members:[{name:"repo",optional:false,type:"string"}],name:"RevokeRepoRequest"}]',
|
|
59
79
|
},
|
|
60
80
|
dhcp_server: {
|
|
61
81
|
version: '1.0.0',
|
|
62
|
-
hash: '
|
|
82
|
+
hash: 'fc5751082942224c9343cb0a1455669b7a32d5442a8ae912efe88bd82b7648ae',
|
|
83
|
+
requests:
|
|
84
|
+
'[{kind:"interface",members:[{name:"getDnsServers",optional:false,type:"Promise<string[]>"},{name:"getDomainName",optional:false,type:"Promise<string>"},{name:"setDnsServers",optional:false,type:"Promise<void>"},{name:"setDomainName",optional:false,type:"Promise<void>"}],name:"DhcpServerCapability"}]',
|
|
63
85
|
},
|
|
64
86
|
dns_internal: {
|
|
65
87
|
version: '1.1.0',
|
|
66
|
-
hash: '
|
|
88
|
+
hash: 'd15a5ead445269d29ca46460268c1746892ef7d613688ac809b8a3d3395c675c',
|
|
89
|
+
requests:
|
|
90
|
+
'[{kind:"interface",members:[{name:"deleteRecord",optional:false,type:"Promise<void>"},{name:"reconcileViews",optional:true,type:"Promise<void>"},{name:"registerRecord",optional:false,type:"Promise<void>"},{name:"registerTransferPeer",optional:true,type:"Promise<void>"}],name:"DnsInternalCapability"},{kind:"interface",members:[{name:"host",optional:false,type:"string"},{name:"type",optional:false,type:"string"},{name:"value",optional:false,type:"string"},{name:"zone",optional:true,type:"string"},{name:"zoneRoutableValue",optional:true,type:"string"}],name:"DnsRecordRequest"},{kind:"interface",members:[{name:"moduleId",optional:false,type:"string"},{name:"serverIp",optional:false,type:"string"}],name:"TransferPeer"},{kind:"interface",members:[{name:"host",optional:false,type:"string"},{name:"ip",optional:false,type:"string"}],name:"ViewOverride"}]',
|
|
67
91
|
},
|
|
68
92
|
external_web: {
|
|
69
|
-
version: '
|
|
70
|
-
hash: '
|
|
93
|
+
version: '2.0.0',
|
|
94
|
+
hash: 'a906dc2a6645eb5da2dfa05d832a42e20f5defa8c1af300a67d6c8ef853b8ea4',
|
|
95
|
+
requests:
|
|
96
|
+
'[{kind:"interface",members:[{name:"publishStaticSite",optional:false,type:"Promise<PublishExternalSiteResult>"}],name:"ExternalWebCapability"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishExternalSiteRequest"},{kind:"interface",members:[{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishExternalSiteResult"}]',
|
|
71
97
|
},
|
|
72
98
|
notification: {
|
|
73
99
|
version: '1.0.0',
|
|
74
|
-
hash: '
|
|
100
|
+
hash: '9d98b38d007b6d190fff6d243b38e871aeb707da2e48e3c111c3cfcc70ab75da',
|
|
101
|
+
requests:
|
|
102
|
+
'[{kind:"interface",members:[{name:"body",optional:false,type:"string"},{name:"messageId",optional:false,type:"string"},{name:"receivedAt",optional:false,type:"number"},{name:"senderAddress",optional:false,type:"string"}],name:"InboundMessage"},{kind:"interface",members:[{name:"receive",optional:true,type:"Promise<ReceiveResult>"},{name:"send",optional:false,type:"Promise<SendNotificationResult>"}],name:"NotificationCapability"},{kind:"interface",members:[{name:"cursor",optional:false,type:"string | null"},{name:"messages",optional:false,type:"InboundMessage[]"}],name:"ReceiveResult"},{kind:"interface",members:[{name:"address",optional:false,type:"string"},{name:"body",optional:false,type:"string"},{name:"token",optional:true,type:"string"}],name:"SendNotificationRequest"},{kind:"interface",members:[{name:"messageId",optional:false,type:"string"}],name:"SendNotificationResult"}]',
|
|
75
103
|
},
|
|
76
104
|
control_plane_vpn: {
|
|
77
105
|
version: '1.0.0',
|
|
78
|
-
hash: '
|
|
106
|
+
hash: '742a739054bb351cda8586c5079ccb5877c22227afc57643c59152a746234e7b',
|
|
107
|
+
requests:
|
|
108
|
+
'[{kind:"interface",members:[{name:"getEndpoint",optional:false,type:"Promise<ControlPlaneVpnEndpoint>"},{name:"listClients",optional:false,type:"Promise<RegisteredClient[]>"},{name:"registerClient",optional:false,type:"Promise<RegisterClientResult>"},{name:"revokeClient",optional:false,type:"Promise<RevokeClientResult>"}],name:"ControlPlaneVpnCapability"},{kind:"interface",members:[{name:"clientPool",optional:false,type:"string"},{name:"endpoint",optional:false,type:"string"},{name:"serverPublicKey",optional:false,type:"string"}],name:"ControlPlaneVpnEndpoint"},{kind:"interface",members:[{name:"address",optional:false,type:"string"},{name:"label",optional:false,type:"string"},{name:"publicKey",optional:false,type:"string"}],name:"RegisterClientRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"}],name:"RegisterClientResult"},{kind:"interface",members:[{name:"address",optional:false,type:"string"},{name:"label",optional:false,type:"string"},{name:"publicKey",optional:false,type:"string"},{name:"registeredAt",optional:false,type:"string"}],name:"RegisteredClient"},{kind:"interface",members:[{name:"publicKey",optional:false,type:"string"}],name:"RevokeClientRequest"},{kind:"interface",members:[{name:"revoked",optional:false,type:"boolean"}],name:"RevokeClientResult"}]',
|
|
79
109
|
},
|
|
80
110
|
control_plane_api: {
|
|
81
111
|
version: '1.0.0',
|
|
82
|
-
hash: '
|
|
112
|
+
hash: '189860acaa746326bdb6e9cd3a5c6af6bbb2f94ce37d84cc7faab01c69f7fb30',
|
|
113
|
+
requests:
|
|
114
|
+
'[{kind:"interface",members:[{name:"enrol_principal",optional:false,type:"Promise<EnrolPrincipalResult>"},{name:"revoke_principal",optional:false,type:"Promise<RevokePrincipalResult>"}],name:"ControlPlaneApiCapability"},{kind:"interface",members:[{name:"name",optional:false,type:"string"},{name:"publicKey",optional:false,type:"string"}],name:"EnrolPrincipalRequest"},{kind:"interface",members:[{name:"created",optional:false,type:"boolean"},{name:"grants",optional:false,type:"string[]"},{name:"name",optional:false,type:"string"}],name:"EnrolPrincipalResult"},{kind:"interface",members:[{name:"name",optional:false,type:"string"}],name:"RevokePrincipalRequest"},{kind:"interface",members:[{name:"revoked",optional:false,type:"boolean"}],name:"RevokePrincipalResult"}]',
|
|
83
115
|
},
|
|
84
116
|
private_web: {
|
|
85
|
-
version: '
|
|
86
|
-
hash: '
|
|
117
|
+
version: '2.0.0',
|
|
118
|
+
hash: 'fb301f5e55ca14fdb0ede0d428d2ac72eabed77e277de8c795eaa0b955695483',
|
|
119
|
+
requests:
|
|
120
|
+
'[{kind:"alias",members:[{name:"_self",optional:false,type:"Record<string, string | number | boolean>"}],name:"ClientConfig"},{kind:"interface",members:[{name:"defaultHostname",optional:false,type:"string"},{name:"getCaCertificate",optional:false,type:"Promise<string>"},{name:"publishStaticSite",optional:false,type:"Promise<PublishStaticSiteResult>"},{name:"registerReverseProxy",optional:false,type:"Promise<RegisterReverseProxyResult>"}],name:"PrivateWebCapability"},{kind:"interface",members:[{name:"clientConfig",optional:true,type:"ClientConfig"},{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishStaticSiteRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishStaticSiteResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string"},{name:"targetPort",optional:false,type:"number"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterReverseProxyRequest"},{kind:"interface",members:[{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterReverseProxyResult"}]',
|
|
87
121
|
},
|
|
88
122
|
cross_module_read: {
|
|
89
123
|
version: '1.0.0',
|
|
90
|
-
hash: '
|
|
124
|
+
hash: 'a8af728f3f3ec7bff57934fe6b4313eb58a32afa606f6a8d2b9923a9e492dd58',
|
|
125
|
+
requests:
|
|
126
|
+
'[{kind:"interface",members:[{name:"granted",optional:false,type:"true"}],name:"CrossModuleReadCapability"}]',
|
|
91
127
|
},
|
|
92
128
|
celilo_module_deploy_worker: {
|
|
93
129
|
version: '1.0.0',
|
|
94
|
-
hash: '
|
|
130
|
+
hash: '51fe35fc321520b37532478490a956c34b97ed3592ee59b3a0f8aed4b8b96672',
|
|
131
|
+
requests:
|
|
132
|
+
'[{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n accepted: true;\\n moduleId: string | null;\\n} | {\\n accepted: false;\\n reason: string;\\n}"}],name:"DestroyInstanceOutcome"},{kind:"interface",members:[{name:"instanceKey",optional:false,type:"string"},{name:"submodule",optional:false,type:"string"}],name:"DestroyInstanceRequest"},{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n accepted: true;\\n moduleId: string;\\n} | {\\n accepted: false;\\n reason: string;\\n}"}],name:"InstanceRequestOutcome"},{kind:"alias",members:[{name:"_self",optional:false,type:"(typeof INSTANCE_STATES)[number]"}],name:"InstanceState"},{kind:"interface",members:[{name:"answers",optional:true,type:"Record<string, unknown>"},{name:"config",optional:true,type:"Record<string, unknown>"},{name:"instanceKey",optional:false,type:"string"},{name:"label",optional:true,type:"string"},{name:"submodule",optional:false,type:"string"}],name:"InstantiateRequest"},{kind:"interface",members:[{name:"states",optional:true,type:"readonly InstanceState[]"},{name:"submodule",optional:true,type:"string"}],name:"ListInstancesRequest"},{kind:"interface",members:[{name:"destroy",optional:false,type:"Promise<DestroyInstanceOutcome>"},{name:"instantiate",optional:false,type:"Promise<InstanceRequestOutcome>"},{name:"list",optional:false,type:"Promise<ModuleInstance[]>"}],name:"ModuleDeployWorkerCapability"},{kind:"interface",members:[{name:"failureReason",optional:false,type:"string | null"},{name:"instanceKey",optional:false,type:"string"},{name:"label",optional:false,type:"string | null"},{name:"moduleId",optional:false,type:"string"},{name:"retryable",optional:false,type:"boolean | null"},{name:"state",optional:false,type:"InstanceState"},{name:"submodule",optional:false,type:"string"}],name:"ModuleInstance"}]',
|
|
95
133
|
},
|
|
96
134
|
web_routes: {
|
|
97
135
|
version: '1.0.0',
|
|
98
|
-
hash: '
|
|
136
|
+
hash: '5ae5e43d2948a37dfc23a5aef490f4ef459c8b4ef09719c42961eea73b8da5ff',
|
|
137
|
+
requests:
|
|
138
|
+
'[{kind:"interface",members:[{name:"getAllRoutes",optional:false,type:"Promise<WebRoute[]>"},{name:"getRoutes",optional:false,type:"Promise<WebRoute[]>"}],name:"RouteReadView"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string | null"},{name:"createdAt",optional:false,type:"Date"},{name:"hostname",optional:false,type:"string"},{name:"id",optional:false,type:"number"},{name:"moduleId",optional:false,type:"string"},{name:"path",optional:false,type:"string"},{name:"slug",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string | null"},{name:"targetPort",optional:false,type:"number | null"},{name:"type",optional:false,type:"\'static\' | \'reverse_proxy\'"},{name:"updatedAt",optional:false,type:"Date"},{name:"websocket",optional:false,type:"boolean"}],name:"WebRoute"}]',
|
|
99
139
|
},
|
|
100
140
|
firewall_registry: {
|
|
101
141
|
version: '2.0.0',
|
|
102
|
-
hash: '
|
|
142
|
+
hash: 'd7a587e961500f1bc464c4a3427dd2ec85c7f80c1ed11a9c64beeb0decea524c',
|
|
143
|
+
requests:
|
|
144
|
+
'[{kind:"interface",members:[{name:"converge",optional:false,type:"Promise<void>"},{name:"withdrawConsumer",optional:false,type:"Promise<void>"}],name:"FirewallProviderView"}]',
|
|
145
|
+
},
|
|
146
|
+
provider_converge: {
|
|
147
|
+
version: '1.0.0',
|
|
148
|
+
hash: '1d828d70433ce4a15a73072883abad0c2acfef92c059bf40432b792722eb7d97',
|
|
149
|
+
requests:
|
|
150
|
+
'[{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n sourceDir: string;\\n overlayDir?: string;\\n} | {\\n unavailable: string;\\n}"}],name:"ConsumerSiteResolution"},{kind:"interface",members:[{name:"content",optional:false,type:"string"},{name:"path",optional:false,type:"string"}],name:"ProviderConfigFile"},{kind:"interface",members:[{name:"converge",optional:false,type:"Promise<{\\n success: boolean;\\n error?: string;\\n unresolved: string[];\\n}>"},{name:"resolveConsumerSite",optional:false,type:"Promise<ConsumerSiteResolution>"}],name:"ProviderConvergeView"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"hostnames",optional:false,type:"string[]"},{name:"moduleId",optional:false,type:"string"},{name:"overlayDir",optional:true,type:"string"},{name:"slug",optional:false,type:"string"},{name:"sourceDir",optional:false,type:"string"}],name:"ProviderSiteRequest"}]',
|
|
103
151
|
},
|
|
104
152
|
};
|
|
@@ -27,7 +27,15 @@ import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
|
|
|
27
27
|
import { is } from 'drizzle-orm';
|
|
28
28
|
import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
|
|
29
29
|
import * as dbSchema from '../db/schema';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type RequestTypeShape,
|
|
32
|
+
capabilitySubjects,
|
|
33
|
+
classifyRequestChange,
|
|
34
|
+
requestTypes,
|
|
35
|
+
requestsKey,
|
|
36
|
+
shapeHash,
|
|
37
|
+
tablesOf,
|
|
38
|
+
} from './capability-shape';
|
|
31
39
|
import { CAPABILITY_SHAPE_BASELINE } from './capability-shape-baseline';
|
|
32
40
|
|
|
33
41
|
/** Physical table name → the drizzle table the running code declares. */
|
|
@@ -160,3 +168,47 @@ describe('Scan B — a shape change carries a version bump', () => {
|
|
|
160
168
|
expect(drifted).toEqual([]);
|
|
161
169
|
});
|
|
162
170
|
});
|
|
171
|
+
|
|
172
|
+
describe('Scan C — a request-contract change carries a MAJOR bump', () => {
|
|
173
|
+
const majorOf = (version: string): number =>
|
|
174
|
+
Number.parseInt(version.replace(/^v/, '').split('-')[0].split('.')[0], 10) || 0;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The regenerate script refuses to absorb a breaking request diff (removed
|
|
178
|
+
* member, optional → required, type change) without a major bump. This test
|
|
179
|
+
* is the same predicate against the COMMITTED baseline, so hand-editing the
|
|
180
|
+
* fixture does not route around the script — and it is what makes the rule
|
|
181
|
+
* a gate rather than a convention (celilo#1361, where `sourceDir` left three
|
|
182
|
+
* request types with no version move at all).
|
|
183
|
+
*/
|
|
184
|
+
test('a request-shape change against the baseline means the major moved', () => {
|
|
185
|
+
const versions = CAPABILITY_CONTRACT_VERSIONS as Record<string, string>;
|
|
186
|
+
const violations: string[] = [];
|
|
187
|
+
for (const capability of capabilitySubjects().keys()) {
|
|
188
|
+
const recorded = CAPABILITY_SHAPE_BASELINE[capability];
|
|
189
|
+
if (!recorded) continue;
|
|
190
|
+
if (recorded.requests === undefined) {
|
|
191
|
+
violations.push(
|
|
192
|
+
`${capability}: baseline has no requests field. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`,
|
|
193
|
+
);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const current = requestsKey(capability);
|
|
197
|
+
if (current === recorded.requests) continue;
|
|
198
|
+
const change = classifyRequestChange(
|
|
199
|
+
JSON.parse(recorded.requests) as RequestTypeShape[],
|
|
200
|
+
requestTypes(capability),
|
|
201
|
+
);
|
|
202
|
+
if (
|
|
203
|
+
change.kind === 'breaking' &&
|
|
204
|
+
majorOf(recorded.version) >= majorOf(versions[capability])
|
|
205
|
+
) {
|
|
206
|
+
violations.push(
|
|
207
|
+
`${capability}: request contract changed without a MAJOR bump (${change.reasons.join('; ')}). ` +
|
|
208
|
+
`Bump CAPABILITY_CONTRACT_VERSIONS.${capability} and regenerate the baseline.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
expect(violations).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
});
|