@celilo/cli 4.1.0 → 5.0.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.
- package/AGENTS.md +2 -2
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/MODULE_PRIMITIVES.md +9 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- package/src/cli/commands/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- package/src/cli/index.ts +22 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/capability-loader.ts +179 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/mount-set.test.ts +28 -0
- package/src/hooks/mount-set.ts +33 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +27 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/module/packaging/build.ts +16 -2
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/registry/client.test.ts +5 -4
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- package/src/services/audit/module-configs.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +80 -6
- package/src/services/celilo-events.ts +115 -12
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-validation.test.ts +6 -6
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +1 -1
- package/src/variables/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
NETWORK_ZONES,
|
|
37
37
|
capabilities,
|
|
38
38
|
modules,
|
|
39
|
+
privateRoutes,
|
|
39
40
|
secrets,
|
|
40
41
|
systemConfig,
|
|
41
42
|
webRoutes,
|
|
@@ -45,7 +46,10 @@ import { decryptSecret } from '../secrets/encryption';
|
|
|
45
46
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
46
47
|
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
47
48
|
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
48
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
emitPrivateRoutesChangedAndWait,
|
|
51
|
+
emitWebRoutesChangedAndWait,
|
|
52
|
+
} from '../services/celilo-events';
|
|
49
53
|
import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from '../services/deployed-systems';
|
|
50
54
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
51
55
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
@@ -95,12 +99,33 @@ import { loadHookConfigMap } from './load-hook-config';
|
|
|
95
99
|
* success anyway, so a consumer could report "ready" while caddy never learned
|
|
96
100
|
* the hostname — no site block, no cert, TLS internal_error for clients.
|
|
97
101
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Which ingress a reconcile is being required for. The two differ only in the
|
|
104
|
+
* event type they emit and the names their failures carry, so they share this
|
|
105
|
+
* function rather than each keeping a copy — a private ingress that silently
|
|
106
|
+
* stopped waiting would look exactly like one with nothing to wait for
|
|
107
|
+
* (providers-converge-declared-state, slice 2).
|
|
108
|
+
*/
|
|
109
|
+
const INGRESS = {
|
|
110
|
+
public_web: { capability: 'public_web', provider: 'caddy', emit: emitWebRoutesChangedAndWait },
|
|
111
|
+
private_web: {
|
|
112
|
+
capability: 'private_web',
|
|
113
|
+
provider: 'caddy-internal',
|
|
114
|
+
emit: emitPrivateRoutesChangedAndWait,
|
|
115
|
+
},
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
async function requireProviderReconcile(
|
|
119
|
+
consumingModuleId: string,
|
|
120
|
+
what: string,
|
|
121
|
+
ingress: keyof typeof INGRESS = 'public_web',
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const { capability, provider, emit } = INGRESS[ingress];
|
|
124
|
+
const reconcile = await emit(consumingModuleId);
|
|
100
125
|
|
|
101
126
|
if (reconcile.noDispatcher) {
|
|
102
127
|
throw new Error(
|
|
103
|
-
|
|
128
|
+
`${capability} ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (${provider}): no event dispatcher is running, so ${provider} never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
|
|
104
129
|
);
|
|
105
130
|
}
|
|
106
131
|
// The handler's own words, when it left any. A crash is retried, so its
|
|
@@ -110,13 +135,20 @@ async function requireProviderReconcile(consumingModuleId: string, what: string)
|
|
|
110
135
|
const because = reconcile.lastError ? ` The provider reported: ${reconcile.lastError}` : '';
|
|
111
136
|
|
|
112
137
|
if (reconcile.timedOut) {
|
|
138
|
+
// "0 ok, 0 failed" reads as "nobody consumed it", which sends the reader to
|
|
139
|
+
// the dispatcher (celilo#1398/#1399) when the truth is that the provider's
|
|
140
|
+
// deliveries were STILL RUNNING at the deadline (celilo#1416). Say which.
|
|
141
|
+
const inFlight =
|
|
142
|
+
reconcile.stillRunning > 0
|
|
143
|
+
? `${reconcile.stillRunning} delivery(ies) still running`
|
|
144
|
+
: `${reconcile.succeeded} ok, ${reconcile.failed} failed`;
|
|
113
145
|
throw new Error(
|
|
114
|
-
|
|
146
|
+
`${capability} reconcile for ${consumingModuleId} (${what}) did not finish within its ${Math.round(reconcile.budgetMs / 1000)}s budget (${inFlight} of ${reconcile.events} change event(s)) — ${provider} did not confirm the change is live.${because}`,
|
|
115
147
|
);
|
|
116
148
|
}
|
|
117
149
|
if (reconcile.failed > 0) {
|
|
118
150
|
throw new Error(
|
|
119
|
-
|
|
151
|
+
`${capability} reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — ${provider} could not apply the change, so the hostname is not served as declared.${because}`,
|
|
120
152
|
);
|
|
121
153
|
}
|
|
122
154
|
// ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
|
|
@@ -126,7 +158,7 @@ async function requireProviderReconcile(consumingModuleId: string, what: string)
|
|
|
126
158
|
// changed and nobody served it — a failure.
|
|
127
159
|
if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
128
160
|
throw new Error(
|
|
129
|
-
|
|
161
|
+
`${capability} ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — ${provider} has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a ${capability} provider (${provider}) is deployed and subscribed.`,
|
|
130
162
|
);
|
|
131
163
|
}
|
|
132
164
|
}
|
|
@@ -175,11 +207,13 @@ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFacto
|
|
|
175
207
|
legacyFactoryName: 'default',
|
|
176
208
|
},
|
|
177
209
|
// MODULE-provided, unlike its sibling public_web above, which the framework
|
|
178
|
-
// implements (createPublicWeb)
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
210
|
+
// implements (createPublicWeb). The two ingresses are separate TABLES, not
|
|
211
|
+
// separate owners: core owns `private_routes` as it owns `web_routes`
|
|
212
|
+
// (migration 0034), and that separation — not the provider keeping its own
|
|
213
|
+
// config — is what stops the PUBLIC caddy, which derives its served
|
|
214
|
+
// hostnames from every row of `web_routes`, from sweeping a private route
|
|
215
|
+
// into the public ingress (celilo#846). What stays module-side is the
|
|
216
|
+
// RENDER: caddy-internal decides what its Caddyfile says.
|
|
183
217
|
private_web: {
|
|
184
218
|
script: 'scripts/private-web-functions.ts',
|
|
185
219
|
legacyFactoryName: 'default',
|
|
@@ -499,6 +533,26 @@ export async function loadCapabilityFunctions(
|
|
|
499
533
|
// same seam. A jailed hook cannot write into its own web root, so a
|
|
500
534
|
// module-provided web provider needs the state overlay to honor it.
|
|
501
535
|
consumerStateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
|
|
536
|
+
// THE PRIVATE INGRESS'S DECLARED ROWS, for the provider that serves
|
|
537
|
+
// them. Scoped to private_web deliberately: these are one
|
|
538
|
+
// capability's declared state, and handing them to every
|
|
539
|
+
// module-provided capability would put the private route table in
|
|
540
|
+
// reach of modules that have no business reading it.
|
|
541
|
+
...(capName === 'private_web'
|
|
542
|
+
? {
|
|
543
|
+
privateRoutes: buildPrivateRouteOps(db),
|
|
544
|
+
// The same seam public_web has (see `onRoutesChanged` below):
|
|
545
|
+
// the capability persists the row and hands the converge to
|
|
546
|
+
// the provider's `reconcile_routes` subscription, then AWAITS
|
|
547
|
+
// that delivery so `registerReverseProxy` still returns only
|
|
548
|
+
// once the route is live (design D7). One primitive, two
|
|
549
|
+
// triggers — the subscription is the second, not a
|
|
550
|
+
// replacement for the call.
|
|
551
|
+
onRoutesChanged: async () => {
|
|
552
|
+
await requireProviderReconcile(consumingModuleId, 'route change', 'private_web');
|
|
553
|
+
},
|
|
554
|
+
}
|
|
555
|
+
: {}),
|
|
502
556
|
});
|
|
503
557
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
504
558
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -817,6 +871,51 @@ export async function loadCapabilityFunctions(
|
|
|
817
871
|
debugLog('public_web: not registered in DB, skipping');
|
|
818
872
|
}
|
|
819
873
|
|
|
874
|
+
// THE PRIVATE INGRESS, symmetric with the public one above (design D7 of
|
|
875
|
+
// providers-converge-declared-state). Its provider gets a view of the rows it
|
|
876
|
+
// serves and the converge that places what it renders — the same two things
|
|
877
|
+
// caddy gets, for the same reason, now that its routes are core-owned state
|
|
878
|
+
// rather than its own module-config JSON.
|
|
879
|
+
//
|
|
880
|
+
// Consumers never see either: only the module that PROVIDES private_web.
|
|
881
|
+
const privateWebProviders = db
|
|
882
|
+
.select()
|
|
883
|
+
.from(capabilities)
|
|
884
|
+
.where(eq(capabilities.capabilityName, 'private_web'))
|
|
885
|
+
.all();
|
|
886
|
+
const privateWebProvider = privateWebProviders[0];
|
|
887
|
+
if (privateWebProvider && consumingModuleId === privateWebProvider.moduleId) {
|
|
888
|
+
result.private_routes = buildPrivateRouteOps(db);
|
|
889
|
+
debugLog(`private_routes: injected for provider ${consumingModuleId}`);
|
|
890
|
+
|
|
891
|
+
const providerModuleId = privateWebProvider.moduleId;
|
|
892
|
+
const privateConverge: ProviderConvergeView = {
|
|
893
|
+
// The private ingress serves no consumer-built static sites today: every
|
|
894
|
+
// row it renders is a reverse proxy. Answering honestly rather than
|
|
895
|
+
// throwing keeps the shape identical to the public provider's, so one
|
|
896
|
+
// renderer contract covers both (design D1).
|
|
897
|
+
resolveConsumerSite: async (moduleId: string) => ({
|
|
898
|
+
unavailable: `the private ingress serves no static sites, so '${moduleId}' has none here`,
|
|
899
|
+
}),
|
|
900
|
+
converge: async (artifacts) => {
|
|
901
|
+
const { convergeProviderConfig, resolveStaticContentRetention } = await import(
|
|
902
|
+
'../services/provider-converge'
|
|
903
|
+
);
|
|
904
|
+
const result = await convergeProviderConfig(db, providerModuleId, {
|
|
905
|
+
...artifacts,
|
|
906
|
+
retention: resolveStaticContentRetention(db, providerModuleId),
|
|
907
|
+
});
|
|
908
|
+
return {
|
|
909
|
+
success: result.success,
|
|
910
|
+
...(result.error ? { error: result.error } : {}),
|
|
911
|
+
unresolved: result.unresolved,
|
|
912
|
+
};
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
result.provider_converge = privateConverge;
|
|
916
|
+
debugLog(`provider_converge: injected for private provider ${consumingModuleId}`);
|
|
917
|
+
}
|
|
918
|
+
|
|
820
919
|
// Framework-granted, so unlike everything above there is no provider row to
|
|
821
920
|
// look up and no script to import — celilo IS the management server whose
|
|
822
921
|
// principals these are (web-ui-console D7b).
|
|
@@ -1488,6 +1587,74 @@ async function buildFirewallChain(
|
|
|
1488
1587
|
return { chain: currentUpstream, self };
|
|
1489
1588
|
}
|
|
1490
1589
|
|
|
1590
|
+
/**
|
|
1591
|
+
* Route ops for the private_web PROVIDER, over `private_routes`.
|
|
1592
|
+
*
|
|
1593
|
+
* The private ingress's rows are core-owned declared state now, exactly as
|
|
1594
|
+
* `web_routes` is for public_web (design D1 of
|
|
1595
|
+
* providers-converge-declared-state). They used to live in caddy-internal's
|
|
1596
|
+
* `routes` module-config JSON, which is why the two ingresses could not share
|
|
1597
|
+
* plumbing: celilo had nothing to render, converge or audit.
|
|
1598
|
+
*
|
|
1599
|
+
* SEPARATE TABLE, and that is the whole safety property. The public provider
|
|
1600
|
+
* derives the hostnames it serves from every row of ITS table, so a private
|
|
1601
|
+
* route sharing that table is one missing WHERE clause from being served
|
|
1602
|
+
* publicly with a public certificate (celilo#846). Injected only into the
|
|
1603
|
+
* private_web provider's own capability functions, the same way the route view
|
|
1604
|
+
* reaches caddy and no one else.
|
|
1605
|
+
*
|
|
1606
|
+
* Rows go out in the snake_case shape the provider's renderer already speaks,
|
|
1607
|
+
* so the module needs no translation layer and its existing route tests keep
|
|
1608
|
+
* asserting the same objects.
|
|
1609
|
+
*/
|
|
1610
|
+
function buildPrivateRouteOps(db: DbClient) {
|
|
1611
|
+
const toRecord = (row: typeof privateRoutes.$inferSelect) => ({
|
|
1612
|
+
module_id: row.moduleId,
|
|
1613
|
+
slug: row.slug,
|
|
1614
|
+
type: row.type,
|
|
1615
|
+
path: row.path,
|
|
1616
|
+
hostname: row.hostname,
|
|
1617
|
+
...(row.targetHost ? { target_host: row.targetHost } : {}),
|
|
1618
|
+
...(row.targetPort ? { target_port: row.targetPort } : {}),
|
|
1619
|
+
...(row.websocket ? { websocket: row.websocket } : {}),
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
return {
|
|
1623
|
+
all: () => db.select().from(privateRoutes).all().map(toRecord),
|
|
1624
|
+
/**
|
|
1625
|
+
* Replace the whole set.
|
|
1626
|
+
*
|
|
1627
|
+
* The provider keeps the full route list in memory for the life of one
|
|
1628
|
+
* hook run and hands it back whole, which is the semantics its config key
|
|
1629
|
+
* had. Honouring that here keeps the change to the module small and the
|
|
1630
|
+
* two halves easy to reason about.
|
|
1631
|
+
*
|
|
1632
|
+
* ponytail: whole-set replace, in one transaction so a reader never sees
|
|
1633
|
+
* an empty ingress. If two providers ever write this table concurrently,
|
|
1634
|
+
* this becomes per-row upsert plus a delete of what the writer dropped.
|
|
1635
|
+
*/
|
|
1636
|
+
replaceAll: (rows: ReturnType<typeof toRecord>[]) => {
|
|
1637
|
+
db.transaction((tx) => {
|
|
1638
|
+
tx.delete(privateRoutes).run();
|
|
1639
|
+
for (const row of rows) {
|
|
1640
|
+
tx.insert(privateRoutes)
|
|
1641
|
+
.values({
|
|
1642
|
+
slug: row.slug,
|
|
1643
|
+
moduleId: row.module_id,
|
|
1644
|
+
type: row.type,
|
|
1645
|
+
path: row.path,
|
|
1646
|
+
hostname: row.hostname,
|
|
1647
|
+
targetHost: row.target_host ?? null,
|
|
1648
|
+
targetPort: row.target_port ?? null,
|
|
1649
|
+
websocket: row.websocket ?? false,
|
|
1650
|
+
})
|
|
1651
|
+
.run();
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
},
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1491
1658
|
/**
|
|
1492
1659
|
* Build RouteOps for the public_web capability implementation.
|
|
1493
1660
|
* Wraps database operations so the capability never touches the DB directly.
|
|
@@ -529,7 +529,7 @@ describe('Hook Executor', () => {
|
|
|
529
529
|
const result = await invokeHook(
|
|
530
530
|
__dirname,
|
|
531
531
|
'container_created',
|
|
532
|
-
'
|
|
532
|
+
'2.0',
|
|
533
533
|
{ script: './test-fixtures/artifact-writing-hook.ts', timeout: 10000 },
|
|
534
534
|
{ vps_ip: '10.0.0.5' },
|
|
535
535
|
{},
|
|
@@ -560,7 +560,7 @@ describe('Hook Executor', () => {
|
|
|
560
560
|
const result = await invokeHook(
|
|
561
561
|
__dirname,
|
|
562
562
|
'container_created',
|
|
563
|
-
'
|
|
563
|
+
'2.0',
|
|
564
564
|
{ script: './test-fixtures/success-hook.ts', timeout: 10000 },
|
|
565
565
|
{ vps_ip: '10.0.0.5' },
|
|
566
566
|
{},
|
|
@@ -585,7 +585,7 @@ describe('Hook Executor', () => {
|
|
|
585
585
|
const result = await invokeHook(
|
|
586
586
|
__dirname,
|
|
587
587
|
'container_created',
|
|
588
|
-
'
|
|
588
|
+
'2.0',
|
|
589
589
|
{ script: './test-fixtures/success-hook.ts', timeout: 10000 },
|
|
590
590
|
{ vps_ip: '10.0.0.5' },
|
|
591
591
|
{},
|
|
@@ -614,7 +614,7 @@ describe('Hook Executor', () => {
|
|
|
614
614
|
const result = await invokeHook(
|
|
615
615
|
__dirname,
|
|
616
616
|
'container_created',
|
|
617
|
-
'
|
|
617
|
+
'2.0',
|
|
618
618
|
definition,
|
|
619
619
|
{ vps_ip: '10.0.0.5' },
|
|
620
620
|
{ username: 'test' },
|
|
@@ -641,7 +641,7 @@ describe('Hook Executor', () => {
|
|
|
641
641
|
const result = await invokeHook(
|
|
642
642
|
__dirname,
|
|
643
643
|
'on_backup',
|
|
644
|
-
'
|
|
644
|
+
'2.0',
|
|
645
645
|
definition,
|
|
646
646
|
{},
|
|
647
647
|
{},
|
|
@@ -663,7 +663,7 @@ describe('Hook Executor', () => {
|
|
|
663
663
|
const result = await invokeHook(
|
|
664
664
|
__dirname,
|
|
665
665
|
'container_created',
|
|
666
|
-
'
|
|
666
|
+
'2.0',
|
|
667
667
|
definition,
|
|
668
668
|
{},
|
|
669
669
|
{},
|
|
@@ -686,7 +686,7 @@ describe('Hook Executor', () => {
|
|
|
686
686
|
const result = await invokeHook(
|
|
687
687
|
__dirname,
|
|
688
688
|
'on_backup',
|
|
689
|
-
'
|
|
689
|
+
'2.0',
|
|
690
690
|
definition,
|
|
691
691
|
{ backup_dir: '/tmp/backup' },
|
|
692
692
|
{},
|
|
@@ -708,7 +708,7 @@ describe('Hook Executor', () => {
|
|
|
708
708
|
const result = await invokeHook(
|
|
709
709
|
__dirname,
|
|
710
710
|
'container_created',
|
|
711
|
-
'
|
|
711
|
+
'2.0',
|
|
712
712
|
definition,
|
|
713
713
|
{},
|
|
714
714
|
{},
|
|
@@ -743,10 +743,10 @@ describe('Hook Executor', () => {
|
|
|
743
743
|
const { logger } = createCapturingLogger();
|
|
744
744
|
const definition: HookDefinition = { script: './test-fixtures/success-hook.ts' };
|
|
745
745
|
|
|
746
|
-
const result = await invokeHook(__dirname, 'on_poop', '
|
|
746
|
+
const result = await invokeHook(__dirname, 'on_poop', '2.0', definition, {}, {}, {}, logger);
|
|
747
747
|
|
|
748
748
|
expect(result.success).toBe(false);
|
|
749
|
-
expect(result.error).toContain("Hook 'on_poop' is not part of celilo_contract
|
|
749
|
+
expect(result.error).toContain("Hook 'on_poop' is not part of celilo_contract 2.0");
|
|
750
750
|
});
|
|
751
751
|
|
|
752
752
|
test('pre-flight: fails when a required capability is missing', async () => {
|
|
@@ -759,7 +759,7 @@ describe('Hook Executor', () => {
|
|
|
759
759
|
const result = await invokeHook(
|
|
760
760
|
__dirname,
|
|
761
761
|
'container_created',
|
|
762
|
-
'
|
|
762
|
+
'2.0',
|
|
763
763
|
definition,
|
|
764
764
|
{ vps_ip: '10.0.0.5' },
|
|
765
765
|
{},
|
|
@@ -786,7 +786,7 @@ describe('Hook Executor', () => {
|
|
|
786
786
|
const result = await invokeHook(
|
|
787
787
|
__dirname,
|
|
788
788
|
'container_created',
|
|
789
|
-
'
|
|
789
|
+
'2.0',
|
|
790
790
|
definition,
|
|
791
791
|
{ vps_ip: '10.0.0.5' },
|
|
792
792
|
{},
|
|
@@ -813,7 +813,7 @@ describe('Hook Executor', () => {
|
|
|
813
813
|
const result = await invokeHook(
|
|
814
814
|
__dirname,
|
|
815
815
|
'container_created',
|
|
816
|
-
'
|
|
816
|
+
'2.0',
|
|
817
817
|
definition,
|
|
818
818
|
{ vps_ip: '10.0.0.5' },
|
|
819
819
|
{},
|
|
@@ -62,7 +62,7 @@ describe('celilo#1000: a hook is TOLD where its state directory is', () => {
|
|
|
62
62
|
const result = await invokeHook(
|
|
63
63
|
root,
|
|
64
64
|
'on_install',
|
|
65
|
-
'
|
|
65
|
+
'2.0',
|
|
66
66
|
{ script: 'hook.ts' },
|
|
67
67
|
{},
|
|
68
68
|
{},
|
|
@@ -97,7 +97,7 @@ describe('celilo#1000: a hook is TOLD where its state directory is', () => {
|
|
|
97
97
|
const result = await invokeHook(
|
|
98
98
|
root,
|
|
99
99
|
'on_install',
|
|
100
|
-
'
|
|
100
|
+
'2.0',
|
|
101
101
|
{ script: 'hook.ts' },
|
|
102
102
|
{},
|
|
103
103
|
{},
|
|
@@ -177,6 +177,34 @@ describe('~/.ssh is never in the mount set (stage 3, D12)', () => {
|
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
describe('a bound browser without fonts is a browser that cannot render', () => {
|
|
181
|
+
// celilo#1422. Binding BROWSER_ROOT makes the browser START; it does not make
|
|
182
|
+
// it RENDER. With no font configuration in the jail, Blink's remote font face
|
|
183
|
+
// path hits a fatal NOTREACHED on any page that loads a web font and the
|
|
184
|
+
// renderer dies mid navigation — surfacing as a Playwright selector timeout
|
|
185
|
+
// that names neither fonts nor the jail. Measured on the management host,
|
|
186
|
+
// same bwrap invocation, only these rows differing: 12 NOTREACHED without,
|
|
187
|
+
// 0 with.
|
|
188
|
+
//
|
|
189
|
+
// Tied to BROWSER_ROOT deliberately. Fonts are only interesting BECAUSE a
|
|
190
|
+
// browser is bound, so if the browser bind is ever dropped this test should
|
|
191
|
+
// be reconsidered with it rather than left asserting an unrelated directory.
|
|
192
|
+
test('every font directory the renderer needs is bound alongside the browser', () => {
|
|
193
|
+
const paths = pathsOf(BASE);
|
|
194
|
+
expect(paths).toContain(BROWSER_ROOT);
|
|
195
|
+
for (const dir of ['/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts']) {
|
|
196
|
+
expect(paths).toContain(dir);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('fonts are read-only — a hook has no business writing to them', () => {
|
|
201
|
+
for (const dir of ['/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts']) {
|
|
202
|
+
const row = deriveMountSet(BASE).entries.find((e) => e.path === dir);
|
|
203
|
+
expect(row?.mode).toBe('ro');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
180
208
|
describe('the fleet browser is reachable, and only read-only (task 4.10)', () => {
|
|
181
209
|
test('BROWSER_ROOT is bound', () => {
|
|
182
210
|
// Task 4.10 names `~/.cache/ms-playwright`. That path is stale:
|
package/src/hooks/mount-set.ts
CHANGED
|
@@ -112,6 +112,34 @@ export interface MountSetRequest {
|
|
|
112
112
|
/** Directories whose contents the runtime needs in order to start at all. */
|
|
113
113
|
const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* What RENDERING needs, as distinct from what running needs.
|
|
117
|
+
*
|
|
118
|
+
* Omitting these does not stop the browser starting, which is why it survived
|
|
119
|
+
* the bind added for `BROWSER_ROOT` below. It kills the RENDERER, and only on a
|
|
120
|
+
* page that loads a web font. Blink's remote font face path hits a fatal
|
|
121
|
+
* `NOTREACHED` with no font configuration present, the renderer dies mid
|
|
122
|
+
* navigation, and Playwright reports `Target page, context or browser has been
|
|
123
|
+
* closed` while awaiting a selector — which reads as a slow page or a flaky
|
|
124
|
+
* selector and names neither fonts nor the jail.
|
|
125
|
+
*
|
|
126
|
+
* Measured 2026-09-25 on the management host, same bwrap invocation, only these
|
|
127
|
+
* three rows differing (celilo#1422):
|
|
128
|
+
*
|
|
129
|
+
* without: 12x ERROR ... remote_font_face_source.cc:365] NOTREACHED hit.
|
|
130
|
+
* with: 0
|
|
131
|
+
*
|
|
132
|
+
* celilo PROVISIONS these deliberately — `celilo-mgmt`'s browser block installs
|
|
133
|
+
* `fontconfig` and `fonts-dejavu-core` because "a screenshot with no glyphs is
|
|
134
|
+
* not worth retaining" (D7). So the fleet already pays for fonts and the jail
|
|
135
|
+
* was hiding them from the one process that needs them: the provisioning and
|
|
136
|
+
* the mount set disagreed, and the browser lost.
|
|
137
|
+
*
|
|
138
|
+
* Read-only, and absent rows are dropped like any other, so a host with no
|
|
139
|
+
* fonts installed is exactly as it was rather than newly fatal.
|
|
140
|
+
*/
|
|
141
|
+
const FONT_DIRS = ['/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts'] as const;
|
|
142
|
+
|
|
115
143
|
/**
|
|
116
144
|
* What resolving a hostname needs. Read-only, and absent ones are dropped.
|
|
117
145
|
*
|
|
@@ -220,6 +248,11 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
220
248
|
for (const dir of RUNTIME_SUPPORT_DIRS) {
|
|
221
249
|
entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime'));
|
|
222
250
|
}
|
|
251
|
+
for (const dir of FONT_DIRS) {
|
|
252
|
+
entries.push(
|
|
253
|
+
entry(dir, 'ro', 'fonts — the renderer dies without them, see FONT_DIRS', 'runtime'),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
223
256
|
for (const file of RESOLVER_FILES) {
|
|
224
257
|
entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime'));
|
|
225
258
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HookStore
|
|
1
|
+
import type { HookStore } from '@celilo/capabilities';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* In-memory hook-owned-state stores for tests that build a HookContext
|
|
@@ -16,10 +16,12 @@ export function configStore(
|
|
|
16
16
|
return attach(values);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function secretStore(
|
|
19
|
+
export function secretStore(
|
|
20
|
+
values: Record<string, string> = {},
|
|
21
|
+
): Record<string, string> & HookStore {
|
|
20
22
|
// Downcast: the caller passed Record<string, string>; attach only adds
|
|
21
23
|
// methods and never writes, so the value type is unchanged.
|
|
22
|
-
return attach(values) as
|
|
24
|
+
return attach(values) as Record<string, string> & HookStore;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
* manifest's `celilo_contract` field selects which contract applies; the
|
|
6
6
|
* registry is the single source of truth for what each version promises.
|
|
7
7
|
*
|
|
8
|
-
* To mint a new contract version: add a new file (e.g. `./
|
|
9
|
-
* contract object here, and register it in `CONTRACTS`.
|
|
10
|
-
* registered
|
|
8
|
+
* To mint a new contract version: add a new file (e.g. `./v3.ts`), import its
|
|
9
|
+
* contract object here, and register it in `CONTRACTS`. A version whose
|
|
10
|
+
* modules still work stays registered; a version whose modules would now FAIL
|
|
11
|
+
* SILENTLY moves to `RETIRED_CONTRACT_VERSIONS` with the reason, so the
|
|
12
|
+
* validator refuses it by name rather than dropping state at deploy time.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
|
-
import { V1_CONTRACT } from './v1';
|
|
14
15
|
import type { ContractHookSignature, ContractHooks } from './v1';
|
|
16
|
+
import { V2_CONTRACT } from './v2';
|
|
15
17
|
|
|
16
18
|
export type { ContractHooks, ContractHookSignature };
|
|
17
19
|
|
|
@@ -26,7 +28,7 @@ export type { ContractHooks, ContractHookSignature };
|
|
|
26
28
|
* `ContractHooks` stays exact.
|
|
27
29
|
*
|
|
28
30
|
* Returns `undefined` for a name the contract does not define; the caller
|
|
29
|
-
* turns that into "Hook 'x' is not part of celilo_contract
|
|
31
|
+
* turns that into "Hook 'x' is not part of celilo_contract 2.0".
|
|
30
32
|
*/
|
|
31
33
|
export function contractHookSignature(
|
|
32
34
|
hooks: ContractHooks,
|
|
@@ -53,16 +55,35 @@ export interface Contract {
|
|
|
53
55
|
* `SUPPORTED_CONTRACT_VERSIONS` below so Zod's enum picks it up.
|
|
54
56
|
*/
|
|
55
57
|
export const CONTRACTS: Record<string, Contract> = {
|
|
56
|
-
'
|
|
58
|
+
'2.0': V2_CONTRACT,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Versions a manifest may still NAME but that no longer validate, mapped to
|
|
63
|
+
* the reason. A retired version is kept here rather than simply deleted so
|
|
64
|
+
* the validator can say what broke and how to move, instead of reporting a
|
|
65
|
+
* known version as an unrecognised string.
|
|
66
|
+
*/
|
|
67
|
+
export const RETIRED_CONTRACT_VERSIONS: Record<string, string> = {
|
|
68
|
+
'1.0':
|
|
69
|
+
'contract 1.0 persisted a hook\'s returned record as the module\'s secrets and config. That channel is removed (openspec/changes/hook-owned-state D5) — a 1.0 hook would deploy successfully and store nothing. Declare celilo_contract "2.0" and persist state with context.config.set / context.secrets.set against names the manifest declares.',
|
|
57
70
|
};
|
|
58
71
|
|
|
59
72
|
/**
|
|
60
73
|
* Const tuple of supported contract version strings, used to drive the Zod
|
|
61
74
|
* enum that validates the manifest's `celilo_contract` field.
|
|
62
75
|
*/
|
|
63
|
-
export const SUPPORTED_CONTRACT_VERSIONS = ['
|
|
76
|
+
export const SUPPORTED_CONTRACT_VERSIONS = ['2.0'] as const;
|
|
64
77
|
export type SupportedContractVersion = (typeof SUPPORTED_CONTRACT_VERSIONS)[number];
|
|
65
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Every version string a manifest is allowed to WRITE — supported plus
|
|
81
|
+
* retired. The schema enum accepts these so a retired version reaches
|
|
82
|
+
* `validateHookContract`, which explains the retirement; anything else is a
|
|
83
|
+
* plain unknown-version error from Zod.
|
|
84
|
+
*/
|
|
85
|
+
export const KNOWN_CONTRACT_VERSIONS = ['1.0', '2.0'] as const;
|
|
86
|
+
|
|
66
87
|
/**
|
|
67
88
|
* Look up a contract by version string.
|
|
68
89
|
*
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Celilo Module Contract — v2.0
|
|
3
|
+
*
|
|
4
|
+
* Hook SIGNATURES are unchanged from v1.0 — `V2_HOOKS` is `V1_HOOKS`. What
|
|
5
|
+
* changed is what the framework does with a hook's return value: nothing.
|
|
6
|
+
*
|
|
7
|
+
* Under v1.0, a record returned from `validate_config`, `on_install` or
|
|
8
|
+
* `container_created` was swept back into the module's secrets (and, for
|
|
9
|
+
* validate_config, its config). That was the only write channel a hook had,
|
|
10
|
+
* so a hook that needed to persist something it discovered had to return it.
|
|
11
|
+
* The channel is gone (openspec/changes/hook-owned-state, D5): a hook now
|
|
12
|
+
* persists state explicitly through `context.config.set` / `context.secrets.set`,
|
|
13
|
+
* against names its manifest declares.
|
|
14
|
+
*
|
|
15
|
+
* This is a MAJOR bump rather than a v1.x clarification because a module
|
|
16
|
+
* written against the old channel keeps deploying and silently stores
|
|
17
|
+
* nothing — the deploy reports success and the state is dropped. Refusing
|
|
18
|
+
* `celilo_contract: "1.0"` outright (see `RETIRED_CONTRACT_VERSIONS`) turns
|
|
19
|
+
* that silent loss into a message naming the fix, which is the whole point of
|
|
20
|
+
* versioning the contract.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { V1_HOOKS } from './v1';
|
|
24
|
+
import type { ContractHooks } from './v1';
|
|
25
|
+
|
|
26
|
+
/** v2.0 hook signatures — identical to v1.0. The break is behavioural. */
|
|
27
|
+
export const V2_HOOKS: ContractHooks = V1_HOOKS;
|
|
28
|
+
|
|
29
|
+
export const V2_CONTRACT = {
|
|
30
|
+
version: '2.0' as const,
|
|
31
|
+
hooks: V2_HOOKS,
|
|
32
|
+
};
|
package/src/manifest/schema.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
MONITOR_INTERVAL_FLOOR_MINUTES,
|
|
7
7
|
cadenceSchema,
|
|
8
8
|
} from '../services/cadence';
|
|
9
|
-
import {
|
|
9
|
+
import { KNOWN_CONTRACT_VERSIONS } from './contracts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Variable declaration sources
|
|
@@ -610,7 +610,18 @@ export const ModuleManifestSchema = z
|
|
|
610
610
|
* here so a future v2 contract can change requirements without breaking
|
|
611
611
|
* v1 modules.
|
|
612
612
|
*/
|
|
613
|
-
|
|
613
|
+
/**
|
|
614
|
+
* The enum accepts RETIRED versions as well as supported ones. Refusing a
|
|
615
|
+
* retired version is a POLICY about importing a manifest, and it lives in
|
|
616
|
+
* `validateManifest`, not here: this schema is also what re-parses the
|
|
617
|
+
* manifest celilo already STORED for an installed module
|
|
618
|
+
* (`module-pause.ts`, `module-remove.ts`, `provider-arrival.ts`), and each
|
|
619
|
+
* of those `continue`s past a parse failure. Refusing here would make
|
|
620
|
+
* every still-unmigrated installed module silently vanish from the
|
|
621
|
+
* dependent-removal check and the provider backfill — a narrowing with no
|
|
622
|
+
* error, which is the exact failure mode this change exists to remove.
|
|
623
|
+
*/
|
|
624
|
+
celilo_contract: z.enum(KNOWN_CONTRACT_VERSIONS),
|
|
614
625
|
|
|
615
626
|
id: z
|
|
616
627
|
.string()
|
|
@@ -849,6 +860,20 @@ export const ModuleManifestSchema = z
|
|
|
849
860
|
command: z.string().min(1).optional(),
|
|
850
861
|
/** Path to a build script (relative to module directory). Mutually exclusive with command. */
|
|
851
862
|
script: z.string().min(1).optional(),
|
|
863
|
+
/**
|
|
864
|
+
* Wall-clock budget for the build, in seconds (celilo#1252).
|
|
865
|
+
*
|
|
866
|
+
* The packager used to kill every build at a hardcoded 300s. A module
|
|
867
|
+
* whose build command declares its own retry budget — forgejo fetches
|
|
868
|
+
* two release archives from codeberg with `curl --retry` — could
|
|
869
|
+
* express far more wall clock than that cap allowed, and the retries
|
|
870
|
+
* written for a flaky upstream never ran. The cap is a property of the
|
|
871
|
+
* fetch the module performs, so the module names it.
|
|
872
|
+
*
|
|
873
|
+
* `packages/e2e/tests/module-build-budget-fits-timeout.test.ts` fails
|
|
874
|
+
* when a build command's worst-case curl budget exceeds this value.
|
|
875
|
+
*/
|
|
876
|
+
timeout_seconds: z.number().int().positive().max(7200).default(300),
|
|
852
877
|
artifacts: z
|
|
853
878
|
.array(
|
|
854
879
|
z
|