@celilo/cli 0.8.1 → 0.9.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/AGENTS.md +86 -0
- package/CELILO_CORE_MODULES.md +61 -0
- package/CELILO_SUBSYSTEMS.md +83 -0
- package/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/0013_dns_view_overrides.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -3
- package/src/capabilities/well-known.test.ts +12 -7
- package/src/capabilities/well-known.ts +11 -3
- package/src/cli/command-registry.ts +65 -1
- package/src/cli/commands/module-upgrade.test.ts +29 -0
- package/src/cli/commands/module-upgrade.ts +57 -24
- package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
- package/src/cli/commands/proxmox-instance-list.ts +140 -0
- package/src/cli/commands/proxmox-instance-resize.ts +235 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -34
- package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
- package/src/cli/commands/proxmox-resize-guards.ts +102 -0
- package/src/cli/commands/proxmox-service.ts +38 -0
- package/src/cli/completion.ts +11 -3
- package/src/cli/index.ts +15 -0
- package/src/db/schema.ts +21 -1
- package/src/hooks/capability-loader.ts +22 -0
- package/src/manifest/template-validator.test.ts +31 -1
- package/src/manifest/template-validator.ts +9 -0
- package/src/services/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/services/dns-internal-records.test.ts +76 -3
- package/src/services/dns-internal-records.ts +52 -3
- package/src/services/dns-provider-backfill.ts +15 -3
- package/src/services/fleet-checks.test.ts +18 -16
- package/src/services/machine-detector.ts +34 -12
- package/src/templates/generator.ts +49 -1
- package/src/variables/context.ts +36 -7
|
@@ -18,7 +18,11 @@ import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
|
|
|
18
18
|
import { and, eq, inArray, or } from 'drizzle-orm';
|
|
19
19
|
import type { DbClient } from '../db/client';
|
|
20
20
|
import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
loadCapabilityFunctions,
|
|
23
|
+
resolveCaddyZoneIp,
|
|
24
|
+
resolveFirewallNatIp,
|
|
25
|
+
} from '../hooks/capability-loader';
|
|
22
26
|
import { runNamedHook } from '../hooks/run-named-hook';
|
|
23
27
|
import type { HookName } from '../hooks/types';
|
|
24
28
|
import { getModuleSystems } from './deployed-systems';
|
|
@@ -137,13 +141,21 @@ export async function backfillWebRouteDns(
|
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
// caddy's zone-routable IP — the in-zone split-horizon answer (ISS-0156). When
|
|
145
|
+
// it differs from the natIp, each fronted hostname carries it as
|
|
146
|
+
// `zoneRoutableValue`; the provider's reconcileViews (driven from the ledger by
|
|
147
|
+
// the registration wrapper) materializes the per-zone view overrides.
|
|
148
|
+
const caddyZoneIp = await resolveCaddyZoneIp(db);
|
|
149
|
+
const zoneRoutableValue = caddyZoneIp && caddyZoneIp !== natIp ? caddyZoneIp : undefined;
|
|
150
|
+
const viewNote = zoneRoutableValue ? ` (in-zone view → ${zoneRoutableValue})` : '';
|
|
151
|
+
|
|
140
152
|
logger.info(
|
|
141
|
-
`Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}`,
|
|
153
|
+
`Backfilling ${hostnames.length} web-route hostname(s) into '${moduleId}' at ${natIp}${viewNote}`,
|
|
142
154
|
);
|
|
143
155
|
const failures: string[] = [];
|
|
144
156
|
for (const host of hostnames) {
|
|
145
157
|
try {
|
|
146
|
-
await dnsInternal.registerRecord({ host, type: 'A', value: natIp });
|
|
158
|
+
await dnsInternal.registerRecord({ host, type: 'A', value: natIp, zoneRoutableValue });
|
|
147
159
|
} catch (err) {
|
|
148
160
|
failures.push(`${host}: ${err instanceof Error ? err.message : String(err)}`);
|
|
149
161
|
}
|
|
@@ -242,7 +242,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
242
242
|
variables: {
|
|
243
243
|
owns: [
|
|
244
244
|
{
|
|
245
|
-
name: '
|
|
245
|
+
name: 'idp_auth_url',
|
|
246
246
|
type: 'string',
|
|
247
247
|
required: true,
|
|
248
248
|
source: 'capability',
|
|
@@ -254,21 +254,21 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
it('fails when the consumed capability has no deployed provider', () => {
|
|
257
|
-
insertModule('forgejo', consumer('$capability:idp.
|
|
257
|
+
insertModule('forgejo', consumer('$capability:idp.auth_url'));
|
|
258
258
|
const f = checkCapabilityProviders(db);
|
|
259
259
|
expect(f.status).toBe('fail');
|
|
260
260
|
expect(f.detail.join(' ')).toContain("no deployed module provides 'idp'");
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
it('fails when the provider lacks the referenced field', () => {
|
|
264
|
-
insertModule('forgejo', consumer('$capability:idp.
|
|
264
|
+
insertModule('forgejo', consumer('$capability:idp.auth_url'));
|
|
265
265
|
insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
|
|
266
266
|
db.insert(capabilitiesTable)
|
|
267
267
|
.values({
|
|
268
268
|
moduleId: 'authentik',
|
|
269
269
|
capabilityName: 'idp',
|
|
270
270
|
version: '1.0.0',
|
|
271
|
-
data: {
|
|
271
|
+
data: { admin_email: 'x' },
|
|
272
272
|
})
|
|
273
273
|
.run();
|
|
274
274
|
const f = checkCapabilityProviders(db);
|
|
@@ -277,14 +277,14 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
it('passes when the provider carries a concrete value', () => {
|
|
280
|
-
insertModule('forgejo', consumer('$capability:idp.
|
|
280
|
+
insertModule('forgejo', consumer('$capability:idp.auth_url'));
|
|
281
281
|
insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
|
|
282
282
|
db.insert(capabilitiesTable)
|
|
283
283
|
.values({
|
|
284
284
|
moduleId: 'authentik',
|
|
285
285
|
capabilityName: 'idp',
|
|
286
286
|
version: '1.0.0',
|
|
287
|
-
data: {
|
|
287
|
+
data: { auth_url: 'https://auth.celilo.computer' },
|
|
288
288
|
})
|
|
289
289
|
.run();
|
|
290
290
|
const f = checkCapabilityProviders(db);
|
|
@@ -292,16 +292,16 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
292
292
|
});
|
|
293
293
|
|
|
294
294
|
it('passes but flags a derived ref for the chain trace (ISS-0114)', () => {
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
insertModule('forgejo', consumer('$capability:idp.
|
|
295
|
+
// idp.auth_url is present but is itself a ref ($self:auth_url) — we can't
|
|
296
|
+
// verify it resolves without the walker.
|
|
297
|
+
insertModule('forgejo', consumer('$capability:idp.auth_url'));
|
|
298
298
|
insertModule('authentik', baseManifest({ id: 'authentik', name: 'Authentik' }));
|
|
299
299
|
db.insert(capabilitiesTable)
|
|
300
300
|
.values({
|
|
301
301
|
moduleId: 'authentik',
|
|
302
302
|
capabilityName: 'idp',
|
|
303
303
|
version: '1.0.0',
|
|
304
|
-
data: {
|
|
304
|
+
data: { auth_url: '$self:auth_url' },
|
|
305
305
|
})
|
|
306
306
|
.run();
|
|
307
307
|
const f = checkCapabilityProviders(db);
|
|
@@ -442,11 +442,11 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
|
|
|
442
442
|
variables: {
|
|
443
443
|
owns: [
|
|
444
444
|
{
|
|
445
|
-
name: '
|
|
445
|
+
name: 'idp_auth_url',
|
|
446
446
|
type: 'string',
|
|
447
447
|
required: true,
|
|
448
448
|
source: 'capability',
|
|
449
|
-
derive_from: '$capability:idp.
|
|
449
|
+
derive_from: '$capability:idp.auth_url',
|
|
450
450
|
},
|
|
451
451
|
],
|
|
452
452
|
imports: [],
|
|
@@ -461,21 +461,23 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
|
|
|
461
461
|
});
|
|
462
462
|
|
|
463
463
|
it('flags empty-value when the field is present but empty', () => {
|
|
464
|
-
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
|
|
464
|
+
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
|
|
465
|
+
idp: { auth_url: '' },
|
|
466
|
+
});
|
|
465
467
|
expect(problems[0].reason).toBe('empty-value');
|
|
466
468
|
});
|
|
467
469
|
|
|
468
470
|
it('flags unresolved-ref when the resolved value is still a template', () => {
|
|
469
471
|
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
|
|
470
|
-
idp: {
|
|
472
|
+
idp: { auth_url: '$self:auth_url' },
|
|
471
473
|
});
|
|
472
474
|
expect(problems[0].reason).toBe('unresolved-ref');
|
|
473
|
-
expect(problems[0].value).toBe('$self:
|
|
475
|
+
expect(problems[0].value).toBe('$self:auth_url');
|
|
474
476
|
});
|
|
475
477
|
|
|
476
478
|
it('returns nothing when the field resolves to a concrete value', () => {
|
|
477
479
|
const problems = findBrokenCapabilityDerivations('forgejo', consumer, {
|
|
478
|
-
idp: {
|
|
480
|
+
idp: { auth_url: 'https://auth.celilo.computer' },
|
|
479
481
|
});
|
|
480
482
|
expect(problems).toHaveLength(0);
|
|
481
483
|
});
|
|
@@ -27,23 +27,45 @@ export class DetectionError extends Error {
|
|
|
27
27
|
export type CommandRunner = (command: string) => string;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* Per-command SSH timeout. Detection opens a fresh SSH connection per
|
|
31
|
+
* attribute (hostname, cpu, memory, ...); under load (e.g. the e2e builder
|
|
32
|
+
* running many containers) a single handshake + command can exceed a tight
|
|
33
|
+
* budget, surfacing as `spawnSync ... ETIMEDOUT` and failing an
|
|
34
|
+
* otherwise-healthy `machine add`. 30s gives headroom without masking a
|
|
35
|
+
* genuinely-unreachable host (which fails fast on connection refusal).
|
|
36
|
+
*/
|
|
37
|
+
const SSH_TIMEOUT_MS = 30_000;
|
|
38
|
+
/** Retry attempts for a detection SSH command. Detection is read-only
|
|
39
|
+
* (hostname / nproc / free / ...), so retrying a transient timeout is safe. */
|
|
40
|
+
const SSH_ATTEMPTS = 3;
|
|
41
|
+
|
|
42
|
+
/** Synchronous sleep — detection runs synchronously (execSync), so there's no
|
|
43
|
+
* async context to await in. */
|
|
44
|
+
function sleepSync(ms: number): void {
|
|
45
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execute SSH command and return output. Retries transient failures
|
|
50
|
+
* (connection timeouts under load) with linear backoff — safe because every
|
|
51
|
+
* detection command is read-only.
|
|
31
52
|
*/
|
|
32
53
|
function sshExec(ip: string, user: string, keyPath: string, command: string): string {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
54
|
+
const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -i "${keyPath}" ${user}@${ip} "${command}"`;
|
|
55
|
+
let lastMessage = 'Unknown error';
|
|
56
|
+
for (let attempt = 1; attempt <= SSH_ATTEMPTS; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return execSync(sshCmd, {
|
|
37
59
|
encoding: 'utf8',
|
|
38
60
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
|
-
timeout:
|
|
40
|
-
}
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
throw new DetectionError(`SSH command failed: ${message}`);
|
|
61
|
+
timeout: SSH_TIMEOUT_MS,
|
|
62
|
+
}).trim();
|
|
63
|
+
} catch (error) {
|
|
64
|
+
lastMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
65
|
+
if (attempt < SSH_ATTEMPTS) sleepSync(1000 * attempt);
|
|
66
|
+
}
|
|
46
67
|
}
|
|
68
|
+
throw new DetectionError(`SSH command failed after ${SSH_ATTEMPTS} attempts: ${lastMessage}`);
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
/** CommandRunner that SSHes to a remote machine. */
|
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
findBrokenCapabilityDerivations,
|
|
28
28
|
} from '../services/fleet-checks';
|
|
29
29
|
import { selectInfrastructure } from '../services/infrastructure-selector';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
deleteModuleConfig,
|
|
32
|
+
getModuleConfigValue,
|
|
33
|
+
upsertModuleConfig,
|
|
34
|
+
} from '../services/module-config';
|
|
31
35
|
import type { InfrastructureSelection } from '../types/infrastructure';
|
|
32
36
|
import { convertSecretsToJinja } from '../variables/ansible-resolver';
|
|
33
37
|
import { buildResolutionContext } from '../variables/context';
|
|
@@ -730,6 +734,50 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
730
734
|
}
|
|
731
735
|
}
|
|
732
736
|
|
|
737
|
+
// Dedicated DNS-ingress IP (ISS-0156). A dns_internal provider now deploys into
|
|
738
|
+
// a PROTECTED zone (dmz) so it can see protected-zone query sources for
|
|
739
|
+
// split-horizon views (v2/INTERNAL_DNS_ZONE_VIEWS.md). `internal` devices have
|
|
740
|
+
// no route into the 10-net, so they reach the resolver through a firewall DNAT
|
|
741
|
+
// on a dedicated `internal`-subnet address. A module opts in by declaring a
|
|
742
|
+
// `dns_ingress_ip` infrastructure variable; we allocate a free IP from the
|
|
743
|
+
// `internal` subnet via IPAM and RESERVE it (so it's never re-handed-out),
|
|
744
|
+
// idempotently (reuse the stored value on re-generate). The resolver's
|
|
745
|
+
// on_install passes it to firewall.exposeService({ ingressIp }).
|
|
746
|
+
const wantsDnsIngress = manifest.variables?.owns?.some(
|
|
747
|
+
(v) => v.name === 'dns_ingress_ip' && v.source === 'infrastructure',
|
|
748
|
+
);
|
|
749
|
+
if (wantsDnsIngress) {
|
|
750
|
+
const existing = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
|
|
751
|
+
if (typeof existing === 'string' && existing.length > 0) {
|
|
752
|
+
log.success(`Using existing DNS-ingress IP ${existing} for ${moduleId}`);
|
|
753
|
+
} else {
|
|
754
|
+
const subnetRow = db.$client
|
|
755
|
+
.prepare('SELECT value FROM system_config WHERE key = ?')
|
|
756
|
+
.get('network.internal.subnet') as { value: string } | undefined;
|
|
757
|
+
if (!subnetRow?.value) {
|
|
758
|
+
return {
|
|
759
|
+
success: false,
|
|
760
|
+
error:
|
|
761
|
+
'network.internal.subnet is not configured — required to allocate the ' +
|
|
762
|
+
'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const { allocateIPFromSubnet, reserveIP } = await import('../ipam/allocator');
|
|
766
|
+
const { stripCIDR } = await import('../ipam/subnet-parser');
|
|
767
|
+
try {
|
|
768
|
+
const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
|
|
769
|
+
await reserveIP(ip, 'internal', `dns-ingress:${moduleId}`, null, db);
|
|
770
|
+
upsertModuleConfig(db, moduleId, 'dns_ingress_ip', ip);
|
|
771
|
+
log.success(`Allocated DNS-ingress IP ${ip} (internal subnet) for ${moduleId}`);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
return {
|
|
774
|
+
success: false,
|
|
775
|
+
error: `DNS-ingress IP allocation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
733
781
|
// Infrastructure Properties Resolution (Proxmox provider config)
|
|
734
782
|
// For Proxmox services, extract provider config and store as temporary values
|
|
735
783
|
// This happens during generation so templates can access target_node, lxc_template, etc.
|
package/src/variables/context.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
machines,
|
|
9
9
|
moduleConfigs,
|
|
10
10
|
moduleInfrastructure,
|
|
11
|
+
moduleSystems,
|
|
11
12
|
modules,
|
|
12
13
|
secrets,
|
|
13
14
|
systemConfig,
|
|
@@ -264,20 +265,48 @@ export async function buildResolutionContext(
|
|
|
264
265
|
const systemResources = getSingularSystemSpec(manifest);
|
|
265
266
|
|
|
266
267
|
if (systemResources) {
|
|
267
|
-
//
|
|
268
|
+
// The DEPLOYED size is the SYSTEM's canonical state (ISS-0150), seeded from
|
|
269
|
+
// requires.system at first provision and thereafter owned by
|
|
270
|
+
// `celilo proxmox … resize`. So sizing flows: module_systems → these config
|
|
271
|
+
// vars → `$self:{cores,memory,disk}` in the instance Terraform.
|
|
272
|
+
//
|
|
273
|
+
// Precedence: the recorded system size WINS and overwrites the cached
|
|
274
|
+
// config (a resize must propagate on the next generate); only when this
|
|
275
|
+
// module has no recorded system size yet (the very first provision, before
|
|
276
|
+
// recordDeployedSystemForModule runs below) do we fall back to
|
|
277
|
+
// requires.system — and seed-when-unset, matching the prior behavior so the
|
|
278
|
+
// first-deploy / golden output is unchanged. `requires.system` stays the
|
|
279
|
+
// minimum floor, never the canonical size. (CLAUDE.md / ISS-0150.)
|
|
280
|
+
const sizedRow = db
|
|
281
|
+
.select({
|
|
282
|
+
cpu: moduleSystems.cpu,
|
|
283
|
+
memory: moduleSystems.memory,
|
|
284
|
+
disk: moduleSystems.disk,
|
|
285
|
+
})
|
|
286
|
+
.from(moduleSystems)
|
|
287
|
+
.where(eq(moduleSystems.moduleId, moduleId))
|
|
288
|
+
.all()
|
|
289
|
+
.find((r) => r.cpu != null || r.memory != null || r.disk != null);
|
|
290
|
+
|
|
268
291
|
const resourceMappings: Array<{
|
|
269
292
|
manifestKey: keyof typeof systemResources;
|
|
270
293
|
configKey: string;
|
|
294
|
+
systemValue: number | null | undefined;
|
|
271
295
|
}> = [
|
|
272
|
-
{ manifestKey: 'cpu', configKey: 'cores' }, //
|
|
273
|
-
{ manifestKey: 'memory', configKey: 'memory' },
|
|
274
|
-
{ manifestKey: 'disk', configKey: 'disk' },
|
|
275
|
-
{ manifestKey: 'storage', configKey: 'storage' },
|
|
296
|
+
{ manifestKey: 'cpu', configKey: 'cores', systemValue: sizedRow?.cpu }, // requires.system.cpu → cores
|
|
297
|
+
{ manifestKey: 'memory', configKey: 'memory', systemValue: sizedRow?.memory },
|
|
298
|
+
{ manifestKey: 'disk', configKey: 'disk', systemValue: sizedRow?.disk },
|
|
299
|
+
{ manifestKey: 'storage', configKey: 'storage', systemValue: undefined }, // pool name, not sizing
|
|
276
300
|
];
|
|
277
301
|
|
|
278
|
-
for (const { manifestKey, configKey } of resourceMappings) {
|
|
302
|
+
for (const { manifestKey, configKey, systemValue } of resourceMappings) {
|
|
303
|
+
if (systemValue != null) {
|
|
304
|
+
// Canonical system size — always wins so a resize propagates.
|
|
305
|
+
upsertModuleConfig(db, moduleId, configKey, systemValue);
|
|
306
|
+
selfConfig[configKey] = String(systemValue);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
279
309
|
const value = systemResources[manifestKey];
|
|
280
|
-
|
|
281
310
|
// Manifest fields are typed (cpu: number, storage: string, etc.).
|
|
282
311
|
// Pass them through unstringified so valueJson preserves the
|
|
283
312
|
// shape — see comment in the variable-defaults block above.
|