@celilo/cli 1.2.0 → 1.4.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 -2
- package/CELILO_SUBSYSTEMS.md +4 -1
- package/package.json +4 -4
- package/src/cli/commands/ipam.ts +93 -0
- package/src/cli/commands/machine-add.ts +22 -0
- package/src/cli/commands/system-doctor.ts +118 -4
- package/src/cli/completion.ts +38 -5
- package/src/cli/index.ts +10 -1
- package/src/hooks/types.ts +20 -74
- package/src/ipam/allocator.test.ts +38 -0
- package/src/ipam/allocator.ts +63 -1
- package/src/ipam/auto-allocator.ts +7 -0
- package/src/manifest/contracts/v1.ts +35 -0
- package/src/manifest/schema.ts +2 -0
- package/src/policy/module-business-baseline.ts +51 -35
- package/src/services/aspect-reconcile.test.ts +460 -0
- package/src/services/aspect-runner.test.ts +1 -0
- package/src/services/aspect-runner.ts +408 -37
- package/src/services/deploy-ansible-recap.test.ts +76 -0
- package/src/services/deploy-ansible.ts +56 -1
- package/src/services/module-deploy.ts +66 -12
- package/src/templates/ingress-ip.test.ts +108 -0
|
@@ -932,19 +932,17 @@ async function deployModuleImpl(
|
|
|
932
932
|
// provider type (machine / proxmox IPAM / DO outputs). This populates
|
|
933
933
|
// ctx.systems for on_install and is the source of truth for system.created
|
|
934
934
|
// and DNS (openspec/specs/module-systems-addressing/spec.md). API-only modules record none.
|
|
935
|
-
{
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
935
|
+
const { recordDeployedSystemForModule } = await import('./deployed-systems');
|
|
936
|
+
const recordedSystems = await recordDeployedSystemForModule(
|
|
937
|
+
moduleId,
|
|
938
|
+
manifest,
|
|
939
|
+
plan.infrastructure,
|
|
940
|
+
db,
|
|
941
|
+
);
|
|
942
|
+
if (recordedSystems.length > 0) {
|
|
943
|
+
log.success(
|
|
944
|
+
`Recorded ${recordedSystems.length} deployed system(s): ${recordedSystems.map((s) => `${s.hostname} (${s.ipv4_address})`).join(', ')}`,
|
|
942
945
|
);
|
|
943
|
-
if (recorded.length > 0) {
|
|
944
|
-
log.success(
|
|
945
|
-
`Recorded ${recorded.length} deployed system(s): ${recorded.map((s) => `${s.hostname} (${s.ipv4_address})`).join(', ')}`,
|
|
946
|
-
);
|
|
947
|
-
}
|
|
948
946
|
}
|
|
949
947
|
|
|
950
948
|
if (Object.keys(resolution.resolved).length > 0) {
|
|
@@ -1152,6 +1150,62 @@ async function deployModuleImpl(
|
|
|
1152
1150
|
phases.sshWait = true;
|
|
1153
1151
|
}
|
|
1154
1152
|
|
|
1153
|
+
// Apply the fleet's aspects to the systems THIS deploy just created, before
|
|
1154
|
+
// the module's own playbook runs against them (celilo#902, design D3).
|
|
1155
|
+
//
|
|
1156
|
+
// A base-module aspect fans out to the systems that exist when its PROVIDER
|
|
1157
|
+
// deploys and to nothing afterwards, so a host provisioned here would keep
|
|
1158
|
+
// its boot-time /etc/resolv.conf and be unable to resolve any fleet-internal
|
|
1159
|
+
// name — silently, with a green deploy on top. This is the inbound half.
|
|
1160
|
+
//
|
|
1161
|
+
// Placed here on purpose: after `waitForSSH`, so the host is proven
|
|
1162
|
+
// reachable and the aspect's Ansible does not fail UNREACHABLE on a
|
|
1163
|
+
// still-booting LXC; before `executeAnsible`, so the module's playbook and
|
|
1164
|
+
// its `on_install` hook see a correctly configured host rather than
|
|
1165
|
+
// configuring themselves on top of a broken one.
|
|
1166
|
+
//
|
|
1167
|
+
// FAILURE IS FATAL HERE, and that is DELIBERATELY the opposite of the
|
|
1168
|
+
// outbound direction below, where a failed fan-out never fails the
|
|
1169
|
+
// provider's own deploy (D4). The asymmetry is the point and is not an
|
|
1170
|
+
// oversight to be tidied up: an inbound aspect is a PREREQUISITE of the host
|
|
1171
|
+
// being configured right now, so continuing would configure a host celilo
|
|
1172
|
+
// knows is misconfigured — exactly the outcome celilo#902 produced. Nothing
|
|
1173
|
+
// is rolled back: the rows, the guest and the IPAM allocation all persist,
|
|
1174
|
+
// and re-running this deploy after fixing the cause converges on the same
|
|
1175
|
+
// system.
|
|
1176
|
+
if (recordedSystems.length > 0) {
|
|
1177
|
+
const { reconcileAspectsForSystems } = await import('./aspect-runner');
|
|
1178
|
+
const reconcile = await reconcileAspectsForSystems({
|
|
1179
|
+
systems: recordedSystems.map((sys) => ({ hostname: sys.hostname, zone: sys.zone })),
|
|
1180
|
+
db,
|
|
1181
|
+
// This module's own aspect is fanned out by `on_install` below, across
|
|
1182
|
+
// the whole fleet rather than only these hosts.
|
|
1183
|
+
excludeModuleIds: [moduleId],
|
|
1184
|
+
});
|
|
1185
|
+
if (reconcile.failures.length > 0) {
|
|
1186
|
+
// Name the PROVIDING module, not the one being deployed — otherwise
|
|
1187
|
+
// this reads as a defect in `moduleId` and the operator debugs the
|
|
1188
|
+
// wrong thing.
|
|
1189
|
+
const detail = reconcile.failures
|
|
1190
|
+
.map(
|
|
1191
|
+
(f) =>
|
|
1192
|
+
` ✗ '${f.providerModuleId}' aspect '${f.role}' on ${f.hostnames.join(', ')}: ${f.error ?? 'unknown error'}`,
|
|
1193
|
+
)
|
|
1194
|
+
.join('\n');
|
|
1195
|
+
return {
|
|
1196
|
+
success: false,
|
|
1197
|
+
phases,
|
|
1198
|
+
error: `Fleet aspects could not be applied to the system(s) this deploy created:\n${detail}\nThese are prerequisites, so '${moduleId}' was not configured on top of them. Fix the cause and re-run \`celilo module deploy ${moduleId}\` — the system is kept and will be reused.`,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
const applied = reconcile.outcomes.filter((o) => o.ran);
|
|
1202
|
+
if (applied.length > 0) {
|
|
1203
|
+
log.success(
|
|
1204
|
+
`Applied ${applied.length} fleet aspect(s) to the new system(s): ${applied.map((o) => `${o.providerModuleId}/${o.role}`).join(', ')}`,
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1155
1209
|
let machineId: string | undefined;
|
|
1156
1210
|
if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
|
|
1157
1211
|
// The management box deploys to ITSELF (registered in the machine pool as
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
12
12
|
import type { DbClient } from '../db/client';
|
|
13
|
+
import { deallocateForModule } from '../ipam/auto-allocator';
|
|
13
14
|
import type { ModuleManifest } from '../manifest/schema';
|
|
14
15
|
import { getModuleConfigValue } from '../services/module-config';
|
|
15
16
|
import { setupTestDatabase } from '../test-utils/database';
|
|
@@ -41,6 +42,22 @@ const storedIp = (moduleId: string, variable = 'dns_ingress_ip'): string | undef
|
|
|
41
42
|
return typeof value === 'string' ? value : undefined;
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
/** Reservation reasons currently held, so a test can assert one is GONE. */
|
|
46
|
+
const reservedReasons = (): string[] =>
|
|
47
|
+
(db.$client.prepare('SELECT reason FROM ip_reservations').all() as Array<{ reason: string }>).map(
|
|
48
|
+
(r) => r.reason,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What `module remove` does to a module's IPAM state. Removal also drops the
|
|
53
|
+
* module row, which cascades the stored config value away — the tests below
|
|
54
|
+
* clear it by hand so a "reinstall" starts from the same state a real one does.
|
|
55
|
+
*/
|
|
56
|
+
const removeModule = async (moduleId: string): Promise<void> => {
|
|
57
|
+
await deallocateForModule(moduleId, db);
|
|
58
|
+
db.$client.prepare('DELETE FROM module_configs WHERE module_id = ?').run(moduleId);
|
|
59
|
+
};
|
|
60
|
+
|
|
44
61
|
beforeEach(async () => {
|
|
45
62
|
db = await setupTestDatabase();
|
|
46
63
|
db.$client
|
|
@@ -137,3 +154,94 @@ describe('ensureIngressIps', () => {
|
|
|
137
154
|
expect(storedIp('caddy-internal', 'ingress_ip')).toMatch(/^10\.226\.1\.\d+$/);
|
|
138
155
|
});
|
|
139
156
|
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The release half (celilo#892). `ensureIngressIps` reserved the address and
|
|
160
|
+
* nothing ever gave it back, so every install/remove cycle permanently burned
|
|
161
|
+
* one address from the internal static range — silently, and with no way to
|
|
162
|
+
* tell the dead row from the live one, since both carry the same reason.
|
|
163
|
+
*
|
|
164
|
+
* On celilo-mgr this left 192.168.0.153 and .154 both reading
|
|
165
|
+
* `ingress:caddy-internal:ingress_ip`, only one of them real.
|
|
166
|
+
*/
|
|
167
|
+
describe('releasing ingress IPs on module removal', () => {
|
|
168
|
+
test('removing a module releases its ingress reservation', async () => {
|
|
169
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
170
|
+
expect(reservedReasons()).toContain('ingress:technitium:dns_ingress_ip');
|
|
171
|
+
|
|
172
|
+
await removeModule('technitium');
|
|
173
|
+
|
|
174
|
+
expect(reservedReasons()).not.toContain('ingress:technitium:dns_ingress_ip');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a reinstall REUSES the address instead of advancing to the next one', async () => {
|
|
178
|
+
// The user-visible symptom: without the release, the range walks forward
|
|
179
|
+
// one address per install/remove cycle until it runs out.
|
|
180
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
181
|
+
const first = storedIp('technitium');
|
|
182
|
+
|
|
183
|
+
await removeModule('technitium');
|
|
184
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
185
|
+
|
|
186
|
+
expect(storedIp('technitium')).toBe(first);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('the exclusion count returns to its pre-install value', async () => {
|
|
190
|
+
const before = reservedReasons().length;
|
|
191
|
+
|
|
192
|
+
await ensureIngressIps('caddy-internal', wantsWebIngress, db);
|
|
193
|
+
expect(reservedReasons().length).toBe(before + 1);
|
|
194
|
+
|
|
195
|
+
await removeModule('caddy-internal');
|
|
196
|
+
|
|
197
|
+
expect(reservedReasons().length).toBe(before);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('releases the LEGACY `dns-ingress:<module>` reason too', async () => {
|
|
201
|
+
// Written before celilo#879 generalized the reason format. celilo-mgr holds
|
|
202
|
+
// one of these right now, so a fix matching only the current format leaves
|
|
203
|
+
// the installed base leaking.
|
|
204
|
+
db.$client
|
|
205
|
+
.prepare('INSERT INTO ip_reservations (ip_start, zone, reason) VALUES (?, ?, ?)')
|
|
206
|
+
.run('10.226.1.42', 'internal', 'dns-ingress:technitium');
|
|
207
|
+
|
|
208
|
+
await removeModule('technitium');
|
|
209
|
+
|
|
210
|
+
expect(reservedReasons()).not.toContain('dns-ingress:technitium');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('releases a module holding BOTH formats at once', async () => {
|
|
214
|
+
// An upgraded installation: the legacy row from before celilo#879 plus a
|
|
215
|
+
// current one written since. Releasing only one still leaks.
|
|
216
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
217
|
+
db.$client
|
|
218
|
+
.prepare('INSERT INTO ip_reservations (ip_start, zone, reason) VALUES (?, ?, ?)')
|
|
219
|
+
.run('10.226.1.42', 'internal', 'dns-ingress:technitium');
|
|
220
|
+
|
|
221
|
+
await removeModule('technitium');
|
|
222
|
+
|
|
223
|
+
expect(reservedReasons()).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("leaves OTHER modules' reservations alone", async () => {
|
|
227
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
228
|
+
await ensureIngressIps('knot-unbound-internal', wantsIngress, db);
|
|
229
|
+
|
|
230
|
+
await removeModule('technitium');
|
|
231
|
+
|
|
232
|
+
expect(reservedReasons()).toEqual(['ingress:knot-unbound-internal:dns_ingress_ip']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('releases even when the module has no zone-IP allocation', async () => {
|
|
236
|
+
// `deallocateForModule` returns early when there is no `ip_allocations`
|
|
237
|
+
// row, which is the normal case for a module deployed onto a machine
|
|
238
|
+
// rather than a celilo-provisioned container. Releasing after that early
|
|
239
|
+
// return would skip exactly the modules that leak.
|
|
240
|
+
await ensureIngressIps('caddy-internal', wantsWebIngress, db);
|
|
241
|
+
|
|
242
|
+
const hadAllocation = await deallocateForModule('caddy-internal', db);
|
|
243
|
+
|
|
244
|
+
expect(hadAllocation).toBe(false);
|
|
245
|
+
expect(reservedReasons()).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|