@celilo/cli 1.8.0 → 1.9.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/CELILO_CORE_MODULES.md +2 -0
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +161 -5
- package/src/hooks/broker.test.ts +153 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/define-hook.test.ts +1 -0
- package/src/hooks/executor.test.ts +7 -0
- package/src/hooks/executor.ts +245 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-state-dir.test.ts +109 -0
- package/src/hooks/hook-timeout.test.ts +104 -0
- package/src/hooks/hook-trespass.test.ts +202 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/mount-set.test.ts +148 -0
- package/src/hooks/mount-set.ts +234 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/contracts/v1.ts +21 -6
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +105 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/capability-shape.ts +13 -1
- package/src/policy/module-business-baseline.ts +36 -0
- package/src/policy/module-dep-reachability.test.ts +167 -0
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +2 -2
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/dns-internal-records.test.ts +3 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/port-forwards.test.ts +2 -2
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/test-utils/module-fixtures.ts +147 -35
- package/src/test-utils/setup-test-db.ts +0 -80
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -39,6 +39,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
39
39
|
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **REQUIRES the networks it has legs on and defines none of them** (`requires.networks: [{from: $self:zones}]`) — the required set is resolved from the same `zones` value that says which NICs the box has, so the legs and the declared networks are ONE list. They used to be two: `zones` and a `provided_networks` array this module wrote into system config from its install hook, with nothing forcing them to agree. A leg whose network has no declared subnet classifies `alien`, and on a firewall with a recorded baseline an alien interface is DISABLED — so under-declaring was never cosmetic. `firewall-interface-classification` §3/D8 reached that conclusion for the test harness; `networks-are-declared-not-written` applies it in production and deletes `provided_networks` outright (breaking: iptables 3.0.0). celilo supplies each range, asking the operator when it holds none. **provides:** `firewall`.
|
|
40
40
|
- **dnsmasq-dhcp** — a DHCP-only dnsmasq in **`internal`**, providing the zone-scoped `dhcp_server` capability for a network with no upstream router to serve it. **provides:** `dhcp_server` (zone `internal`). The other two `dhcp_server` providers, **axon** and **greenwave**, do not run a DHCP server at all — they drive an ISP router's built-in one over its API, which works only while the home network shares a broadcast domain with that router. Put `internal` behind the celilo firewall and DHCP discovery, being a broadcast, no longer reaches it: celilo could describe that topology and not make it work. Importing this module at all depends on well-known-provider uniqueness being scoped PER ZONE (celilo#1028), since axon/greenwave already claim `dhcp_server` fleet-wide. **`port=0` is load-bearing**: dnsmasq ships a resolver as well as a DHCP server, and `dns_internal` is a different capability with its own providers — two resolvers answering on one network is a split-brain that is very hard to see from outside. celilo's transactional artifacts go to `/var/lib/celilo/config-state/` via `applyRenderedConfig`'s `statePath`, NOT beside the managed fragment, because dnsmasq parses every file in `/etc/dnsmasq.d` and a `.celilo-bak` there is configuration the daemon reads. It declares a `requires.system` and is placed by celilo like any other module; an earlier shape declared none, which meant no host in the inventory, an `apt-get` shelled over SSH from a hook, and an operator-typed address — and which also evaded `validateMachineRoleForModule`, the check that refuses to place a non-firewall module on a multi-interface router.
|
|
41
41
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
42
|
+
- **knot-unbound-secondary** — a read-only secondary for **knot-unbound-internal**: Knot transfers every managed zone from the primary and Unbound serves the same split-horizon view. Lives in **`dmz`**, symmetric with the primary (design D5e) — NOT in `internal` as first written. That placement was chosen deliberately and the reasoning is worth keeping: design D5a records the secondary's justification as weak on purpose, since steady state is already a single resolver address and the one recurring non-bug case a secondary covers is redeploying the primary, which blanks fleet DNS for the duration. Same-zone covers that completely. A split placement buys zone-level independence, which D5a explicitly declined, and would not deliver it anyway — celilo has no host anti-affinity, so both LXCs land on the container service's `default_target_node`. **provides:** `dns_internal_secondary`, carrying its OWN addresses rather than being advertised by the primary: if the primary carried them its published data would be stale from the moment this module deployed until something recomputed it, and recomputing means redeploying the primary — the manual step D5 exists to remove. **requires:** `dns_internal` (hard, and honestly declared: it sat under `optional` until celilo#854, because the import-time secret gate refused a consumer over a `tsig_key` it never reads). Its protected-zone ingress is a `*_ingress_ip` / `source: infrastructure` variable that `ensureIngressIps` allocates from `network.internal.subnet`, so the operator types none of the three addresses the fork's version asked for. Registering it as a transfer peer on the primary is `registerTransferPeer` (design D5f) — ⚠️ the empty peer state MUST render an explicit `deny: on`, because a knot acl with `action: transfer` and no `address:` matches ANY source and `knotc conf-check` accepts that silently.
|
|
42
43
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
43
44
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
|
|
44
45
|
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
@@ -74,6 +75,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
74
75
|
## E2E fixtures & probes (not production apps)
|
|
75
76
|
|
|
76
77
|
- **hello-foo** / **hello-bar** — hostless single-page sites (one hand-written `site/index.html`, no build step) published at a configurable `domain` + `path`. They exist so the caddy route-lifecycle e2e can put several independent routes on one caddy and watch them interact — celilo-website is the only other static-site module, and one site can't answer "does removing `/` break `/foo`?". **requires:** `public_web`, `dns_registrar`.
|
|
78
|
+
- **hello-trespass** — TEST FIXTURE, never deploy. Its `on_install` hook attempts three things a hook has no business doing (read the master key at its default location, write into a sibling module's install tree, read the SSH private key / `remoteExec` another module's system) and REPORTS which succeeded rather than failing. It is the recurrence gate for `openspec/changes/hook-process-boundary`: each stage flips exactly one row and the test asserts the rows that stage does NOT claim still succeed, so a gate cannot quietly grow to cover work that was never done. **requires:** nothing.
|
|
77
79
|
- **hello-private-foo** / **hello-private-bar** — the `private_web` twins of the pair above: hostless single-page sites published through the fleet-only ingress. Two of them for the same reason — one consumer cannot answer "does withdrawing `/foo` leave `/bar` alone?", and per-consumer scoping is exactly what `unregisterRoutes()` promises. Their hook is `private_web ?? public_web` with no branching after it, which is the spec's "same consumer code, either ingress" requirement made executable. **hello-private-foo** additionally calls `getCaCertificate()` and writes the PEM into its own site before publishing — the CA-bundling pattern a real consumer uses, and the only way to observe that the capability returned something usable. **requires:** `private_web`.
|
|
78
80
|
- **source-forge-probe** — hostless e2e probe that exercises the `source_forge` capability (issue/revoke runner token) against a deployed Forgejo. **requires:** `source_forge`.
|
|
79
81
|
- **test-ansible-output** — e2e fixture that runs 25×1s sleep tasks to assert Ansible output streams in real time. Not a real app.
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -47,6 +47,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
47
47
|
- **Define a capability function** — `packages/capabilities/src/define-capability-function.ts` — `defineCapabilityFunction`.
|
|
48
48
|
- **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Public surface: `packages/capabilities/src/index.ts`.
|
|
49
49
|
- **Loader (wires provider factories into hook contexts)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadCapabilityFunctions`, `resolveFirewallNatIp`.
|
|
50
|
+
- **Which provider a consumer actually resolved to (`capability_bindings`)** — `apps/celilo/src/services/capability-bindings.ts` — `recordCapabilityBinding`, `listCapabilityBindings`, `withBindingRecord`, written from the single return of `loadCapabilityFunctions`. The `capabilities` table is provider-side only, so celilo could answer "who COULD provide this" and never "who does this module actually use"; every caller needing the second question rebuilt the same approximation (the consumer's `requires` + `optional` crossed with `capabilities`), which names every provider a module MIGHT have bound to. `tango-nexus` declares four optional capabilities, is deployed against one off-fleet host, and that approximation carries three edges that do not exist. **The CALL is the binding, not the resolution** — the loader injects every registered capability regardless of what the consumer declared (its own comment: "not just required ones"), so recording what it resolves is a SUPERSET of even the permissive set. A consumer's hook invoking a method is the only event that separates the optional it uses from the ones it merely declares, which is why this matters most for the capabilities that mint nothing (`external_web`, `idp`, `source_forge`, `registry_publish`, `control_plane_vpn`, `apt_publish`) and therefore leave no artefact anywhere else. Recorded on ATTEMPT rather than success: a consumer that called a provider and got an error is standing on that provider, and hiding the edge exactly when the call fails hides it when it matters most. Unique on (consumer, capability) so a redeploy re-asserts and a provider swap rewrites in place; `bound_at` is LAST-SEEN, so a binding last exercised forty deploys ago is a queryable fact rather than a silent lie. Cascades on the consumer. It runs BESIDE `planConsumerCleanup`, never narrowing it — cleanup must still reach every provider that might hold minted state, including one whose binding row was never written. See celilo#1072.
|
|
50
51
|
- **Ledger wrappers (stateful capabilities)** — `apps/celilo/src/services/dns-registrations.ts` (`withDnsRegistrationLedger`), `apps/celilo/src/services/dns-internal-records.ts` (`withDnsInternalLedger`).
|
|
51
52
|
- **Public DNS ledger (`dns_registrations` + `dns_registration_consumers`)** — `apps/celilo/src/services/dns-registrations.ts` — records WHICH MODULE ASKED FOR WHICH NAME, and deliberately **no address**: the published value is source-detected on every assert and observable on demand from public DNS, and a stored copy is one careless read away from becoming an instruction again (celilo#626 — the 15-minute `refresh_registrations` replayed addresses written by an older celilo and took five public names dark for nine days while every in-fleet check stayed green). Consumers are a SET, not a column: the FK cascade on a single `consumer_module_id` decided when a LIVE record was forgotten, so a row now survives until its LAST consumer is removed (`openspec/changes/public-dns-reachability/design.md` D5). Orphaned rows are pruned on read (`pruneOrphanedRegistrations`) rather than by trigger — SQLite fires delete triggers for FK-cascaded deletes only with `recursive_triggers` on. `companion: true` marks a name celilo claimed on a module's behalf (`www.<domain>` ↔ `<domain>`) rather than one a module asked for; the provider reports it as `outputs.companion_fqdn` whether or not the claim reported success, because a provider's success response is not evidence of publication. Operator surface: `celilo dns registrations`.
|
|
52
53
|
|
|
@@ -179,6 +180,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
179
180
|
## Hooks & deploy
|
|
180
181
|
|
|
181
182
|
- **Hook executor / ABI** — `apps/celilo/src/hooks/executor.ts` (`invokeHook`, `executeHookScript`, `checkRequiredCapabilities`), types in `apps/celilo/src/hooks/types.ts` (`HookContext`, `HookDefinition`, `HookName`). Named-hook runner: `apps/celilo/src/hooks/run-named-hook.ts`. Manifest hook config: `apps/celilo/src/hooks/load-hook-config.ts`.
|
|
183
|
+
- **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code). `executeHookScript` spawns `bun hook-runner.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`, `CELILO_HOOK_*`, `CELILO_DEBUG`), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stage 1 of `openspec/changes/hook-process-boundary`; it claims the environment and the process, not the filesystem (a hook can still compute the default master-key path) and not the network.
|
|
182
184
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
183
185
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
184
186
|
- **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium). Two directions, with DELIBERATELY OPPOSITE failure semantics:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Which provider a consumer actually resolved to.
|
|
2
|
+
--
|
|
3
|
+
-- `capabilities` is provider-side only. It answers "who could provide this" and
|
|
4
|
+
-- has never answered "who does this module actually use", so every caller that
|
|
5
|
+
-- needed the second question rebuilt the same approximation: the consumer's
|
|
6
|
+
-- `requires` + `optional` crossed with `capabilities`. That names every provider
|
|
7
|
+
-- a module MIGHT have bound to. A module declaring four optional capabilities
|
|
8
|
+
-- and using one gets three false edges and no way to tell which is real.
|
|
9
|
+
--
|
|
10
|
+
-- A row is written when a consumer's hook first calls a method on an injected
|
|
11
|
+
-- capability. Unique on (consumer, capability) so a redeploy re-asserts rather
|
|
12
|
+
-- than duplicates, and a provider swap rewrites the provider in place.
|
|
13
|
+
--
|
|
14
|
+
-- `provider_module_id` is a plain column with no foreign key, matching
|
|
15
|
+
-- `dns_internal_records` after 0027: removing a provider must not erase the
|
|
16
|
+
-- record that a consumer was bound to it.
|
|
17
|
+
CREATE TABLE `capability_bindings` (
|
|
18
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
19
|
+
`consumer_module_id` text NOT NULL,
|
|
20
|
+
`capability_name` text NOT NULL,
|
|
21
|
+
`provider_module_id` text NOT NULL,
|
|
22
|
+
`bound_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
23
|
+
FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
|
|
24
|
+
);
|
|
25
|
+
--> statement-breakpoint
|
|
26
|
+
CREATE UNIQUE INDEX `capability_bindings_consumer_capability_idx` ON `capability_bindings` (`consumer_module_id`,`capability_name`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- Instances of a submodule (openspec/changes/submodules, D2 and D4).
|
|
2
|
+
--
|
|
3
|
+
-- An instance IS a row in `modules`, under a celilo-derived id. That is the
|
|
4
|
+
-- decision this table exists to support rather than to work around: every
|
|
5
|
+
-- deployment-state table in celilo keys on `module_id` and only on `module_id`
|
|
6
|
+
-- (`module_systems` is PRIMARY KEY (module_id, name), `module_configs` is
|
|
7
|
+
-- UNIQUE (module_id, key), `module_infrastructure` references it), so making an
|
|
8
|
+
-- instance a module row means health, backup, fleet status, `module list` and
|
|
9
|
+
-- the removal path all needed no change at all.
|
|
10
|
+
--
|
|
11
|
+
-- The alternative was adding an `instance_id` column to each of those tables and
|
|
12
|
+
-- widening every key. It touches every reader and it fails QUIETLY: a query that
|
|
13
|
+
-- still filters on module_id alone compiles clean and returns N rows where it
|
|
14
|
+
-- expected one.
|
|
15
|
+
--
|
|
16
|
+
-- `module_id` is both the primary key and the FK, so an instance cannot exist
|
|
17
|
+
-- without the module row it names, and dropping the module row drops this.
|
|
18
|
+
--
|
|
19
|
+
-- IDENTITY IS THE TRIPLE (parent_id, submodule, instance_key), never
|
|
20
|
+
-- instance_key alone. Two parents may use the same key string and one parent may
|
|
21
|
+
-- use the same key across two of its submodules; both are legal and neither
|
|
22
|
+
-- collides. `instance_key` is opaque: celilo stores it and never interprets it,
|
|
23
|
+
-- because a key derived from a display name orphans a running system the first
|
|
24
|
+
-- time somebody is renamed at the identity provider.
|
|
25
|
+
--
|
|
26
|
+
-- `failure_reason` and `retryable` are NULL unless state = 'failed'. That is the
|
|
27
|
+
-- Rule 3.4 exception (null meaning this whole section is absent) rather than a
|
|
28
|
+
-- missing default. `retryable` is a column rather than something a caller infers
|
|
29
|
+
-- from the reason text: a parent's reconcile loop reads it to decide whether to
|
|
30
|
+
-- rebuild, and a missing interview answer will fail identically forever while a
|
|
31
|
+
-- timed-out provision is worth another go. Forcing a caller to string-match the
|
|
32
|
+
-- reason is how a reporter gets trusted by its shape and quietly answers wrong.
|
|
33
|
+
|
|
34
|
+
CREATE TABLE `module_instances` (
|
|
35
|
+
`module_id` text PRIMARY KEY NOT NULL,
|
|
36
|
+
`parent_id` text NOT NULL,
|
|
37
|
+
`submodule` text NOT NULL,
|
|
38
|
+
`instance_key` text NOT NULL,
|
|
39
|
+
`label` text,
|
|
40
|
+
`state` text DEFAULT 'pending' NOT NULL,
|
|
41
|
+
`failure_reason` text,
|
|
42
|
+
`retryable` integer,
|
|
43
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
44
|
+
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
45
|
+
FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
46
|
+
FOREIGN KEY (`parent_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
|
|
47
|
+
);
|
|
48
|
+
--> statement-breakpoint
|
|
49
|
+
-- The real identity. A database fact rather than a check somebody has to
|
|
50
|
+
-- remember, so "this parent already has an instance under this key" cannot be
|
|
51
|
+
-- got wrong by a caller.
|
|
52
|
+
CREATE UNIQUE INDEX `module_instances_identity_idx` ON `module_instances` (`parent_id`,`submodule`,`instance_key`);--> statement-breakpoint
|
|
53
|
+
-- Every reconcile call lists one parent's instances. That must not become a
|
|
54
|
+
-- table scan once a fleet carries tens of them.
|
|
55
|
+
CREATE INDEX `module_instances_parent_idx` ON `module_instances` (`parent_id`);--> statement-breakpoint
|
|
56
|
+
-- `list` filters by state, and the collapsed operator view rolls health up per
|
|
57
|
+
-- parent, which reads the same way.
|
|
58
|
+
CREATE INDEX `module_instances_state_idx` ON `module_instances` (`state`);
|
|
@@ -197,6 +197,20 @@
|
|
|
197
197
|
"when": 1784300000000,
|
|
198
198
|
"tag": "0027_dns_internal_records_consumer_cascade",
|
|
199
199
|
"breakpoints": true
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"idx": 28,
|
|
203
|
+
"version": "6",
|
|
204
|
+
"when": 1784400000000,
|
|
205
|
+
"tag": "0028_capability_bindings",
|
|
206
|
+
"breakpoints": true
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"idx": 29,
|
|
210
|
+
"version": "6",
|
|
211
|
+
"when": 1784400000000,
|
|
212
|
+
"tag": "0029_module_instances",
|
|
213
|
+
"breakpoints": true
|
|
200
214
|
}
|
|
201
215
|
]
|
|
202
216
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^
|
|
61
|
+
"@celilo/capabilities": "^3.1.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.9.1",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
@@ -165,6 +165,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
|
|
|
165
165
|
// compile error here rather than silently rendering as "Unknown zone". Both
|
|
166
166
|
// `internal` and `secure-mgmt` were missing from the previous string-keyed map.
|
|
167
167
|
const zoneDescriptions: Record<NetworkZone, string> = {
|
|
168
|
+
'isp-transit': 'ISP transit (Private segment to the router upstream of the firewall)',
|
|
168
169
|
internal: 'Internal (Semi-trusted network behind the firewall)',
|
|
169
170
|
dmz: 'DMZ (Public-facing services)',
|
|
170
171
|
app: 'Application (Internal services)',
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gate for celilo#1074.
|
|
3
|
+
*
|
|
4
|
+
* `db/client.ts` runs `PRAGMA foreign_keys = ON` before handing back the real
|
|
5
|
+
* database. The test helpers did not, and SQLite defaults the pragma OFF per
|
|
6
|
+
* connection, so every one of the schema's `onDelete: 'cascade'` declarations
|
|
7
|
+
* was enforced in production and inert in the suite.
|
|
8
|
+
*
|
|
9
|
+
* The direction is what made it invisible. An unenforced cascade can only ever
|
|
10
|
+
* make a test pass that should have failed, so nothing has ever gone red over
|
|
11
|
+
* it and nothing ever could. `consumer-cleanup.ts`'s whole design reasons about
|
|
12
|
+
* what the cascade removes and when, and no test could observe any of it.
|
|
13
|
+
*
|
|
14
|
+
* Two assertions, because the pragma buys two different things: rows go away
|
|
15
|
+
* when their parent does, and a row referencing an absent parent is refused.
|
|
16
|
+
* The second is what stops a structurally impossible fixture from reading as
|
|
17
|
+
* valid.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
21
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import {
|
|
25
|
+
cleanupTestDatabase,
|
|
26
|
+
setupTestDatabase,
|
|
27
|
+
setupTestDatabaseAt,
|
|
28
|
+
setupTestDatabaseFile,
|
|
29
|
+
} from '../test-utils/database';
|
|
30
|
+
import type { DbClient } from './client';
|
|
31
|
+
|
|
32
|
+
describe('test databases enforce foreign keys', () => {
|
|
33
|
+
let db: DbClient;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
db = await setupTestDatabase();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await cleanupTestDatabase(db);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('deleting a module takes its dependent rows with it', () => {
|
|
44
|
+
db.$client.run(
|
|
45
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy-consumer', 'consumer', '1.0.0', '/tmp/c', '{}')`,
|
|
46
|
+
);
|
|
47
|
+
db.$client.run(
|
|
48
|
+
`INSERT INTO web_routes (slug, module_id, type, path, hostname) VALUES ('s', 'caddy-consumer', 'reverse_proxy', '/', 'example.com')`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
db.$client.run(`DELETE FROM modules WHERE id = 'caddy-consumer'`);
|
|
52
|
+
|
|
53
|
+
const rows = db.$client
|
|
54
|
+
.query(`SELECT id FROM web_routes WHERE module_id = 'caddy-consumer'`)
|
|
55
|
+
.all();
|
|
56
|
+
expect(rows).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a row referencing a module that does not exist is refused', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
db.$client.run(
|
|
62
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data) VALUES ('nonexistent', 'public_web', '1.0.0', '{}')`,
|
|
63
|
+
),
|
|
64
|
+
).toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every way to get a test database, checked directly.
|
|
70
|
+
*
|
|
71
|
+
* `test-utils/database.ts` is now the only module that hands one out — the
|
|
72
|
+
* second one collided with it on two exported names and disagreed with it on
|
|
73
|
+
* this pragma, which is why the gap was invisible from any call site. A helper
|
|
74
|
+
* added here later must be added to this list; there is no longer another module
|
|
75
|
+
* for it to hide in.
|
|
76
|
+
*/
|
|
77
|
+
describe('every test-database helper enforces foreign keys', () => {
|
|
78
|
+
const enforced = (client: DbClient): boolean =>
|
|
79
|
+
(client.$client.query('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys ===
|
|
80
|
+
1;
|
|
81
|
+
|
|
82
|
+
test('setupTestDatabase (in memory)', async () => {
|
|
83
|
+
const memory = await setupTestDatabase();
|
|
84
|
+
expect(enforced(memory)).toBe(true);
|
|
85
|
+
await cleanupTestDatabase(memory);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('setupTestDatabaseFile (temp directory it owns)', async () => {
|
|
89
|
+
const { db: file, cleanup } = await setupTestDatabaseFile();
|
|
90
|
+
expect(enforced(file)).toBe(true);
|
|
91
|
+
await cleanup();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('setupTestDatabaseAt (path the caller chooses)', async () => {
|
|
95
|
+
const dir = await mkdtemp(join(tmpdir(), 'celilo-fk-'));
|
|
96
|
+
const at = await setupTestDatabaseAt(join(dir, 'celilo.db'));
|
|
97
|
+
expect(enforced(at)).toBe(true);
|
|
98
|
+
at.$client.close();
|
|
99
|
+
await rm(dir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/db/schema.ts
CHANGED
|
@@ -153,6 +153,54 @@ export const capabilities = sqliteTable('capabilities', {
|
|
|
153
153
|
.default(sql`(unixepoch())`),
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Capability bindings — which provider a consumer actually resolved to.
|
|
158
|
+
*
|
|
159
|
+
* `capabilities` is provider-side only: it answers "who COULD provide this",
|
|
160
|
+
* never "who does this module actually use". Resolution happened at hook time
|
|
161
|
+
* and vanished into the generated project. Every consumer of that fact
|
|
162
|
+
* reconstructed the same approximation — the consumer's `requires` + `optional`
|
|
163
|
+
* crossed with `capabilities` — which names every provider a module MIGHT have
|
|
164
|
+
* bound to. For a module declaring four optional capabilities and using one,
|
|
165
|
+
* that is three false edges and no way to tell which.
|
|
166
|
+
*
|
|
167
|
+
* A row is written when a consumer's hook first CALLS a method on an injected
|
|
168
|
+
* capability, not when the loader resolves one. The loader deliberately injects
|
|
169
|
+
* every registered capability regardless of what the consumer declared
|
|
170
|
+
* (`loadCapabilityFunctions`, "not just required ones"), so resolution is a
|
|
171
|
+
* superset of the declared set and recording it would restate the
|
|
172
|
+
* approximation. The call is the binding.
|
|
173
|
+
*
|
|
174
|
+
* Unique on (consumer, capability): a redeploy re-asserts the row rather than
|
|
175
|
+
* duplicating it, and a provider swap rewrites `provider_module_id` in place.
|
|
176
|
+
* Cascaded on the consumer: the binding dies with the module that made it.
|
|
177
|
+
*
|
|
178
|
+
* This does NOT replace the permissive set. `planConsumerCleanup` must still
|
|
179
|
+
* notify every provider that might hold minted state, including ones this table
|
|
180
|
+
* has no row for.
|
|
181
|
+
*
|
|
182
|
+
* @owner celilo — cross-module bookkeeping of who resolved to whom (T8, beside `capabilities`)
|
|
183
|
+
*/
|
|
184
|
+
export const capabilityBindings = sqliteTable(
|
|
185
|
+
'capability_bindings',
|
|
186
|
+
{
|
|
187
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
188
|
+
consumerModuleId: text('consumer_module_id')
|
|
189
|
+
.notNull()
|
|
190
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
191
|
+
capabilityName: text('capability_name').notNull(),
|
|
192
|
+
providerModuleId: text('provider_module_id').notNull(),
|
|
193
|
+
/** Last time the consumer called into this provider. */
|
|
194
|
+
boundAt: integer('bound_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
195
|
+
},
|
|
196
|
+
(table) => ({
|
|
197
|
+
consumerCapabilityUnique: uniqueIndex('capability_bindings_consumer_capability_idx').on(
|
|
198
|
+
table.consumerModuleId,
|
|
199
|
+
table.capabilityName,
|
|
200
|
+
),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
156
204
|
/**
|
|
157
205
|
* Capability secrets table - stores encrypted secrets owned by capabilities
|
|
158
206
|
* Values are encrypted with AES-256-GCM using master key
|
|
@@ -251,6 +299,100 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
|
|
|
251
299
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
252
300
|
});
|
|
253
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Lifecycle states an instance moves through, reported by the deploy worker's
|
|
304
|
+
* `list` so a parent can compare desired against observed (design D6).
|
|
305
|
+
*
|
|
306
|
+
* `failed` and `pending` must stay distinguishable: a parent that cannot tell
|
|
307
|
+
* "has not started" from "will never work" either retries a bug forever or
|
|
308
|
+
* gives up on work that was merely queued.
|
|
309
|
+
*/
|
|
310
|
+
export const INSTANCE_STATES = [
|
|
311
|
+
'pending',
|
|
312
|
+
'provisioning',
|
|
313
|
+
'ready',
|
|
314
|
+
'failed',
|
|
315
|
+
'destroying',
|
|
316
|
+
] as const;
|
|
317
|
+
|
|
318
|
+
export type InstanceState = (typeof INSTANCE_STATES)[number];
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Instances of a submodule (openspec/changes/submodules, D2 and D4).
|
|
322
|
+
*
|
|
323
|
+
* An instance IS a row in `modules`, under a celilo-derived id, which is what
|
|
324
|
+
* lets every table and reader keyed on `moduleId` keep working unchanged:
|
|
325
|
+
* `module_systems`, `module_configs`, `module_infrastructure`, health, backup,
|
|
326
|
+
* fleet status and the removal path all needed no change. `moduleId` here is
|
|
327
|
+
* both this table's key and the FK to that row, so an instance cannot exist
|
|
328
|
+
* without the module row it names.
|
|
329
|
+
*
|
|
330
|
+
* This table carries only what a module row cannot express: who owns it, which
|
|
331
|
+
* submodule it came from, and the opaque key its parent knows it by.
|
|
332
|
+
*
|
|
333
|
+
* IDENTITY IS THE TRIPLE (parent, submodule, instanceKey), not `instanceKey`
|
|
334
|
+
* alone (D2). Two parents may use the same key string, and one parent may use
|
|
335
|
+
* the same key across two of its submodules; both are legal and neither
|
|
336
|
+
* collides. `instanceKey` is opaque — celilo stores it and never interprets
|
|
337
|
+
* it, because a key derived from a display name orphans a running system the
|
|
338
|
+
* first time somebody is renamed at the identity provider.
|
|
339
|
+
* @owner celilo — instance ownership and lifecycle state
|
|
340
|
+
*/
|
|
341
|
+
export const moduleInstances = sqliteTable(
|
|
342
|
+
'module_instances',
|
|
343
|
+
{
|
|
344
|
+
/**
|
|
345
|
+
* The derived `modules.id` this instance runs as, e.g. `byoi-lab-sdf82c1e`.
|
|
346
|
+
* Both this table's key and the FK, so the two cannot drift apart.
|
|
347
|
+
*/
|
|
348
|
+
moduleId: text('module_id')
|
|
349
|
+
.primaryKey()
|
|
350
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
351
|
+
/** The module that declared the submodule and created this instance. */
|
|
352
|
+
parentId: text('parent_id')
|
|
353
|
+
.notNull()
|
|
354
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
355
|
+
/** The submodule name the parent declared — its directory under `submodules/`. */
|
|
356
|
+
submodule: text('submodule').notNull(),
|
|
357
|
+
/** Opaque, stable id supplied by the parent. Never interpreted. */
|
|
358
|
+
instanceKey: text('instance_key').notNull(),
|
|
359
|
+
/**
|
|
360
|
+
* Non-authoritative label for operator display. NOTHING may key on it, and
|
|
361
|
+
* changing it does not change identity — that is the whole point of D2.
|
|
362
|
+
*/
|
|
363
|
+
label: text('label'),
|
|
364
|
+
state: text('state').$type<InstanceState>().notNull().default('pending'),
|
|
365
|
+
/**
|
|
366
|
+
* Why a `failed` instance failed. Null unless `state = 'failed'` — the
|
|
367
|
+
* legitimate "this section is absent" case rather than a missing default.
|
|
368
|
+
*/
|
|
369
|
+
failureReason: text('failure_reason'),
|
|
370
|
+
/**
|
|
371
|
+
* Whether retrying could succeed. Set with `failureReason`.
|
|
372
|
+
*
|
|
373
|
+
* A parent's reconcile loop reads this to decide whether to rebuild. A
|
|
374
|
+
* missing interview answer (D7) is deterministic and will fail identically
|
|
375
|
+
* forever, while a timed-out provision is worth another go. Without the
|
|
376
|
+
* flag a caller has to string-match `failureReason`, which is the class of
|
|
377
|
+
* bug where a reporter is trusted by its shape and quietly answers wrong.
|
|
378
|
+
*/
|
|
379
|
+
retryable: integer('retryable', { mode: 'boolean' }),
|
|
380
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
381
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
382
|
+
},
|
|
383
|
+
(table) => ({
|
|
384
|
+
// The real identity (D2). Makes "this parent already has an instance under
|
|
385
|
+
// this key" a database fact rather than a check somebody has to remember.
|
|
386
|
+
identity: unique().on(table.parentId, table.submodule, table.instanceKey),
|
|
387
|
+
// Every reconcile call lists one parent's instances, so that must not be a
|
|
388
|
+
// table scan once a fleet carries tens of them.
|
|
389
|
+
parentIdx: index('module_instances_parent_idx').on(table.parentId),
|
|
390
|
+
// `list` filters by state, and the collapsed operator view rolls up health
|
|
391
|
+
// per parent, which reads the same way.
|
|
392
|
+
stateIdx: index('module_instances_state_idx').on(table.state),
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
|
|
254
396
|
/**
|
|
255
397
|
* IPAM (IP Address Management) allocations table
|
|
256
398
|
* Tracks VMID and IP address assignments per module
|
|
@@ -327,6 +469,12 @@ export const moduleBuilds = sqliteTable('module_builds', {
|
|
|
327
469
|
* something declares `network.<zone>.subnet` — normally the module that supplies
|
|
328
470
|
* the network. Defining a zone here costs nothing and activates nothing.
|
|
329
471
|
*
|
|
472
|
+
* - isp-transit: the private segment between a celilo firewall and the router
|
|
473
|
+
* upstream of it. Non-placement and non-allocatable, because the upstream
|
|
474
|
+
* router is the address authority. It exists so a deployment CAN separate the
|
|
475
|
+
* wire to that router from the workload network, not so every deployment
|
|
476
|
+
* must: a firewall whose egress leg IS the workload network declares
|
|
477
|
+
* `default_route_zone: internal` and behaves exactly as it does today.
|
|
330
478
|
* - internal: the semi-trusted LAN the management server sits on
|
|
331
479
|
* - dmz: public-facing services
|
|
332
480
|
* - app: internal application services
|
|
@@ -345,6 +493,7 @@ export const moduleBuilds = sqliteTable('module_builds', {
|
|
|
345
493
|
* holds a leg on and must translate for.
|
|
346
494
|
*/
|
|
347
495
|
export const NETWORK_ZONES = [
|
|
496
|
+
'isp-transit',
|
|
348
497
|
'internal',
|
|
349
498
|
'dmz',
|
|
350
499
|
'app',
|
|
@@ -366,8 +515,9 @@ export type NetworkZone = (typeof NETWORK_ZONES)[number];
|
|
|
366
515
|
* Zones an IP allocation or reservation can name: every NetworkZone whose
|
|
367
516
|
* addresses celilo hands out.
|
|
368
517
|
*
|
|
369
|
-
*
|
|
518
|
+
* Three are excluded, for the same underlying reason — somebody else is the
|
|
370
519
|
* address authority:
|
|
520
|
+
* - `isp-transit`, whose addresses the upstream router assigns;
|
|
371
521
|
* - `external`, whose systems are addressed by the cloud/VPS provider;
|
|
372
522
|
* - `vpn`, whose client addresses are assigned by the module terminating the
|
|
373
523
|
* tunnel. celilo allocating into that subnet would collide with the VPN
|
|
@@ -378,17 +528,21 @@ export type NetworkZone = (typeof NETWORK_ZONES)[number];
|
|
|
378
528
|
* cast in machine-pool.ts, and the cast had already drifted (it was missing
|
|
379
529
|
* `secure-mgmt`, and its comment claimed the only difference was `external`).
|
|
380
530
|
*/
|
|
381
|
-
export type AllocatableZone = Exclude<
|
|
531
|
+
export type AllocatableZone = Exclude<
|
|
532
|
+
NetworkZone,
|
|
533
|
+
'isp-transit' | 'external' | 'control-plane-vpn'
|
|
534
|
+
>;
|
|
382
535
|
|
|
383
536
|
/** The zones whose addresses celilo hands out, as a runtime list. */
|
|
384
537
|
export const ALLOCATABLE_ZONES: AllocatableZone[] = NETWORK_ZONES.filter(
|
|
385
|
-
(zone): zone is AllocatableZone =>
|
|
538
|
+
(zone): zone is AllocatableZone =>
|
|
539
|
+
zone !== 'isp-transit' && zone !== 'external' && zone !== 'control-plane-vpn',
|
|
386
540
|
);
|
|
387
541
|
|
|
388
542
|
/**
|
|
389
543
|
* Is this a zone celilo allocates addresses in? Use this rather than testing
|
|
390
|
-
* `zone !== 'external'` by hand — that check predates
|
|
391
|
-
*
|
|
544
|
+
* `zone !== 'external'` by hand — that check predates the other externally
|
|
545
|
+
* addressed roles and silently makes them allocatable.
|
|
392
546
|
*/
|
|
393
547
|
export function isAllocatableZone(zone: NetworkZone): zone is AllocatableZone {
|
|
394
548
|
return (ALLOCATABLE_ZONES as string[]).includes(zone);
|
|
@@ -1409,3 +1563,5 @@ export type Alert = typeof alerts.$inferSelect;
|
|
|
1409
1563
|
export type NewAlert = typeof alerts.$inferInsert;
|
|
1410
1564
|
export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
|
|
1411
1565
|
export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;
|
|
1566
|
+
export type CapabilityBinding = typeof capabilityBindings.$inferSelect;
|
|
1567
|
+
export type NewCapabilityBinding = typeof capabilityBindings.$inferInsert;
|