@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.
@@ -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
- const { recordDeployedSystemForModule } = await import('./deployed-systems');
937
- const recorded = await recordDeployedSystemForModule(
938
- moduleId,
939
- manifest,
940
- plan.infrastructure,
941
- db,
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
+ });