@celilo/cli 0.3.30-alpha.0 → 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 +3 -3
- 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
|
@@ -70,6 +70,75 @@ export async function parseConfigValue(
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Upsert a `module_configs` row. THE ONLY supported write path.
|
|
75
|
+
*
|
|
76
|
+
* Direct `db.insert(moduleConfigs).values({...})` calls are an
|
|
77
|
+
* anti-pattern — they bypass valueJson population and leave reads
|
|
78
|
+
* unable to recover the original type. Defect 1 was exactly this:
|
|
79
|
+
* primitive writes that set `value` but not `valueJson`, so reads
|
|
80
|
+
* had to guess (and got it wrong for stringly-looking-numeric
|
|
81
|
+
* values, etc.). Every write site routes through here now.
|
|
82
|
+
*
|
|
83
|
+
* Callers pass the canonical JS value (`number`, `boolean`,
|
|
84
|
+
* `string`, array, object); we JSON-stringify into `valueJson`
|
|
85
|
+
* (the typed canonical) and produce a human-readable form for
|
|
86
|
+
* `value` (used only for CLI display).
|
|
87
|
+
*/
|
|
88
|
+
export function upsertModuleConfig(
|
|
89
|
+
db: DbClient,
|
|
90
|
+
moduleId: string,
|
|
91
|
+
key: string,
|
|
92
|
+
value: string | number | boolean | unknown[] | Record<string, unknown> | null,
|
|
93
|
+
): void {
|
|
94
|
+
if (value === null || value === undefined) {
|
|
95
|
+
throw new Error(`upsertModuleConfig(${moduleId}, ${key}): value must not be null/undefined`);
|
|
96
|
+
}
|
|
97
|
+
const valueJson = JSON.stringify(value);
|
|
98
|
+
const displayValue = isComplexValue(value) ? valueJson : String(value);
|
|
99
|
+
|
|
100
|
+
db.insert(moduleConfigs)
|
|
101
|
+
.values({ moduleId, key, value: displayValue, valueJson })
|
|
102
|
+
.onConflictDoUpdate({
|
|
103
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
104
|
+
set: { value: displayValue, valueJson, updatedAt: new Date() },
|
|
105
|
+
})
|
|
106
|
+
.run();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse a stored module_configs row into its canonical typed value.
|
|
111
|
+
* Throws if valueJson is null — that's a row written before the
|
|
112
|
+
* always-populate-valueJson invariant, and we no longer support it.
|
|
113
|
+
* See schema.ts module_configs doc comment for context.
|
|
114
|
+
*
|
|
115
|
+
* Exported so other read sites (hook config loader, variables
|
|
116
|
+
* context, ansible inventory builder, etc.) can share one parsing
|
|
117
|
+
* path — a single source of truth for "DB row → typed value".
|
|
118
|
+
*/
|
|
119
|
+
export function parseStoredConfigValue(
|
|
120
|
+
row: typeof moduleConfigs.$inferSelect,
|
|
121
|
+
): string | number | boolean | unknown[] | Record<string, unknown> {
|
|
122
|
+
if (row.valueJson === null || row.valueJson === undefined) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`module_configs row ${row.moduleId}.${row.key} has null valueJson — ` +
|
|
125
|
+
`pre-Defect-1 row, no longer supported. Re-run \`celilo module config set ${row.moduleId} ${row.key} <value>\` to populate.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(row.valueJson) as
|
|
130
|
+
| string
|
|
131
|
+
| number
|
|
132
|
+
| boolean
|
|
133
|
+
| unknown[]
|
|
134
|
+
| Record<string, unknown>;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Failed to parse valueJson for ${row.moduleId}.${row.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
73
142
|
/**
|
|
74
143
|
* Get module configuration value
|
|
75
144
|
* Returns parsed value (primitive or complex type)
|
|
@@ -89,41 +158,11 @@ export function getModuleConfigValue(
|
|
|
89
158
|
return null;
|
|
90
159
|
}
|
|
91
160
|
|
|
92
|
-
|
|
93
|
-
if (config.valueJson) {
|
|
94
|
-
try {
|
|
95
|
-
const parsed = JSON.parse(config.valueJson);
|
|
96
|
-
return {
|
|
97
|
-
key: config.key,
|
|
98
|
-
value: parsed,
|
|
99
|
-
isPrimitive: false,
|
|
100
|
-
};
|
|
101
|
-
} catch (error) {
|
|
102
|
-
throw new Error(
|
|
103
|
-
`Failed to parse config value for ${moduleId}.${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Primitive type (stored in value)
|
|
109
|
-
// Parse the string value to correct type
|
|
110
|
-
let parsedValue: string | number | boolean = config.value;
|
|
111
|
-
|
|
112
|
-
// Boolean
|
|
113
|
-
if (config.value === 'true') parsedValue = true;
|
|
114
|
-
else if (config.value === 'false') parsedValue = false;
|
|
115
|
-
// Number
|
|
116
|
-
else {
|
|
117
|
-
const num = Number(config.value);
|
|
118
|
-
if (!Number.isNaN(num)) {
|
|
119
|
-
parsedValue = num;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
161
|
+
const parsed = parseStoredConfigValue(config);
|
|
123
162
|
return {
|
|
124
163
|
key: config.key,
|
|
125
|
-
value:
|
|
126
|
-
isPrimitive:
|
|
164
|
+
value: parsed,
|
|
165
|
+
isPrimitive: !isComplexValue(parsed),
|
|
127
166
|
};
|
|
128
167
|
}
|
|
129
168
|
|
|
@@ -134,40 +173,11 @@ export function getAllModuleConfigValues(moduleId: string, db: DbClient = getDb(
|
|
|
134
173
|
const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
|
|
135
174
|
|
|
136
175
|
return configs.map((config: typeof moduleConfigs.$inferSelect) => {
|
|
137
|
-
|
|
138
|
-
if (config.valueJson) {
|
|
139
|
-
try {
|
|
140
|
-
const parsed = JSON.parse(config.valueJson);
|
|
141
|
-
return {
|
|
142
|
-
key: config.key,
|
|
143
|
-
value: parsed,
|
|
144
|
-
isPrimitive: false,
|
|
145
|
-
};
|
|
146
|
-
} catch (error) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
`Failed to parse config value for ${moduleId}.${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Primitive type
|
|
154
|
-
let parsedValue: string | number | boolean = config.value;
|
|
155
|
-
|
|
156
|
-
// Boolean
|
|
157
|
-
if (config.value === 'true') parsedValue = true;
|
|
158
|
-
else if (config.value === 'false') parsedValue = false;
|
|
159
|
-
// Number
|
|
160
|
-
else {
|
|
161
|
-
const num = Number(config.value);
|
|
162
|
-
if (!Number.isNaN(num)) {
|
|
163
|
-
parsedValue = num;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
176
|
+
const parsed = parseStoredConfigValue(config);
|
|
167
177
|
return {
|
|
168
178
|
key: config.key,
|
|
169
|
-
value:
|
|
170
|
-
isPrimitive:
|
|
179
|
+
value: parsed,
|
|
180
|
+
isPrimitive: !isComplexValue(parsed),
|
|
171
181
|
};
|
|
172
182
|
});
|
|
173
183
|
}
|
|
@@ -228,61 +238,9 @@ export async function setModuleConfigValue(
|
|
|
228
238
|
throw new Error(formatValidationErrors(validation.errors || []));
|
|
229
239
|
}
|
|
230
240
|
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// Check if config already exists
|
|
235
|
-
const existingConfig = db
|
|
236
|
-
.select()
|
|
237
|
-
.from(moduleConfigs)
|
|
238
|
-
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
|
|
239
|
-
.get();
|
|
240
|
-
|
|
241
|
-
if (existingConfig) {
|
|
242
|
-
// Update existing config
|
|
243
|
-
if (isComplex) {
|
|
244
|
-
// Store in valueJson column
|
|
245
|
-
db.update(moduleConfigs)
|
|
246
|
-
.set({
|
|
247
|
-
value: '', // Empty string for complex types
|
|
248
|
-
valueJson: JSON.stringify(parsedValue),
|
|
249
|
-
updatedAt: new Date(),
|
|
250
|
-
})
|
|
251
|
-
.where(eq(moduleConfigs.id, existingConfig.id))
|
|
252
|
-
.run();
|
|
253
|
-
} else {
|
|
254
|
-
// Store in value column
|
|
255
|
-
db.update(moduleConfigs)
|
|
256
|
-
.set({
|
|
257
|
-
value: String(parsedValue),
|
|
258
|
-
valueJson: null,
|
|
259
|
-
updatedAt: new Date(),
|
|
260
|
-
})
|
|
261
|
-
.where(eq(moduleConfigs.id, existingConfig.id))
|
|
262
|
-
.run();
|
|
263
|
-
}
|
|
264
|
-
} else {
|
|
265
|
-
// Insert new config
|
|
266
|
-
if (isComplex) {
|
|
267
|
-
db.insert(moduleConfigs)
|
|
268
|
-
.values({
|
|
269
|
-
moduleId,
|
|
270
|
-
key,
|
|
271
|
-
value: '', // Empty string for complex types
|
|
272
|
-
valueJson: JSON.stringify(parsedValue),
|
|
273
|
-
})
|
|
274
|
-
.run();
|
|
275
|
-
} else {
|
|
276
|
-
db.insert(moduleConfigs)
|
|
277
|
-
.values({
|
|
278
|
-
moduleId,
|
|
279
|
-
key,
|
|
280
|
-
value: String(parsedValue),
|
|
281
|
-
valueJson: null,
|
|
282
|
-
})
|
|
283
|
-
.run();
|
|
284
|
-
}
|
|
285
|
-
}
|
|
241
|
+
// Delegate to the shared upsert helper — single storage path,
|
|
242
|
+
// single place where the valueJson invariant lives.
|
|
243
|
+
upsertModuleConfig(db, moduleId, key, parsedValue);
|
|
286
244
|
}
|
|
287
245
|
|
|
288
246
|
/**
|
|
@@ -20,6 +20,7 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
20
20
|
import { decryptSecret } from '../secrets/encryption';
|
|
21
21
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
22
22
|
import { buildResolutionContext } from '../variables/context';
|
|
23
|
+
import { maybeRunAspectForTrigger } from './aspect-runner';
|
|
23
24
|
import {
|
|
24
25
|
autoDeriveMachineConfig,
|
|
25
26
|
findEnsureOnProvider,
|
|
@@ -33,6 +34,7 @@ import { planDeployment } from './deploy-planner';
|
|
|
33
34
|
import { waitForSSH } from './deploy-ssh';
|
|
34
35
|
import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
|
|
35
36
|
import { validateAndPrepareDeployment } from './deploy-validation';
|
|
37
|
+
import { getModuleSystems } from './deployed-systems';
|
|
36
38
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
37
39
|
import { findMachineForModule } from './machine-pool';
|
|
38
40
|
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
@@ -263,7 +265,9 @@ export async function deployModule(
|
|
|
263
265
|
const startedAt = Date.now();
|
|
264
266
|
const { emitDeployCompleted, emitDeployFailed, emitDeployStarted, emitHealthCheckFailed } =
|
|
265
267
|
await import('./celilo-events');
|
|
268
|
+
const { startOperation, completeOperation, failOperation } = await import('./module-operations');
|
|
266
269
|
|
|
270
|
+
const opId = startOperation(moduleId, 'deploy');
|
|
267
271
|
emitDeployStarted({ module: moduleId, startedAt });
|
|
268
272
|
|
|
269
273
|
let result: DeployResult;
|
|
@@ -271,19 +275,22 @@ export async function deployModule(
|
|
|
271
275
|
result = await deployModuleImpl(moduleId, db, options);
|
|
272
276
|
} catch (err) {
|
|
273
277
|
const error = err instanceof Error ? err.message : String(err);
|
|
278
|
+
failOperation(opId, err);
|
|
274
279
|
emitDeployFailed({
|
|
275
280
|
module: moduleId,
|
|
276
281
|
startedAt,
|
|
277
|
-
durationMs: Date.now() - startedAt,
|
|
282
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
278
283
|
error,
|
|
279
284
|
});
|
|
280
285
|
throw err;
|
|
281
286
|
}
|
|
282
287
|
|
|
283
|
-
const durationMs = Date.now() - startedAt;
|
|
288
|
+
const durationMs = Math.max(0, Date.now() - startedAt);
|
|
284
289
|
if (result.success) {
|
|
290
|
+
completeOperation(opId);
|
|
285
291
|
emitDeployCompleted({ module: moduleId, startedAt, durationMs });
|
|
286
292
|
} else {
|
|
293
|
+
failOperation(opId, result.error ?? 'unknown error');
|
|
287
294
|
emitDeployFailed({
|
|
288
295
|
module: moduleId,
|
|
289
296
|
startedAt,
|
|
@@ -607,6 +614,7 @@ async function deployModuleImpl(
|
|
|
607
614
|
debug: options.debug,
|
|
608
615
|
capabilities: capabilityFunctions,
|
|
609
616
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
617
|
+
systems: getModuleSystems(moduleId, db),
|
|
610
618
|
},
|
|
611
619
|
);
|
|
612
620
|
|
|
@@ -745,6 +753,7 @@ async function deployModuleImpl(
|
|
|
745
753
|
debug: options.debug,
|
|
746
754
|
capabilities: capFns,
|
|
747
755
|
requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
|
|
756
|
+
systems: getModuleSystems(moduleId, db),
|
|
748
757
|
},
|
|
749
758
|
};
|
|
750
759
|
},
|
|
@@ -783,6 +792,25 @@ async function deployModuleImpl(
|
|
|
783
792
|
}
|
|
784
793
|
}
|
|
785
794
|
|
|
795
|
+
// Fan out the module's base-module aspect (if any) per the
|
|
796
|
+
// on_install trigger. Failures here don't fail the primary
|
|
797
|
+
// deploy — D4 in v2/CELILO_BASE.md: aspects are idempotent
|
|
798
|
+
// and forward-progress; a partial fleet update is expected to
|
|
799
|
+
// converge on the next fan-out. We log the result instead.
|
|
800
|
+
const aspectOutcome = await maybeRunAspectForTrigger({
|
|
801
|
+
moduleId,
|
|
802
|
+
manifest,
|
|
803
|
+
trigger: 'on_install',
|
|
804
|
+
db,
|
|
805
|
+
});
|
|
806
|
+
if (aspectOutcome.ran && !aspectOutcome.success) {
|
|
807
|
+
log.warn(
|
|
808
|
+
`Base-module aspect fan-out for '${moduleId}' failed: ${aspectOutcome.runResult?.error ?? 'unknown'}. Primary deploy succeeded; re-run \`celilo fleet redeploy ${moduleId}\` after fixing.`,
|
|
809
|
+
);
|
|
810
|
+
} else if (aspectOutcome.ran) {
|
|
811
|
+
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
812
|
+
}
|
|
813
|
+
|
|
786
814
|
// Mirror the infrastructure-path success message at the end of
|
|
787
815
|
// a successful deploy. Without this, config-only deploys end
|
|
788
816
|
// abruptly with whatever the last hook line was — operator sees
|
|
@@ -875,6 +903,25 @@ async function deployModuleImpl(
|
|
|
875
903
|
db,
|
|
876
904
|
);
|
|
877
905
|
|
|
906
|
+
// Record the module's deployed system(s) now that the IP is known for every
|
|
907
|
+
// provider type (machine / proxmox IPAM / DO outputs). This populates
|
|
908
|
+
// ctx.systems for on_install and is the source of truth for system.created
|
|
909
|
+
// and DNS (v2/MODULE_SYSTEMS_ADDRESSING.md). API-only modules record none.
|
|
910
|
+
{
|
|
911
|
+
const { recordDeployedSystemForModule } = await import('./deployed-systems');
|
|
912
|
+
const recorded = await recordDeployedSystemForModule(
|
|
913
|
+
moduleId,
|
|
914
|
+
manifest,
|
|
915
|
+
plan.infrastructure,
|
|
916
|
+
db,
|
|
917
|
+
);
|
|
918
|
+
if (recorded.length > 0) {
|
|
919
|
+
log.success(
|
|
920
|
+
`Recorded ${recorded.length} deployed system(s): ${recorded.map((s) => `${s.hostname} (${s.ipv4_address})`).join(', ')}`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
878
925
|
if (Object.keys(resolution.resolved).length > 0) {
|
|
879
926
|
// Build complete message with all variables
|
|
880
927
|
const lines = ['Infrastructure variables resolved:'];
|
|
@@ -999,6 +1046,7 @@ async function deployModuleImpl(
|
|
|
999
1046
|
debug: options.debug,
|
|
1000
1047
|
capabilities: capFunctions,
|
|
1001
1048
|
requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
|
|
1049
|
+
systems: getModuleSystems(capRecord.moduleId, db),
|
|
1002
1050
|
},
|
|
1003
1051
|
);
|
|
1004
1052
|
|
|
@@ -1137,31 +1185,15 @@ async function deployModuleImpl(
|
|
|
1137
1185
|
}
|
|
1138
1186
|
}
|
|
1139
1187
|
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
// Mirrors proxmox-state-recovery.ts for the machine-deployed
|
|
1145
|
-
// case.
|
|
1188
|
+
// Inject the machine's IP as ip.primary for hooks that still read it
|
|
1189
|
+
// (firewall NAT target, etc.). The host's address for hooks now comes
|
|
1190
|
+
// from ctx.systems (v2/MODULE_SYSTEMS_ADDRESSING.md), recorded into
|
|
1191
|
+
// module_systems during generate — no target_ip is written here.
|
|
1146
1192
|
if (machineId) {
|
|
1147
1193
|
const { getMachine } = await import('./machine-pool');
|
|
1148
1194
|
const deployMachine = await getMachine(machineId);
|
|
1149
1195
|
if (deployMachine) {
|
|
1150
1196
|
installConfigMap['ip.primary'] = deployMachine.ipAddress;
|
|
1151
|
-
installConfigMap.target_ip = deployMachine.ipAddress;
|
|
1152
|
-
|
|
1153
|
-
await db
|
|
1154
|
-
.insert(pcTable)
|
|
1155
|
-
.values({
|
|
1156
|
-
moduleId,
|
|
1157
|
-
key: 'target_ip',
|
|
1158
|
-
value: deployMachine.ipAddress,
|
|
1159
|
-
})
|
|
1160
|
-
.onConflictDoUpdate({
|
|
1161
|
-
target: [pcTable.moduleId, pcTable.key],
|
|
1162
|
-
set: { value: deployMachine.ipAddress },
|
|
1163
|
-
})
|
|
1164
|
-
.run();
|
|
1165
1197
|
}
|
|
1166
1198
|
}
|
|
1167
1199
|
|
|
@@ -1201,6 +1233,7 @@ async function deployModuleImpl(
|
|
|
1201
1233
|
debug: options.debug,
|
|
1202
1234
|
capabilities: capFns,
|
|
1203
1235
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
1236
|
+
systems: getModuleSystems(moduleId, db),
|
|
1204
1237
|
},
|
|
1205
1238
|
};
|
|
1206
1239
|
},
|
|
@@ -1253,31 +1286,71 @@ async function deployModuleImpl(
|
|
|
1253
1286
|
}
|
|
1254
1287
|
|
|
1255
1288
|
// Auto-register module hostname in internal DNS (if available).
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1289
|
+
// When a dns_internal provider deploys, backfill DNS for every
|
|
1290
|
+
// already-deployed system by invoking its own on_system_event hook per
|
|
1291
|
+
// host — the event path below only covers systems that deploy AFTER the
|
|
1292
|
+
// provider (deliveries bind at emit time). Non-providers skip this
|
|
1293
|
+
// entirely; their registration rides the system.created event below.
|
|
1294
|
+
// v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md.
|
|
1295
|
+
const { isDnsInternalProvider, backfillProviderDns } = await import(
|
|
1296
|
+
'./dns-provider-backfill'
|
|
1297
|
+
);
|
|
1298
|
+
if (isDnsInternalProvider(moduleId, db)) {
|
|
1299
|
+
// FuelGauge so the per-host hook invocations nest as sub-events under
|
|
1300
|
+
// one step rather than leaking to scrollback.
|
|
1301
|
+
const dnsGauge = new FuelGauge(`Backfilling internal DNS via ${moduleId}`, {
|
|
1302
|
+
skipAnimation: !process.stdout.isTTY,
|
|
1303
|
+
});
|
|
1304
|
+
dnsGauge.start();
|
|
1305
|
+
try {
|
|
1306
|
+
const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'dns_backfill');
|
|
1307
|
+
await backfillProviderDns(moduleId, db, dnsLogger);
|
|
1308
|
+
dnsGauge.stop(true);
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
dnsGauge.stop(false);
|
|
1311
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1312
|
+
log.warn(
|
|
1313
|
+
`DNS backfill failed for '${moduleId}': ${msg}. Some internal DNS records may need manual setup.`,
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Announce each deployed system on the bus (D5/D6) — one
|
|
1319
|
+
// system.created.<module> per host, so a dns_internal provider's
|
|
1320
|
+
// subscription registers every one (v2/MODULE_SYSTEMS_ADDRESSING.md,
|
|
1321
|
+
// v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md, v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md).
|
|
1265
1322
|
try {
|
|
1266
|
-
const
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1269
|
-
|
|
1323
|
+
const { emitSystemCreated } = await import('./celilo-events');
|
|
1324
|
+
for (const sys of getModuleSystems(moduleId, db)) {
|
|
1325
|
+
emitSystemCreated({
|
|
1326
|
+
module: moduleId,
|
|
1327
|
+
hostname: sys.hostname,
|
|
1328
|
+
targetIp: sys.ipv4_address,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1270
1331
|
} catch (error) {
|
|
1271
|
-
|
|
1332
|
+
// Best-effort: a bus hiccup must not fail an otherwise-good deploy.
|
|
1272
1333
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1273
|
-
log.warn(`
|
|
1274
|
-
dnsRegistered = false;
|
|
1334
|
+
log.warn(`Failed to emit system.created for ${moduleId}: ${msg}`);
|
|
1275
1335
|
}
|
|
1276
1336
|
|
|
1277
|
-
if
|
|
1337
|
+
// Fan out the module's base-module aspect (if any) per the
|
|
1338
|
+
// on_install trigger. Failures here don't fail the primary
|
|
1339
|
+
// deploy — D4 in v2/CELILO_BASE.md: aspects are idempotent
|
|
1340
|
+
// and forward-progress; a partial fleet update is expected to
|
|
1341
|
+
// converge on the next fan-out. We log the result instead.
|
|
1342
|
+
const aspectOutcome = await maybeRunAspectForTrigger({
|
|
1343
|
+
moduleId,
|
|
1344
|
+
manifest,
|
|
1345
|
+
trigger: 'on_install',
|
|
1346
|
+
db,
|
|
1347
|
+
});
|
|
1348
|
+
if (aspectOutcome.ran && !aspectOutcome.success) {
|
|
1278
1349
|
log.warn(
|
|
1279
|
-
`
|
|
1350
|
+
`Base-module aspect fan-out for '${moduleId}' failed: ${aspectOutcome.runResult?.error ?? 'unknown'}. Primary deploy succeeded; re-run \`celilo fleet redeploy ${moduleId}\` after fixing.`,
|
|
1280
1351
|
);
|
|
1352
|
+
} else if (aspectOutcome.ran) {
|
|
1353
|
+
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
1281
1354
|
}
|
|
1282
1355
|
|
|
1283
1356
|
log.success(`Module '${moduleId}' deployed successfully`);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { closeDb } from '../db/client';
|
|
7
|
+
import { runMigrations } from '../db/migrate';
|
|
8
|
+
import {
|
|
9
|
+
InFlightError,
|
|
10
|
+
checkInFlight,
|
|
11
|
+
completeOperation,
|
|
12
|
+
failOperation,
|
|
13
|
+
isPidAlive,
|
|
14
|
+
refuseIfInFlight,
|
|
15
|
+
startOperation,
|
|
16
|
+
} from './module-operations';
|
|
17
|
+
|
|
18
|
+
describe('module-operations', () => {
|
|
19
|
+
let dir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-ops-test-'));
|
|
23
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
24
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
closeDb();
|
|
29
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
30
|
+
try {
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
} catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('startOperation / completeOperation / failOperation', () => {
|
|
38
|
+
it('records an in-progress row on start, transitions to completed on complete', () => {
|
|
39
|
+
const id = startOperation('homebridge', 'deploy');
|
|
40
|
+
const inFlight = checkInFlight();
|
|
41
|
+
expect(inFlight).toHaveLength(1);
|
|
42
|
+
expect(inFlight[0].operation.id).toBe(id);
|
|
43
|
+
expect(inFlight[0].operation.status).toBe('in_progress');
|
|
44
|
+
expect(inFlight[0].operation.operation).toBe('deploy');
|
|
45
|
+
expect(inFlight[0].operation.moduleId).toBe('homebridge');
|
|
46
|
+
|
|
47
|
+
completeOperation(id);
|
|
48
|
+
expect(checkInFlight()).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('transitions to failed with error message on failOperation', () => {
|
|
52
|
+
const id = startOperation('caddy', 'backup');
|
|
53
|
+
failOperation(id, new Error('disk full'));
|
|
54
|
+
expect(checkInFlight()).toHaveLength(0);
|
|
55
|
+
|
|
56
|
+
// Re-querying directly to verify the failed row is recorded
|
|
57
|
+
const id2 = startOperation('caddy', 'backup');
|
|
58
|
+
const inFlight = checkInFlight();
|
|
59
|
+
expect(inFlight).toHaveLength(1);
|
|
60
|
+
expect(inFlight[0].operation.id).toBe(id2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('failOperation accepts non-Error values', () => {
|
|
64
|
+
const id = startOperation('caddy', 'restore');
|
|
65
|
+
failOperation(id, 'string error reason');
|
|
66
|
+
expect(checkInFlight()).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('checkInFlight', () => {
|
|
71
|
+
it('returns empty when no operations are in flight', () => {
|
|
72
|
+
expect(checkInFlight()).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('describes conflicts with module + operation + pid', () => {
|
|
76
|
+
const id = startOperation('caddy', 'deploy');
|
|
77
|
+
const conflicts = checkInFlight();
|
|
78
|
+
expect(conflicts).toHaveLength(1);
|
|
79
|
+
expect(conflicts[0].describe).toBe(`deploy of caddy (pid ${process.pid})`);
|
|
80
|
+
completeOperation(id);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('excludes the operation matching excludeOperationId', () => {
|
|
84
|
+
const own = startOperation('caddy', 'backup');
|
|
85
|
+
const other = startOperation('homebridge', 'deploy');
|
|
86
|
+
const conflicts = checkInFlight(own);
|
|
87
|
+
expect(conflicts).toHaveLength(1);
|
|
88
|
+
expect(conflicts[0].operation.id).toBe(other);
|
|
89
|
+
completeOperation(own);
|
|
90
|
+
completeOperation(other);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('ignores rows whose pid is no longer alive', () => {
|
|
94
|
+
// Spawn a short-lived process, capture its pid, wait for it to exit.
|
|
95
|
+
const child = spawnSync('node', ['-e', 'process.exit(0)']);
|
|
96
|
+
const deadPid = child.pid;
|
|
97
|
+
expect(deadPid).toBeGreaterThan(0);
|
|
98
|
+
expect(isPidAlive(deadPid)).toBe(false);
|
|
99
|
+
|
|
100
|
+
// Insert a fake row with the dead pid via raw SQL (bypasses pid=process.pid in startOperation).
|
|
101
|
+
const { getDb } = require('../db/client');
|
|
102
|
+
const { moduleOperations } = require('../db/schema');
|
|
103
|
+
const db = getDb();
|
|
104
|
+
db.insert(moduleOperations)
|
|
105
|
+
.values({
|
|
106
|
+
id: 'fake-dead-row',
|
|
107
|
+
moduleId: 'orphan',
|
|
108
|
+
operation: 'deploy',
|
|
109
|
+
status: 'in_progress',
|
|
110
|
+
pid: deadPid,
|
|
111
|
+
})
|
|
112
|
+
.run();
|
|
113
|
+
|
|
114
|
+
const conflicts = checkInFlight();
|
|
115
|
+
expect(conflicts).toHaveLength(0); // dead pid filtered out
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('refuseIfInFlight', () => {
|
|
120
|
+
it('throws InFlightError when conflicts exist', () => {
|
|
121
|
+
const id = startOperation('caddy', 'deploy');
|
|
122
|
+
expect(() => refuseIfInFlight()).toThrow(InFlightError);
|
|
123
|
+
try {
|
|
124
|
+
refuseIfInFlight();
|
|
125
|
+
} catch (err) {
|
|
126
|
+
expect(err).toBeInstanceOf(InFlightError);
|
|
127
|
+
expect((err as InFlightError).conflicts).toHaveLength(1);
|
|
128
|
+
expect((err as Error).message).toContain('deploy of caddy');
|
|
129
|
+
}
|
|
130
|
+
completeOperation(id);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('is a no-op when no conflicts exist', () => {
|
|
134
|
+
expect(() => refuseIfInFlight()).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('respects excludeOperationId', () => {
|
|
138
|
+
const own = startOperation('caddy', 'backup');
|
|
139
|
+
expect(() => refuseIfInFlight(own)).not.toThrow();
|
|
140
|
+
completeOperation(own);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('isPidAlive', () => {
|
|
145
|
+
it('returns true for the current process', () => {
|
|
146
|
+
expect(isPidAlive(process.pid)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns false for a dead pid', () => {
|
|
150
|
+
const child = spawnSync('node', ['-e', 'process.exit(0)']);
|
|
151
|
+
expect(isPidAlive(child.pid as number)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|