@celilo/cli 1.1.0 → 1.3.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 +16 -1
- package/package.json +4 -4
- package/src/cli/commands/hook-run.ts +5 -8
- package/src/cli/commands/ipam.ts +93 -0
- package/src/cli/commands/machine-add.ts +22 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-doctor.ts +148 -5
- package/src/cli/commands/system-update.ts +2 -0
- package/src/cli/completion.ts +38 -5
- package/src/cli/index.ts +10 -1
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +41 -1
- package/src/hooks/artifact-retention.test.ts +136 -0
- package/src/hooks/artifact-retention.ts +159 -0
- package/src/hooks/executor.test.ts +80 -0
- package/src/hooks/executor.ts +68 -23
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
- package/src/hooks/types.ts +20 -2
- 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/policy/module-business-baseline.ts +404 -0
- package/src/policy/no-module-business-in-core.test.ts +504 -0
- package/src/services/alerting/keys.ts +21 -1
- package/src/services/alerting/run-monitor.ts +6 -1
- 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/audit/browser-pin.test.ts +167 -0
- package/src/services/audit/browser-pin.ts +185 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/deploy-ansible-recap.test.ts +76 -0
- package/src/services/deploy-ansible.ts +56 -1
- package/src/services/health-runner.ts +15 -1
- package/src/services/module-deploy.ts +70 -16
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/system/browser-provisioning.test.ts +67 -0
- package/src/system/prereqs.test.ts +73 -0
- package/src/system/prereqs.ts +89 -12
- package/src/templates/ingress-ip.test.ts +108 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import type { ContractHookSignature } from '../manifest/contracts';
|
|
4
5
|
import {
|
|
@@ -272,6 +273,85 @@ describe('Hook Executor', () => {
|
|
|
272
273
|
});
|
|
273
274
|
});
|
|
274
275
|
|
|
276
|
+
describe('invokeHook artifacts', () => {
|
|
277
|
+
test('collects EVERY file the hook wrote, not just the newest .png', async () => {
|
|
278
|
+
const { logger } = createCapturingLogger();
|
|
279
|
+
const result = await invokeHook(
|
|
280
|
+
__dirname,
|
|
281
|
+
'container_created',
|
|
282
|
+
'1.0',
|
|
283
|
+
{ script: './test-fixtures/artifact-writing-hook.ts', timeout: 10000 },
|
|
284
|
+
{ vps_ip: '10.0.0.5' },
|
|
285
|
+
{},
|
|
286
|
+
{},
|
|
287
|
+
logger,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(result.success).toBe(true);
|
|
291
|
+
const names = (result.artifactPaths ?? []).map((p) => p.split('/').pop());
|
|
292
|
+
// The old behaviour returned exactly one of these — the .png — and
|
|
293
|
+
// threw away the DOM and the request log, which is the least useful
|
|
294
|
+
// third of a post-mortem on its own.
|
|
295
|
+
expect(names).toEqual(['spa-failure.html', 'spa-failure.png', 'spa-failure.requests.txt']);
|
|
296
|
+
|
|
297
|
+
// Per-run, so a consumer writing FIXED filenames cannot overwrite its
|
|
298
|
+
// own previous run — which is what makes retention meaningful.
|
|
299
|
+
const dir = (result.artifactPaths ?? [])[0];
|
|
300
|
+
expect(dir).toContain('/screenshots/container_created-');
|
|
301
|
+
|
|
302
|
+
rmSync(join(__dirname, 'screenshots'), { recursive: true, force: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('a hook that writes nothing leaves no directory behind', async () => {
|
|
306
|
+
// Every hook invocation creates a run directory. If empty ones were
|
|
307
|
+
// kept, a module would accrue one per run forever — ~96 a day for a
|
|
308
|
+
// 15-minute monitor — and nothing would ever reclaim them.
|
|
309
|
+
const { logger } = createCapturingLogger();
|
|
310
|
+
const result = await invokeHook(
|
|
311
|
+
__dirname,
|
|
312
|
+
'container_created',
|
|
313
|
+
'1.0',
|
|
314
|
+
{ script: './test-fixtures/success-hook.ts', timeout: 10000 },
|
|
315
|
+
{ vps_ip: '10.0.0.5' },
|
|
316
|
+
{},
|
|
317
|
+
{},
|
|
318
|
+
logger,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(result.success).toBe(true);
|
|
322
|
+
expect(result.artifactPaths).toBeUndefined();
|
|
323
|
+
expect(existsSync(join(__dirname, 'screenshots', 'container_created'))).toBe(false);
|
|
324
|
+
const runDirs = existsSync(join(__dirname, 'screenshots'))
|
|
325
|
+
? readdirSync(join(__dirname, 'screenshots'))
|
|
326
|
+
: [];
|
|
327
|
+
expect(runDirs).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('an early return before execution leaks no directory', async () => {
|
|
331
|
+
// The capability pre-flight returns between "create the directory" and
|
|
332
|
+
// the try/finally that reclaims it. Creating the directory too early
|
|
333
|
+
// therefore leaked one on every such run, and nothing cleans them up.
|
|
334
|
+
const { logger } = createCapturingLogger();
|
|
335
|
+
const result = await invokeHook(
|
|
336
|
+
__dirname,
|
|
337
|
+
'container_created',
|
|
338
|
+
'1.0',
|
|
339
|
+
{ script: './test-fixtures/success-hook.ts', timeout: 10000 },
|
|
340
|
+
{ vps_ip: '10.0.0.5' },
|
|
341
|
+
{},
|
|
342
|
+
{},
|
|
343
|
+
logger,
|
|
344
|
+
{ requiredCapabilities: ['dns_internal'], capabilities: {} },
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
expect(result.success).toBe(false);
|
|
348
|
+
const runDirs = existsSync(join(__dirname, 'screenshots'))
|
|
349
|
+
? readdirSync(join(__dirname, 'screenshots'))
|
|
350
|
+
: [];
|
|
351
|
+
expect(runDirs).toEqual([]);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
275
355
|
describe('invokeHook', () => {
|
|
276
356
|
test('full successful invocation', async () => {
|
|
277
357
|
const { logger, messages } = createCapturingLogger();
|
package/src/hooks/executor.ts
CHANGED
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
23
|
-
import { join, resolve } from 'node:path';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
23
|
+
import { dirname, join, resolve } from 'node:path';
|
|
24
24
|
import {
|
|
25
25
|
type DeployedSystem,
|
|
26
26
|
isCompiledHook,
|
|
27
27
|
isMissingProviderInputError,
|
|
28
|
+
moduleArtifactDir,
|
|
28
29
|
} from '@celilo/capabilities';
|
|
29
30
|
import {
|
|
30
31
|
type ContractHookSignature,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
supportedContractVersions,
|
|
34
35
|
} from '../manifest/contracts';
|
|
35
36
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
37
|
+
import { pruneModuleArtifacts } from './artifact-retention';
|
|
36
38
|
import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
|
|
37
39
|
|
|
38
40
|
/** Default total timeout: 60 seconds */
|
|
@@ -373,22 +375,45 @@ export function checkRequiredCapabilities(
|
|
|
373
375
|
* @param since - Only consider files created after this timestamp (ms)
|
|
374
376
|
* @returns Path to screenshot, or undefined
|
|
375
377
|
*/
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
/**
|
|
379
|
+
* Every file the hook wrote to this run's artifact directory.
|
|
380
|
+
*
|
|
381
|
+
* No mtime filter is needed and none is wanted: the directory is created
|
|
382
|
+
* fresh for this run, so everything in it was written by this run. The
|
|
383
|
+
* previous version returned only the newest `.png`, which discarded the
|
|
384
|
+
* page content and the observed-request log — the two things that make a
|
|
385
|
+
* screenshot diagnosable.
|
|
386
|
+
*/
|
|
387
|
+
function collectArtifacts(dir: string): string[] {
|
|
388
|
+
try {
|
|
389
|
+
return readdirSync(dir)
|
|
390
|
+
.map((file) => join(dir, file))
|
|
391
|
+
.filter((path) => {
|
|
392
|
+
try {
|
|
393
|
+
return statSync(path).isFile();
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
.sort();
|
|
399
|
+
} catch {
|
|
400
|
+
// Best effort — never mask the original error with a readdir failure.
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
378
404
|
|
|
405
|
+
/** `undefined` rather than `[]`, so an empty result carries no field at all. */
|
|
406
|
+
function nonEmpty(paths: string[]): string[] | undefined {
|
|
407
|
+
return paths.length > 0 ? paths : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Remove a run's artifact directory when the hook wrote nothing into it. */
|
|
411
|
+
function discardIfEmpty(dir: string): void {
|
|
379
412
|
try {
|
|
380
|
-
|
|
381
|
-
for (const file of files) {
|
|
382
|
-
const fullPath = join(dir, file);
|
|
383
|
-
const stat = statSync(fullPath);
|
|
384
|
-
if (stat.mtimeMs >= since) {
|
|
385
|
-
return fullPath;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
413
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
388
414
|
} catch {
|
|
389
|
-
// Best effort —
|
|
415
|
+
// Best effort — a leftover directory is not worth failing a hook over.
|
|
390
416
|
}
|
|
391
|
-
return undefined;
|
|
392
417
|
}
|
|
393
418
|
|
|
394
419
|
/**
|
|
@@ -470,9 +495,16 @@ export async function invokeHook(
|
|
|
470
495
|
};
|
|
471
496
|
}
|
|
472
497
|
|
|
473
|
-
// Prepare
|
|
474
|
-
|
|
475
|
-
|
|
498
|
+
// Prepare this run's artifact directory. Per-run, because a hook writing
|
|
499
|
+
// fixed filenames — a reasonable thing to do — would otherwise overwrite
|
|
500
|
+
// its own previous artifacts and leave retention nothing to retain.
|
|
501
|
+
// `moduleArtifactDir` is the one definition of that layout; a bus
|
|
502
|
+
// subscriber, which gets no HookContext, calls it directly.
|
|
503
|
+
// Path now, directory later. Creating it here would leak an empty
|
|
504
|
+
// directory on every early return between this point and the try/finally
|
|
505
|
+
// below — the capability pre-flight is one — and nothing ever cleans
|
|
506
|
+
// those up, so a module accrues one per affected run forever.
|
|
507
|
+
const screenshotDir = moduleArtifactDir(modulePath, `${hookName}-${startTime}`);
|
|
476
508
|
|
|
477
509
|
// Build context
|
|
478
510
|
const loadedCapabilities = options.capabilities ?? {};
|
|
@@ -515,6 +547,13 @@ export async function invokeHook(
|
|
|
515
547
|
debug,
|
|
516
548
|
);
|
|
517
549
|
|
|
550
|
+
// Create the artifact directory only once every early return is behind
|
|
551
|
+
// us, so the `finally` below is guaranteed to run and reclaim it.
|
|
552
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
553
|
+
// Prune on write, so retention needs no scheduler of its own and cannot
|
|
554
|
+
// fall behind a module that runs often.
|
|
555
|
+
pruneModuleArtifacts(dirname(screenshotDir));
|
|
556
|
+
|
|
518
557
|
// Execute
|
|
519
558
|
try {
|
|
520
559
|
logger.info(`Executing hook: ${hookName}`);
|
|
@@ -535,16 +574,17 @@ export async function invokeHook(
|
|
|
535
574
|
return {
|
|
536
575
|
success: true,
|
|
537
576
|
outputs,
|
|
577
|
+
artifactPaths: nonEmpty(collectArtifacts(screenshotDir)),
|
|
538
578
|
duration: Date.now() - startTime,
|
|
539
579
|
};
|
|
540
580
|
} catch (error) {
|
|
541
581
|
const message = error instanceof Error ? error.message : String(error);
|
|
542
582
|
logger.error(`Hook ${hookName} failed: ${message}`);
|
|
543
583
|
|
|
544
|
-
//
|
|
545
|
-
const
|
|
546
|
-
if (
|
|
547
|
-
logger.info(`
|
|
584
|
+
// Artifacts the hook wrote before it failed — the post-mortem.
|
|
585
|
+
const artifactPaths = nonEmpty(collectArtifacts(screenshotDir));
|
|
586
|
+
if (artifactPaths) {
|
|
587
|
+
logger.info(`Artifacts saved:\n ${artifactPaths.join('\n ')}`);
|
|
548
588
|
}
|
|
549
589
|
|
|
550
590
|
// Recognise the cross-module structured error so the orchestrator can
|
|
@@ -557,7 +597,7 @@ export async function invokeHook(
|
|
|
557
597
|
success: false,
|
|
558
598
|
outputs: {},
|
|
559
599
|
error: message,
|
|
560
|
-
|
|
600
|
+
artifactPaths,
|
|
561
601
|
duration: Date.now() - startTime,
|
|
562
602
|
missingProviderInput: {
|
|
563
603
|
providerModuleId: error.providerModuleId,
|
|
@@ -572,8 +612,13 @@ export async function invokeHook(
|
|
|
572
612
|
success: false,
|
|
573
613
|
outputs: {},
|
|
574
614
|
error: message,
|
|
575
|
-
|
|
615
|
+
artifactPaths,
|
|
576
616
|
duration: Date.now() - startTime,
|
|
577
617
|
};
|
|
618
|
+
} finally {
|
|
619
|
+
// Most hooks write nothing, and every hook invocation would otherwise
|
|
620
|
+
// leave an empty directory behind. Discard it; a run that produced
|
|
621
|
+
// artifacts keeps its directory.
|
|
622
|
+
discardIfEmpty(screenshotDir);
|
|
578
623
|
}
|
|
579
624
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that writes a post-mortem set to its artifact
|
|
3
|
+
* directory, the way a browser-driven check does — a screenshot, the page
|
|
4
|
+
* content, and the observed requests.
|
|
5
|
+
*
|
|
6
|
+
* Three files with three different extensions on purpose: the executor
|
|
7
|
+
* used to report only the newest `.png`, which silently discarded the two
|
|
8
|
+
* that make the screenshot diagnosable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { defineHook } from '@celilo/capabilities';
|
|
14
|
+
|
|
15
|
+
export default defineHook({
|
|
16
|
+
hook: 'container_created',
|
|
17
|
+
requires: [],
|
|
18
|
+
handler: async (ctx) => {
|
|
19
|
+
const dir = ctx.screenshotDir as string;
|
|
20
|
+
writeFileSync(join(dir, 'spa-failure.png'), 'not-really-a-png');
|
|
21
|
+
writeFileSync(join(dir, 'spa-failure.html'), '<html></html>');
|
|
22
|
+
writeFileSync(join(dir, 'spa-failure.requests.txt'), 'GET /api/getActiveMonth 200');
|
|
23
|
+
return { api_key: 'wrote-artifacts' };
|
|
24
|
+
},
|
|
25
|
+
});
|
package/src/hooks/types.ts
CHANGED
|
@@ -91,8 +91,13 @@ export interface HookResult {
|
|
|
91
91
|
success: boolean;
|
|
92
92
|
outputs: Record<string, unknown>;
|
|
93
93
|
error?: string;
|
|
94
|
-
/**
|
|
95
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Every file the hook wrote to its per-run artifact directory.
|
|
96
|
+
* All of them, not just the newest image: a screenshot without the
|
|
97
|
+
* DOM and the observed requests is the least useful third of a
|
|
98
|
+
* post-mortem.
|
|
99
|
+
*/
|
|
100
|
+
artifactPaths?: string[];
|
|
96
101
|
/** Duration in milliseconds */
|
|
97
102
|
duration: number;
|
|
98
103
|
/**
|
|
@@ -120,3 +125,16 @@ import type { HookName } from '@celilo/capabilities';
|
|
|
120
125
|
* Hook manifest section - maps hook names to definitions
|
|
121
126
|
*/
|
|
122
127
|
export type HookManifest = Partial<Record<HookName, HookDefinition>>;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render collected artifacts for an operator-facing error message.
|
|
131
|
+
*
|
|
132
|
+
* One definition, because three call sites rendered the single old
|
|
133
|
+
* `screenshotPath` three separate times and would have drifted the moment
|
|
134
|
+
* one of them learned about the others.
|
|
135
|
+
*/
|
|
136
|
+
export function describeArtifacts(artifactPaths: string[] | undefined): string {
|
|
137
|
+
if (!artifactPaths || artifactPaths.length === 0) return '';
|
|
138
|
+
const label = artifactPaths.length === 1 ? 'Artifact saved' : 'Artifacts saved';
|
|
139
|
+
return `\n\n${label}:\n ${artifactPaths.join('\n ')}`;
|
|
140
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
reserveVMID,
|
|
26
26
|
unreserveIP,
|
|
27
27
|
unreserveVMID,
|
|
28
|
+
updateReservationReason,
|
|
28
29
|
} from './allocator';
|
|
29
30
|
|
|
30
31
|
describe('IPAM Allocator', () => {
|
|
@@ -295,6 +296,43 @@ describe('IPAM Allocator', () => {
|
|
|
295
296
|
const reservations = await listReservations(db);
|
|
296
297
|
expect(reservations).toHaveLength(2);
|
|
297
298
|
});
|
|
299
|
+
|
|
300
|
+
// Editing in place, rather than include-then-exclude: that dance drops the
|
|
301
|
+
// row for a moment and can race an allocation into the held address.
|
|
302
|
+
test('should edit a reservation reason in place', async () => {
|
|
303
|
+
await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
|
|
304
|
+
|
|
305
|
+
const updated = await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
|
|
306
|
+
|
|
307
|
+
expect(updated).toBe(true);
|
|
308
|
+
const reservations = await db.select().from(ipReservations).all();
|
|
309
|
+
expect(reservations).toHaveLength(1);
|
|
310
|
+
expect(reservations[0].reason).toBe('Retired LB');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('should leave the address held while editing', async () => {
|
|
314
|
+
await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
|
|
315
|
+
|
|
316
|
+
await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
|
|
317
|
+
|
|
318
|
+
expect(await isIPAvailable('10.0.10.50', 'dmz', db)).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('should edit only the addressed row', async () => {
|
|
322
|
+
await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
|
|
323
|
+
await reserveIP('10.0.10.51', 'dmz', 'LB', null, db);
|
|
324
|
+
|
|
325
|
+
await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
|
|
326
|
+
|
|
327
|
+
const reasons = (await listReservations(db)).map((r) => r.reason);
|
|
328
|
+
expect(reasons.sort()).toEqual(['LB', 'Retired LB']);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('should report when no reservation exists to edit', async () => {
|
|
332
|
+
const updated = await updateReservationReason('10.0.10.99', 'dmz', 'Nothing there', db);
|
|
333
|
+
|
|
334
|
+
expect(updated).toBe(false);
|
|
335
|
+
});
|
|
298
336
|
});
|
|
299
337
|
|
|
300
338
|
describe('getAllocatedIPsInSubnet', () => {
|
package/src/ipam/allocator.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Prevents conflicts and tracks allocations
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { and, eq } from 'drizzle-orm';
|
|
7
|
+
import { and, eq, like, or } from 'drizzle-orm';
|
|
8
8
|
import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
|
|
9
9
|
import type { DbClient } from '../db/client';
|
|
10
10
|
import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema';
|
|
@@ -246,6 +246,68 @@ export async function unreserveIP(
|
|
|
246
246
|
.where(and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone)));
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Release every ingress-IP reservation a module holds (celilo#892).
|
|
251
|
+
*
|
|
252
|
+
* `ensureIngressIps` reserves an `internal`-subnet address at generate time and
|
|
253
|
+
* nothing released it at removal, so each install/remove cycle permanently
|
|
254
|
+
* burned one address from the static range — with no error and no way to tell
|
|
255
|
+
* the dead row from the live one, since both carry the same reason string.
|
|
256
|
+
*
|
|
257
|
+
* TWO reason formats exist in live databases and both must go: the current
|
|
258
|
+
* `ingress:<module>:<variable>` (since celilo#879) and the pre-879
|
|
259
|
+
* `dns-ingress:<module>`, which celilo-mgr still holds. Matching only the
|
|
260
|
+
* current one would leave the installed base leaking.
|
|
261
|
+
*
|
|
262
|
+
* Deletion is by REASON, not by the stored config value: a module can hold an
|
|
263
|
+
* ingress reservation with no `ip_allocations` row (it deploys onto a machine
|
|
264
|
+
* rather than a container), and the config rows are about to be cascade-deleted
|
|
265
|
+
* anyway. The reason string is the only thing that names the owner.
|
|
266
|
+
*
|
|
267
|
+
* @returns The addresses released, for reporting.
|
|
268
|
+
*/
|
|
269
|
+
export async function releaseIngressReservations(
|
|
270
|
+
moduleId: string,
|
|
271
|
+
db: DbOrTransaction,
|
|
272
|
+
): Promise<string[]> {
|
|
273
|
+
// Module IDs are validated kebab-case, so they carry no LIKE wildcards.
|
|
274
|
+
const ownedByModule = or(
|
|
275
|
+
like(ipReservations.reason, `ingress:${moduleId}:%`),
|
|
276
|
+
eq(ipReservations.reason, `dns-ingress:${moduleId}`),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const held = await db.select().from(ipReservations).where(ownedByModule).all();
|
|
280
|
+
if (held.length === 0) return [];
|
|
281
|
+
|
|
282
|
+
await db.delete(ipReservations).where(ownedByModule);
|
|
283
|
+
|
|
284
|
+
return held.map((r: typeof ipReservations.$inferSelect) => r.ipStart);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Change an existing reservation's reason in place.
|
|
289
|
+
*
|
|
290
|
+
* The alternative — include then re-exclude — drops the row for a moment and
|
|
291
|
+
* can race an allocation into the address it was holding.
|
|
292
|
+
*
|
|
293
|
+
* @returns False when no reservation exists for that IP in that zone.
|
|
294
|
+
*/
|
|
295
|
+
export async function updateReservationReason(
|
|
296
|
+
ipStart: string,
|
|
297
|
+
zone: IpamZone,
|
|
298
|
+
reason: string,
|
|
299
|
+
db: DbOrTransaction,
|
|
300
|
+
): Promise<boolean> {
|
|
301
|
+
const ip = stripCIDR(ipStart);
|
|
302
|
+
const match = and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone));
|
|
303
|
+
|
|
304
|
+
const existing = await db.select().from(ipReservations).where(match).all();
|
|
305
|
+
if (existing.length === 0) return false;
|
|
306
|
+
|
|
307
|
+
await db.update(ipReservations).set({ reason }).where(match);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
249
311
|
/**
|
|
250
312
|
* List all IP reservations
|
|
251
313
|
*/
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { eq } from 'drizzle-orm';
|
|
11
11
|
import type { DbClient } from '../db/client';
|
|
12
12
|
import { ipAllocations } from '../db/schema';
|
|
13
|
+
import { releaseIngressReservations } from './allocator';
|
|
13
14
|
|
|
14
15
|
export interface IpamAllocation {
|
|
15
16
|
moduleId: string;
|
|
@@ -258,6 +259,12 @@ export async function allocateForModule(
|
|
|
258
259
|
* @returns True if allocation was removed, false if none existed
|
|
259
260
|
*/
|
|
260
261
|
export async function deallocateForModule(moduleId: string, db: DbClient): Promise<boolean> {
|
|
262
|
+
// Ingress reservations are released FIRST and unconditionally (celilo#892).
|
|
263
|
+
// A module can hold one with no `ip_allocations` row at all — it deploys onto
|
|
264
|
+
// a machine rather than a celilo-provisioned container — so releasing it
|
|
265
|
+
// after the early return below would skip exactly the modules that leak.
|
|
266
|
+
await releaseIngressReservations(moduleId, db);
|
|
267
|
+
|
|
261
268
|
// Check if allocation exists before deleting
|
|
262
269
|
const existing = getAllocation(moduleId, db);
|
|
263
270
|
if (!existing) {
|