@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +5 -4
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -7,14 +7,22 @@ import { existsSync } from 'node:fs';
|
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { getDb } from '../../db/client';
|
|
10
|
+
import type { NetworkZone } from '../../db/schema';
|
|
10
11
|
import {
|
|
11
12
|
detectMachineInfo,
|
|
13
|
+
detectMachineInfoLocal,
|
|
12
14
|
detectNetworkInterfaces,
|
|
15
|
+
detectNetworkInterfacesLocal,
|
|
13
16
|
testSshConnection,
|
|
14
17
|
} from '../../services/machine-detector';
|
|
15
18
|
import { addMachine, getMachineByIp } from '../../services/machine-pool';
|
|
16
19
|
import { loadExistingConfiguration } from '../../services/system-init';
|
|
17
20
|
import { detectZoneFromIp } from '../../services/zone-detector';
|
|
21
|
+
import type {
|
|
22
|
+
DetectedMachineInfo,
|
|
23
|
+
MachineRole,
|
|
24
|
+
NetworkInterface,
|
|
25
|
+
} from '../../types/infrastructure';
|
|
18
26
|
import { celiloIntro, celiloOutro, promptText } from '../prompts';
|
|
19
27
|
import type { CommandResult } from '../types';
|
|
20
28
|
import { validateIpAddress, validateRequired } from '../validators';
|
|
@@ -95,7 +103,6 @@ export async function handleMachineAdd(
|
|
|
95
103
|
// Hybrid mode: use flags for what's provided, prompt for what's missing
|
|
96
104
|
let ipAddress: string;
|
|
97
105
|
let sshUser: string;
|
|
98
|
-
let sshKeyPath: string;
|
|
99
106
|
|
|
100
107
|
// IP address: from positional arg, --ip flag, or prompt
|
|
101
108
|
if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) {
|
|
@@ -118,8 +125,21 @@ export async function handleMachineAdd(
|
|
|
118
125
|
};
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
//
|
|
122
|
-
|
|
128
|
+
// The local management box (127.0.0.1, or explicit --local) deploys
|
|
129
|
+
// over Ansible's local connection — no SSH, no key, no connectivity
|
|
130
|
+
// test. Determine this BEFORE the SSH-user step so the local path
|
|
131
|
+
// never prompts (it would hang a non-interactive bootstrap postinst).
|
|
132
|
+
const isLocal = ipAddress === '127.0.0.1' || flags.local === true;
|
|
133
|
+
// An explicit --zone overrides inference (needed for the local box,
|
|
134
|
+
// whose 127.0.0.1 matches no zone subnet, and useful before a firewall
|
|
135
|
+
// has provided the target zone).
|
|
136
|
+
const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined;
|
|
137
|
+
|
|
138
|
+
// SSH user: irrelevant for a local machine (local connection); else
|
|
139
|
+
// from flag, default to 'root', or prompt.
|
|
140
|
+
if (isLocal) {
|
|
141
|
+
sshUser = 'root';
|
|
142
|
+
} else if (typeof flags['ssh-user'] === 'string') {
|
|
123
143
|
sshUser = flags['ssh-user'];
|
|
124
144
|
} else {
|
|
125
145
|
sshUser = await promptText({
|
|
@@ -130,76 +150,100 @@ export async function handleMachineAdd(
|
|
|
130
150
|
});
|
|
131
151
|
}
|
|
132
152
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
153
|
+
let detectedInfo: DetectedMachineInfo;
|
|
154
|
+
let zone: NetworkZone;
|
|
155
|
+
let interfaces: NetworkInterface[];
|
|
156
|
+
let role: MachineRole;
|
|
157
|
+
let sshKey: string;
|
|
158
|
+
|
|
159
|
+
if (isLocal) {
|
|
160
|
+
console.log('\nLocal machine — detecting locally (no SSH)...');
|
|
161
|
+
detectedInfo = await detectMachineInfoLocal();
|
|
162
|
+
const net = await detectNetworkInterfacesLocal();
|
|
163
|
+
interfaces = net.interfaces;
|
|
164
|
+
role = net.role;
|
|
165
|
+
// The box you're installing on is, by definition, on the internal LAN.
|
|
166
|
+
zone = zoneOverride ?? 'internal';
|
|
167
|
+
sshKey = ''; // local connection — no key needed
|
|
168
|
+
console.log(
|
|
169
|
+
`✓ Local: ${detectedInfo.hostname} — ${detectedInfo.hardware.cpu_cores} cores, ` +
|
|
170
|
+
`${detectedInfo.hardware.memory_mb} MB, ${detectedInfo.hardware.disk_gb} GB (zone ${zone}, connection local)\n`,
|
|
171
|
+
);
|
|
140
172
|
} else {
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
173
|
+
// SSH key: from flag, auto-detect, or error
|
|
174
|
+
let sshKeyPath: string;
|
|
175
|
+
if (typeof flags['ssh-key-file'] === 'string') {
|
|
176
|
+
sshKeyPath = flags['ssh-key-file'];
|
|
177
|
+
const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
|
|
178
|
+
if (!existsSync(expandedPath)) {
|
|
179
|
+
return { success: false, error: `SSH key file not found: ${expandedPath}` };
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Auto-detect SSH key from system config
|
|
183
|
+
const detectedKeyPath = findSshPrivateKey();
|
|
184
|
+
if (!detectedKeyPath) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
error:
|
|
188
|
+
'Cannot find SSH private key.\n\n' +
|
|
189
|
+
'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
|
|
190
|
+
'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
sshKeyPath = detectedKeyPath;
|
|
194
|
+
console.log(`Using SSH key: ${sshKeyPath}`);
|
|
151
195
|
}
|
|
152
|
-
sshKeyPath = detectedKeyPath;
|
|
153
|
-
console.log(`Using SSH key: ${sshKeyPath}`);
|
|
154
|
-
}
|
|
155
196
|
|
|
156
|
-
|
|
157
|
-
|
|
197
|
+
// Expand tilde in path
|
|
198
|
+
const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
|
|
158
199
|
|
|
159
|
-
|
|
160
|
-
|
|
200
|
+
// Read SSH key content
|
|
201
|
+
sshKey = readFileSync(expandedKeyPath, 'utf8');
|
|
161
202
|
|
|
162
|
-
|
|
203
|
+
console.log('\nTesting SSH connection...');
|
|
163
204
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
// Test SSH connectivity
|
|
206
|
+
const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath);
|
|
207
|
+
if (!canConnect) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
172
213
|
|
|
173
|
-
|
|
214
|
+
console.log('✓ SSH connection successful\n');
|
|
174
215
|
|
|
175
|
-
|
|
216
|
+
console.log('Detecting machine information...');
|
|
176
217
|
|
|
177
|
-
|
|
178
|
-
|
|
218
|
+
// Auto-detect machine info
|
|
219
|
+
detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath);
|
|
179
220
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
221
|
+
console.log('✓ Machine detected:');
|
|
222
|
+
console.log(` Hostname: ${detectedInfo.hostname}`);
|
|
223
|
+
console.log(` OS: ${detectedInfo.osInfo}`);
|
|
224
|
+
console.log(
|
|
225
|
+
` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`,
|
|
226
|
+
);
|
|
227
|
+
console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
|
|
228
|
+
console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
|
|
188
229
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
230
|
+
// Zone: explicit override, else infer from IP.
|
|
231
|
+
console.log('Detecting network zone...');
|
|
232
|
+
zone = zoneOverride ?? (await detectZoneFromIp(ipAddress));
|
|
233
|
+
console.log(`✓ Zone: ${zone}\n`);
|
|
193
234
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
235
|
+
// Detect network interfaces and classify machine
|
|
236
|
+
console.log('Detecting network interfaces...');
|
|
237
|
+
const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
|
|
238
|
+
interfaces = net.interfaces;
|
|
239
|
+
role = net.role;
|
|
197
240
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
241
|
+
console.log(`✓ Role: ${role}`);
|
|
242
|
+
for (const iface of interfaces) {
|
|
243
|
+
console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
|
|
244
|
+
}
|
|
245
|
+
console.log('');
|
|
201
246
|
}
|
|
202
|
-
console.log('');
|
|
203
247
|
|
|
204
248
|
// Add machine to pool
|
|
205
249
|
const earmark = typeof flags.earmark === 'string' ? flags.earmark : undefined;
|
|
@@ -18,14 +18,23 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { existsSync } from 'node:fs';
|
|
21
|
-
import { unlink } from 'node:fs/promises';
|
|
21
|
+
import { rm, unlink } from 'node:fs/promises';
|
|
22
22
|
import { tmpdir } from 'node:os';
|
|
23
23
|
import { join, resolve } from 'node:path';
|
|
24
|
+
import { eq } from 'drizzle-orm';
|
|
24
25
|
import { getModuleStoragePath, shortenPath } from '../../config/paths';
|
|
25
26
|
import { getDb } from '../../db/client';
|
|
27
|
+
import { modules } from '../../db/schema';
|
|
28
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
26
29
|
import { importModule } from '../../module/import';
|
|
27
30
|
import { RegistryClient } from '../../registry/client';
|
|
28
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
checkAspectApproval,
|
|
33
|
+
computeAspectScopeHash,
|
|
34
|
+
recordAspectApproval,
|
|
35
|
+
} from '../../services/aspect-approvals';
|
|
36
|
+
import { getArg, getFlag, hasFlag } from '../parser';
|
|
37
|
+
import { promptConfirm } from '../prompts';
|
|
29
38
|
import type { CommandResult } from '../types';
|
|
30
39
|
import { generateTypesForImportedModule } from './module-types';
|
|
31
40
|
|
|
@@ -46,6 +55,124 @@ Examples:
|
|
|
46
55
|
*
|
|
47
56
|
* Exposed for tests; not exported from the package.
|
|
48
57
|
*/
|
|
58
|
+
/**
|
|
59
|
+
* Run the base-module-aspect approval flow after a module's primary
|
|
60
|
+
* import succeeds. Per v2/CELILO_BASE.md D2:
|
|
61
|
+
*
|
|
62
|
+
* - Modules without a `base_module_aspect` block: no-op.
|
|
63
|
+
* - Re-imports of a version that's already been approved with the
|
|
64
|
+
* same scope: no-op (don't re-prompt).
|
|
65
|
+
* - First-time import (or new version) with a declared aspect:
|
|
66
|
+
* show scope, prompt unless `--accept-aspects` is passed.
|
|
67
|
+
* - Operator declines: roll back the import (DB row + on-disk
|
|
68
|
+
* source dir) so re-running is a clean re-do.
|
|
69
|
+
*
|
|
70
|
+
* Returns `{ approved: true, message?: string }` on success, or
|
|
71
|
+
* `{ approved: false, error: string }` on decline or non-interactive
|
|
72
|
+
* mismatch. The caller is responsible for surfacing the error.
|
|
73
|
+
*/
|
|
74
|
+
async function handleAspectApprovalAfterImport(args: {
|
|
75
|
+
moduleId: string;
|
|
76
|
+
targetPath: string;
|
|
77
|
+
flags: Record<string, string | boolean>;
|
|
78
|
+
db: ReturnType<typeof getDb>;
|
|
79
|
+
}): Promise<{ approved: true; message?: string } | { approved: false; error: string }> {
|
|
80
|
+
const { moduleId, targetPath, flags, db } = args;
|
|
81
|
+
|
|
82
|
+
const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
83
|
+
if (!moduleRow) {
|
|
84
|
+
// Shouldn't happen — importModule just succeeded — but fail
|
|
85
|
+
// loudly rather than silently no-op.
|
|
86
|
+
return {
|
|
87
|
+
approved: false,
|
|
88
|
+
error: `Imported module '${moduleId}' missing from DB after import.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const manifest = moduleRow.manifestData as ModuleManifest;
|
|
93
|
+
const aspect = manifest.base_module_aspect;
|
|
94
|
+
if (!aspect) {
|
|
95
|
+
// No aspect declared — nothing to approve.
|
|
96
|
+
return { approved: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const status = checkAspectApproval(moduleId, moduleRow.version, aspect, db);
|
|
100
|
+
if (status === 'approved') {
|
|
101
|
+
// Same version + same scope already approved (re-import of a
|
|
102
|
+
// previously-accepted version). Skip the prompt silently.
|
|
103
|
+
return { approved: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const acceptViaFlag = hasFlag(flags, 'accept-aspects');
|
|
107
|
+
const approver = process.env.USER ?? null;
|
|
108
|
+
|
|
109
|
+
if (acceptViaFlag) {
|
|
110
|
+
recordAspectApproval({
|
|
111
|
+
moduleId,
|
|
112
|
+
version: moduleRow.version,
|
|
113
|
+
scopeHash: computeAspectScopeHash(aspect),
|
|
114
|
+
approver,
|
|
115
|
+
db,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
approved: true,
|
|
119
|
+
message: `Base-module aspect approved via --accept-aspects (zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')})`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Interactive prompt path. Display the scope clearly so the
|
|
124
|
+
// operator's consent is informed.
|
|
125
|
+
const scopeMsg = [
|
|
126
|
+
`Module '${moduleId}' declares a base-module aspect:`,
|
|
127
|
+
` Ansible role: ${aspect.ansible_role}`,
|
|
128
|
+
` Target zones: ${aspect.applicable_zones.join(', ')}`,
|
|
129
|
+
` Triggers: ${aspect.triggers.join(', ')}`,
|
|
130
|
+
'',
|
|
131
|
+
`This means: when this module's primary deploys (or one of the listed triggers fires),`,
|
|
132
|
+
`celilo will run the '${aspect.ansible_role}' Ansible role on every non-api_only system`,
|
|
133
|
+
'in those zones.',
|
|
134
|
+
].join('\n');
|
|
135
|
+
// Use process.stderr.write rather than the prompt UI so the
|
|
136
|
+
// multi-line scope block is preserved verbatim — @clack/prompts
|
|
137
|
+
// mangles multi-line message arguments.
|
|
138
|
+
process.stderr.write(`\n${scopeMsg}\n\n`);
|
|
139
|
+
|
|
140
|
+
const accepted = await promptConfirm({
|
|
141
|
+
message: 'Approve this base-module aspect?',
|
|
142
|
+
initialValue: false,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!accepted) {
|
|
146
|
+
// Roll back the import — DB delete cascades to configs/secrets/etc.
|
|
147
|
+
// We also rm the source dir so a re-import doesn't trip on the
|
|
148
|
+
// existing files.
|
|
149
|
+
db.delete(modules).where(eq(modules.id, moduleId)).run();
|
|
150
|
+
try {
|
|
151
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
152
|
+
} catch {
|
|
153
|
+
// Best-effort cleanup; if rm fails the operator can clean up
|
|
154
|
+
// manually. Don't mask the original "approval declined" error.
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
approved: false,
|
|
158
|
+
error:
|
|
159
|
+
'Aspect approval declined; import rolled back. Re-run with --accept-aspects to skip the prompt.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
recordAspectApproval({
|
|
164
|
+
moduleId,
|
|
165
|
+
version: moduleRow.version,
|
|
166
|
+
scopeHash: computeAspectScopeHash(aspect),
|
|
167
|
+
approver,
|
|
168
|
+
db,
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
approved: true,
|
|
172
|
+
message: `Base-module aspect approved (zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')})`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
49
176
|
export function classifyImportArg(arg: string): 'path' | 'name' {
|
|
50
177
|
if (arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../')) return 'path';
|
|
51
178
|
if (arg.startsWith('~/') || arg === '~') return 'path';
|
|
@@ -142,12 +269,23 @@ async function handleFileImport(
|
|
|
142
269
|
};
|
|
143
270
|
}
|
|
144
271
|
|
|
272
|
+
const aspectOutcome = await handleAspectApprovalAfterImport({
|
|
273
|
+
moduleId: result.moduleId,
|
|
274
|
+
targetPath: result.targetPath,
|
|
275
|
+
flags,
|
|
276
|
+
db,
|
|
277
|
+
});
|
|
278
|
+
if (!aspectOutcome.approved) {
|
|
279
|
+
return { success: false, error: aspectOutcome.error };
|
|
280
|
+
}
|
|
281
|
+
|
|
145
282
|
const typesPath = await generateTypesForImportedModule(result.targetPath);
|
|
146
283
|
const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : '';
|
|
284
|
+
const aspectMessage = aspectOutcome.message ? `\n${aspectOutcome.message}` : '';
|
|
147
285
|
|
|
148
286
|
return {
|
|
149
287
|
success: true,
|
|
150
|
-
message: `Successfully imported module: ${result.moduleId}\nFiles copied to: ${shortenPath(result.targetPath)}${typesMessage}`,
|
|
288
|
+
message: `Successfully imported module: ${result.moduleId}\nFiles copied to: ${shortenPath(result.targetPath)}${typesMessage}${aspectMessage}`,
|
|
151
289
|
data: {
|
|
152
290
|
moduleId: result.moduleId,
|
|
153
291
|
targetPath: result.targetPath,
|
|
@@ -212,13 +350,24 @@ async function handlePublicRegistryImport(
|
|
|
212
350
|
return { success: false, error: result.error, details: result.details };
|
|
213
351
|
}
|
|
214
352
|
|
|
353
|
+
const aspectOutcome = await handleAspectApprovalAfterImport({
|
|
354
|
+
moduleId: result.moduleId,
|
|
355
|
+
targetPath: result.targetPath,
|
|
356
|
+
flags,
|
|
357
|
+
db,
|
|
358
|
+
});
|
|
359
|
+
if (!aspectOutcome.approved) {
|
|
360
|
+
return { success: false, error: aspectOutcome.error };
|
|
361
|
+
}
|
|
362
|
+
const aspectMessage = aspectOutcome.message ? `\n${aspectOutcome.message}` : '';
|
|
363
|
+
|
|
215
364
|
// Type-generation is a developer-mode aid for module AUTHORS editing
|
|
216
365
|
// hook scripts in the source tree. Registry installs are black-box
|
|
217
366
|
// dependencies — the operator isn't editing them — so skipping the
|
|
218
367
|
// type-gen pass saves the work and keeps the install output clean.
|
|
219
368
|
return {
|
|
220
369
|
success: true,
|
|
221
|
-
message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}`,
|
|
370
|
+
message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}${aspectMessage}`,
|
|
222
371
|
data: {
|
|
223
372
|
moduleId: result.moduleId,
|
|
224
373
|
version: latest.vers,
|
|
@@ -16,7 +16,13 @@ import { runNamedHook } from '../../hooks/run-named-hook';
|
|
|
16
16
|
import { deallocateForModule } from '../../ipam/auto-allocator';
|
|
17
17
|
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
18
18
|
import { executeBuildWithProgress } from '../../services/build-stream';
|
|
19
|
+
import {
|
|
20
|
+
emitUninstallCompleted,
|
|
21
|
+
emitUninstallFailed,
|
|
22
|
+
emitUninstallStarted,
|
|
23
|
+
} from '../../services/celilo-events';
|
|
19
24
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
25
|
+
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
20
26
|
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
21
27
|
import { log, promptConfirm } from '../prompts';
|
|
22
28
|
import type { CommandResult } from '../types';
|
|
@@ -94,6 +100,57 @@ export async function handleModuleRemove(
|
|
|
94
100
|
}
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
// Pre-flight passed; we're committed to attempting the uninstall. Start
|
|
104
|
+
// operation tracking + emit lifecycle events from here on so backups and
|
|
105
|
+
// restores see the uninstall as in-flight and downstream subscribers can
|
|
106
|
+
// react.
|
|
107
|
+
const startedAt = Date.now();
|
|
108
|
+
const opId = startOperation(moduleId, 'uninstall');
|
|
109
|
+
emitUninstallStarted({ module: moduleId, startedAt });
|
|
110
|
+
|
|
111
|
+
let result: CommandResult;
|
|
112
|
+
try {
|
|
113
|
+
result = await performModuleRemove(moduleId, module, force, db);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
failOperation(opId, err);
|
|
116
|
+
emitUninstallFailed({
|
|
117
|
+
module: moduleId,
|
|
118
|
+
startedAt,
|
|
119
|
+
durationMs: Date.now() - startedAt,
|
|
120
|
+
error: err instanceof Error ? err.message : String(err),
|
|
121
|
+
});
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const durationMs = Date.now() - startedAt;
|
|
126
|
+
if (result.success) {
|
|
127
|
+
completeOperation(opId);
|
|
128
|
+
emitUninstallCompleted({ module: moduleId, startedAt, durationMs });
|
|
129
|
+
} else {
|
|
130
|
+
failOperation(opId, result.error ?? 'unknown error');
|
|
131
|
+
emitUninstallFailed({
|
|
132
|
+
module: moduleId,
|
|
133
|
+
startedAt,
|
|
134
|
+
durationMs,
|
|
135
|
+
error: result.error ?? 'unknown error',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The actual uninstall work — on_uninstall hook, terraform destroy, DNS
|
|
143
|
+
* deregister, IPAM deallocate, subscription cleanup, DB delete. Split out
|
|
144
|
+
* so `handleModuleRemove` can wrap the call with operation tracking +
|
|
145
|
+
* lifecycle event emission. Pre-conditions (module exists, no dependents)
|
|
146
|
+
* are validated by the caller.
|
|
147
|
+
*/
|
|
148
|
+
async function performModuleRemove(
|
|
149
|
+
moduleId: string,
|
|
150
|
+
module: typeof modules.$inferSelect,
|
|
151
|
+
force: boolean,
|
|
152
|
+
db: ReturnType<typeof getDb>,
|
|
153
|
+
): Promise<CommandResult> {
|
|
97
154
|
// Run on_uninstall hook (if defined) BEFORE terraform destroy. Hooks
|
|
98
155
|
// typically need the module's runtime to still be up so they can talk
|
|
99
156
|
// to remote services — e.g. caddy's teardown SSHes the caddy host to
|
|
@@ -221,23 +278,35 @@ export async function handleModuleRemove(
|
|
|
221
278
|
log.success('Infrastructure destroyed');
|
|
222
279
|
}
|
|
223
280
|
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
281
|
+
// Capture the deployed systems BEFORE the module_systems rows are deleted
|
|
282
|
+
// (cascade on module removal) so the system.destroyed events (D5/D6) carry
|
|
283
|
+
// the host/IP each subscriber needs to deregister. One event per system.
|
|
284
|
+
const removedSystems = await (async () => {
|
|
285
|
+
try {
|
|
286
|
+
const { getModuleSystems } = await import('../../services/deployed-systems');
|
|
287
|
+
return getModuleSystems(moduleId, db);
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
})();
|
|
292
|
+
|
|
293
|
+
// Announce teardown on the bus (D5, v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md). A
|
|
294
|
+
// dns_internal provider's system.destroyed.* subscription runs its
|
|
295
|
+
// on_system_event hook (op: deregister) to remove each host's records.
|
|
296
|
+
if (removedSystems.length > 0) {
|
|
297
|
+
try {
|
|
298
|
+
const { emitSystemDestroyed } = await import('../../services/celilo-events');
|
|
299
|
+
for (const sys of removedSystems) {
|
|
300
|
+
emitSystemDestroyed({
|
|
301
|
+
module: moduleId,
|
|
302
|
+
hostname: sys.hostname,
|
|
303
|
+
targetIp: sys.ipv4_address,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
308
|
+
log.warn(`Failed to emit system.destroyed for ${moduleId}: ${msg}`);
|
|
309
|
+
}
|
|
241
310
|
}
|
|
242
311
|
|
|
243
312
|
// Deallocate IPAM resources (if any)
|
|
@@ -88,8 +88,12 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
|
|
|
88
88
|
if (configs.length > 0) {
|
|
89
89
|
const configLines = ['Configuration:'];
|
|
90
90
|
for (const config of configs) {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// `value` is the human-readable display form (e.g. "test-host"
|
|
92
|
+
// for a string, "2222" for a number, JSON-stringified for
|
|
93
|
+
// complex types) — populated by upsertModuleConfig alongside
|
|
94
|
+
// the canonical valueJson. Using it here keeps the status
|
|
95
|
+
// output free of JSON-quote noise around primitives.
|
|
96
|
+
configLines.push(` ${config.key}: ${config.value}`);
|
|
93
97
|
}
|
|
94
98
|
sections.push(configLines.join('\n'));
|
|
95
99
|
} else {
|