@celilo/cli 1.6.0 → 1.7.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 +2 -0
- package/MODULE_PRIMITIVES.md +6 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +149 -2
- package/src/cli/commands/module-update.ts +113 -25
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/module/import.ts +20 -5
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +88 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
getServiceCredentials,
|
|
18
18
|
listContainerServices,
|
|
19
19
|
removeContainerService,
|
|
20
|
+
updateServiceCredentials,
|
|
21
|
+
updateVerificationStatus,
|
|
20
22
|
} from './container-service';
|
|
21
23
|
|
|
22
24
|
describe('container-service', () => {
|
|
@@ -270,6 +272,38 @@ describe('container-service', () => {
|
|
|
270
272
|
/Container service not found/,
|
|
271
273
|
);
|
|
272
274
|
});
|
|
275
|
+
|
|
276
|
+
it('re-encrypts replacements and clears stale verification state', async () => {
|
|
277
|
+
const service = await addContainerService({
|
|
278
|
+
name: 'Moving Proxmox',
|
|
279
|
+
providerName: 'proxmox',
|
|
280
|
+
zones: ['internal'],
|
|
281
|
+
providerConfig: {},
|
|
282
|
+
apiCredentials: {
|
|
283
|
+
api_url: 'https://192.168.0.50:8006',
|
|
284
|
+
api_token_id: 'root@pam!celilo',
|
|
285
|
+
api_token_secret: 'existing-secret',
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
await updateVerificationStatus(service.id, { success: true, message: 'Connected' });
|
|
289
|
+
|
|
290
|
+
await updateServiceCredentials(service.id, {
|
|
291
|
+
api_url: 'https://10.77.20.50:8006',
|
|
292
|
+
api_token_id: 'root@pam!celilo',
|
|
293
|
+
api_token_secret: 'existing-secret',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const credentials = await getServiceCredentials(service.id);
|
|
297
|
+
const updated = await getContainerService(service.id);
|
|
298
|
+
expect(credentials).toEqual({
|
|
299
|
+
api_url: 'https://10.77.20.50:8006',
|
|
300
|
+
api_token_id: 'root@pam!celilo',
|
|
301
|
+
api_token_secret: 'existing-secret',
|
|
302
|
+
});
|
|
303
|
+
expect(updated?.verified).toBe(false);
|
|
304
|
+
expect(updated?.verifiedAt).toBeNull();
|
|
305
|
+
expect(updated?.verificationError).toBeNull();
|
|
306
|
+
});
|
|
273
307
|
});
|
|
274
308
|
|
|
275
309
|
describe('removeContainerService', () => {
|
|
@@ -251,6 +251,50 @@ export async function getServiceCredentials(serviceId: string): Promise<ServiceC
|
|
|
251
251
|
);
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Replace a container service's encrypted API credentials.
|
|
256
|
+
*
|
|
257
|
+
* Credentials identify the remote provider endpoint as well as the principal
|
|
258
|
+
* used there, so changing either invalidates the previous verification result.
|
|
259
|
+
* Callers should explicitly re-run service verification after this update.
|
|
260
|
+
*/
|
|
261
|
+
export async function updateServiceCredentials(
|
|
262
|
+
id: string,
|
|
263
|
+
credentials: ServiceCredentials,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const service = await getContainerService(id);
|
|
266
|
+
if (!service) {
|
|
267
|
+
throw new Error(`Container service not found: ${id}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const validated =
|
|
271
|
+
service.providerName === 'proxmox'
|
|
272
|
+
? ProxmoxCredentialsSchema.parse(credentials)
|
|
273
|
+
: service.providerName === 'digitalocean'
|
|
274
|
+
? DigitalOceanCredentialsSchema.parse(credentials)
|
|
275
|
+
: null;
|
|
276
|
+
|
|
277
|
+
if (!validated) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Unsupported provider for credential validation: ${service.providerName}. Supported providers: proxmox, digitalocean`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const masterKey = await getOrCreateMasterKey();
|
|
284
|
+
const encrypted = encryptSecret(JSON.stringify(validated), masterKey);
|
|
285
|
+
|
|
286
|
+
await getDb()
|
|
287
|
+
.update(containerServices)
|
|
288
|
+
.set({
|
|
289
|
+
apiCredentialsEncrypted: JSON.stringify(encrypted),
|
|
290
|
+
verified: false,
|
|
291
|
+
verifiedAt: null,
|
|
292
|
+
verificationError: null,
|
|
293
|
+
updatedAt: new Date(),
|
|
294
|
+
})
|
|
295
|
+
.where(eq(containerServices.id, id));
|
|
296
|
+
}
|
|
297
|
+
|
|
254
298
|
/**
|
|
255
299
|
* List container services with optional filters
|
|
256
300
|
*/
|
|
@@ -266,6 +266,57 @@ describe('backfillModuleSystems', () => {
|
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
test('backfill records a local machine by its interface in the resolved zone', () => {
|
|
270
|
+
db.insert(machines)
|
|
271
|
+
.values({
|
|
272
|
+
id: 'm-local-bf',
|
|
273
|
+
hostname: 'celilo-mgr',
|
|
274
|
+
// Local-execution sentinel, not the machine's network identity.
|
|
275
|
+
ipAddress: '127.0.0.1',
|
|
276
|
+
sshUser: 'jem',
|
|
277
|
+
sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
|
|
278
|
+
hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
|
|
279
|
+
zone: 'secure-mgmt',
|
|
280
|
+
interfaces: [
|
|
281
|
+
{ name: 'en0', ipAddress: '192.168.0.32', zone: 'external' },
|
|
282
|
+
{ name: 'ens18', ipAddress: '10.77.20.32', zone: 'secure-mgmt' },
|
|
283
|
+
],
|
|
284
|
+
})
|
|
285
|
+
.run();
|
|
286
|
+
db.insert(modules)
|
|
287
|
+
.values({
|
|
288
|
+
id: 'celilo-mgmt',
|
|
289
|
+
name: 'celilo-mgmt',
|
|
290
|
+
version: '1.0.0',
|
|
291
|
+
manifestData: { requires: { system: { zone: 'internal' } } },
|
|
292
|
+
sourcePath: '/tmp/celilo-mgmt',
|
|
293
|
+
state: 'VERIFIED',
|
|
294
|
+
})
|
|
295
|
+
.run();
|
|
296
|
+
db.insert(moduleInfrastructure)
|
|
297
|
+
.values({
|
|
298
|
+
id: 'infra-celilo-mgmt-local',
|
|
299
|
+
moduleId: 'celilo-mgmt',
|
|
300
|
+
infrastructureType: 'machine',
|
|
301
|
+
machineId: 'm-local-bf',
|
|
302
|
+
})
|
|
303
|
+
.run();
|
|
304
|
+
db.insert(moduleConfigs)
|
|
305
|
+
.values({
|
|
306
|
+
moduleId: 'celilo-mgmt',
|
|
307
|
+
key: 'hostname',
|
|
308
|
+
value: 'celilo-mgr',
|
|
309
|
+
valueJson: '"celilo-mgr"',
|
|
310
|
+
})
|
|
311
|
+
.run();
|
|
312
|
+
|
|
313
|
+
expect(backfillModuleSystems(db)).toEqual(['celilo-mgmt']);
|
|
314
|
+
expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({
|
|
315
|
+
zone: 'secure-mgmt',
|
|
316
|
+
ipv4_address: '10.77.20.32',
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
269
320
|
// The control-plane case. celilo-mgmt declares `internal` (it bootstraps before
|
|
270
321
|
// any firewall exists) but in a segmented fleet the operator earmarks a box on
|
|
271
322
|
// `secure-mgmt`. What gets RECORDED must be where the system actually is, since
|
|
@@ -318,6 +369,56 @@ describe('backfillModuleSystems', () => {
|
|
|
318
369
|
expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({ zone: 'secure-mgmt' });
|
|
319
370
|
});
|
|
320
371
|
|
|
372
|
+
test('records a local machine by its interface in the resolved zone', async () => {
|
|
373
|
+
db.insert(machines)
|
|
374
|
+
.values({
|
|
375
|
+
id: 'm-local',
|
|
376
|
+
hostname: 'celilo-mgr',
|
|
377
|
+
// Local-execution sentinel, not the machine's network identity.
|
|
378
|
+
ipAddress: '127.0.0.1',
|
|
379
|
+
sshUser: 'jem',
|
|
380
|
+
sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
|
|
381
|
+
hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
|
|
382
|
+
zone: 'secure-mgmt',
|
|
383
|
+
earmarkedModule: 'celilo-mgmt',
|
|
384
|
+
interfaces: [
|
|
385
|
+
{ name: 'en0', ipAddress: '192.168.0.32', zone: 'external' },
|
|
386
|
+
{ name: 'ens18', ipAddress: '10.77.20.32', zone: 'secure-mgmt' },
|
|
387
|
+
],
|
|
388
|
+
})
|
|
389
|
+
.run();
|
|
390
|
+
db.insert(modules)
|
|
391
|
+
.values({
|
|
392
|
+
id: 'celilo-mgmt',
|
|
393
|
+
name: 'celilo-mgmt',
|
|
394
|
+
version: '1.0.0',
|
|
395
|
+
manifestData: { requires: { system: { zone: 'internal' } } },
|
|
396
|
+
sourcePath: '/tmp/celilo-mgmt',
|
|
397
|
+
state: 'VERIFIED',
|
|
398
|
+
})
|
|
399
|
+
.run();
|
|
400
|
+
db.insert(moduleConfigs)
|
|
401
|
+
.values({
|
|
402
|
+
moduleId: 'celilo-mgmt',
|
|
403
|
+
key: 'hostname',
|
|
404
|
+
value: 'celilo-mgr',
|
|
405
|
+
valueJson: '"celilo-mgr"',
|
|
406
|
+
})
|
|
407
|
+
.run();
|
|
408
|
+
|
|
409
|
+
const recorded = await recordDeployedSystemForModule(
|
|
410
|
+
'celilo-mgmt',
|
|
411
|
+
{ requires: { system: { zone: 'internal' } } } as ModuleManifest,
|
|
412
|
+
{ type: 'machine', machineId: 'm-local' },
|
|
413
|
+
db,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
expect(recorded[0]).toMatchObject({
|
|
417
|
+
zone: 'secure-mgmt',
|
|
418
|
+
ipv4_address: '10.77.20.32',
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
321
422
|
test('skips an API-only module (no declared systems)', () => {
|
|
322
423
|
ensureProxmoxService(db);
|
|
323
424
|
db.insert(modules)
|
|
@@ -236,6 +236,35 @@ function asZone(value: string | null | undefined): NetworkZone | null {
|
|
|
236
236
|
: null;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/** True for an IPv4 loopback address, including CIDR-form input. */
|
|
240
|
+
function isLoopbackIpv4(value: string): boolean {
|
|
241
|
+
return stripCidr(value).startsWith('127.');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The machine pool uses 127.0.0.1 as a transport sentinel for the machine
|
|
246
|
+
* running celilo itself: it selects Ansible's local connection rather than
|
|
247
|
+
* SSH. That value is not the machine's network identity and must never leak
|
|
248
|
+
* into module_systems (and, downstream, internal DNS).
|
|
249
|
+
*
|
|
250
|
+
* For an ordinary machine, its primary address remains canonical. For the
|
|
251
|
+
* local sentinel, use the catalogued non-loopback interface in the system's
|
|
252
|
+
* resolved zone. Deliberately do not fall back to an arbitrary interface: a
|
|
253
|
+
* control plane can also have an upstream/Wi-Fi address, and publishing that
|
|
254
|
+
* would be a quieter version of the same identity bug.
|
|
255
|
+
*/
|
|
256
|
+
export function resolveMachineIdentityAddress(
|
|
257
|
+
machine: typeof machines.$inferSelect | undefined,
|
|
258
|
+
zone: NetworkZone,
|
|
259
|
+
): string | undefined {
|
|
260
|
+
if (!machine) return undefined;
|
|
261
|
+
if (!isLoopbackIpv4(machine.ipAddress)) return machine.ipAddress;
|
|
262
|
+
|
|
263
|
+
return machine.interfaces.find(
|
|
264
|
+
(iface) => asZone(iface.zone) === zone && !isLoopbackIpv4(iface.ipAddress),
|
|
265
|
+
)?.ipAddress;
|
|
266
|
+
}
|
|
267
|
+
|
|
239
268
|
/**
|
|
240
269
|
* Resolve a module's deployed system(s) from the deploy state and persist them
|
|
241
270
|
* to `module_systems`. Called during deploy after infrastructure variables are
|
|
@@ -265,14 +294,9 @@ export async function recordDeployedSystemForModule(
|
|
|
265
294
|
// No hostname → not yet addressable. A modeled state, not an error.
|
|
266
295
|
if (!hostname) return [];
|
|
267
296
|
|
|
268
|
-
// IP: the module's resolved target_ip, else ip.primary, else the assigned
|
|
269
|
-
// machine's own IP (machine-pool deploys where neither was written).
|
|
270
|
-
let ip = cfg('target_ip') ?? cfg('ip.primary');
|
|
271
297
|
const machine = infrastructure?.machineId
|
|
272
298
|
? db.select().from(machines).where(eq(machines.id, infrastructure.machineId)).get()
|
|
273
299
|
: undefined;
|
|
274
|
-
if (!ip) ip = machine?.ipAddress;
|
|
275
|
-
if (!ip) return [];
|
|
276
300
|
|
|
277
301
|
// Single-system transition: take the first declared system's name + zone.
|
|
278
302
|
const decl = declared[0];
|
|
@@ -289,6 +313,13 @@ export async function recordDeployedSystemForModule(
|
|
|
289
313
|
);
|
|
290
314
|
}
|
|
291
315
|
|
|
316
|
+
// IP: the module's resolved target_ip, else ip.primary, else the assigned
|
|
317
|
+
// machine's network identity. A local machine's 127.0.0.1 primary address is
|
|
318
|
+
// an execution-transport sentinel, so resolve its identity from the
|
|
319
|
+
// catalogued interface in the system's actual zone instead.
|
|
320
|
+
const ip = cfg('target_ip') ?? cfg('ip.primary') ?? resolveMachineIdentityAddress(machine, zone);
|
|
321
|
+
if (!ip) return [];
|
|
322
|
+
|
|
292
323
|
const infraType = infrastructure?.type ?? 'machine';
|
|
293
324
|
const vmidStr = cfg('vmid');
|
|
294
325
|
const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null;
|
|
@@ -352,15 +383,9 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
352
383
|
const hostname = cfg('hostname');
|
|
353
384
|
if (!hostname) continue;
|
|
354
385
|
|
|
355
|
-
// IP: resolved target_ip, else ip.primary, else the assigned machine's own
|
|
356
|
-
// IP (machine-pool deploy where neither was written). Mirrors
|
|
357
|
-
// recordDeployedSystemForModule. CIDR is stripped by upsertDeployedSystem.
|
|
358
|
-
let ip = cfg('target_ip') ?? cfg('ip.primary');
|
|
359
386
|
const machine = infra.machineId
|
|
360
387
|
? db.select().from(machines).where(eq(machines.id, infra.machineId)).get()
|
|
361
388
|
: undefined;
|
|
362
|
-
if (!ip) ip = machine?.ipAddress;
|
|
363
|
-
if (!ip) continue;
|
|
364
389
|
|
|
365
390
|
const decl = declared[0];
|
|
366
391
|
// Same precedence as recordDeployedSystemForModule: the machine a deploy
|
|
@@ -373,6 +398,13 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
373
398
|
// backfill (a re-deploy will record it properly).
|
|
374
399
|
if (!zone) continue;
|
|
375
400
|
|
|
401
|
+
// Mirrors recordDeployedSystemForModule. For celilo's local machine,
|
|
402
|
+
// 127.0.0.1 is only a local-transport sentinel; the zone-matching
|
|
403
|
+
// catalogued interface is the deployed system's address.
|
|
404
|
+
const ip =
|
|
405
|
+
cfg('target_ip') ?? cfg('ip.primary') ?? resolveMachineIdentityAddress(machine, zone);
|
|
406
|
+
if (!ip) continue;
|
|
407
|
+
|
|
376
408
|
const vmidStr = cfg('vmid');
|
|
377
409
|
const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null;
|
|
378
410
|
|
|
@@ -12,6 +12,36 @@
|
|
|
12
12
|
* All DNS mechanics (which zones, register vs delete) live in the module's
|
|
13
13
|
* hook; this file only decides WHICH hosts and invokes the hook. That is the
|
|
14
14
|
* D5 division of labour: celilo owns host inventory, the module owns DNS.
|
|
15
|
+
*
|
|
16
|
+
* WHY THIS SURVIVED THE GENERIC PROVIDER-ARRIVAL BACKFILL
|
|
17
|
+
* (openspec/changes/capability-owned-tables task 1.7). `provider-arrival.ts`
|
|
18
|
+
* replaced `public-web-republish.ts` and covers `firewall` for the first time.
|
|
19
|
+
* It does NOT cover either function here, and deleting this file over it would
|
|
20
|
+
* silently take internal DNS with it. Two independent reasons, both checked
|
|
21
|
+
* against the manifests rather than assumed:
|
|
22
|
+
*
|
|
23
|
+
* 1. `backfillProviderDns` replays celilo's HOST INVENTORY — one record per
|
|
24
|
+
* deployed system, across every module. Those systems are hosts, not
|
|
25
|
+
* consumers. There is no `requires`/`optional` edge to fan out along
|
|
26
|
+
* because none of them asked `dns_internal` for anything. It is not a
|
|
27
|
+
* capability registration set, so a mechanism defined over consumer
|
|
28
|
+
* declarations cannot reach it.
|
|
29
|
+
*
|
|
30
|
+
* 2. `backfillWebRouteDns` covers the FQDNs modules publish through
|
|
31
|
+
* `public_web`. Those modules declare `public_web` — `authentik`,
|
|
32
|
+
* `forgejo`, `celilo-registry`, `celilo-apt-repo`, `celilo-website`,
|
|
33
|
+
* `npm-cache-node` and the two hello fixtures. NOT ONE declares
|
|
34
|
+
* `dns_internal`. The only module that does is `caddy-internal`, and it
|
|
35
|
+
* declares it `optional`. So a `dns_internal` provider arriving has no
|
|
36
|
+
* edge to the modules whose records it needs to learn.
|
|
37
|
+
*
|
|
38
|
+
* Point 2 is still second-implementation debt, and it is already ledgered:
|
|
39
|
+
* `module-business-baseline.ts` carries this file under S3 (#945). Retiring it
|
|
40
|
+
* needs a `public_web` fan-out triggered by a `dns_internal` arrival — a
|
|
41
|
+
* CROSS-capability edge the pull-shaped design does not define — because
|
|
42
|
+
* `register_route` (packages/capabilities/src/public-web.ts) already writes
|
|
43
|
+
* exactly the record `backfillWebRouteDns` reconstructs by hand, with the same
|
|
44
|
+
* value and the same `zoneRoutableValue`.
|
|
15
45
|
*/
|
|
16
46
|
|
|
17
47
|
import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
|
|
@@ -650,6 +650,32 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
|
|
|
650
650
|
expect(problems).toHaveLength(0);
|
|
651
651
|
});
|
|
652
652
|
|
|
653
|
+
it('accepts a broken optional derivation when the manifest declares a fallback', () => {
|
|
654
|
+
const withFallback = baseManifest({
|
|
655
|
+
id: 'secondary-dns',
|
|
656
|
+
variables: {
|
|
657
|
+
owns: [
|
|
658
|
+
{
|
|
659
|
+
name: 'managed_domains',
|
|
660
|
+
type: 'array',
|
|
661
|
+
required: false,
|
|
662
|
+
default: [],
|
|
663
|
+
source: 'capability',
|
|
664
|
+
derive_from: '$capability:dns_internal.dns.managed_domains',
|
|
665
|
+
},
|
|
666
|
+
],
|
|
667
|
+
imports: [],
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
expect(findBrokenCapabilityDerivations('secondary-dns', withFallback, {})).toEqual([]);
|
|
672
|
+
expect(
|
|
673
|
+
findBrokenCapabilityDerivations('secondary-dns', withFallback, {
|
|
674
|
+
dns_internal: { dns: { managed_domains: '$self:managed_domains' } },
|
|
675
|
+
}),
|
|
676
|
+
).toEqual([]);
|
|
677
|
+
});
|
|
678
|
+
|
|
653
679
|
it('walks dotted paths', () => {
|
|
654
680
|
const m = baseManifest({
|
|
655
681
|
id: 'consumer',
|
|
@@ -502,6 +502,8 @@ interface CapabilityRef {
|
|
|
502
502
|
variable: string;
|
|
503
503
|
capability: string;
|
|
504
504
|
path: string;
|
|
505
|
+
/** A manifest default makes an absent/broken optional derivation safe. */
|
|
506
|
+
hasFallback: boolean;
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
export type CapabilityDerivationReason = 'no-provider' | 'empty-value' | 'unresolved-ref';
|
|
@@ -531,7 +533,12 @@ function parseCapabilityRefs(manifest: ModuleManifest): CapabilityRef[] {
|
|
|
531
533
|
const re = /\$\{?capability:([\w-]+)\.([\w.]+)/g;
|
|
532
534
|
let m: RegExpExecArray | null = re.exec(v.derive_from);
|
|
533
535
|
while (m !== null) {
|
|
534
|
-
refs.push({
|
|
536
|
+
refs.push({
|
|
537
|
+
variable: v.name,
|
|
538
|
+
capability: m[1],
|
|
539
|
+
path: m[2],
|
|
540
|
+
hasFallback: Object.prototype.hasOwnProperty.call(v, 'default'),
|
|
541
|
+
});
|
|
535
542
|
m = re.exec(v.derive_from);
|
|
536
543
|
}
|
|
537
544
|
}
|
|
@@ -578,15 +585,18 @@ export function findBrokenCapabilityDerivations(
|
|
|
578
585
|
};
|
|
579
586
|
const data = capabilities[ref.capability];
|
|
580
587
|
if (!data) {
|
|
588
|
+
if (ref.hasFallback) continue;
|
|
581
589
|
problems.push({ ...base, reason: 'no-provider' });
|
|
582
590
|
continue;
|
|
583
591
|
}
|
|
584
592
|
const value = getNested(data, ref.path);
|
|
585
593
|
if (value === undefined || value === null || value === '') {
|
|
594
|
+
if (ref.hasFallback) continue;
|
|
586
595
|
problems.push({ ...base, reason: 'empty-value' });
|
|
587
596
|
continue;
|
|
588
597
|
}
|
|
589
598
|
if (typeof value === 'string' && UNRESOLVED_REF.test(value)) {
|
|
599
|
+
if (ref.hasFallback) continue;
|
|
590
600
|
problems.push({ ...base, reason: 'unresolved-ref', value });
|
|
591
601
|
}
|
|
592
602
|
}
|
|
@@ -40,8 +40,12 @@ import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
|
|
|
40
40
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
41
41
|
import { findMachineForModule } from './machine-pool';
|
|
42
42
|
import { ensureRequiredNetworks } from './network-ensure';
|
|
43
|
+
import {
|
|
44
|
+
type ProviderBackfillResult,
|
|
45
|
+
loadProviderBackfillPlan,
|
|
46
|
+
runProviderBackfill,
|
|
47
|
+
} from './provider-arrival';
|
|
43
48
|
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
44
|
-
import { republishStaticWebConsumers } from './public-web-republish';
|
|
45
49
|
import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
|
|
46
50
|
import { buildTerraformEnvForService } from './terraform-env';
|
|
47
51
|
|
|
@@ -224,6 +228,81 @@ async function invokeHookWithEnsureRetry(
|
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
/**
|
|
232
|
+
* A provider just arrived — re-run the consumers that were already here.
|
|
233
|
+
*
|
|
234
|
+
* The generic mirror of `consumer-cleanup.ts`. It replaces
|
|
235
|
+
* `republishStaticWebConsumers`, which did this for `public_web` alone, and it
|
|
236
|
+
* covers `firewall` for the first time — three modules provide `firewall` and
|
|
237
|
+
* none of their consumers inherited anything on deploy (celilo#1011).
|
|
238
|
+
*
|
|
239
|
+
* Never fatal to the provider's own deploy. The provider deployed fine; what
|
|
240
|
+
* can fail is one consumer's re-registration, and the honest report is that
|
|
241
|
+
* consumer named with the command that retries it. Silence would report a clean
|
|
242
|
+
* deploy over a fleet where some consumers never re-registered.
|
|
243
|
+
*/
|
|
244
|
+
// ponytail: fans out on EVERY deploy of a module that provides anything, so a
|
|
245
|
+
// caddy redeploy re-runs all eight `public_web` consumers' `on_install` even
|
|
246
|
+
// though caddy was already their provider and they have nothing to gain. The
|
|
247
|
+
// hooks are idempotent by contract and a provider deploy is already heavyweight,
|
|
248
|
+
// so this is accepted rather than guessed at — every predicate for "this
|
|
249
|
+
// consumer has something to gain" that does not need new state is wrong in some
|
|
250
|
+
// case (a redeploy of the incumbent provider looks identical to a first
|
|
251
|
+
// deploy). If it measurably hurts, the upgrade is to record which provider a
|
|
252
|
+
// consumer last bound to and re-run only where that changed.
|
|
253
|
+
async function backfillArrivedProvider(moduleId: string, db: DbClient): Promise<void> {
|
|
254
|
+
const plan = loadProviderBackfillPlan(moduleId, db);
|
|
255
|
+
if (plan.length === 0) return;
|
|
256
|
+
|
|
257
|
+
const attempts = plan.filter((target) => !target.skip);
|
|
258
|
+
const gauge = new FuelGauge(`Re-registering ${attempts.length} consumer(s) with ${moduleId}`, {
|
|
259
|
+
skipAnimation: !process.stdout.isTTY,
|
|
260
|
+
});
|
|
261
|
+
gauge.start();
|
|
262
|
+
let result: ProviderBackfillResult;
|
|
263
|
+
try {
|
|
264
|
+
const logger = createGaugeLogger(gauge, moduleId, 'provider_arrival');
|
|
265
|
+
result = await runProviderBackfill(moduleId, plan, db, logger);
|
|
266
|
+
gauge.stop(result.failures.length === 0);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
gauge.stop(false);
|
|
269
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
270
|
+
log.warn(`Consumer re-registration against '${moduleId}' could not run: ${msg}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (result.rerun.length > 0) {
|
|
275
|
+
log.success(
|
|
276
|
+
`Re-registered ${result.rerun.length} consumer(s) with '${moduleId}': ${result.rerun.join(', ')}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
// Re-surfaced outside the gauge: the gauge's own preview scrolls away, and a
|
|
280
|
+
// consumer that never re-registered is exactly what an operator must still be
|
|
281
|
+
// able to read once the deploy has finished.
|
|
282
|
+
//
|
|
283
|
+
// INFO, not warn, and the level is load-bearing. `unpause --cascade`
|
|
284
|
+
// redeploys in topological order and clears `pausedAt` only AFTER each
|
|
285
|
+
// redeploy succeeds (`module-pause.ts#executeUnpause`), so the provider's own
|
|
286
|
+
// redeploy runs while every consumer in the cascade is still PAUSED. Warning
|
|
287
|
+
// there would put one alarm per consumer immediately before the cascade
|
|
288
|
+
// redeploys each of them successfully — an alarm about the normal path.
|
|
289
|
+
//
|
|
290
|
+
// Nothing is at risk either way: a paused module cannot return to service
|
|
291
|
+
// without a redeploy, and that redeploy re-resolves its capabilities. That is
|
|
292
|
+
// the same guarantee `remove-guard.ts` relies on to treat a paused module as
|
|
293
|
+
// not a dependent. So this is worth saying and not worth warning about.
|
|
294
|
+
for (const paused of result.skipped.filter((s) => s.reason === 'paused')) {
|
|
295
|
+
log.info(
|
|
296
|
+
`'${paused.consumerId}' is paused, so it was not re-registered with '${moduleId}'. It rebinds when it is unpaused and redeployed.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
for (const failure of result.failures) {
|
|
300
|
+
log.warn(
|
|
301
|
+
`'${failure.consumerId}' failed to re-register with '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.consumerId}\` to retry.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
227
306
|
/**
|
|
228
307
|
* Orchestrate module deployment workflow
|
|
229
308
|
* Orchestrator function - coordinates deployment phases
|
|
@@ -836,26 +915,10 @@ async function deployModuleImpl(
|
|
|
836
915
|
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
837
916
|
}
|
|
838
917
|
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
// are logged, never fatal to the provider's own deploy.
|
|
844
|
-
const republish = await republishStaticWebConsumers({
|
|
845
|
-
providerModuleId: moduleId,
|
|
846
|
-
manifest,
|
|
847
|
-
db,
|
|
848
|
-
});
|
|
849
|
-
if (republish.republished.length > 0) {
|
|
850
|
-
log.success(
|
|
851
|
-
`Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
|
|
852
|
-
);
|
|
853
|
-
}
|
|
854
|
-
for (const failure of republish.failures) {
|
|
855
|
-
log.warn(
|
|
856
|
-
`Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
|
|
857
|
-
);
|
|
858
|
-
}
|
|
918
|
+
// Whatever this module provides, the consumers that were already here
|
|
919
|
+
// never got to ask it for anything. Re-run them so they register through
|
|
920
|
+
// the path that worked the first time (design D7).
|
|
921
|
+
await backfillArrivedProvider(moduleId, db);
|
|
859
922
|
|
|
860
923
|
// Mirror the infrastructure-path success message at the end of
|
|
861
924
|
// a successful deploy. Without this, config-only deploys end
|
|
@@ -1514,26 +1577,10 @@ async function deployModuleImpl(
|
|
|
1514
1577
|
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
1515
1578
|
}
|
|
1516
1579
|
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
|
|
1521
|
-
// are logged, never fatal to the provider's own deploy.
|
|
1522
|
-
const republish = await republishStaticWebConsumers({
|
|
1523
|
-
providerModuleId: moduleId,
|
|
1524
|
-
manifest,
|
|
1525
|
-
db,
|
|
1526
|
-
});
|
|
1527
|
-
if (republish.republished.length > 0) {
|
|
1528
|
-
log.success(
|
|
1529
|
-
`Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
|
|
1530
|
-
);
|
|
1531
|
-
}
|
|
1532
|
-
for (const failure of republish.failures) {
|
|
1533
|
-
log.warn(
|
|
1534
|
-
`Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
|
|
1535
|
-
);
|
|
1536
|
-
}
|
|
1580
|
+
// Whatever this module provides, the consumers that were already here
|
|
1581
|
+
// never got to ask it for anything. Re-run them so they register through
|
|
1582
|
+
// the path that worked the first time (design D7).
|
|
1583
|
+
await backfillArrivedProvider(moduleId, db);
|
|
1537
1584
|
|
|
1538
1585
|
log.success(`Module '${moduleId}' deployed successfully`);
|
|
1539
1586
|
return {
|