import type { HookName } from '@celilo/capabilities'; import { z } from 'zod'; import { NETWORK_ZONES } from '../db/schema'; import { BACKUP_CADENCE_FLOOR_MINUTES, MONITOR_INTERVAL_FLOOR_MINUTES, cadenceSchema, } from '../services/cadence'; import { SUPPORTED_CONTRACT_VERSIONS } from './contracts'; /** * Variable declaration sources */ export const VariableSourceSchema = z.enum([ 'user', 'capability', 'system', 'terraform', 'infrastructure', 'hook', ]); /** * Variable types */ export const VariableTypeSchema = z.enum([ 'string', 'integer', 'number', 'boolean', 'array', 'object', ]); /** * Variable declaration in manifest * Declares variables that this module needs */ export const VariableDeclareSchema = z.object({ name: z.string().min(1), type: VariableTypeSchema, required: z.boolean(), description: z.string().optional(), source: VariableSourceSchema, default: z.unknown().optional(), // Declarative derivation // Template string for auto-deriving variable value // Supports: $system:key, {variable}, $capability:name.path derive_from: z.string().optional(), // Validation constraints (optional) minimum: z.number().optional(), maximum: z.number().optional(), pattern: z.string().optional(), // Multi-select options for the CLI interview options: z .array( z.object({ value: z.string(), label: z.string(), hint: z.string().optional(), }), ) .optional(), // Follow-up prompt for each selected option (conditional interview) per_selection: z .object({ /** Config key pattern. {value} is replaced with each selected value. */ key_pattern: z.string(), /** Prompt message pattern. {value}, {label}, {hint} are replaced. */ prompt: z.string(), /** Variable type for the follow-up (default: string) */ type: z.string().optional(), /** Derivation source for follow-up values (e.g., "$machine:zone_ip") */ derive_from: z.string().optional(), }) .optional(), }); /** * Variable import in manifest * References variables from other modules' capabilities, making them * available in this module's templates. Was called `variables.uses` in v1. */ export const VariableImportSchema = z.object({ name: z.string().min(1), source: VariableSourceSchema, from: z.string().min(1), }); /** * Secret declaration in manifest * Declares secrets that this module needs */ /** * Secret auto-generation configuration * When present, the secret is auto-generated during deployment instead of prompting the user */ export const SecretGenerateSchema = z.object({ method: z.enum(['random', 'gpg']).default('random'), length: z.number().int().positive().default(32), encoding: z.enum(['base64', 'hex']).default('base64'), // For method: 'gpg' — the user ID stamped on the generated signing key // (e.g. "apt.celilo.computer"). The secret holds the base64'd ASCII-armored // private key, minted once at the config-interview stage. identity: z.string().optional(), }); /** * A recorded decision that a module needs no health check * (openspec/changes/health-waiver-mechanism, D2). All three fields are * required: an anonymous or undated waiver is a check that disappeared. * Strict — an unknown field must fail manifest load, not silently vanish. */ export const HealthWaiverSchema = z .object({ /** The human's words, recorded verbatim. */ reason: z.string().min(1), /** Who made the decision. */ by: z.string().min(1), /** When — an ISO date. */ at: z.string().refine((value) => !Number.isNaN(Date.parse(value)), { message: 'health_waiver.at must be an ISO date string', }), }) .strict(); export const SecretDeclareSchema = z.object({ name: z.string().min(1), // `string-map` is `Record` (e.g. domain → password). It's // stored as JSON.stringify'd text on disk, but the interview uses an // add-loop UX so the operator never has to type JSON braces. type: z.enum(['string', 'integer', 'number', 'string-map']).default('string'), required: z.boolean().default(false), description: z.string().optional(), sensitive: z.boolean().default(true), // Don't log in CLI output generate: SecretGenerateSchema.optional(), // Auto-generate instead of prompting // Validation constraints (optional) minimum: z.number().optional(), maximum: z.number().optional(), pattern: z.string().optional(), // For `type: string-map` only: human-readable labels shown in the // add-loop prompt. Defaults are 'key' / 'value' which are usually too // generic; namecheap wants 'Domain' / 'Password'. key_label: z.string().optional(), value_label: z.string().optional(), // For `type: string-map` only: optional regex applied to each entered // key. The responder rejects mismatches and re-prompts. Use for // domain-shape validation (apex-only), hostname/email shape, etc. // Pair with key_pattern_message to give the operator the rule in // plain English when they hit it. key_pattern: z.string().optional(), key_pattern_message: z.string().optional(), // Same idea for value validation. Less commonly useful for secrets // (passwords vary), but provided for symmetry. value_pattern: z.string().optional(), value_pattern_message: z.string().optional(), }); /** * Capability requirement * Module requires this capability from another module */ export const CapabilityRequirementSchema = z.object({ name: z.string().min(1), version: z.string().min(1), }); /** * Capability secret definition * Defines a secret owned by a capability */ export const CapabilitySecretSchema = z.object({ name: z.string().min(1), type: z.enum(['string', 'number', 'boolean']).default('string'), description: z.string().optional(), readable_by: z.array(z.string()).optional(), // Array of capability names that can access this secret secret_ref: z.string().optional(), // Reference to provider module's own secret (e.g., $secret:tsig_key) }); /** * Ensure-input declaration. * * Each `inputs` entry tells the framework how to apply one piece of the * cross-module update. The `target:` prefix (`config.*` vs `secret.*`) * disambiguates whether the framework should write to the module's * config or its secrets — secret targets get the same masking and * storage path as a regular `secrets:` declaration. * * Templates: `{{value}}` is replaced with the consumer-supplied value. * * Kinds: * - `append_to_array`: read current config array, append `value` if not * already present, write back. Idempotent. * - `set_in_object`: prompt for a string, set it under `key` (a template, * typically `"{{value}}"`) on a JSON-object config or secret. */ export const EnsureInputSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('append_to_array'), /** `config.` or `secret.` — pointer to the array to append to. */ target: z.string().regex(/^(config|secret)\.[A-Za-z_][A-Za-z0-9_]*$/), }), z.object({ kind: z.literal('set_in_object'), /** `config.` or `secret.` — pointer to a JSON-object value. */ target: z.string().regex(/^(config|secret)\.[A-Za-z_][A-Za-z0-9_]*$/), /** Template for the object key. Typically `"{{value}}"`. */ key: z.string().min(1), /** Prompt shown to the user when collecting the per-key value. */ prompt: z.string().min(1), /** Optional supplemental hint shown under the prompt. */ hint: z.string().optional(), }), ]); export type EnsureInput = z.infer; /** * Post-action vocabulary — what the framework should do after applying * `inputs`. Phase 3 only handles `redeploy_self`. Future modules may * want `restart_service`, `run_hook`, etc.; add when needed. */ export const EnsurePostSchema = z.enum(['redeploy_self']); /** * Ensure block — declares how the framework can "ensure" a value is * covered by this provider's config. Consumers reference these by `id` * via `MissingProviderInputError(ensureId, value)`. See * `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`. */ export const EnsureSchema = z.object({ id: z .string() .min(1) .regex(/^[a-z][a-z0-9_]*$/, 'Ensure id must be snake_case'), description: z.string().optional(), inputs: z.array(EnsureInputSchema).min(1), post: EnsurePostSchema.optional(), }); export type Ensure = z.infer; /** * A computed capability field (openspec/specs/internal-dns-split-horizon/spec.md D1). * * Declared alongside `data`, but derived ON ACCESS from other values via the * `value:` DSL (see src/variables/computed/) and never persisted. The result * TYPE is inferred from the expression — deliberately not declared, which is * why `type` is the fixed literal `computed` rather than a real type. */ export const ComputedFieldSchema = z.object({ name: z.string().min(1), type: z.literal('computed'), value: z.string().min(1), description: z.string().optional(), }); export type ComputedField = z.infer; /** * Capability provider * Module provides this capability to other modules */ export const CapabilityProviderSchema = z.object({ name: z.string().min(1), version: z.string().min(1), // A purely imperative capability (functions-only, no cross-module data — // e.g. registry_publish) carries no `data` block; default to {}. data: z.record(z.unknown()).default({}), /** * Computed fields, merged into the capability's data namespace at access * time. A consumer reads `$capability:.` exactly like * a static data field; the resolver evaluates the DSL in the PROVIDER's * context. Names must not collide with `data` keys. */ computed: z.array(ComputedFieldSchema).optional(), secrets: z.array(CapabilitySecretSchema).optional(), functions: z.array(z.string()).optional(), /** Zones this capability applies to. Null/undefined = zone-agnostic. */ zones: z.array(z.string()).optional(), /** * Cross-module ensure points. When a consumer's capability call detects * a value isn't covered by this provider's config, the framework looks * up an ensure here by id and runs the matching interview against this * module's config/secrets. */ ensures: z.array(EnsureSchema).optional(), }); /** * Lifecycle hook definition. * * The expected `inputs` and `outputs` for a hook are defined by the contract * version the manifest declares (see `./contracts/v1.ts`), not by the * manifest itself. The hook block here only carries the script and timeout. */ export const LifecycleHookSchema = z.object({ script: z.string().min(1), timeout: z.number().positive().optional(), }); /** * `health_check` accepts everything a lifecycle hook does, plus an optional * `interval` — the module author's SUGGESTED monitoring cadence. * * It is only a suggestion: the operator's `health_check_interval` override * decides, and resolution happens at read time so a corrected suggestion * reaches every install that has not overridden * ([[services/alerting/health-cadence.ts]]). A module that omits it is not * monitored until someone names a cadence. * * The same spellings as every other cadence in celilo — a named period, a * duration, or `manual` — with the floor derived from the alerting sweep's * tick. */ export const HealthCheckHookSchema = LifecycleHookSchema.extend({ interval: cadenceSchema({ floorMinutes: MONITOR_INTERVAL_FLOOR_MINUTES, description: 'Suggested monitoring cadence: a duration like "15m", "1h" or "1d", a named period (hourly, daily, weekly, monthly), or "manual". The monitor sweep runs on a 5-minute grid, so nothing finer than 5m can be served.', }).optional(), }); /** * Build-bus upstream-publish hook. Fires when a publish event lands * on the local event bus from the receiver daemon. Each module can declare * multiple entries, each with its own match rule, so one manifest can react * to two different packages with two different scripts (celilo-mgmt did * exactly that) without the hook itself having to filter. * * As of module-orchestrator-primitives slice 7 the script is an ordinary * TypeScript `defineHook` module dispatched through the executor, jailed, * with the verified PublishEvent as the `event` input in the hook's typed * context. It used to be a bash path spawned detached on a second execution * path with celilo's whole `process.env` (design D6). That path is gone; the * match rules survive as receiver-side trigger filtering. */ export const UpstreamPublishHookSchema = z.object({ /** Optional display label for logs / `celilo subscribers status`. */ name: z.string().optional(), /** * Match rule — every non-undefined field must equal the * corresponding field on the incoming event. Shape mirrors * `SubscriberMatch` in @celilo/event-bus/build-bus. */ match: z .object({ registry: z.string().optional(), tag: z.string().optional(), package_pattern: z.string().optional(), }) .default({}), /** * Path to the hook script, relative to the module directory. A TypeScript * `defineHook` module; the hook receives the verified PublishEvent as its * `event` input. A shell script is not a hook language. */ script: z.string().min(1), /** Milliseconds before the hook is killed. Default: 600000. */ timeout: z.number().positive().optional(), }); /** * The manifest schema for every invokable lifecycle hook, derived from the one * list (celilo#821). * * `satisfies Record` is doing the enforcement, in both directions: * a name in `HOOK_NAMES` with no entry here is a type error, and an entry here * that is not a `HookName` is a type error too. That is why this is a literal * rather than something built with `Object.fromEntries` — a computed object * would widen the keys to `string` and take `ModuleManifest['hooks']` down with * it, trading one silent drift for another. * * Per-hook prose lives in `contracts/v1.ts`, which documents the same names. * Two copies of that commentary is how they came to disagree. */ const LIFECYCLE_HOOK_SCHEMAS = { container_created: LifecycleHookSchema.optional(), on_install: LifecycleHookSchema.optional(), on_uninstall: LifecycleHookSchema.optional(), on_consumer_removed: LifecycleHookSchema.optional(), health_check: HealthCheckHookSchema.optional().describe( "Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.", ), validate_config: LifecycleHookSchema.optional(), on_backup: LifecycleHookSchema.optional(), on_backup_analyze: LifecycleHookSchema.optional(), on_restore: LifecycleHookSchema.optional(), on_system_event: LifecycleHookSchema.optional(), reconcile_routes: LifecycleHookSchema.optional(), refresh_registrations: LifecycleHookSchema.optional(), reassert_dhcp_dns: LifecycleHookSchema.optional(), reconcile_clients: LifecycleHookSchema.optional(), list_peers: LifecycleHookSchema.optional(), reconcile_peers: LifecycleHookSchema.optional(), sweep_revisions: LifecycleHookSchema.optional(), /** * The one HookName whose schema entry is an ARRAY (see * UpstreamPublishHookSchema): multiple match-rule entries per manifest, * dispatched per event by the build-bus receiver daemon through the * ordinary executor. Deliberately not a LifecycleHookSchema. */ on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(), } satisfies Record; /** * What `manifest.hooks` accepts: every lifecycle hook, each derived from the * one list (celilo#821). `on_upstream_publish` lives in * `LIFECYCLE_HOOK_SCHEMAS` with the rest now that it is a HookName; its array * entry is commented there. */ const HOOK_SCHEMAS = { ...LIFECYCLE_HOOK_SCHEMAS, }; /** * Machine resource recommendations * Module declares recommended machine resources (CPU, memory, disk, storage) */ export const SystemResourceSchema = z.object({ cpu: z.number().int().positive().optional().describe('Recommended CPU cores'), memory: z.number().int().positive().optional().describe('Recommended memory in MB'), disk: z.number().int().positive().optional().describe('Recommended disk size in GB'), storage: z.string().optional().describe('Proxmox storage backend (defaults to system config)'), type: z .enum(['lxc', 'vm']) .default('lxc') .describe( 'Proxmox provisioning type: lxc (default) or vm (qemu, for Docker / kernel-module workloads). ' + 'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.', ), // Derived from NETWORK_ZONES, not re-listed: a hand-written copy here would be // a fourth place a new zone has to be remembered, and the copies in // module-show and machine-pool had both already drifted. zone: z.enum(NETWORK_ZONES).describe('Required security zone for this module'), }); /** * A network a module depends on, declared under `requires.networks` * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * * It names the network and stops there. celilo owns the network namespace: the * range belongs to the operator's topology, not to whichever module happens to * arrive first and need it. A module that carried the value here would be * defining the network by another route, which is the authority this change * reverses — so `.strict()` is load-bearing rather than tidiness, and its * message says why. * * When the named network has no `network..subnet` in system config, the * deploy asks for one BEFORE anything is generated or any hook runs * ([[services/network-ensure.ts]]). * * ── Two forms, because some requirements are not knowable at authoring time ── * * `name:` is a literal — `wireguard` always needs `control-plane-vpn`, and says * so once. `from:` names a config array whose VALUES are the network names, for * a module whose set is decided per install: a firewall requires the networks it * has legs on, and which legs it has is a property of the box it lands on. * * `from:` is not a loophole in "a requirement carries no value". It still names * networks and still carries none — it just names them indirectly. What it fixes * is the split that `firewall-interface-classification` §3 already diagnosed from * the harness side: the legs a firewall has and the networks it declares were two * hand-maintained lists, and every one of the ~20 call sites that had to keep * them in step got it wrong. A leg whose network has no declared subnet * classifies `alien`, so under-declaring is not cosmetic — it is how an interface * gets isolated. One list makes that structurally impossible. */ export const NetworkRequirementSchema = z .object({ name: z .string() .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'network name must be kebab-case') .describe('Name of a network this module needs defined before it deploys') .optional(), from: z .string() .regex( /^\$self:[a-zA-Z_][a-zA-Z0-9_]*$/, 'from must reference one of this module\'s own config values, e.g. "$self:zones"', ) .describe('A config array whose values are network names, e.g. "$self:zones"') .optional(), }) .strict( 'A network requirement NAMES networks; it never carries their values. celilo owns ' + 'the network namespace — the range is supplied before the deploy runs — so a ' + 'requirement takes `name:` (a literal) or `from:` (a $self: config array of names) ' + 'and nothing else. Read the range with `source: system` / ' + '`derive_from: "$system:network..subnet"`.', ) .refine((requirement) => Boolean(requirement.name) !== Boolean(requirement.from), { message: 'A network requirement declares exactly one of `name:` (a literal network) or ' + '`from:` (a $self: config array whose values are network names)', }); export type NetworkRequirement = z.infer; /** * One system a module deploys (openspec/specs/module-systems-addressing/spec.md). A module * declares 0..N of these under `requires.systems`. `name` is the stable * authoring-time handle — referenced in templates via `$infra:.…` and the * per-system key in `module_systems`. `resources` carries the per-system machine * spec + zone (each system can sit in a different zone). */ export const SystemDeclarationSchema = z.object({ name: z .string() .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'system name must be kebab-case') .describe('Stable handle for this system; used in $infra: and as the per-system key'), resources: SystemResourceSchema, }); /** * Ansible collection requirement * Declares Ansible collections this module needs */ export const AnsibleCollectionSchema = z.object({ name: z .string() .min(1) .regex( /^[a-z0-9_]+\.[a-z0-9_]+$/, 'Collection name must be in format namespace.name (e.g., community.general)', ), version: z .string() .min(1) .regex( /^(>=|==|<=|>|<)\d+\.\d+\.\d+(,(<|>|<=|>=|==)\d+\.\d+\.\d+)?$/, 'Version must be semver constraint (e.g., >=8.0.0 or >=8.0.0,<10.0.0)', ), modules_used: z.array(z.string()).optional(), reason: z.string().optional(), }); /** * Event-bus subscription declaration in a module manifest. * * Each entry becomes a row in the bus's `subscribers` table when the * module is imported, and is removed when the module is removed. * * Substitutions resolved at subscribe time: * - `$self` in `pattern` → the module's id * - `${MODULE_PATH}` in `handler` → the module's installed path */ export const ModuleSubscriptionSchema = z .object({ name: z .string() .min(1) .regex( /^[a-z][a-z0-9_-]*$/, 'Subscription name must be kebab/snake_case (lowercase, alphanumeric, dash/underscore)', ), pattern: z.string().min(1), /** * Shell-command handler the dispatcher spawns as a subprocess. Mutually * exclusive with `hook` — a subscription declares exactly one. */ handler: z.string().min(1).optional(), /** * Name of one of THIS module's own hooks to invoke when a matching event * fires (openspec/specs/event-driven-hook-subscriptions/spec.md). The framework synthesizes * a `celilo events run-hook` handler that runs the hook in a * fault-isolated subprocess with backend access (DB, capabilities, * secrets). Mutually exclusive with `handler`. */ hook: z.string().min(1).optional(), /** * Static inputs merged into the hook's `inputs` (e.g. `{ op: register }`), * so two subscriptions can drive the same hook with different intent. * Event payload fields are merged on top. Only valid alongside `hook`. */ hook_inputs: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), max_attempts: z.number().int().positive().optional(), timeout_ms: z.number().int().positive().optional(), }) .strict() .refine((s) => (s.handler ? 1 : 0) + (s.hook ? 1 : 0) === 1, { message: 'subscription must declare exactly one of `handler` or `hook`', }) .refine((s) => !s.hook_inputs || Boolean(s.hook), { message: '`hook_inputs` is only valid together with `hook`', }); export type ModuleSubscription = z.infer; /** * Module manifest schema (v2 shape). * * Every module must declare which Celilo contract version it complies * with via `celilo_contract`. The contract version determines the * canonical inputs/outputs of every lifecycle hook (see `./contracts/v1.ts`). */ /** * Codepoints carrying Unicode's `Emoji` property, which the `icon` field * rejects. Note this property is broader than "looks like an emoji": ASCII * digits, `#` and `*` carry it too, because they form keycap sequences. That * over-rejection costs nothing — none of them is a plausible module glyph. */ const EMOJI_CODEPOINT = /\p{Emoji}/u; export const ModuleManifestSchema = z .object({ /** * Celilo contract version this manifest targets. Determines hook * signatures, validation rules, and the overall manifest shape. Pinned * here so a future v2 contract can change requirements without breaking * v1 modules. */ celilo_contract: z.enum(SUPPORTED_CONTRACT_VERSIONS), id: z .string() .min(1) .regex( /^[a-z0-9]+(-[a-z0-9]+)*$/, 'Module ID must use kebab-case (lowercase letters, numbers, hyphens between segments)', ), name: z.string().min(1), version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semantic version (e.g., 1.0.0)'), description: z.string().optional(), /** * One glyph identifying this module wherever celilo draws it — the console * roster, the topology boxes, the registry browse page. Optional: a module * declaring none falls back to the consumer's built-in table, then to a * placeholder (openspec/changes/module-icons, D3/D5). * * Exactly one Unicode scalar, inside the BMP, without Unicode's `Emoji` * property. These glyphs inherit the colour of the row they are drawn in, * so a firing module's icon goes red with the rest of the row. An emoji * codepoint paints its own colours and would stay cheerful while its * module's state said otherwise. * * BMP-and-not-Emoji is a PROXY for "renders monochrome", not a proof. Some * BMP codepoints outside the Emoji property still get an emoji font on some * platforms. What it does catch is the whole SMP emoji range, which is * where an author reaching for a padlock or a shield actually lands, and * that is the case worth catching. * * `ModuleManifestSchema` is strict, so a celilo predating this field * rejects a manifest declaring it. The CLI release accepting `icon` ships * before any module published to the registry declares one (D2). */ icon: z .string() .superRefine((value, ctx) => { const scalars = [...value]; if (scalars.length !== 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `icon must be exactly one character, got ${scalars.length}`, }); return; } const codePoint = value.codePointAt(0) ?? 0; if (codePoint > 0xffff || EMOJI_CODEPOINT.test(value)) { const hex = codePoint.toString(16).toUpperCase().padStart(4, '0'); ctx.addIssue({ code: z.ZodIssueCode.custom, message: [ `icon '${value}' (U+${hex}) must be a monochrome glyph: it inherits the colour`, 'of the row it is drawn in, and an emoji codepoint paints its own colours, so it', "would stay cheerful while the module went red. Use a BMP symbol outside Unicode's", "Emoji property (a key '\u26bf', not a padlock '\u{1f512}').", ].join(' '), }); } }) .optional(), /** * How `manifest.yml#version` (the PAYLOAD version) is determined — see * openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version * lives in `provides.capabilities[].version` and is unrelated to this. * * - `changeset` — first-party apps/content (lunacycle): the version is * authored via `.changeset/*.md` keyed by the module id; `celilo module * version` stamps it here. Ordered by the `+N` revision; semver is a * blast-radius/posture signal, not a contract. * - `pin` — wrapper modules (caddy, forgejo): `resolver` returns the * upstream version actually installed; `module check` fails on drift. * - `recipe` — payload-less modules (iptables): no software version; `+N` * orders. The default when this block is absent, preserving today's * behaviour for unmigrated modules. */ version_source: z .object({ kind: z.enum(['pin', 'changeset', 'recipe']), /** For `kind: pin`: path (module-relative) to a script printing the upstream version. */ resolver: z.string().optional(), }) .strict() .optional(), /** * Submodules this module owns (openspec/changes/submodules, D1). * * Each name is a directory under this module's own `submodules/`, holding * an ordinary module manifest. A submodule is never a top-level module: an * operator does not import it, does not deploy it, and never sees it in the * registry. It ships inside this package, versions with this manifest, and * exists only as instances this module creates through the * `celilo_module_deploy_worker` capability. * * Ownership is declared by the PARENT rather than flagged on the child, and * that is what makes the exclusivity structural rather than enforced. A * submodule is not in `modules/`, so there is nothing to deploy by accident * and no flag for a code path to forget to check. * * A plain list of names, not a block. Per-submodule policy such as a * maximum instance count is the operator's business rather than the * author's — the same reason `requires.system` states a minimum rather than * a deployed size. A `string | object` union stays reachable additively if * a genuinely author-owned per-submodule fact ever turns up. * * v1.0 contract field — additive, no contract version bump needed, exactly * like `optional.capabilities`. */ submodules: z .array( z .string() .regex( /^[a-z0-9]+(-[a-z0-9]+)*$/, 'Submodule name must use kebab-case — it names a directory under submodules/', ), ) .optional(), requires: z .object({ capabilities: z.array(CapabilityRequirementSchema).default([]), /** * The 0..N systems this module deploys (openspec/specs/module-systems-addressing/spec.md). * Preferred over the singular `system`. A module declaring * `systems` gets one host per entry, each addressable as `$infra:`. */ systems: z.array(SystemDeclarationSchema).optional(), /** * Networks that must be DEFINED in celilo's own config before this * module deploys. Names only — see `NetworkRequirementSchema`. */ networks: z.array(NetworkRequirementSchema).optional(), /** * Singular form — sugar for a single unnamed system. Normalized to one * `systems` entry (name `main`) by `getDeclaredSystems`. Optional because * config-only providers (e.g. namecheap) deploy no infrastructure. * Modules declaring more than one host should use `systems`. */ system: SystemResourceSchema.optional(), }) .default({ capabilities: [] }), /** * Optional capabilities the module *can* use if a provider is installed. * Parallel to `requires.capabilities`. Distinguishes "this hook will not * run without idp" from "this hook will use monitoring if it's there". * * Hook scripts using `defineHook({ optional: ['monitoring'] })` get * `capabilities.monitoring?: MonitoringCapability` typed as possibly * undefined; required capabilities are typed as guaranteed non-null. * * Added in HOOK_API_V2 Phase 3 (D3). v1.0 contract field — additive, * no contract version bump needed. Truly optional at the schema level * so existing manifests and test fixtures need no churn; readers should * use `manifest.optional?.capabilities ?? []`. */ optional: z .object({ capabilities: z.array(CapabilityRequirementSchema).default([]), }) .optional(), provides: z .object({ capabilities: z.array(CapabilityProviderSchema).default([]), }) .default({ capabilities: [] }), variables: z .object({ /** Variables this module owns. Was `variables.declares` in v1. */ owns: z.array(VariableDeclareSchema).default([]), /** * Variables imported from other modules' capabilities, made * available in this module's templates. Was `variables.uses` in v1. */ imports: z.array(VariableImportSchema).default([]), }) .default({ owns: [], imports: [] }), secrets: z .object({ declares: z.array(SecretDeclareSchema).default([]), }) .optional(), hooks: z.object(HOOK_SCHEMAS).strict().optional(), /** * How much of `celilo module verify` this module can honestly answer. * * `deep: false` opts the module out of the host plane — the `--deep` pass * that evaluates its generated playbook in check mode. The escape hatch * exists because Ansible's check mode SKIPS a task it cannot evaluate, so a * role driven by `command:` / `shell:` can report `changed=0` having never * been applied. For most roles that lands as `unmeasured`, which is honest. * A role where it would land as a persistent false `drift` should say so * here instead of training operators to ignore the output. * * `reason` is REQUIRED. An opt-out with no stated reason is a check that * disappeared, and celilo prints the reason wherever the module is verified * (openspec/changes/module-integrity-rigor, D4 / task 6.3). */ verify: z .object({ deep: z.literal(false), reason: z.string().min(1), }) .strict() .optional(), /** * A recorded, first-class decision that this module needs no health * check (openspec/changes/health-waiver-mechanism, D1-D4). * * A waiver is NOT `skip`. `skip` says a check could not run this * time. A waiver says a human decided this module needs no check, * and records WHO and WHY. It annotates the module's `unmeasured` * audit finding; the severity stays `unmeasured` and the fleet * verdict stays UNKNOWN. It never re-greens the board. * * It lives here rather than in operator state because it must * survive reinstall, reprovision and registry publish, and because * `git diff` should show the decision changing. There is no expiry * by design: a lapsed waiver would silently re-green, which is the * failure being guarded. Revisiting the decision is a new manifest * edit, not a timer. */ health_waiver: HealthWaiverSchema.optional(), build: z .object({ /** Inline shell command to build the module (run via bash -c). Mutually exclusive with script. */ command: z.string().min(1).optional(), /** Path to a build script (relative to module directory). Mutually exclusive with command. */ script: z.string().min(1).optional(), artifacts: z .array( z .string() .min(1) .refine( (path) => { // Reject absolute paths if (path.startsWith('/')) return false; // Reject tilde expansion if (path.startsWith('~')) return false; // Reject path traversal attempts if (path.includes('..')) return false; // Reject leading ./ if (path.startsWith('./')) return false; // Reject backslashes (Windows-style paths) if (path.includes('\\')) return false; // Reject null bytes if (path.includes('\0')) return false; return true; }, { message: 'Build artifact paths must be relative to module directory. No absolute paths, tilde expansion, path traversal (..), backslashes, or null bytes allowed.', }, ), ) .default([]), }) .refine((build) => build.command || build.script, { message: 'Build section must specify either "command" or "script"', }) .refine((build) => !(build.command && build.script), { message: 'Build section must specify either "command" or "script", not both', }) .optional(), backup: z .object({ // The author's SUGGESTED cadence. The operator's `backup_schedule` // override wins and is resolved at read time — see // [[services/backup-schedule.ts]]. Absent from both means `daily`; // opting out of backups entirely is a real decision and takes an // explicit `manual`. // schedule: cadenceSchema({ floorMinutes: BACKUP_CADENCE_FLOOR_MINUTES, description: 'Suggested backup cadence: a named period (hourly, daily, weekly, monthly), a duration like "6h", or "manual" to opt out. The backup sweep runs hourly, so nothing finer than 1h can be served.', }).default('daily'), retention: z .object({ count: z.number().int().positive().default(7), max_age_days: z.number().int().positive().default(30), }) .optional(), }) .optional(), ansible: z .object({ collections: z.array(AnsibleCollectionSchema).default([]), }) .optional(), e2e: z .object({ tests_dir: z.string().min(1), }) .optional(), /** * Event-bus subscriptions this module wants registered. See * `infra/openspec/specs/event-bus/spec.md`. Truly optional so existing * test fixtures and manifests don't need a churn-pass; readers * use `manifest.subscriptions ?? []`. */ subscriptions: z.array(ModuleSubscriptionSchema).optional(), /** * Optional base-module aspect — lightweight fleet-configuration * code applied to OTHER systems in declared zones, on top of * the module's primary deployment. See openspec/specs/base-module-aspects/spec.md. * * Modules without this block work exactly as today. When * declared, the operator approves the aspect's scope at * `celilo module import` time (or passes `--accept-aspects` for * non-interactive use). The framework then runs the named * Ansible role against every non-`api_only` system in * `applicable_zones` whenever a declared trigger fires. */ base_module_aspect: z .object({ /** * The Ansible role under `base-module-aspect/ansible/roles/` * that runs on each target system. Receives module config * + capability data as ansible vars plus a `target_zone` * fact. */ ansible_role: z.string().min(1), /** * Zones this aspect fans out to. There is no operator-level * opt-in beyond import-time approval — these zones ARE the * target set. At least one zone must be listed. */ applicable_zones: z.array(z.string().min(1)).min(1), /** * Events that should cause the aspect to fan out. * * Phase 1 ships only `on_install`. Later phases add * `on_new_system_in_zone`, `on_aspect_change`, * `on_module_config_change`, `on_capability_data_change` — * the enum is open here so manifests can declare future * triggers ahead of framework support landing, but only the * triggers the framework knows about will actually fire. */ triggers: z .array( z.enum([ 'on_install', 'on_new_system_in_zone', 'on_aspect_change', 'on_module_config_change', 'on_capability_data_change', ]), ) .min(1) .default(['on_install']), /** * Optional Ansible variables to inject into the aspect's * inventory before the role runs. Each entry maps a * variable name (consumed by the role as `{{ name }}`) to * a value template using the same `$self:` / `$capability:` * / `$system:` substitution celilo uses elsewhere. Values * resolve against the PROVIDING module's context at fan-out * time, then land in `group_vars/all/aspect_vars.yml` so * every targeted system's role can read them. * * Example (knot-unbound-internal): * ansible_vars: * knot_server_ip: $self:target_ip * * Without this block the role only receives `target_zone` * — useful but rarely sufficient. */ ansible_vars: z.record(z.string(), z.string()).optional(), /** * Optional Proxmox reconciliation (openspec/specs/base-module-aspects/spec.md D5). * * When an aspect manages a setting that Proxmox also owns * authoritatively (DNS resolver via `proxmox_lxc.nameserver` * is the canonical example), declaring `proxmox_reconcile.tfvars` * tells the framework to ALSO update the LXC's owning * module's terraform config — not just the running * /etc/resolv.conf via Ansible. Without this, a re-provision * would revert to the stale Proxmox-baked value. * * Each entry maps a `terraform.tfvars` variable name to a * value-template. Templates support the same `$capability:` * and `$self:` substitutions celilo uses elsewhere; the * value is resolved against the providing module's context * (capability data + module config) at fan-out time. * * Only applies to systems whose `module_infrastructure` * row is `container_service` AND the service's provider is * Proxmox. Machine-pool systems silently skip this block * because they aren't terraform-managed at the celilo * layer. */ proxmox_reconcile: z .object({ tfvars: z.record(z.string(), z.string()), }) .strict() .optional(), }) // strict on the inner object: operators approve this exact // block at import time (D2), so silently-ignored unknown // fields would let a manifest broaden scope without consent. .strict() .optional(), }) .strict(); /** * Type exports */ export type ModuleManifest = z.infer; export type VariableDeclare = z.infer; export type VariableImport = z.infer; export type SecretDeclare = z.infer; export type CapabilityRequirement = z.infer; export type CapabilityProvider = z.infer; export type LifecycleHook = z.infer; export type VariableSource = z.infer; export type VariableType = z.infer; export type AnsibleCollection = z.infer; export type SystemResource = z.infer; export type SystemDeclaration = z.infer; export type HealthWaiver = z.infer; export type BaseModuleAspect = NonNullable; export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number]; /** * Normalize a manifest's declared systems (openspec/specs/module-systems-addressing/spec.md). * Returns `requires.systems` if present; else the singular `requires.system` * as one entry named `main`; else `[]` (config-only module like namecheap). * This is the single place the system→systems sugar lives, so the rest of the * codebase only ever reasons about the 0..N collection. */ export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[] { const requires = manifest.requires; if (requires?.systems && requires.systems.length > 0) { return requires.systems; } const singular = getSingularSystemSpec(manifest); if (singular) { return [{ name: 'main', resources: singular }]; } return []; } /** * The singular system resource spec a module declares: `requires.system`. The * one place callers read the single-system spec (zone, sizing, "does this module * need infra?"). Returns undefined for config-only modules or those declaring the * plural `requires.systems`. */ export function getSingularSystemSpec(manifest: ModuleManifest): SystemResource | undefined { return manifest.requires?.system; } /** * The network names a module requires * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * Deduplicated and in declaration order. Empty for the modules that depend on no * network, which is most of them. * * `from:` entries are resolved against `config` — the module's own values, as * answered by the config interview. A module whose required set is decided per * install (a firewall requires the networks it has legs on) therefore needs its * config populated before this is meaningful, which is why the ensure runs after * the config interview and before generation. * * `external` is dropped wherever it appears. It is the RESIDUAL — an interface is * `external` because it is publicly routable and matched no declared zone, never * because it is contained in a subnet — so giving it one would reintroduce the * overload that `readDeclaredNetworks` and `deployFirewall` both already exclude. */ export function getRequiredNetworkNames( manifest: ModuleManifest, config: Record = {}, ): string[] { const names: string[] = []; for (const requirement of manifest.requires?.networks ?? []) { if (requirement.name) { names.push(requirement.name); continue; } if (!requirement.from) continue; const key = requirement.from.slice('$self:'.length); names.push(...asNetworkNameArray(config[key])); } return [...new Set(names)].filter((name) => name !== 'external'); } /** * Narrow a `from:`-referenced config value to network names. * * Tolerates the JSON-string form as well as a real array: an array config value * arrives as either depending on how it was written, and a requirement that * silently resolved to nothing would leave a leg undeclared — which is the * failure this whole mechanism exists to prevent. */ function asNetworkNameArray(raw: unknown): string[] { const value = typeof raw === 'string' && raw.trim().startsWith('[') ? (() => { try { return JSON.parse(raw) as unknown; } catch { return undefined; } })() : raw; if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); }