@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Proxmox-service resolution for the `celilo proxmox …` command tree
|
|
3
|
+
* (node list, vm/ct list|show|resize). Resolves an explicit service-id arg, else
|
|
4
|
+
* the sole Proxmox service; errors when ambiguous or unknown.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { listContainerServices } from '../../services/container-service';
|
|
8
|
+
|
|
9
|
+
type Services = Awaited<ReturnType<typeof listContainerServices>>;
|
|
10
|
+
|
|
11
|
+
export function resolveProxmoxService(
|
|
12
|
+
services: Services,
|
|
13
|
+
requested: string | undefined,
|
|
14
|
+
): { service: Services[number] } | { error: string } {
|
|
15
|
+
const proxmox = services.filter((s) => s.providerName === 'proxmox');
|
|
16
|
+
if (proxmox.length === 0) {
|
|
17
|
+
return {
|
|
18
|
+
error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (requested) {
|
|
22
|
+
const match = proxmox.find((s) => s.serviceId === requested);
|
|
23
|
+
if (!match) {
|
|
24
|
+
return {
|
|
25
|
+
error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { service: match };
|
|
29
|
+
}
|
|
30
|
+
if (proxmox.length > 1) {
|
|
31
|
+
return {
|
|
32
|
+
error: `Multiple Proxmox services — specify one with <service-id>:\n ${proxmox
|
|
33
|
+
.map((s) => s.serviceId)
|
|
34
|
+
.join('\n ')}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { service: proxmox[0] };
|
|
38
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -248,13 +248,21 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
248
248
|
// Service subcommands
|
|
249
249
|
// Proxmox subcommands
|
|
250
250
|
if (command === 'proxmox' && currentIndex === 1) {
|
|
251
|
-
return filterSuggestions(['node'], args[1] || '');
|
|
251
|
+
return filterSuggestions(['node', 'vm', 'ct'], args[1] || '');
|
|
252
252
|
}
|
|
253
253
|
if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
|
|
254
254
|
return filterSuggestions(['list'], args[2] || '');
|
|
255
255
|
}
|
|
256
|
-
|
|
257
|
-
|
|
256
|
+
if (command === 'proxmox' && (args[1] === 'vm' || args[1] === 'ct') && currentIndex === 2) {
|
|
257
|
+
return filterSuggestions(['list', 'resize'], args[2] || '');
|
|
258
|
+
}
|
|
259
|
+
// proxmox <node|vm|ct> list <service-id> — proxmox services only
|
|
260
|
+
if (
|
|
261
|
+
command === 'proxmox' &&
|
|
262
|
+
(args[1] === 'node' || args[1] === 'vm' || args[1] === 'ct') &&
|
|
263
|
+
args[2] === 'list' &&
|
|
264
|
+
currentIndex === 3
|
|
265
|
+
) {
|
|
258
266
|
const services = await listContainerServices();
|
|
259
267
|
const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
|
|
260
268
|
return filterSuggestions(serviceIds, args[3] || '');
|
package/src/cli/index.ts
CHANGED
|
@@ -69,6 +69,8 @@ import { handleModuleUpgrade } from './commands/module-upgrade';
|
|
|
69
69
|
import { moduleVerify } from './commands/module-verify';
|
|
70
70
|
import { handleModuleVersion } from './commands/module-version';
|
|
71
71
|
import { handlePackage } from './commands/package';
|
|
72
|
+
import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
|
|
73
|
+
import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
|
|
72
74
|
import { handleProxmoxNodeList } from './commands/proxmox-node-list';
|
|
73
75
|
import { main as runPublish } from './commands/publish';
|
|
74
76
|
import { handleSecretList } from './commands/secret-list';
|
|
@@ -1822,6 +1824,19 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1822
1824
|
error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
|
|
1823
1825
|
};
|
|
1824
1826
|
}
|
|
1827
|
+
if (parsed.subcommand === 'vm' || parsed.subcommand === 'ct') {
|
|
1828
|
+
const action = parsed.args[0];
|
|
1829
|
+
if (action === 'list') {
|
|
1830
|
+
return handleProxmoxInstanceList(parsed.subcommand, parsed.args.slice(1));
|
|
1831
|
+
}
|
|
1832
|
+
if (action === 'resize') {
|
|
1833
|
+
return handleProxmoxInstanceResize(parsed.subcommand, parsed.args.slice(1), parsed.flags);
|
|
1834
|
+
}
|
|
1835
|
+
return {
|
|
1836
|
+
success: false,
|
|
1837
|
+
error: `Proxmox ${parsed.subcommand} action required (list, resize)\n\nRun "celilo proxmox --help" for usage`,
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1825
1840
|
return {
|
|
1826
1841
|
success: false,
|
|
1827
1842
|
error: `Unknown proxmox subcommand: ${parsed.subcommand}\n\nRun "celilo proxmox --help" for usage`,
|
package/src/db/schema.ts
CHANGED
|
@@ -392,6 +392,17 @@ export const moduleSystems = sqliteTable(
|
|
|
392
392
|
serviceId: text('service_id').references(() => containerServices.id),
|
|
393
393
|
/** Proxmox VMID — set only for proxmox containers. */
|
|
394
394
|
vmid: integer('vmid'),
|
|
395
|
+
// Canonical deployed SIZE of this system (ISS-0150). For celilo-provisioned
|
|
396
|
+
// VM/LXC instances only (null for machine-pool systems celilo doesn't size).
|
|
397
|
+
// Seeded from the module's `requires.system` at first provision, then owned
|
|
398
|
+
// by `celilo proxmox … resize` — `requires.system` is only the minimum floor,
|
|
399
|
+
// never the live size. See CLAUDE.md "requires.system is the MINIMUM".
|
|
400
|
+
/** vCPU cores. */
|
|
401
|
+
cpu: integer('cpu'),
|
|
402
|
+
/** RAM in MB. */
|
|
403
|
+
memory: integer('memory'),
|
|
404
|
+
/** Root disk in GB. */
|
|
405
|
+
disk: integer('disk'),
|
|
395
406
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
396
407
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
397
408
|
},
|
|
@@ -492,8 +503,17 @@ export const dnsInternalRecords = sqliteTable(
|
|
|
492
503
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
493
504
|
/** The registered hostname (e.g. "git-ssh.git.celilo.computer"). */
|
|
494
505
|
host: text('host').notNull(),
|
|
495
|
-
/** The A-record value celilo asked the resolver to serve
|
|
506
|
+
/** The A-record value celilo asked the resolver to serve — the LAN/default
|
|
507
|
+
* answer (firewall natIp for a caddy-fronted host). */
|
|
496
508
|
ip: text('ip').notNull(),
|
|
509
|
+
/**
|
|
510
|
+
* In-zone split-horizon answer (caddy's zone-routable IP), when this is a
|
|
511
|
+
* caddy-fronted hostname that needs source-based views (ISS-0156,
|
|
512
|
+
* v2/INTERNAL_DNS_ZONE_VIEWS.md). NULL for records with no zone override
|
|
513
|
+
* (per-system identity, plain A records). This column is the durable
|
|
514
|
+
* desired-state the resolver's view config is reconciled from.
|
|
515
|
+
*/
|
|
516
|
+
zoneRoutableIp: text('zone_routable_ip'),
|
|
497
517
|
registeredAt: integer('registered_at', { mode: 'timestamp' })
|
|
498
518
|
.notNull()
|
|
499
519
|
.default(sql`(unixepoch())`),
|
|
@@ -124,6 +124,28 @@ export async function resolveFirewallNatIp(db: DbClient): Promise<string | undef
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Caddy's zone-routable IP — its own DMZ ingress address (`target_ip`, the same
|
|
129
|
+
* value public_web exposes as `dmz_ip`). This is the in-zone split-horizon
|
|
130
|
+
* answer (ISS-0156): clients INSIDE the segmented zones reach caddy here, since
|
|
131
|
+
* they can't route to the firewall natIp. Returns undefined when no public_web
|
|
132
|
+
* provider advertises a `target_ip`. Shared by the live public_web registration
|
|
133
|
+
* and the deploy-time backfill so both write the same `zoneRoutableValue`.
|
|
134
|
+
*/
|
|
135
|
+
export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefined> {
|
|
136
|
+
const webProviders = db
|
|
137
|
+
.select()
|
|
138
|
+
.from(capabilities)
|
|
139
|
+
.where(eq(capabilities.capabilityName, 'public_web'))
|
|
140
|
+
.all();
|
|
141
|
+
for (const wp of webProviders) {
|
|
142
|
+
const cfg = await loadModuleConfig(wp.moduleId, db);
|
|
143
|
+
const ip = String(cfg.target_ip ?? '').split('/')[0];
|
|
144
|
+
if (ip) return ip;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
127
149
|
export async function loadCapabilityFunctions(
|
|
128
150
|
consumingModuleId: string,
|
|
129
151
|
db: DbClient,
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import type { ModuleManifest } from './schema';
|
|
3
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type TemplateValidationError,
|
|
8
|
+
formatTemplateValidationErrors,
|
|
9
|
+
validateModuleTemplates,
|
|
10
|
+
} from './template-validator';
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
13
|
* Create a minimal valid manifest for testing
|
|
@@ -66,6 +73,29 @@ describe('template-validator', () => {
|
|
|
66
73
|
expect(system?.storage).toBe('local-lvm');
|
|
67
74
|
});
|
|
68
75
|
|
|
76
|
+
test('accepts $self:{cores,memory,disk,storage} as auto-allocated sizing (ISS-0150)', async () => {
|
|
77
|
+
// ISS-0150 repointed instance Terraform to read $self:cores/memory/disk/
|
|
78
|
+
// storage, which are injected at generate time from module_systems (see
|
|
79
|
+
// variables/context.ts) — not declared in the manifest. They must validate
|
|
80
|
+
// at `module import` time like vmid/target_ip, or every VM/CT module fails
|
|
81
|
+
// to import. Regression guard: this previously errored "Self variable
|
|
82
|
+
// 'cores' not found in module configuration".
|
|
83
|
+
const manifest = createTestManifest();
|
|
84
|
+
const dir = await mkdtemp(join(tmpdir(), 'celilo-tpl-'));
|
|
85
|
+
try {
|
|
86
|
+
await mkdir(join(dir, 'terraform'), { recursive: true });
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(dir, 'terraform/main.tf.tpl'),
|
|
89
|
+
'cores = $self:cores\nmemory = $self:memory\ndisk = $self:disk\nstorage = "$self:storage"\n',
|
|
90
|
+
);
|
|
91
|
+
const result = await validateModuleTemplates(dir, manifest);
|
|
92
|
+
expect(result.errors).toEqual([]);
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
69
99
|
test('validates capability references', async () => {
|
|
70
100
|
const manifest = createTestManifest({
|
|
71
101
|
requires: {
|
|
@@ -73,6 +73,15 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
|
|
|
73
73
|
'gateway', // Auto-derived from zone configuration
|
|
74
74
|
'target_node', // Can be auto-derived from system config
|
|
75
75
|
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (v2/LXC_INTERNAL_DNS.md)
|
|
76
|
+
// Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
|
|
77
|
+
// disk,storage}, which are injected during resolution from the module_systems
|
|
78
|
+
// table (falling back to requires.system.*) — see variables/context.ts. Like
|
|
79
|
+
// vmid/target_ip they are populated at generate time, not declared in the
|
|
80
|
+
// manifest, so they are auto-allocated rather than import-time validation errors.
|
|
81
|
+
'cores', // requires.system.cpu / module_systems.cpu
|
|
82
|
+
'memory', // requires.system.memory / module_systems.memory
|
|
83
|
+
'disk', // requires.system.disk / module_systems.disk
|
|
84
|
+
'storage', // storage pool name (requires.system.storage)
|
|
76
85
|
]);
|
|
77
86
|
|
|
78
87
|
/**
|
|
@@ -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
|
};
|