@celilo/cli 2.2.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability compatibility between a module's declared requirements and the
|
|
3
|
+
* providers actually deployed in the fleet.
|
|
4
|
+
*
|
|
5
|
+
* The pure half mirrors the runtime lookup's semantics (see
|
|
6
|
+
* `deploy-preflight.ts` section 2): a requirement is served when ANY deployed
|
|
7
|
+
* provider satisfies it under `compareConsumerToProvider`. The reason
|
|
8
|
+
* `deploy-preflight` and the upgrade path must agree is the failure
|
|
9
|
+
* celilo#1361 recorded: celilo-website was upgraded against a caddy whose
|
|
10
|
+
* bundled provider rejected its publish calls, and every attempt burned a
|
|
11
|
+
* release. The upgrade path now asks this question BEFORE updating, and
|
|
12
|
+
* defers instead of deploying into a wall.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { compareConsumerToProvider } from '@celilo/capabilities';
|
|
16
|
+
import type { DbClient } from '../db/client';
|
|
17
|
+
import { capabilities } from '../db/schema';
|
|
18
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
19
|
+
import { isPrivilegedCapability } from '../manifest/validate';
|
|
20
|
+
|
|
21
|
+
export interface CapabilityRequirement {
|
|
22
|
+
name: string;
|
|
23
|
+
version: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DeployedCapabilityProvider {
|
|
27
|
+
capabilityName: string;
|
|
28
|
+
moduleId: string;
|
|
29
|
+
version: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UnservedCapability {
|
|
33
|
+
capability: string;
|
|
34
|
+
/** The version the module's manifest requires. */
|
|
35
|
+
required: string;
|
|
36
|
+
/** Example provider that does not serve it (the first mismatch found). */
|
|
37
|
+
providerModuleId: string | null;
|
|
38
|
+
providerVersion: string | null;
|
|
39
|
+
reason:
|
|
40
|
+
| 'no_provider'
|
|
41
|
+
| 'major_mismatch_higher'
|
|
42
|
+
| 'major_mismatch_lower'
|
|
43
|
+
| 'minor_mismatch_higher'
|
|
44
|
+
| 'caller_minor_too_old';
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which of a module's capability requirements no deployed provider serves.
|
|
50
|
+
* Pure (Rule 10): the I/O — reading the capabilities table — happens in
|
|
51
|
+
* `capabilityBlockersForManifest`.
|
|
52
|
+
*/
|
|
53
|
+
export function unservedCapabilityRequirements(
|
|
54
|
+
requirements: CapabilityRequirement[] | undefined,
|
|
55
|
+
providers: DeployedCapabilityProvider[],
|
|
56
|
+
): UnservedCapability[] {
|
|
57
|
+
const unserved: UnservedCapability[] = [];
|
|
58
|
+
for (const cap of requirements ?? []) {
|
|
59
|
+
// Framework-granted privileges (e.g. cross_module_read) are not
|
|
60
|
+
// provider-backed — the same skip `deploy-preflight` makes.
|
|
61
|
+
if (isPrivilegedCapability(cap.name)) continue;
|
|
62
|
+
|
|
63
|
+
const installed = providers.filter((p) => p.capabilityName === cap.name);
|
|
64
|
+
if (installed.length === 0) {
|
|
65
|
+
unserved.push({
|
|
66
|
+
capability: cap.name,
|
|
67
|
+
required: cap.version,
|
|
68
|
+
providerModuleId: null,
|
|
69
|
+
providerVersion: null,
|
|
70
|
+
reason: 'no_provider',
|
|
71
|
+
message: `no deployed provider for '${cap.name}'`,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let anyCompatible = false;
|
|
76
|
+
let example: DeployedCapabilityProvider | undefined;
|
|
77
|
+
let reason: UnservedCapability['reason'] = 'major_mismatch_higher';
|
|
78
|
+
for (const p of installed) {
|
|
79
|
+
const result = compareConsumerToProvider(cap.version, p.version);
|
|
80
|
+
if (result.compatible) {
|
|
81
|
+
anyCompatible = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (!example) {
|
|
85
|
+
example = p;
|
|
86
|
+
reason = result.reason;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (anyCompatible || !example) continue;
|
|
90
|
+
unserved.push({
|
|
91
|
+
capability: cap.name,
|
|
92
|
+
required: cap.version,
|
|
93
|
+
providerModuleId: example.moduleId,
|
|
94
|
+
providerVersion: example.version,
|
|
95
|
+
reason,
|
|
96
|
+
message: `requires ${cap.name}@${cap.version} but deployed provider '${example.moduleId}' provides ${cap.name}@${example.version} (${reason})`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return unserved;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every capability provider currently registered in the fleet. */
|
|
103
|
+
export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[] {
|
|
104
|
+
return db
|
|
105
|
+
.select({
|
|
106
|
+
capabilityName: capabilities.capabilityName,
|
|
107
|
+
moduleId: capabilities.moduleId,
|
|
108
|
+
version: capabilities.version,
|
|
109
|
+
})
|
|
110
|
+
.from(capabilities)
|
|
111
|
+
.all();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The upgrade-path entry point: which of a target manifest's capability
|
|
116
|
+
* requirements the deployed fleet cannot serve. A non-empty result means the
|
|
117
|
+
* upgrade would deploy into a wall the preflight would then reject — defer
|
|
118
|
+
* instead of burning the attempt.
|
|
119
|
+
*/
|
|
120
|
+
export function capabilityBlockersForManifest(
|
|
121
|
+
db: DbClient,
|
|
122
|
+
manifest: Pick<ModuleManifest, 'requires' | 'id'>,
|
|
123
|
+
): UnservedCapability[] {
|
|
124
|
+
return unservedCapabilityRequirements(
|
|
125
|
+
manifest.requires?.capabilities as CapabilityRequirement[] | undefined,
|
|
126
|
+
readDeployedProviders(db),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -11,6 +11,14 @@ import type { HookContext } from '@celilo/capabilities';
|
|
|
11
11
|
// runnable from the apps/celilo working dir.
|
|
12
12
|
const HOOK_DIR = join(import.meta.dirname, '../../../../modules/celilo-mgmt/scripts');
|
|
13
13
|
|
|
14
|
+
// on_backup reads its machine-pool snapshot from the context input the
|
|
15
|
+
// framework stages (backup-create.ts, from listMachines()). These tests
|
|
16
|
+
// inject a JSON string so the snapshot is deterministic on every host. The
|
|
17
|
+
// pre-staging version of this hook shelled `celilo machine list --json` and
|
|
18
|
+
// these tests had to inject a runner stub to avoid depending on the installed
|
|
19
|
+
// CLI's age (celilo#1333); the staged input made that seam unnecessary.
|
|
20
|
+
const STUB_MACHINE_POOL = [{ hostname: 'stub-machine', zone: 'dmz' }];
|
|
21
|
+
|
|
14
22
|
interface BackupHookOutput {
|
|
15
23
|
artifact_count: number;
|
|
16
24
|
size_bytes: number;
|
|
@@ -116,6 +124,7 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
116
124
|
backup_dir: backupDir,
|
|
117
125
|
cross_module_root: crossModuleRoot,
|
|
118
126
|
system_state_root: systemStateRoot,
|
|
127
|
+
machine_pool: JSON.stringify(STUB_MACHINE_POOL),
|
|
119
128
|
}),
|
|
120
129
|
)) as BackupHookOutput;
|
|
121
130
|
|
|
@@ -135,7 +144,6 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
135
144
|
expect(result.artifact_count).toBeGreaterThan(0);
|
|
136
145
|
expect(result.size_bytes).toBeGreaterThan(0);
|
|
137
146
|
});
|
|
138
|
-
|
|
139
147
|
it('refuses when the framework did not stage celilo state', async () => {
|
|
140
148
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
141
149
|
|
|
@@ -148,24 +156,34 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
148
156
|
).rejects.toThrow(/system_state_root was not provided/);
|
|
149
157
|
});
|
|
150
158
|
|
|
151
|
-
it('machine-pool.json is valid JSON
|
|
159
|
+
it('machine-pool.json is the staged snapshot (valid JSON array)', async () => {
|
|
152
160
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
153
161
|
await hook(
|
|
154
162
|
buildContext({
|
|
155
163
|
backup_dir: backupDir,
|
|
156
164
|
cross_module_root: crossModuleRoot,
|
|
157
165
|
system_state_root: systemStateRoot,
|
|
166
|
+
machine_pool: JSON.stringify(STUB_MACHINE_POOL),
|
|
158
167
|
}),
|
|
159
168
|
);
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
// Content, not just shape. A bare Array.isArray also passes when the
|
|
171
|
+
// hook swallowed a snapshot failure and wrote an empty pool, which is
|
|
172
|
+
// exactly the failure mode this stub exists to rule out.
|
|
173
|
+
const machinePool: unknown = JSON.parse(
|
|
174
|
+
readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'),
|
|
175
|
+
);
|
|
176
|
+
expect(machinePool).toEqual(STUB_MACHINE_POOL);
|
|
163
177
|
});
|
|
164
178
|
|
|
165
179
|
it('proceeds (with a warning) when cross_module_root is missing', async () => {
|
|
166
180
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
167
181
|
const result = (await hook(
|
|
168
|
-
buildContext({
|
|
182
|
+
buildContext({
|
|
183
|
+
backup_dir: backupDir,
|
|
184
|
+
system_state_root: systemStateRoot,
|
|
185
|
+
machine_pool: JSON.stringify(STUB_MACHINE_POOL),
|
|
186
|
+
}),
|
|
169
187
|
)) as BackupHookOutput;
|
|
170
188
|
|
|
171
189
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
@@ -72,7 +72,7 @@ describe('materializeCrossModuleRoot', () => {
|
|
|
72
72
|
afterEach(() => {
|
|
73
73
|
closeDb();
|
|
74
74
|
resetTestDbPath();
|
|
75
|
-
process.env.CELILO_DATA_DIR
|
|
75
|
+
delete process.env.CELILO_DATA_DIR;
|
|
76
76
|
try {
|
|
77
77
|
rmSync(dir, { recursive: true, force: true });
|
|
78
78
|
} catch {
|
|
@@ -180,7 +180,7 @@ describe('applyCrossModuleWriteRoot', () => {
|
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
afterEach(() => {
|
|
183
|
-
process.env.CELILO_DATA_DIR
|
|
183
|
+
delete process.env.CELILO_DATA_DIR;
|
|
184
184
|
try {
|
|
185
185
|
rmSync(dir, { recursive: true, force: true });
|
|
186
186
|
} catch {
|
|
@@ -27,6 +27,7 @@ import type { DbClient } from '../db/client';
|
|
|
27
27
|
import { getDb } from '../db/client';
|
|
28
28
|
import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
|
|
29
29
|
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
30
|
+
import { isPrivilegedCapability } from '../manifest/validate';
|
|
30
31
|
import { buildResolutionContext } from '../variables/context';
|
|
31
32
|
import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
|
|
32
33
|
import { describeCapabilityProblem, findBrokenCapabilityDerivations } from './fleet-checks';
|
|
@@ -113,6 +114,12 @@ export async function runPreflight(
|
|
|
113
114
|
// template-generation against an ABI the provider can't fulfil.
|
|
114
115
|
if (manifest.requires?.capabilities) {
|
|
115
116
|
for (const cap of manifest.requires.capabilities) {
|
|
117
|
+
// Framework-granted privileges (e.g. cross_module_read) aren't
|
|
118
|
+
// provider-backed — they're gated by the allow-list at import time,
|
|
119
|
+
// not satisfied by deploying another module. Skip them here.
|
|
120
|
+
if (isPrivilegedCapability(cap.name)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
116
123
|
const providers = db
|
|
117
124
|
.select()
|
|
118
125
|
.from(capabilities)
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { log } from '../cli/prompts';
|
|
4
4
|
import { executeBuildWithProgress } from './build-stream';
|
|
5
|
-
import { validateTerraformPlanSafety } from './terraform-safety';
|
|
5
|
+
import { planAfterImportVerdict, validateTerraformPlanSafety } from './terraform-safety';
|
|
6
6
|
|
|
7
7
|
export interface TerraformResult {
|
|
8
8
|
success: boolean;
|
|
@@ -173,6 +173,43 @@ export async function executeTerraform(
|
|
|
173
173
|
|
|
174
174
|
if (importResult.success) {
|
|
175
175
|
log.success('Resource imported into Terraform state');
|
|
176
|
+
|
|
177
|
+
// The import REPLACED the plan validated above. That plan described a
|
|
178
|
+
// create; the plan terraform computes now can describe a REPLACE,
|
|
179
|
+
// because `ostemplate` and `rootfs[0].storage` do not round-trip
|
|
180
|
+
// through an import and each forces one. Re-plan and re-check, or the
|
|
181
|
+
// retry applies a destroy the safety gate exists to forbid — which is
|
|
182
|
+
// how a redeploy destroyed signal's LXC on a converged fleet
|
|
183
|
+
// (celilo#1374).
|
|
184
|
+
const rePlan = await executeBuildWithProgress({
|
|
185
|
+
command: 'terraform',
|
|
186
|
+
args: ['plan', '-detailed-exitcode', '-no-color'],
|
|
187
|
+
cwd: terraformDir,
|
|
188
|
+
title: 'Re-planning after import',
|
|
189
|
+
env: terraformEnv,
|
|
190
|
+
noInteractive,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const verdict = planAfterImportVerdict(rePlan.exitCode, rePlan.output);
|
|
194
|
+
|
|
195
|
+
if (verdict.action === 'done') {
|
|
196
|
+
// The import alone reconciled state with reality: adopt and stop.
|
|
197
|
+
if (phases) phases.terraformApply = true;
|
|
198
|
+
log.success('Infrastructure adopted by import (no changes needed)');
|
|
199
|
+
return { success: true, output: rePlan.output, exitCode: 0 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (verdict.action === 'refuse') {
|
|
203
|
+
if (phases) phases.terraformApply = false;
|
|
204
|
+
log.error('Refusing to apply the plan computed after import');
|
|
205
|
+
return {
|
|
206
|
+
success: false,
|
|
207
|
+
output: rePlan.output,
|
|
208
|
+
error: verdict.error,
|
|
209
|
+
exitCode: 2,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
176
213
|
log.message(' Retrying deployment...');
|
|
177
214
|
|
|
178
215
|
// Retry apply after successful import
|
|
@@ -242,7 +242,7 @@ export function upsertDeployedSystem(
|
|
|
242
242
|
.run();
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
function asZone(value: string | null | undefined): NetworkZone | null {
|
|
245
|
+
export function asZone(value: string | null | undefined): NetworkZone | null {
|
|
246
246
|
return value && (NETWORK_ZONES as readonly string[]).includes(value)
|
|
247
247
|
? (value as NetworkZone)
|
|
248
248
|
: null;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readInstalledUnit,
|
|
11
11
|
renderLaunchdPlist,
|
|
12
12
|
renderSystemdUnit,
|
|
13
|
+
resolveDaemonPathEnv,
|
|
13
14
|
resolveRestartScope,
|
|
14
15
|
resolveRunAsUser,
|
|
15
16
|
restartDaemon,
|
|
@@ -27,11 +28,15 @@ describe('renderSystemdUnit', () => {
|
|
|
27
28
|
concurrency: 4,
|
|
28
29
|
home: '/home/op',
|
|
29
30
|
scope: 'user',
|
|
31
|
+
pathEnv: '/bun/bin:/usr/bin:/bin',
|
|
30
32
|
});
|
|
31
33
|
expect(out).toContain(
|
|
32
34
|
'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
|
|
33
35
|
);
|
|
34
36
|
expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
|
|
37
|
+
// celilo#1373 — the unit's environment must resolve bun; the global celilo
|
|
38
|
+
// wrapper is a bash script whose first act is `command -v bun`.
|
|
39
|
+
expect(out).toContain('Environment=PATH=/bun/bin:/usr/bin:/bin');
|
|
35
40
|
expect(out).toContain('Restart=on-failure');
|
|
36
41
|
expect(out).toContain('WantedBy=default.target');
|
|
37
42
|
expect(out).not.toContain('User=');
|
|
@@ -45,6 +50,7 @@ describe('renderSystemdUnit', () => {
|
|
|
45
50
|
concurrency: 8,
|
|
46
51
|
home: '/h',
|
|
47
52
|
scope: 'user',
|
|
53
|
+
pathEnv: '/bun/bin:/usr/bin',
|
|
48
54
|
});
|
|
49
55
|
expect(out).toContain('--poll-ms 250 --concurrency 8');
|
|
50
56
|
});
|
|
@@ -57,6 +63,7 @@ describe('renderSystemdUnit', () => {
|
|
|
57
63
|
concurrency: 4,
|
|
58
64
|
home: '/root',
|
|
59
65
|
scope: 'system',
|
|
66
|
+
pathEnv: '/bun/bin:/usr/bin',
|
|
60
67
|
runAsUser: 'celilo',
|
|
61
68
|
});
|
|
62
69
|
expect(out).toContain('User=celilo');
|
|
@@ -75,6 +82,7 @@ describe('renderLaunchdPlist', () => {
|
|
|
75
82
|
concurrency: 4,
|
|
76
83
|
home: '/Users/op',
|
|
77
84
|
scope: 'user',
|
|
85
|
+
pathEnv: '/bun/bin:/usr/bin:/bin',
|
|
78
86
|
});
|
|
79
87
|
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
|
|
80
88
|
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
|
|
@@ -85,6 +93,29 @@ describe('renderLaunchdPlist', () => {
|
|
|
85
93
|
expect(out).not.toContain('<key>UserName</key>');
|
|
86
94
|
});
|
|
87
95
|
|
|
96
|
+
// celilo#1373 — launchd starts jobs with its own default PATH (no bun),
|
|
97
|
+
// and the global celilo wrapper is a bash script whose first act is
|
|
98
|
+
// `command -v bun`. The rendered plist MUST carry a PATH that resolves
|
|
99
|
+
// bun, inside EnvironmentVariables, or the unit crash-loops forever.
|
|
100
|
+
it('carries a PATH in EnvironmentVariables that resolves bun', () => {
|
|
101
|
+
const out = renderLaunchdPlist({
|
|
102
|
+
celiloPath: '/Users/op/.bun/bin/celilo',
|
|
103
|
+
busDbPath: '/db',
|
|
104
|
+
pollMs: 1000,
|
|
105
|
+
concurrency: 4,
|
|
106
|
+
home: '/Users/op',
|
|
107
|
+
scope: 'user',
|
|
108
|
+
pathEnv: '/bun/bin:/usr/local/bin:/usr/bin:/bin',
|
|
109
|
+
});
|
|
110
|
+
expect(out).toContain('<key>PATH</key>');
|
|
111
|
+
expect(out).toContain('<string>/bun/bin:/usr/local/bin:/usr/bin:/bin</string>');
|
|
112
|
+
// PATH lives INSIDE the EnvironmentVariables dict, after EVENT_BUS_DB.
|
|
113
|
+
const envIdx = out.indexOf('<key>EnvironmentVariables</key>');
|
|
114
|
+
const pathIdx = out.indexOf('<key>PATH</key>');
|
|
115
|
+
expect(envIdx).toBeGreaterThan(-1);
|
|
116
|
+
expect(pathIdx).toBeGreaterThan(envIdx);
|
|
117
|
+
});
|
|
118
|
+
|
|
88
119
|
it('system scope sets UserName and logs under /Library/Logs', () => {
|
|
89
120
|
const out = renderLaunchdPlist({
|
|
90
121
|
celiloPath: '/c',
|
|
@@ -93,6 +124,7 @@ describe('renderLaunchdPlist', () => {
|
|
|
93
124
|
concurrency: 4,
|
|
94
125
|
home: '/Users/op',
|
|
95
126
|
scope: 'system',
|
|
127
|
+
pathEnv: '/bun/bin:/usr/bin',
|
|
96
128
|
runAsUser: 'celilo',
|
|
97
129
|
});
|
|
98
130
|
expect(out).toContain('<key>UserName</key>');
|
|
@@ -103,6 +135,31 @@ describe('renderLaunchdPlist', () => {
|
|
|
103
135
|
});
|
|
104
136
|
});
|
|
105
137
|
|
|
138
|
+
describe('resolveDaemonPathEnv', () => {
|
|
139
|
+
// celilo#1373 — the whole point of the PATH key is that the bun the
|
|
140
|
+
// wrapper needs is actually in the directory we wrote. First colon
|
|
141
|
+
// segment, and a bun executable must exist in it.
|
|
142
|
+
it('starts with a directory that contains a bun executable', () => {
|
|
143
|
+
const firstDir = resolveDaemonPathEnv().split(':')[0] ?? '';
|
|
144
|
+
expect(firstDir).not.toBe('');
|
|
145
|
+
expect(existsSync(join(firstDir, 'bun'))).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('planDaemonInstall pins the PATH into the rendered unit', () => {
|
|
149
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-pathenv-'));
|
|
150
|
+
const celiloPath = join(dir, 'fake-celilo');
|
|
151
|
+
writeFileSync(celiloPath, '#!/bin/sh\n', { mode: 0o755 });
|
|
152
|
+
const plan = planDaemonInstall({
|
|
153
|
+
platform: 'darwin',
|
|
154
|
+
scope: 'user',
|
|
155
|
+
home: join(dir, 'home'),
|
|
156
|
+
celiloPath,
|
|
157
|
+
pathEnv: '/bun/bin:/usr/bin:/bin',
|
|
158
|
+
});
|
|
159
|
+
expect(plan.unitContent).toContain('<string>/bun/bin:/usr/bin:/bin</string>');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
106
163
|
describe('getDaemonUnitPath', () => {
|
|
107
164
|
it('returns the systemd user path on linux', () => {
|
|
108
165
|
expect(getDaemonUnitPath('linux', '/home/op')).toBe(
|
|
@@ -48,6 +48,12 @@ export interface InstallDaemonOptions {
|
|
|
48
48
|
platform?: SupervisorPlatform;
|
|
49
49
|
/** Override the bus DB path. Defaults to celilo's getEventBusPath(). */
|
|
50
50
|
busDbPath?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Override the PATH written into the unit's environment. Defaults to
|
|
53
|
+
* resolveDaemonPathEnv() captured at render time. Test seam — pin it so
|
|
54
|
+
* assertions don't depend on the machine's bun install.
|
|
55
|
+
*/
|
|
56
|
+
pathEnv?: string;
|
|
51
57
|
/** Override the home directory used to compute install paths. */
|
|
52
58
|
home?: string;
|
|
53
59
|
/** Prefix for system-scope paths. Test seam — see getDaemonUnitPath. */
|
|
@@ -159,6 +165,47 @@ export function getDaemonUnitPath(
|
|
|
159
165
|
: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
export interface LaunchdUnitStatus {
|
|
169
|
+
/** The pid launchd currently runs the unit under, or null when it isn't running. */
|
|
170
|
+
pid: number | null;
|
|
171
|
+
/**
|
|
172
|
+
* The unit's last exit status in wait4 form (256 = exited 1, 15 = killed by
|
|
173
|
+
* SIGTERM), or null when launchd has no record of one.
|
|
174
|
+
*/
|
|
175
|
+
lastExitStatus: number | null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Probe launchd for the unit's health. Uses `launchctl list <label>` —
|
|
180
|
+
* on current macOS the richer `launchctl print gui/<uid>/<label>` answers
|
|
181
|
+
* "Domain does not support specified action" for legacy-loaded jobs, while
|
|
182
|
+
* `list <label>` prints a legacy-style dict carrying `PID` (absent when not
|
|
183
|
+
* running) and `LastExitStatus`.
|
|
184
|
+
*
|
|
185
|
+
* Returns null when launchd doesn't answer at all (not loaded, linux, the
|
|
186
|
+
* binary missing) — null is "don't know", never "bad". Only the user scope
|
|
187
|
+
* is probed: the system domain needs root, and a mac running a system-scope
|
|
188
|
+
* celilo daemon is not a supported shape (see supervisorCommands).
|
|
189
|
+
*/
|
|
190
|
+
export function launchdUnitStatus(scope: SupervisorScope = 'user'): LaunchdUnitStatus | null {
|
|
191
|
+
if (scope !== 'user' || detectPlatform() !== 'darwin') return null;
|
|
192
|
+
let out: string;
|
|
193
|
+
try {
|
|
194
|
+
out = execFileSync('launchctl', ['list', LAUNCHD_LABEL], {
|
|
195
|
+
encoding: 'utf-8',
|
|
196
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const pidMatch = out.match(/"PID" = (\d+)/);
|
|
202
|
+
const exitMatch = out.match(/"LastExitStatus" = (-?\d+)/);
|
|
203
|
+
return {
|
|
204
|
+
pid: pidMatch ? Number(pidMatch[1]) : null,
|
|
205
|
+
lastExitStatus: exitMatch ? Number(exitMatch[1]) : null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
162
209
|
/**
|
|
163
210
|
* Find the celilo executable. Priority:
|
|
164
211
|
* 1. Explicit override
|
|
@@ -185,6 +232,25 @@ export function resolveCeliloPath(override?: string): string {
|
|
|
185
232
|
return found;
|
|
186
233
|
}
|
|
187
234
|
|
|
235
|
+
/**
|
|
236
|
+
* A PATH that resolves bun, for a supervisor unit's environment.
|
|
237
|
+
*
|
|
238
|
+
* launchd starts jobs with its own default PATH, and systemd runs
|
|
239
|
+
* ExecStart with a similarly minimal one. Neither includes bun, and the
|
|
240
|
+
* global `celilo` entry is a bash wrapper whose first act is
|
|
241
|
+
* `command -v bun` — so a unit without a PATH key crash-loops forever on
|
|
242
|
+
* "Error: celilo requires bun" (celilo#1373: 22680 respawns on one Mac).
|
|
243
|
+
* The PATH is captured at RENDER time — the moment the unit is written —
|
|
244
|
+
* from Bun.which('bun'), falling back to process.execPath, which IS the
|
|
245
|
+
* bun binary under the bun runtime. Standard dirs follow so the
|
|
246
|
+
* dispatcher's own subprocesses keep working.
|
|
247
|
+
*/
|
|
248
|
+
export function resolveDaemonPathEnv(): string {
|
|
249
|
+
const bun =
|
|
250
|
+
(Bun as unknown as { which: (cmd: string) => string | null }).which('bun') ?? process.execPath;
|
|
251
|
+
return `${dirname(bun)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
|
|
252
|
+
}
|
|
253
|
+
|
|
188
254
|
/**
|
|
189
255
|
* Run-as user for a system-scope unit: the owner of the celilo state
|
|
190
256
|
* dir (the bus DB's directory). Falls back to the current user when
|
|
@@ -211,6 +277,11 @@ interface UnitInputs {
|
|
|
211
277
|
concurrency: number;
|
|
212
278
|
home: string;
|
|
213
279
|
scope: SupervisorScope;
|
|
280
|
+
/**
|
|
281
|
+
* PATH injected into the unit's environment (celilo#1373). Required:
|
|
282
|
+
* a unit without it cannot resolve bun and crash-loops.
|
|
283
|
+
*/
|
|
284
|
+
pathEnv: string;
|
|
214
285
|
/** Required for system scope; ignored for user scope. */
|
|
215
286
|
runAsUser?: string;
|
|
216
287
|
}
|
|
@@ -232,6 +303,7 @@ ${userLine}ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --
|
|
|
232
303
|
Restart=on-failure
|
|
233
304
|
RestartSec=10s
|
|
234
305
|
Environment=EVENT_BUS_DB=${input.busDbPath}
|
|
306
|
+
Environment=PATH=${input.pathEnv}
|
|
235
307
|
# stdout/stderr are captured by ${journalHint}.
|
|
236
308
|
StandardOutput=journal
|
|
237
309
|
StandardError=journal
|
|
@@ -271,6 +343,8 @@ ${userNameBlock} <key>ProgramArguments</key>
|
|
|
271
343
|
<dict>
|
|
272
344
|
<key>EVENT_BUS_DB</key>
|
|
273
345
|
<string>${input.busDbPath}</string>
|
|
346
|
+
<key>PATH</key>
|
|
347
|
+
<string>${input.pathEnv}</string>
|
|
274
348
|
</dict>
|
|
275
349
|
<key>RunAtLoad</key>
|
|
276
350
|
<true/>
|
|
@@ -334,6 +408,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
|
|
|
334
408
|
const home = opts.home ?? homedir();
|
|
335
409
|
const celiloPath = resolveCeliloPath(opts.celiloPath);
|
|
336
410
|
const busDbPath = opts.busDbPath ?? getEventBusPath();
|
|
411
|
+
const pathEnv = opts.pathEnv ?? resolveDaemonPathEnv();
|
|
337
412
|
const pollMs = opts.pollMs ?? 1000;
|
|
338
413
|
const concurrency = opts.concurrency ?? 4;
|
|
339
414
|
const runAsUser = scope === 'system' ? resolveRunAsUser(busDbPath, opts.runAsUser) : undefined;
|
|
@@ -346,6 +421,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
|
|
|
346
421
|
concurrency,
|
|
347
422
|
home,
|
|
348
423
|
scope,
|
|
424
|
+
pathEnv,
|
|
349
425
|
runAsUser,
|
|
350
426
|
};
|
|
351
427
|
const unitContent =
|
|
@@ -57,27 +57,40 @@ export function loadZoneSubnets(db: DbClient): string[] {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Read each firewall's live ruleset and pair it with celilo's composed trusted
|
|
60
|
-
* set.
|
|
61
|
-
*
|
|
60
|
+
* set. Firewalls whose ruleset cannot be read are reported separately, as
|
|
61
|
+
* `unreachable` — the audit turns each into an `unmeasured` finding, because
|
|
62
|
+
* an unreadable box is unknown, not clean (D7).
|
|
62
63
|
*/
|
|
63
|
-
export
|
|
64
|
+
export interface FirewallReachCollection {
|
|
65
|
+
firewalls: FirewallReachState[];
|
|
66
|
+
unreachableFirewalls: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function collectFirewallReach(
|
|
70
|
+
db: DbClient,
|
|
71
|
+
run: Runner = execRunner,
|
|
72
|
+
): FirewallReachCollection {
|
|
64
73
|
const zoneSubnets = loadZoneSubnets(db);
|
|
65
|
-
if (zoneSubnets.length === 0) return [];
|
|
74
|
+
if (zoneSubnets.length === 0) return { firewalls: [], unreachableFirewalls: [] };
|
|
66
75
|
|
|
67
|
-
const
|
|
76
|
+
const firewalls: FirewallReachState[] = [];
|
|
77
|
+
const unreachableFirewalls: string[] = [];
|
|
68
78
|
for (const firewallIp of listFirewallIps(db)) {
|
|
69
79
|
// escape-hatch: reads the running filter table (`iptables-save`), which is
|
|
70
80
|
// the only place an out-of-band rule appears. Read-only, no capability.
|
|
71
81
|
const result = runAppCommand({ ipv4_address: firewallIp }, 'iptables-save -t filter', run, {
|
|
72
82
|
timeoutMs: IPTABLES_SAVE_TIMEOUT_MS,
|
|
73
83
|
});
|
|
74
|
-
if (!result.ok)
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
unreachableFirewalls.push(firewallIp);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
75
88
|
|
|
76
|
-
|
|
89
|
+
firewalls.push({
|
|
77
90
|
firewallIp,
|
|
78
91
|
live: parseLiveReachRules(result.stdout, zoneSubnets),
|
|
79
92
|
known: loadTrustedSubnets(db, firewallIp).map((e) => e.subnet),
|
|
80
93
|
});
|
|
81
94
|
}
|
|
82
|
-
return
|
|
95
|
+
return { firewalls, unreachableFirewalls };
|
|
83
96
|
}
|