@celilo/cli 0.11.0 → 0.12.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 +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +52 -6
- package/MODULE_PRIMITIVES.md +164 -0
- package/README.md +5 -5
- package/drizzle/0015_port_forwards.sql +12 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +13 -5
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/api/protocol.test.ts +1 -1
- package/src/api/remote-client.test.ts +1 -1
- package/src/api/serve.ts +5 -5
- package/src/capabilities/public-web-publish.test.ts +209 -0
- package/src/capabilities/validation.test.ts +1 -1
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +2 -2
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/events.ts +1 -1
- package/src/cli/commands/module-changeset.ts +1 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/module-publish.ts +1 -1
- package/src/cli/commands/module-remove.ts +26 -1
- package/src/cli/commands/module-types.ts +1 -1
- package/src/cli/commands/module-upgrade.ts +1 -1
- package/src/cli/commands/module-version.ts +1 -1
- package/src/cli/commands/module-where.test.ts +26 -0
- package/src/cli/commands/module-where.ts +130 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -1
- package/src/cli/commands/publish/alpha.test.ts +1 -1
- package/src/cli/commands/publish/alpha.ts +3 -3
- package/src/cli/commands/publish/changesets.ts +1 -1
- package/src/cli/commands/publish/global-install.ts +6 -2
- package/src/cli/commands/publish/helpers.ts +21 -14
- package/src/cli/commands/publish/index.ts +4 -4
- package/src/cli/commands/publish/plan.ts +3 -3
- package/src/cli/commands/publish/preflight.ts +2 -2
- package/src/cli/commands/publish/types.ts +2 -2
- package/src/cli/commands/publish/workspace.test.ts +1 -1
- package/src/cli/commands/publish/workspace.ts +2 -2
- package/src/cli/commands/registry-owner.test.ts +166 -0
- package/src/cli/commands/registry-owner.ts +124 -0
- package/src/cli/commands/registry-token.test.ts +109 -0
- package/src/cli/commands/registry-token.ts +194 -0
- package/src/cli/commands/restore.ts +1 -1
- package/src/cli/commands/service-add-proxmox.ts +1 -1
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/commands/subscribers-list.ts +1 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
- package/src/cli/commands/system-apply-config.ts +1 -1
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-init.ts +2 -2
- package/src/cli/commands/token.test.ts +26 -0
- package/src/cli/commands/token.ts +160 -0
- package/src/cli/completion.ts +21 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +170 -7
- package/src/cli/parser.ts +1 -1
- package/src/cli/restore-command.test.ts +1 -1
- package/src/db/client.ts +1 -1
- package/src/db/schema.ts +50 -7
- package/src/hooks/capability-loader-firewall.test.ts +4 -3
- package/src/hooks/capability-loader.ts +100 -10
- package/src/hooks/define-hook.test.ts +24 -0
- package/src/hooks/executor.test.ts +1 -1
- package/src/hooks/executor.ts +2 -2
- package/src/hooks/types.ts +1 -1
- package/src/manifest/contracts/v1.ts +2 -2
- package/src/manifest/schema.ts +13 -13
- package/src/manifest/template-validator.ts +1 -1
- package/src/module/packaging/build.ts +19 -0
- package/src/module/packaging/workspace-deps.test.ts +94 -0
- package/src/module/packaging/workspace-deps.ts +185 -0
- package/src/module/versioning/changeset-version.ts +1 -1
- package/src/policy/no-hand-built-ssh.test.ts +90 -0
- package/src/registry/client.test.ts +86 -0
- package/src/registry/client.ts +67 -1
- package/src/services/api-access.ts +1 -1
- package/src/services/aspect-approvals.ts +1 -1
- package/src/services/aspect-runner.ts +3 -3
- package/src/services/aspect-template-resolver.test.ts +1 -1
- package/src/services/aspect-template-resolver.ts +1 -1
- package/src/services/build-bus/delivery-events.ts +1 -1
- package/src/services/build-bus/fan-out.ts +1 -1
- package/src/services/build-bus/hook-dispatch.ts +1 -1
- package/src/services/build-bus/receiver-server.ts +1 -1
- package/src/services/build-bus/status.test.ts +1 -1
- package/src/services/build-bus/status.ts +1 -1
- package/src/services/build-bus/subscriber-store.ts +1 -1
- package/src/services/bus-interview.ts +1 -1
- package/src/services/celilo-events.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +1 -1
- package/src/services/cross-module-read.ts +1 -1
- package/src/services/deploy-posture.ts +1 -1
- package/src/services/deployed-systems.test.ts +1 -1
- package/src/services/deployed-systems.ts +4 -4
- package/src/services/dns-provider-backfill.ts +2 -2
- package/src/services/events-daemon.ts +1 -1
- package/src/services/machine-pool.ts +3 -3
- package/src/services/module-deploy.ts +93 -8
- package/src/services/module-subscriptions.ts +1 -1
- package/src/services/module-types-generator.ts +1 -1
- package/src/services/module-validator/git-hygiene.ts +1 -1
- package/src/services/port-forwards.test.ts +92 -0
- package/src/services/port-forwards.ts +86 -0
- package/src/services/proxmox-reconcile.ts +2 -2
- package/src/services/public-web-republish.test.ts +189 -0
- package/src/services/public-web-republish.ts +84 -0
- package/src/services/restore-from-file.ts +1 -1
- package/src/services/restore-preflight.ts +1 -1
- package/src/services/system-identity.ts +1 -1
- package/src/services/system-init.test.ts +2 -2
- package/src/services/system-init.ts +2 -2
- package/src/services/web-route-cleanup.test.ts +250 -0
- package/src/services/web-route-cleanup.ts +144 -0
- package/src/templates/generator.ts +2 -2
- package/src/types/infrastructure.ts +1 -1
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/computed/evaluate.ts +1 -1
- package/src/variables/computed/parse.ts +1 -1
- package/src/variables/context.test.ts +1 -1
- package/src/variables/context.ts +5 -5
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/src/variables/resolver.ts +2 -2
- package/src/variables/types.ts +1 -1
- package/src/api/protocol.ts +0 -159
- package/src/api/remote-client.ts +0 -218
- package/src/cli/command-registry.ts +0 -1488
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route cleanup on module removal — the consumer half of ISS-0035.
|
|
3
|
+
*
|
|
4
|
+
* `register_route` / `unregister_routes` emit `public_web.routes_changed`, and
|
|
5
|
+
* the provider's `reconcile_routes` subscription re-renders its config from
|
|
6
|
+
* `web_routes`. But `unregister_routes` is only reachable from a module's own
|
|
7
|
+
* `on_uninstall` teardown script, and most consumers don't define one —
|
|
8
|
+
* celilo-website, celilo-registry, celilo-apt-repo and celilo-mgmt all publish
|
|
9
|
+
* a site and have no teardown. For those, `module remove` deleted the route
|
|
10
|
+
* rows through the FK cascade on `modules.id` and emitted NOTHING, so the
|
|
11
|
+
* provider never learned the route was gone: the rendered Caddyfile kept a site
|
|
12
|
+
* block for a module that no longer exists, and `/srv/www/<slug>` was orphaned.
|
|
13
|
+
*
|
|
14
|
+
* The fix lives here rather than in each module's teardown script for the usual
|
|
15
|
+
* reason: one call site in `performModuleRemove` covers every consumer and
|
|
16
|
+
* can't be forgotten by the next one. Modules that DO define an `on_uninstall`
|
|
17
|
+
* calling `unregister_routes` still win — their hook runs first and this
|
|
18
|
+
* becomes a no-op (zero owned routes).
|
|
19
|
+
*
|
|
20
|
+
* Everything here is best-effort. A failed asset `rm` or an undelivered
|
|
21
|
+
* reconcile is reported as a warning, never a thrown error: a stuck provider is
|
|
22
|
+
* no reason to strand a module in a half-removed state.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execRunner, runAppCommand, shellEscape } from '@celilo/capabilities';
|
|
26
|
+
import type { Runner } from '@celilo/capabilities';
|
|
27
|
+
import { eq } from 'drizzle-orm';
|
|
28
|
+
import type { DbClient } from '../db/client';
|
|
29
|
+
import { capabilities, webRoutes } from '../db/schema';
|
|
30
|
+
import { loadHookConfigMap } from '../hooks/load-hook-config';
|
|
31
|
+
import { emitWebRoutesChangedAndWait } from './celilo-events';
|
|
32
|
+
|
|
33
|
+
export interface WebRouteCleanupResult {
|
|
34
|
+
routesRemoved: number;
|
|
35
|
+
/** Asset roots actually deleted from the provider host. */
|
|
36
|
+
assetDirsRemoved: string[];
|
|
37
|
+
/** Non-fatal problems, for the caller to log. Never thrown. */
|
|
38
|
+
warnings: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WebRouteCleanupDeps {
|
|
42
|
+
moduleId: string;
|
|
43
|
+
db: DbClient;
|
|
44
|
+
/** Injectable remote runner — tests pass a mock. */
|
|
45
|
+
run?: Runner;
|
|
46
|
+
/** Injectable event emit + wait — tests pass a stub. */
|
|
47
|
+
emitAndWait?: typeof emitWebRoutesChangedAndWait;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The provider host that owns `/srv/www` — the same `config.target_ip` the
|
|
52
|
+
* public_web capability uses for its asset uploads.
|
|
53
|
+
*/
|
|
54
|
+
async function resolvePublicWebHostIp(db: DbClient): Promise<string | undefined> {
|
|
55
|
+
const providers = db
|
|
56
|
+
.select()
|
|
57
|
+
.from(capabilities)
|
|
58
|
+
.where(eq(capabilities.capabilityName, 'public_web'))
|
|
59
|
+
.all();
|
|
60
|
+
const provider = providers[0];
|
|
61
|
+
if (!provider) return undefined;
|
|
62
|
+
|
|
63
|
+
const config = await loadHookConfigMap(provider.moduleId, db);
|
|
64
|
+
// target_ip may carry a CIDR suffix; the capability strips it the same way.
|
|
65
|
+
return String(config.target_ip ?? '').split('/')[0] || undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Delete a module's web routes, reclaim its static asset roots, and tell the
|
|
70
|
+
* provider to reconcile. Returns counts + warnings; never throws.
|
|
71
|
+
*/
|
|
72
|
+
export async function cleanupWebRoutesForModule(
|
|
73
|
+
deps: WebRouteCleanupDeps,
|
|
74
|
+
): Promise<WebRouteCleanupResult> {
|
|
75
|
+
const { moduleId, db, run = execRunner, emitAndWait = emitWebRoutesChangedAndWait } = deps;
|
|
76
|
+
|
|
77
|
+
const owned = db.select().from(webRoutes).where(eq(webRoutes.moduleId, moduleId)).all();
|
|
78
|
+
if (owned.length === 0) {
|
|
79
|
+
// Either the module never registered a route, or its own on_uninstall
|
|
80
|
+
// already called unregister_routes (which emitted and reconciled). Nothing
|
|
81
|
+
// to do — and importantly, no event, so we don't trigger a redundant
|
|
82
|
+
// reconcile on every module removal.
|
|
83
|
+
return { routesRemoved: 0, assetDirsRemoved: [], warnings: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const warnings: string[] = [];
|
|
87
|
+
const assetDirsRemoved: string[] = [];
|
|
88
|
+
|
|
89
|
+
const staticSlugs = owned.filter((r) => r.type === 'static').map((r) => r.slug);
|
|
90
|
+
if (staticSlugs.length > 0) {
|
|
91
|
+
const providerIp = await resolvePublicWebHostIp(db);
|
|
92
|
+
if (!providerIp) {
|
|
93
|
+
warnings.push(
|
|
94
|
+
`No public_web provider host found — left ${staticSlugs.length} asset dir(s) in /srv/www on disk`,
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
for (const slug of staticSlugs) {
|
|
98
|
+
const dir = `/srv/www/${slug}`;
|
|
99
|
+
// escape-hatch: reclaiming a consumer's static web root on the provider
|
|
100
|
+
// host. No capability owns asset-root teardown, and it is neither
|
|
101
|
+
// desired-state nor a service action.
|
|
102
|
+
const result = runAppCommand(
|
|
103
|
+
{ ipv4_address: providerIp },
|
|
104
|
+
`rm -rf ${shellEscape(dir)}`,
|
|
105
|
+
run,
|
|
106
|
+
{ timeoutMs: 30_000 },
|
|
107
|
+
);
|
|
108
|
+
if (result.ok) {
|
|
109
|
+
assetDirsRemoved.push(dir);
|
|
110
|
+
} else {
|
|
111
|
+
warnings.push(
|
|
112
|
+
`Failed to remove ${dir} on ${providerIp} (continuing): ${result.stderr || result.stdout || 'unknown'}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
db.delete(webRoutes).where(eq(webRoutes.moduleId, moduleId)).run();
|
|
120
|
+
|
|
121
|
+
// Same signal a consumer's own unregister_routes emits. Unlike the deploy
|
|
122
|
+
// path (ISS-0081), an undelivered reconcile here is a warning, not a
|
|
123
|
+
// failure — removal must complete regardless.
|
|
124
|
+
const reconcile = await emitAndWait(moduleId);
|
|
125
|
+
if (reconcile.noDispatcher) {
|
|
126
|
+
warnings.push(
|
|
127
|
+
`Routes for ${moduleId} were deleted but no event dispatcher is running, so the public_web provider has not reconciled — it is still serving the removed route(s) until its next reconcile.`,
|
|
128
|
+
);
|
|
129
|
+
} else if (reconcile.timedOut) {
|
|
130
|
+
warnings.push(
|
|
131
|
+
`public_web provider did not confirm the route removal within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} event(s)).`,
|
|
132
|
+
);
|
|
133
|
+
} else if (reconcile.failed > 0) {
|
|
134
|
+
warnings.push(
|
|
135
|
+
`public_web provider failed to apply the route removal (${reconcile.failed} delivery failure(s)) — it may still be serving the removed route(s).`,
|
|
136
|
+
);
|
|
137
|
+
} else if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
138
|
+
warnings.push(
|
|
139
|
+
`Routes for ${moduleId} were deleted but NO provider reconciled — the provider has no reconcile_routes subscription, so the removed route(s) may still be served.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { routesRemoved: owned.length, assetDirsRemoved, warnings };
|
|
144
|
+
}
|
|
@@ -158,7 +158,7 @@ export function getOutputFilename(templateFilename: string): string {
|
|
|
158
158
|
* a no-op and a real change an in-place UPDATE — never the destructive
|
|
159
159
|
* REPLACE the terraform-safety guard (create-only, see terraform-safety.ts)
|
|
160
160
|
* rejects. terraform = birth DNS, the `dns-client-config` aspect = ongoing
|
|
161
|
-
* DNS. The per-type lists are in the callback. See
|
|
161
|
+
* DNS. The per-type lists are in the callback. See openspec/specs/lxc-dns-at-birth/spec.md.
|
|
162
162
|
*
|
|
163
163
|
* Anchored on the resource's opening line — it never brace-matches the nested
|
|
164
164
|
* disk/network/features blocks, so it's robust to attribute order/formatting.
|
|
@@ -736,7 +736,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
736
736
|
|
|
737
737
|
// Dedicated DNS-ingress IP (ISS-0156). A dns_internal provider now deploys into
|
|
738
738
|
// a PROTECTED zone (dmz) so it can see protected-zone query sources for
|
|
739
|
-
// split-horizon views (
|
|
739
|
+
// split-horizon views (openspec/specs/internal-dns-zone-views/spec.md). `internal` devices have
|
|
740
740
|
// no route into the 10-net, so they reach the resolver through a firewall DNAT
|
|
741
741
|
// on a dedicated `internal`-subnet address. A module opts in by declaring a
|
|
742
742
|
// `dns_ingress_ip` infrastructure variable; we allocate a free IP from the
|
|
@@ -96,7 +96,7 @@ export interface Machine {
|
|
|
96
96
|
/**
|
|
97
97
|
* Appliance / API-only machines (e.g., greenwave) where celilo has
|
|
98
98
|
* no shell access. Base-module aspects skip these — see
|
|
99
|
-
*
|
|
99
|
+
* openspec/specs/base-module-aspects/spec.md D8. Optional on the type because most call
|
|
100
100
|
* sites don't care; the DB column defaults to false so the
|
|
101
101
|
* effective value is well-defined regardless.
|
|
102
102
|
*/
|
|
@@ -159,7 +159,7 @@ describe('computed capability fields — DB integration', () => {
|
|
|
159
159
|
if (!result.success) expect(result.error).toMatch(/could not be resolved|Failed to evaluate/);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
// Gap B (
|
|
162
|
+
// Gap B (openspec/specs/internal-dns-split-horizon/spec.md step 3): buildResolutionContext
|
|
163
163
|
// must EAGERLY evaluate computed markers into the capabilities map, so the
|
|
164
164
|
// `variables.imports` path (which reads context.capabilities directly, not via
|
|
165
165
|
// resolveVariable) sees the real array.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* integration (a later step) decide how refs map onto live data.
|
|
9
9
|
*
|
|
10
10
|
* The function allow-list is the v1 set from
|
|
11
|
-
*
|
|
11
|
+
* openspec/specs/internal-dns-split-horizon/spec.md (D1.1):
|
|
12
12
|
* keys, values, map, concat, unique, format
|
|
13
13
|
*
|
|
14
14
|
* Secret-projection rule: `values(secret.*)` is rejected — it would leak
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parser for the computed-variable DSL.
|
|
3
3
|
*
|
|
4
|
-
* Computed variables (
|
|
4
|
+
* Computed variables (openspec/specs/internal-dns-split-horizon/spec.md, D1) are
|
|
5
5
|
* declared in a manifest with `type: computed` and a `value:` expression
|
|
6
6
|
* derived on access from other values, e.g.:
|
|
7
7
|
*
|
|
@@ -21,7 +21,7 @@ const TEST_DB_PATH = './test-context.db';
|
|
|
21
21
|
/**
|
|
22
22
|
* Select a proxmox container_service for a module + give it a hostname, so the
|
|
23
23
|
* deployed-system recording in buildResolutionContext fires (IPAM allocates and
|
|
24
|
-
* records into module_systems).
|
|
24
|
+
* records into module_systems). openspec/specs/module-systems-addressing/spec.md.
|
|
25
25
|
*/
|
|
26
26
|
function setupProxmoxInfra(db: DbClient, moduleId: string, zone: string): void {
|
|
27
27
|
const serviceId = `svc-${moduleId}`;
|
package/src/variables/context.ts
CHANGED
|
@@ -148,7 +148,7 @@ function autoDeriveInventoryVariables(
|
|
|
148
148
|
* its deployed systems. `$self:` supports the optional `[N]` array-index suffix
|
|
149
149
|
* (e.g. `$self:domains[0]`). `$infra:` references a provider's deployed system
|
|
150
150
|
* by name (the replacement for the old `$self:target_ip` — see
|
|
151
|
-
*
|
|
151
|
+
* openspec/specs/module-systems-addressing/spec.md). Non-string values and strings that don't
|
|
152
152
|
* start with one of those prefixes are returned unchanged.
|
|
153
153
|
*/
|
|
154
154
|
function resolveSelfRefsInObject(
|
|
@@ -323,7 +323,7 @@ export async function buildResolutionContext(
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
// Deployed-system recording (
|
|
326
|
+
// Deployed-system recording (openspec/specs/module-systems-addressing/spec.md).
|
|
327
327
|
// Record this module's system(s) into module_systems at generate time, so
|
|
328
328
|
// `$infra:<name>.…` resolves in templates. The address comes from:
|
|
329
329
|
// - machine pool → the machine's own IP; no vmid (no container).
|
|
@@ -468,7 +468,7 @@ export async function buildResolutionContext(
|
|
|
468
468
|
providerSystems,
|
|
469
469
|
);
|
|
470
470
|
|
|
471
|
-
// Evaluate computed-field markers (
|
|
471
|
+
// Evaluate computed-field markers (openspec/specs/internal-dns-split-horizon/spec.md
|
|
472
472
|
// D1) in the PROVIDER's context, so consumers — both `$capability:` template
|
|
473
473
|
// refs AND `variables.imports` — see the real value (e.g. domain_list as an
|
|
474
474
|
// array) rather than the raw marker. Only build the provider lookup when a
|
|
@@ -498,7 +498,7 @@ export async function buildResolutionContext(
|
|
|
498
498
|
systemConfigMap[row.key] = row.value;
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
-
// LXC nameserver list (
|
|
501
|
+
// LXC nameserver list (openspec/specs/lxc-dns-at-birth/spec.md). Proxmox's `nameserver` is a
|
|
502
502
|
// space-separated primary/secondary list. Compose it so every celilo-built
|
|
503
503
|
// LXC boots with a working resolver — before Ansible's apt update, and
|
|
504
504
|
// independent of which Proxmox node it lands on (the nodes' DNS defaults
|
|
@@ -566,7 +566,7 @@ export async function buildResolutionContext(
|
|
|
566
566
|
// Build the `$infra:<name>.<field>` lookup from the systems recorded above
|
|
567
567
|
// (proxmox/machine at generate; DigitalOcean gets refreshed at deploy from
|
|
568
568
|
// terraform outputs). This is what lets `$infra:` resolve in templates.
|
|
569
|
-
//
|
|
569
|
+
// openspec/specs/module-systems-addressing/spec.md.
|
|
570
570
|
let infraSystemsMap: Record<string, import('./types').InfraSystemFields> = {};
|
|
571
571
|
if (module?.manifestData) {
|
|
572
572
|
const { buildInfraSystemsMap } = await import('../services/deployed-systems');
|
|
@@ -9,7 +9,7 @@ const TEST_DB_PATH = './test-lxc-nameserver.db';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Coverage for the generate-time `lxc_nameserver` composition
|
|
12
|
-
* (
|
|
12
|
+
* (openspec/specs/lxc-dns-at-birth/spec.md): proxmox_lxc templates read `$self:lxc_nameserver`,
|
|
13
13
|
* a space-separated primary/secondary list = internal dns_internal resolver
|
|
14
14
|
* first (CIDR stripped), then the public dns.primary/dns.fallback resolvers;
|
|
15
15
|
* public-only when no dns_internal provider is registered (bootstrap).
|
|
@@ -75,7 +75,7 @@ export async function resolveVariable(
|
|
|
75
75
|
|
|
76
76
|
case 'infra': {
|
|
77
77
|
// Format: $infra:<system-name>.<field> — references a deployed system by
|
|
78
|
-
// its stable handle (requires.systems[].name).
|
|
78
|
+
// its stable handle (requires.systems[].name). openspec/specs/module-systems-addressing/spec.md.
|
|
79
79
|
const dot = variable.path.indexOf('.');
|
|
80
80
|
if (dot === -1) {
|
|
81
81
|
return {
|
|
@@ -208,7 +208,7 @@ export async function resolveVariable(
|
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
// Computed field (
|
|
211
|
+
// Computed field (openspec/specs/internal-dns-split-horizon/spec.md D1):
|
|
212
212
|
// evaluate the DSL expression in the PROVIDER's context. Like the
|
|
213
213
|
// lazy $self: block below, we look up the provider module, but here
|
|
214
214
|
// we build a typed lookup over its config/secrets/system data.
|
package/src/variables/types.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface ResolutionContext {
|
|
|
24
24
|
capabilities: Record<string, Record<string, unknown>>;
|
|
25
25
|
/**
|
|
26
26
|
* The module's deployed systems, keyed by name, for `$infra:<name>.<field>`
|
|
27
|
-
* resolution at generate time (
|
|
27
|
+
* resolution at generate time (openspec/specs/module-systems-addressing/spec.md). Each value
|
|
28
28
|
* exposes the snake_cased fields a template can reference: `ipv4_address`,
|
|
29
29
|
* `hostname`, `zone`, `vmid`, `cidr`. Optional only so the many test-stub
|
|
30
30
|
* `ResolutionContext` literals need no churn — production (buildResolutionContext)
|
package/src/api/protocol.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remote API wire protocol (Slice 1).
|
|
3
|
-
*
|
|
4
|
-
* A versioned, Zod-validated tagged union carried as NDJSON. This is the
|
|
5
|
-
* transport-neutral message layer for driving the CLI over the wire — see
|
|
6
|
-
* `v2/API_COMMUNICATION.md`. Slice 1 covers non-interactive command execution
|
|
7
|
-
* + streamed output; `interview` / `answer` / `cancel` arrive in Slice 3.
|
|
8
|
-
*
|
|
9
|
-
* The command crosses the wire as a *structured, validated* `argv` array —
|
|
10
|
-
* never a shell string handed to `exec`.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { z } from 'zod';
|
|
14
|
-
|
|
15
|
-
export const API_PROTOCOL_VERSION = 1;
|
|
16
|
-
|
|
17
|
-
// ── client → server ──────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
export const CommandMessageSchema = z.object({
|
|
20
|
-
type: z.literal('command'),
|
|
21
|
-
/** Structured argv (e.g. ["module", "list"]) — dispatched into runCli. */
|
|
22
|
-
argv: z.array(z.string()).min(1),
|
|
23
|
-
});
|
|
24
|
-
export type CommandMessage = z.infer<typeof CommandMessageSchema>;
|
|
25
|
-
|
|
26
|
-
/** Client's answer to a server `interview` message, correlated by `id`. */
|
|
27
|
-
export const AnswerMessageSchema = z.object({
|
|
28
|
-
type: z.literal('answer'),
|
|
29
|
-
id: z.string(),
|
|
30
|
-
value: z.unknown(),
|
|
31
|
-
});
|
|
32
|
-
export type AnswerMessage = z.infer<typeof AnswerMessageSchema>;
|
|
33
|
-
|
|
34
|
-
export const ClientMessageSchema = z.discriminatedUnion('type', [
|
|
35
|
-
CommandMessageSchema,
|
|
36
|
-
AnswerMessageSchema,
|
|
37
|
-
// Slice 3+: CancelMessageSchema
|
|
38
|
-
]);
|
|
39
|
-
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
40
|
-
|
|
41
|
-
// ── server → client ──────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/** Handshake: server announces itself + the protocol version it speaks. */
|
|
44
|
-
export const ReadyMessageSchema = z.object({
|
|
45
|
-
type: z.literal('ready'),
|
|
46
|
-
protocolVersion: z.literal(API_PROTOCOL_VERSION),
|
|
47
|
-
});
|
|
48
|
-
export type ReadyMessage = z.infer<typeof ReadyMessageSchema>;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* A forwarded `ProgressDisplay` protocol-mode marker. Mirrors the markers
|
|
52
|
-
* emitted by `packages/cli-display` in protocol mode (start/push/done/fail/sub
|
|
53
|
-
* and the legacy instant `[progress]`).
|
|
54
|
-
*/
|
|
55
|
-
export const ProgressMessageSchema = z.object({
|
|
56
|
-
type: z.literal('progress'),
|
|
57
|
-
kind: z.enum(['start', 'push', 'done', 'fail', 'sub', 'message']),
|
|
58
|
-
doing: z.string().optional(),
|
|
59
|
-
done: z.string().optional(),
|
|
60
|
-
message: z.string().optional(),
|
|
61
|
-
});
|
|
62
|
-
export type ProgressMessage = z.infer<typeof ProgressMessageSchema>;
|
|
63
|
-
|
|
64
|
-
/** A plain output line from the command that isn't a progress marker. */
|
|
65
|
-
export const LogMessageSchema = z.object({
|
|
66
|
-
type: z.literal('log'),
|
|
67
|
-
message: z.string(),
|
|
68
|
-
});
|
|
69
|
-
export type LogMessage = z.infer<typeof LogMessageSchema>;
|
|
70
|
-
|
|
71
|
-
/** Terminal message for a command run. */
|
|
72
|
-
export const ResultMessageSchema = z.object({
|
|
73
|
-
type: z.literal('result'),
|
|
74
|
-
success: z.boolean(),
|
|
75
|
-
exitCode: z.number(),
|
|
76
|
-
});
|
|
77
|
-
export type ResultMessage = z.infer<typeof ResultMessageSchema>;
|
|
78
|
-
|
|
79
|
-
/** Protocol-level error (malformed message, etc.) — distinct from a failed command. */
|
|
80
|
-
export const ErrorMessageSchema = z.object({
|
|
81
|
-
type: z.literal('error'),
|
|
82
|
-
error: z.string(),
|
|
83
|
-
});
|
|
84
|
-
export type ErrorMessage = z.infer<typeof ErrorMessageSchema>;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* A mid-run interview question forwarded from the server's event bus. The client
|
|
88
|
-
* renders it, then replies with an `answer` carrying the same `id`. Mirrors the
|
|
89
|
-
* generic `interview.required.*` payload (see bus-interview.ts).
|
|
90
|
-
*/
|
|
91
|
-
export const InterviewMessageSchema = z.object({
|
|
92
|
-
type: z.literal('interview'),
|
|
93
|
-
id: z.string(),
|
|
94
|
-
kind: z.enum(['text', 'confirm', 'select', 'multiselect']),
|
|
95
|
-
message: z.string(),
|
|
96
|
-
description: z.string().optional(),
|
|
97
|
-
defaultValue: z.string().optional(),
|
|
98
|
-
placeholder: z.string().optional(),
|
|
99
|
-
options: z
|
|
100
|
-
.array(z.object({ value: z.string(), label: z.string(), hint: z.string().optional() }))
|
|
101
|
-
.optional(),
|
|
102
|
-
required: z.boolean().optional(),
|
|
103
|
-
});
|
|
104
|
-
export type InterviewMessage = z.infer<typeof InterviewMessageSchema>;
|
|
105
|
-
|
|
106
|
-
export const ServerMessageSchema = z.discriminatedUnion('type', [
|
|
107
|
-
ReadyMessageSchema,
|
|
108
|
-
ProgressMessageSchema,
|
|
109
|
-
LogMessageSchema,
|
|
110
|
-
ResultMessageSchema,
|
|
111
|
-
ErrorMessageSchema,
|
|
112
|
-
InterviewMessageSchema,
|
|
113
|
-
]);
|
|
114
|
-
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
115
|
-
|
|
116
|
-
// ── translation ───────────────────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Translate one line of the command's stdout into a server message. Progress
|
|
120
|
-
* markers become structured `progress` messages; everything else is a `log`.
|
|
121
|
-
*
|
|
122
|
-
* ponytail: the marker grammar is duplicated from packages/cli-display's
|
|
123
|
-
* ProgressDisplay (which *emits* it). If a third consumer appears, extract a
|
|
124
|
-
* shared parser into @celilo/cli-display and have both sides import it.
|
|
125
|
-
*/
|
|
126
|
-
export function translateOutputLine(line: string): ProgressMessage | LogMessage {
|
|
127
|
-
const startPush = line.match(/^\[progress:(start|push)\] (.*?) \| (.*)$/);
|
|
128
|
-
if (startPush) {
|
|
129
|
-
return {
|
|
130
|
-
type: 'progress',
|
|
131
|
-
kind: startPush[1] as 'start' | 'push',
|
|
132
|
-
doing: startPush[2],
|
|
133
|
-
done: startPush[3],
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const done = line.match(/^\[progress:done\](.*)$/);
|
|
138
|
-
if (done) {
|
|
139
|
-
const msg = done[1].trim();
|
|
140
|
-
return { type: 'progress', kind: 'done', message: msg || undefined };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const fail = line.match(/^\[progress:fail\] (.*)$/);
|
|
144
|
-
if (fail) {
|
|
145
|
-
return { type: 'progress', kind: 'fail', message: fail[1] };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const sub = line.match(/^\[progress:sub\] (.*)$/);
|
|
149
|
-
if (sub) {
|
|
150
|
-
return { type: 'progress', kind: 'sub', message: sub[1] };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const legacy = line.match(/^\[progress\] (.*)$/);
|
|
154
|
-
if (legacy) {
|
|
155
|
-
return { type: 'progress', kind: 'message', message: legacy[1] };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return { type: 'log', message: line };
|
|
159
|
-
}
|
package/src/api/remote-client.ts
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remote API client — `celilo --remote <dest> <command...>` (Slice 2c).
|
|
3
|
-
*
|
|
4
|
-
* SSHes to <dest> (whose sshd forced command runs `api-serve --principal=…`),
|
|
5
|
-
* ships the command as a structured argv over the NDJSON protocol, renders the
|
|
6
|
-
* streamed `progress`/`log` through the local ProgressDisplay, and exits with
|
|
7
|
-
* the command's exit code. Reuses the system `ssh` client, so keys, known_hosts,
|
|
8
|
-
* agent, and ~/.ssh/config all apply — execution is remote, presentation local.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { type DisplayWriter, ProgressDisplay } from '@celilo/cli-display';
|
|
12
|
-
import * as clack from '@clack/prompts';
|
|
13
|
-
import { type InterviewMessage, type ServerMessage, ServerMessageSchema } from './protocol';
|
|
14
|
-
|
|
15
|
-
const REMOTE_FLAG = '--remote';
|
|
16
|
-
|
|
17
|
-
/** Render a forwarded interview via clack, returning the operator's answer. */
|
|
18
|
-
async function defaultRenderInterview(iv: InterviewMessage): Promise<unknown> {
|
|
19
|
-
const options = (iv.options ?? []).map((o) => ({ value: o.value, label: o.label, hint: o.hint }));
|
|
20
|
-
let result: unknown;
|
|
21
|
-
switch (iv.kind) {
|
|
22
|
-
case 'confirm':
|
|
23
|
-
result = await clack.confirm({
|
|
24
|
-
message: iv.message,
|
|
25
|
-
initialValue: iv.defaultValue === 'true',
|
|
26
|
-
});
|
|
27
|
-
break;
|
|
28
|
-
case 'select':
|
|
29
|
-
result = await clack.select({ message: iv.message, options });
|
|
30
|
-
break;
|
|
31
|
-
case 'multiselect':
|
|
32
|
-
result = await clack.multiselect({
|
|
33
|
-
message: iv.message,
|
|
34
|
-
options,
|
|
35
|
-
required: iv.required ?? false,
|
|
36
|
-
});
|
|
37
|
-
break;
|
|
38
|
-
default:
|
|
39
|
-
result = await clack.text({
|
|
40
|
-
message: iv.message,
|
|
41
|
-
defaultValue: iv.defaultValue,
|
|
42
|
-
placeholder: iv.placeholder,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
if (clack.isCancel(result)) {
|
|
46
|
-
clack.cancel('Cancelled.');
|
|
47
|
-
process.exit(130);
|
|
48
|
-
}
|
|
49
|
-
return result;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Minimal transport surface — satisfied by a `Bun.spawn` of `ssh`. */
|
|
53
|
-
export interface RemoteTransport {
|
|
54
|
-
stdin: { write(chunk: string): void; flush?(): number | Promise<number> };
|
|
55
|
-
stdout: ReadableStream<Uint8Array>;
|
|
56
|
-
kill(): void;
|
|
57
|
-
exited: Promise<number>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Resolve the remote target from a leading `--remote <dest>` / `--remote=<dest>`
|
|
62
|
-
* or the `CELILO_REMOTE` env. Returns null for a local invocation.
|
|
63
|
-
*/
|
|
64
|
-
export function resolveRemote(
|
|
65
|
-
argv: string[],
|
|
66
|
-
remoteEnv: string | undefined = process.env.CELILO_REMOTE,
|
|
67
|
-
): { dest: string; commandArgv: string[] } | null {
|
|
68
|
-
const rest = argv.slice(2);
|
|
69
|
-
|
|
70
|
-
if (rest[0] === REMOTE_FLAG && rest[1]) {
|
|
71
|
-
return { dest: rest[1], commandArgv: rest.slice(2) };
|
|
72
|
-
}
|
|
73
|
-
if (rest[0]?.startsWith(`${REMOTE_FLAG}=`)) {
|
|
74
|
-
return { dest: rest[0].slice(REMOTE_FLAG.length + 1), commandArgv: rest.slice(1) };
|
|
75
|
-
}
|
|
76
|
-
if (remoteEnv) {
|
|
77
|
-
return { dest: remoteEnv, commandArgv: rest };
|
|
78
|
-
}
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function openSshTransport(dest: string): RemoteTransport {
|
|
83
|
-
// -T: no pseudo-tty — we want a raw NDJSON pipe. stderr inherits so ssh's own
|
|
84
|
-
// diagnostics (host-key prompts, connection failures) reach the operator.
|
|
85
|
-
const proc = Bun.spawn(['ssh', '-T', dest], {
|
|
86
|
-
stdin: 'pipe',
|
|
87
|
-
stdout: 'pipe',
|
|
88
|
-
stderr: 'inherit',
|
|
89
|
-
});
|
|
90
|
-
return {
|
|
91
|
-
stdin: proc.stdin,
|
|
92
|
-
stdout: proc.stdout,
|
|
93
|
-
kill: () => proc.kill(),
|
|
94
|
-
exited: proc.exited,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Apply one server message to the local display; returns an exit code if terminal. */
|
|
99
|
-
function applyMessage(
|
|
100
|
-
msg: ServerMessage,
|
|
101
|
-
display: ProgressDisplay,
|
|
102
|
-
out: DisplayWriter,
|
|
103
|
-
): number | null {
|
|
104
|
-
switch (msg.type) {
|
|
105
|
-
case 'ready':
|
|
106
|
-
return null;
|
|
107
|
-
case 'progress':
|
|
108
|
-
switch (msg.kind) {
|
|
109
|
-
case 'start':
|
|
110
|
-
display.startStep(msg.doing ?? '', msg.done ?? '');
|
|
111
|
-
break;
|
|
112
|
-
case 'push':
|
|
113
|
-
display.pushStep(msg.doing ?? '', msg.done ?? '');
|
|
114
|
-
break;
|
|
115
|
-
case 'done':
|
|
116
|
-
display.doneStep(msg.message);
|
|
117
|
-
break;
|
|
118
|
-
case 'fail':
|
|
119
|
-
display.failStep(msg.message ?? '');
|
|
120
|
-
break;
|
|
121
|
-
case 'sub':
|
|
122
|
-
display.subEvent(msg.message ?? '');
|
|
123
|
-
break;
|
|
124
|
-
case 'message':
|
|
125
|
-
display.instantEvent(msg.message ?? '');
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
129
|
-
case 'log':
|
|
130
|
-
// Nest under the active step so the footer isn't corrupted; otherwise print.
|
|
131
|
-
if (display.hasPending) {
|
|
132
|
-
display.subEvent(msg.message);
|
|
133
|
-
} else {
|
|
134
|
-
out.write(`${msg.message}\n`);
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
case 'error':
|
|
138
|
-
process.stderr.write(`${msg.error}\n`);
|
|
139
|
-
return null;
|
|
140
|
-
case 'interview':
|
|
141
|
-
// Handled by the caller (needs the transport to reply) — never reached here.
|
|
142
|
-
return null;
|
|
143
|
-
case 'result':
|
|
144
|
-
return msg.exitCode;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export async function runRemoteClient(
|
|
149
|
-
dest: string,
|
|
150
|
-
argv: string[],
|
|
151
|
-
opts: {
|
|
152
|
-
openTransport?: (dest: string) => RemoteTransport;
|
|
153
|
-
out?: DisplayWriter;
|
|
154
|
-
renderInterview?: (interview: InterviewMessage) => Promise<unknown>;
|
|
155
|
-
} = {},
|
|
156
|
-
): Promise<number> {
|
|
157
|
-
if (argv.length === 0) {
|
|
158
|
-
process.stderr.write(
|
|
159
|
-
'celilo --remote <dest> requires a command (e.g. celilo --remote host module list)\n',
|
|
160
|
-
);
|
|
161
|
-
return 2;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const openTransport = opts.openTransport ?? openSshTransport;
|
|
165
|
-
const out = opts.out ?? process.stdout;
|
|
166
|
-
const renderInterview = opts.renderInterview ?? defaultRenderInterview;
|
|
167
|
-
const display = new ProgressDisplay({ out });
|
|
168
|
-
|
|
169
|
-
const transport = openTransport(dest);
|
|
170
|
-
transport.stdin.write(`${JSON.stringify({ type: 'command', argv })}\n`);
|
|
171
|
-
await transport.stdin.flush?.();
|
|
172
|
-
|
|
173
|
-
let resultExit: number | null = null;
|
|
174
|
-
const decoder = new TextDecoder();
|
|
175
|
-
let buffer = '';
|
|
176
|
-
|
|
177
|
-
outer: for await (const chunk of transport.stdout) {
|
|
178
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
179
|
-
let nl = buffer.indexOf('\n');
|
|
180
|
-
while (nl >= 0) {
|
|
181
|
-
const line = buffer.slice(0, nl);
|
|
182
|
-
buffer = buffer.slice(nl + 1);
|
|
183
|
-
nl = buffer.indexOf('\n');
|
|
184
|
-
if (!line.trim()) continue;
|
|
185
|
-
|
|
186
|
-
let msg: ServerMessage;
|
|
187
|
-
try {
|
|
188
|
-
msg = ServerMessageSchema.parse(JSON.parse(line));
|
|
189
|
-
} catch {
|
|
190
|
-
// Non-protocol line (e.g. an ssh notice) — pass through.
|
|
191
|
-
out.write(`${line}\n`);
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (msg.type === 'interview') {
|
|
196
|
-
const value = await renderInterview(msg);
|
|
197
|
-
transport.stdin.write(`${JSON.stringify({ type: 'answer', id: msg.id, value })}\n`);
|
|
198
|
-
await transport.stdin.flush?.();
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const exit = applyMessage(msg, display, out);
|
|
203
|
-
if (exit !== null) {
|
|
204
|
-
resultExit = exit;
|
|
205
|
-
break outer;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (resultExit !== null) {
|
|
211
|
-
// Server is still looping awaiting more input — tear it down.
|
|
212
|
-
transport.kill();
|
|
213
|
-
return resultExit;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Stream ended without a result — surface the transport's exit (ssh failure).
|
|
217
|
-
return transport.exited;
|
|
218
|
-
}
|