@celilo/cli 0.8.2 → 0.9.1
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 +10 -18
- package/CELILO_CORE_MODULES.md +61 -0
- package/CELILO_SUBSYSTEMS.md +83 -0
- package/README.md +1539 -48
- package/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/0013_dns_view_overrides.sql +1 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +5 -10
- package/src/capabilities/well-known.test.ts +12 -66
- package/src/capabilities/well-known.ts +11 -12
- 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 -37
- package/src/cli/index.ts +15 -0
- package/src/cli/validators.test.ts +1 -206
- package/src/cli/validators.ts +0 -168
- 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/aspect-approvals.test.ts +52 -0
- package/src/services/aspect-approvals.ts +41 -8
- 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/services/programmatic-responder.aspect.test.ts +157 -0
- package/src/services/programmatic-responder.ts +51 -0
- package/src/templates/generator.ts +49 -1
- package/src/utils/shell.test.ts +1 -163
- package/src/utils/shell.ts +0 -100
- package/src/validation/schemas.ts +0 -5
- package/src/variables/context.ts +36 -7
- package/CLI_USAGE.md +0 -433
- package/src/config/env.ts +0 -41
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { and, eq } from 'drizzle-orm';
|
|
4
5
|
import { type DbClient, createDbClient } from '../db/client';
|
|
5
6
|
import {
|
|
6
7
|
containerServices,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
moduleSystems,
|
|
11
12
|
modules,
|
|
12
13
|
} from '../db/schema';
|
|
13
|
-
import { backfillModuleSystems, getModuleSystems } from './deployed-systems';
|
|
14
|
+
import { backfillModuleSystems, getModuleSystems, upsertDeployedSystem } from './deployed-systems';
|
|
14
15
|
|
|
15
16
|
const TEST_DB_PATH = './test-deployed-systems.db';
|
|
16
17
|
|
|
@@ -233,3 +234,74 @@ describe('backfillModuleSystems', () => {
|
|
|
233
234
|
expect(getModuleSystems('namecheap', db)).toHaveLength(0);
|
|
234
235
|
});
|
|
235
236
|
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Canonical instance sizing (ISS-0150): sizing is seeded onto module_systems
|
|
240
|
+
* once at first provision and then OWNED by `celilo proxmox … resize` — a routine
|
|
241
|
+
* re-deploy must never reset a resized instance back to its manifest minimum.
|
|
242
|
+
*/
|
|
243
|
+
describe('upsertDeployedSystem sizing — seed-once (ISS-0150)', () => {
|
|
244
|
+
let db: DbClient;
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
db = createDbClient({ path: TEST_DB_PATH });
|
|
248
|
+
db.insert(modules)
|
|
249
|
+
.values({
|
|
250
|
+
id: 'm1',
|
|
251
|
+
name: 'm1',
|
|
252
|
+
version: '1.0.0',
|
|
253
|
+
manifestData: {},
|
|
254
|
+
sourcePath: '/tmp/m1',
|
|
255
|
+
state: 'VERIFIED',
|
|
256
|
+
})
|
|
257
|
+
.run();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
afterEach(async () => {
|
|
261
|
+
db.$client.close();
|
|
262
|
+
for (const suffix of ['', '-shm', '-wal']) {
|
|
263
|
+
const p = `${TEST_DB_PATH}${suffix}`;
|
|
264
|
+
if (existsSync(p)) await rm(p);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const sizeArgs = (memory: number) => ({
|
|
269
|
+
name: 'main',
|
|
270
|
+
hostname: 'h',
|
|
271
|
+
ipv4Address: '10.0.0.5/24',
|
|
272
|
+
zone: 'app' as const,
|
|
273
|
+
infraType: 'container_service' as const,
|
|
274
|
+
vmid: 200,
|
|
275
|
+
cpu: 4,
|
|
276
|
+
memory,
|
|
277
|
+
disk: 80,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const row = () =>
|
|
281
|
+
db
|
|
282
|
+
.select()
|
|
283
|
+
.from(moduleSystems)
|
|
284
|
+
.where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
|
|
285
|
+
.get();
|
|
286
|
+
|
|
287
|
+
test('seeds sizing on first insert', () => {
|
|
288
|
+
upsertDeployedSystem(db, 'm1', sizeArgs(8192));
|
|
289
|
+
const r = row();
|
|
290
|
+
expect(r?.cpu).toBe(4);
|
|
291
|
+
expect(r?.memory).toBe(8192);
|
|
292
|
+
expect(r?.disk).toBe(80);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('a re-deploy does NOT reset a resized instance to the manifest minimum', () => {
|
|
296
|
+
upsertDeployedSystem(db, 'm1', sizeArgs(8192)); // first provision: seed 8 GB
|
|
297
|
+
// Simulate `celilo proxmox vm resize` bumping the canonical size to 16 GB.
|
|
298
|
+
db.update(moduleSystems)
|
|
299
|
+
.set({ memory: 16384 })
|
|
300
|
+
.where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
|
|
301
|
+
.run();
|
|
302
|
+
// Re-deploy passes the manifest minimum (8 GB) again — must be ignored.
|
|
303
|
+
upsertDeployedSystem(db, 'm1', sizeArgs(8192));
|
|
304
|
+
expect(row()?.memory).toBe(16384);
|
|
305
|
+
expect(row()?.cpu).toBe(4);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -52,6 +52,52 @@ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem
|
|
|
52
52
|
return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A celilo-provisioned instance with its canonical size (ISS-0150). CLI-internal
|
|
57
|
+
* shape (NOT the `DeployedSystem` capability type) for the `celilo proxmox
|
|
58
|
+
* vm/ct …` surface, which needs the sizing columns the capability type omits.
|
|
59
|
+
*/
|
|
60
|
+
export interface ProvisionedSystem {
|
|
61
|
+
moduleId: string;
|
|
62
|
+
name: string;
|
|
63
|
+
hostname: string;
|
|
64
|
+
ipv4Address: string;
|
|
65
|
+
zone: NetworkZone;
|
|
66
|
+
serviceId: string | null;
|
|
67
|
+
vmid: number | null;
|
|
68
|
+
/** Canonical desired size (null until seeded / for non-Proxmox). */
|
|
69
|
+
cpu: number | null;
|
|
70
|
+
memory: number | null;
|
|
71
|
+
disk: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every celilo-provisioned (container_service) system with a Proxmox vmid, across
|
|
76
|
+
* all modules, including its canonical sizing — the read model behind
|
|
77
|
+
* `celilo proxmox vm/ct list|show`. Ordered by vmid for stable output.
|
|
78
|
+
*/
|
|
79
|
+
export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] {
|
|
80
|
+
return db
|
|
81
|
+
.select()
|
|
82
|
+
.from(moduleSystems)
|
|
83
|
+
.where(eq(moduleSystems.infraType, 'container_service'))
|
|
84
|
+
.all()
|
|
85
|
+
.filter((r) => r.vmid != null)
|
|
86
|
+
.map((r) => ({
|
|
87
|
+
moduleId: r.moduleId,
|
|
88
|
+
name: r.name,
|
|
89
|
+
hostname: r.hostname,
|
|
90
|
+
ipv4Address: r.ipv4Address,
|
|
91
|
+
zone: r.zone,
|
|
92
|
+
serviceId: r.serviceId,
|
|
93
|
+
vmid: r.vmid,
|
|
94
|
+
cpu: r.cpu,
|
|
95
|
+
memory: r.memory,
|
|
96
|
+
disk: r.disk,
|
|
97
|
+
}))
|
|
98
|
+
.sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0));
|
|
99
|
+
}
|
|
100
|
+
|
|
55
101
|
/**
|
|
56
102
|
* All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
|
|
57
103
|
* `zones`, across every module — the LXC complement to machine-pool's
|
|
@@ -93,6 +139,15 @@ export interface DeployedSystemInput {
|
|
|
93
139
|
machineId?: string | null;
|
|
94
140
|
serviceId?: string | null;
|
|
95
141
|
vmid?: number | null;
|
|
142
|
+
/**
|
|
143
|
+
* Canonical deployed size (ISS-0150), seeded from the module's
|
|
144
|
+
* `requires.system` at first provision. Seed-once: written on INSERT only and
|
|
145
|
+
* preserved across re-deploys (omitted from the conflict update), so a later
|
|
146
|
+
* `celilo proxmox … resize` is not reset back to the manifest minimum.
|
|
147
|
+
*/
|
|
148
|
+
cpu?: number | null;
|
|
149
|
+
memory?: number | null;
|
|
150
|
+
disk?: number | null;
|
|
96
151
|
}
|
|
97
152
|
|
|
98
153
|
/**
|
|
@@ -117,6 +172,11 @@ export function upsertDeployedSystem(
|
|
|
117
172
|
machineId: system.machineId ?? null,
|
|
118
173
|
serviceId: system.serviceId ?? null,
|
|
119
174
|
vmid: system.vmid ?? null,
|
|
175
|
+
// Seed-once (ISS-0150): set on insert; deliberately omitted from the
|
|
176
|
+
// conflict update below so a resize survives re-deploys.
|
|
177
|
+
cpu: system.cpu ?? null,
|
|
178
|
+
memory: system.memory ?? null,
|
|
179
|
+
disk: system.disk ?? null,
|
|
120
180
|
updatedAt: new Date(),
|
|
121
181
|
})
|
|
122
182
|
.onConflictDoUpdate({
|
|
@@ -129,6 +189,9 @@ export function upsertDeployedSystem(
|
|
|
129
189
|
machineId: system.machineId ?? null,
|
|
130
190
|
serviceId: system.serviceId ?? null,
|
|
131
191
|
vmid: system.vmid ?? null,
|
|
192
|
+
// NOTE: cpu/memory/disk intentionally NOT updated here — sizing is
|
|
193
|
+
// canonical state owned by `celilo proxmox … resize`, not reset by a
|
|
194
|
+
// routine re-deploy (seed-once). See ISS-0150 / CLAUDE.md.
|
|
132
195
|
updatedAt: new Date(),
|
|
133
196
|
},
|
|
134
197
|
})
|
|
@@ -207,6 +270,11 @@ export async function recordDeployedSystemForModule(
|
|
|
207
270
|
machineId: infrastructure?.machineId ?? null,
|
|
208
271
|
serviceId: infrastructure?.serviceId ?? null,
|
|
209
272
|
vmid: Number.isNaN(vmid as number) ? null : vmid,
|
|
273
|
+
// Seed canonical size from requires.system (seed-once; preserved across
|
|
274
|
+
// re-deploys). Only meaningful for celilo-provisioned instances.
|
|
275
|
+
cpu: decl.resources.cpu ?? null,
|
|
276
|
+
memory: decl.resources.memory ?? null,
|
|
277
|
+
disk: decl.resources.disk ?? null,
|
|
210
278
|
});
|
|
211
279
|
|
|
212
280
|
return getModuleSystems(moduleId, db);
|
|
@@ -279,6 +347,10 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
279
347
|
machineId: infra.machineId ?? null,
|
|
280
348
|
serviceId: infra.serviceId ?? null,
|
|
281
349
|
vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null,
|
|
350
|
+
// Seed canonical size from requires.system for upgraded deployments.
|
|
351
|
+
cpu: decl.resources.cpu ?? null,
|
|
352
|
+
memory: decl.resources.memory ?? null,
|
|
353
|
+
disk: decl.resources.disk ?? null,
|
|
282
354
|
});
|
|
283
355
|
backfilled.push(infra.moduleId);
|
|
284
356
|
}
|
|
@@ -3,11 +3,13 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import type { DnsRecordRequest } from '@celilo/capabilities';
|
|
6
|
+
import type { ViewOverride } from '@celilo/capabilities';
|
|
6
7
|
import type { DbClient } from '../db/client';
|
|
7
8
|
import { modules } from '../db/schema';
|
|
8
9
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
9
10
|
import {
|
|
10
11
|
listDnsInternalRecords,
|
|
12
|
+
listViewOverrides,
|
|
11
13
|
recordDnsInternalRecord,
|
|
12
14
|
removeDnsInternalRecord,
|
|
13
15
|
withDnsInternalLedger,
|
|
@@ -70,10 +72,27 @@ describe('dns-internal-records ledger', () => {
|
|
|
70
72
|
expect(rows.map((r) => r.host)).toEqual(['b.x']);
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
it('listViewOverrides returns only fronted records (zone IP set), as host→zoneIp', () => {
|
|
76
|
+
recordDnsInternalRecord(db, { ...ctx(), host: 'plain.x', ip: '192.168.0.253' });
|
|
77
|
+
recordDnsInternalRecord(db, {
|
|
78
|
+
...ctx(),
|
|
79
|
+
host: 'git.celilo.computer',
|
|
80
|
+
ip: '192.168.0.253',
|
|
81
|
+
zoneRoutableIp: '10.0.10.10',
|
|
82
|
+
});
|
|
83
|
+
const overrides = listViewOverrides(db, 'technitium');
|
|
84
|
+
expect(overrides).toEqual([{ host: 'git.celilo.computer', ip: '10.0.10.10' }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
73
87
|
describe('withDnsInternalLedger', () => {
|
|
74
|
-
function fakeProvider() {
|
|
88
|
+
function fakeProvider(withViews = false) {
|
|
75
89
|
const calls: Array<['register' | 'delete', DnsRecordRequest]> = [];
|
|
76
|
-
const
|
|
90
|
+
const reconcileCalls: ViewOverride[][] = [];
|
|
91
|
+
const iface: {
|
|
92
|
+
registerRecord(req: DnsRecordRequest): Promise<void>;
|
|
93
|
+
deleteRecord(req: DnsRecordRequest): Promise<void>;
|
|
94
|
+
reconcileViews?(o: ViewOverride[]): Promise<void>;
|
|
95
|
+
} = {
|
|
77
96
|
async registerRecord(req: DnsRecordRequest) {
|
|
78
97
|
calls.push(['register', req]);
|
|
79
98
|
},
|
|
@@ -81,7 +100,12 @@ describe('dns-internal-records ledger', () => {
|
|
|
81
100
|
calls.push(['delete', req]);
|
|
82
101
|
},
|
|
83
102
|
};
|
|
84
|
-
|
|
103
|
+
if (withViews) {
|
|
104
|
+
iface.reconcileViews = async (o: ViewOverride[]) => {
|
|
105
|
+
reconcileCalls.push(o);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { iface, calls, reconcileCalls };
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
it('records A-record registrations and passes the call through', async () => {
|
|
@@ -109,6 +133,55 @@ describe('dns-internal-records ledger', () => {
|
|
|
109
133
|
expect(listDnsInternalRecords(db)).toHaveLength(0);
|
|
110
134
|
});
|
|
111
135
|
|
|
136
|
+
it('reconciles views from the full ledger set when a fronted record is registered', async () => {
|
|
137
|
+
const { iface, reconcileCalls } = fakeProvider(true);
|
|
138
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
139
|
+
// A plain (non-fronted) record must NOT trigger a view reconcile.
|
|
140
|
+
await wrapped.registerRecord({ host: 'plain.x', type: 'A', value: '192.168.0.253' });
|
|
141
|
+
expect(reconcileCalls).toHaveLength(0);
|
|
142
|
+
// A fronted record (zoneRoutableValue set) triggers a reconcile from the
|
|
143
|
+
// COMPLETE fronted set in the ledger.
|
|
144
|
+
await wrapped.registerRecord({
|
|
145
|
+
host: 'git.celilo.computer',
|
|
146
|
+
type: 'A',
|
|
147
|
+
value: '192.168.0.253',
|
|
148
|
+
zoneRoutableValue: '10.0.10.10',
|
|
149
|
+
});
|
|
150
|
+
expect(reconcileCalls).toHaveLength(1);
|
|
151
|
+
expect(reconcileCalls[0]).toEqual([{ host: 'git.celilo.computer', ip: '10.0.10.10' }]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('reconciles views on delete (a removed host drops from the set)', async () => {
|
|
155
|
+
const { iface, reconcileCalls } = fakeProvider(true);
|
|
156
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
157
|
+
await wrapped.registerRecord({
|
|
158
|
+
host: 'git.celilo.computer',
|
|
159
|
+
type: 'A',
|
|
160
|
+
value: '192.168.0.253',
|
|
161
|
+
zoneRoutableValue: '10.0.10.10',
|
|
162
|
+
});
|
|
163
|
+
await wrapped.deleteRecord({
|
|
164
|
+
host: 'git.celilo.computer',
|
|
165
|
+
type: 'A',
|
|
166
|
+
value: '192.168.0.253',
|
|
167
|
+
});
|
|
168
|
+
// Last reconcile reflects the now-empty fronted set.
|
|
169
|
+
expect(reconcileCalls.at(-1)).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('is a no-op reconcile path for providers without view support', async () => {
|
|
173
|
+
const { iface } = fakeProvider(false);
|
|
174
|
+
const wrapped = withDnsInternalLedger(iface, ctx());
|
|
175
|
+
// Must not throw despite a fronted registration when reconcileViews is absent.
|
|
176
|
+
await wrapped.registerRecord({
|
|
177
|
+
host: 'git.celilo.computer',
|
|
178
|
+
type: 'A',
|
|
179
|
+
value: '192.168.0.253',
|
|
180
|
+
zoneRoutableValue: '10.0.10.10',
|
|
181
|
+
});
|
|
182
|
+
expect(listViewOverrides(db, 'technitium')).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
112
185
|
it('does not write the ledger if the underlying register throws', async () => {
|
|
113
186
|
const iface = {
|
|
114
187
|
async registerRecord(): Promise<void> {
|
|
@@ -13,14 +13,16 @@
|
|
|
13
13
|
* Row lifecycle is FK cascade — records die with their provider or consumer.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { DnsInternalCapability, DnsRecordRequest } from '@celilo/capabilities';
|
|
17
|
-
import { and, eq } from 'drizzle-orm';
|
|
16
|
+
import type { DnsInternalCapability, DnsRecordRequest, ViewOverride } from '@celilo/capabilities';
|
|
17
|
+
import { and, eq, isNotNull } from 'drizzle-orm';
|
|
18
18
|
import type { DbClient } from '../db/client';
|
|
19
19
|
import { dnsInternalRecords } from '../db/schema';
|
|
20
20
|
|
|
21
21
|
export interface DnsInternalRecordRow {
|
|
22
22
|
host: string;
|
|
23
23
|
ip: string;
|
|
24
|
+
/** In-zone split-horizon answer (caddy's zone IP), or null for plain records. */
|
|
25
|
+
zoneRoutableIp: string | null;
|
|
24
26
|
providerModuleId: string;
|
|
25
27
|
consumerModuleId: string;
|
|
26
28
|
registeredAt: Date;
|
|
@@ -34,6 +36,7 @@ export function listDnsInternalRecords(
|
|
|
34
36
|
.select({
|
|
35
37
|
host: dnsInternalRecords.host,
|
|
36
38
|
ip: dnsInternalRecords.ip,
|
|
39
|
+
zoneRoutableIp: dnsInternalRecords.zoneRoutableIp,
|
|
37
40
|
providerModuleId: dnsInternalRecords.providerModuleId,
|
|
38
41
|
consumerModuleId: dnsInternalRecords.consumerModuleId,
|
|
39
42
|
registeredAt: dnsInternalRecords.registeredAt,
|
|
@@ -44,16 +47,45 @@ export function listDnsInternalRecords(
|
|
|
44
47
|
: query.all();
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The COMPLETE set of source-based split-horizon view overrides a provider
|
|
52
|
+
* should serve — every ledger record that carries a zone-routable IP (ISS-0156).
|
|
53
|
+
* This is the desired state the resolver's view config is reconciled from; the
|
|
54
|
+
* provider's `reconcileViews` is the single writer that materializes it.
|
|
55
|
+
*/
|
|
56
|
+
export function listViewOverrides(db: DbClient, providerModuleId: string): ViewOverride[] {
|
|
57
|
+
return db
|
|
58
|
+
.select({ host: dnsInternalRecords.host, ip: dnsInternalRecords.zoneRoutableIp })
|
|
59
|
+
.from(dnsInternalRecords)
|
|
60
|
+
.where(
|
|
61
|
+
and(
|
|
62
|
+
eq(dnsInternalRecords.providerModuleId, providerModuleId),
|
|
63
|
+
isNotNull(dnsInternalRecords.zoneRoutableIp),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
.all()
|
|
67
|
+
.map((r) => ({ host: r.host, ip: r.ip as string }));
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
export function recordDnsInternalRecord(
|
|
48
71
|
db: DbClient,
|
|
49
|
-
record: {
|
|
72
|
+
record: {
|
|
73
|
+
providerModuleId: string;
|
|
74
|
+
consumerModuleId: string;
|
|
75
|
+
host: string;
|
|
76
|
+
ip: string;
|
|
77
|
+
/** In-zone split-horizon answer (caddy's zone IP); null/absent for plain records. */
|
|
78
|
+
zoneRoutableIp?: string | null;
|
|
79
|
+
},
|
|
50
80
|
): void {
|
|
81
|
+
const zoneRoutableIp = record.zoneRoutableIp ?? null;
|
|
51
82
|
db.insert(dnsInternalRecords)
|
|
52
83
|
.values({
|
|
53
84
|
providerModuleId: record.providerModuleId,
|
|
54
85
|
consumerModuleId: record.consumerModuleId,
|
|
55
86
|
host: record.host,
|
|
56
87
|
ip: record.ip,
|
|
88
|
+
zoneRoutableIp,
|
|
57
89
|
registeredAt: new Date(),
|
|
58
90
|
})
|
|
59
91
|
.onConflictDoUpdate({
|
|
@@ -61,6 +93,7 @@ export function recordDnsInternalRecord(
|
|
|
61
93
|
set: {
|
|
62
94
|
consumerModuleId: record.consumerModuleId,
|
|
63
95
|
ip: record.ip,
|
|
96
|
+
zoneRoutableIp,
|
|
64
97
|
registeredAt: new Date(),
|
|
65
98
|
},
|
|
66
99
|
})
|
|
@@ -93,6 +126,17 @@ export function withDnsInternalLedger(
|
|
|
93
126
|
ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
|
|
94
127
|
): DnsInternalCapability {
|
|
95
128
|
const isA = (request: DnsRecordRequest) => request.type.toUpperCase() === 'A';
|
|
129
|
+
|
|
130
|
+
// Source-based split-horizon (ISS-0156): after the ledger changes, re-materialize
|
|
131
|
+
// the provider's view config from the COMPLETE desired set. The provider's
|
|
132
|
+
// reconcileViews is the single writer; driving it from the ledger here (which
|
|
133
|
+
// both the live public_web path and the deploy-time backfill flow through)
|
|
134
|
+
// keeps live and reconcile in agreement. No-op for providers without views.
|
|
135
|
+
const reconcileViews = async (): Promise<void> => {
|
|
136
|
+
if (typeof iface.reconcileViews !== 'function') return;
|
|
137
|
+
await iface.reconcileViews(listViewOverrides(ctx.db, ctx.providerModuleId));
|
|
138
|
+
};
|
|
139
|
+
|
|
96
140
|
return {
|
|
97
141
|
...iface,
|
|
98
142
|
async registerRecord(request: DnsRecordRequest): Promise<void> {
|
|
@@ -103,7 +147,10 @@ export function withDnsInternalLedger(
|
|
|
103
147
|
consumerModuleId: ctx.consumerModuleId,
|
|
104
148
|
host: request.host,
|
|
105
149
|
ip: request.value,
|
|
150
|
+
zoneRoutableIp: request.zoneRoutableValue ?? null,
|
|
106
151
|
});
|
|
152
|
+
// Only a fronted record (one carrying a zone-routable IP) changes views.
|
|
153
|
+
if (request.zoneRoutableValue) await reconcileViews();
|
|
107
154
|
}
|
|
108
155
|
},
|
|
109
156
|
async deleteRecord(request: DnsRecordRequest): Promise<void> {
|
|
@@ -113,6 +160,8 @@ export function withDnsInternalLedger(
|
|
|
113
160
|
providerModuleId: ctx.providerModuleId,
|
|
114
161
|
host: request.host,
|
|
115
162
|
});
|
|
163
|
+
// The removed host may have been a fronted override — recompute the set.
|
|
164
|
+
await reconcileViews();
|
|
116
165
|
}
|
|
117
166
|
},
|
|
118
167
|
};
|
|
@@ -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. */
|