@celilo/cli 4.0.0 → 5.0.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 +2 -2
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +41 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +185 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/api-access.test.ts +44 -0
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/module-configs.test.ts +1 -1
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +119 -6
- package/src/services/celilo-events.ts +156 -16
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +86 -7
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +165 -1
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/context.ts +14 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -100,12 +100,16 @@ export class CommandTreeParser {
|
|
|
100
100
|
|
|
101
101
|
// Track whether we're in a Commands/Subcommands section
|
|
102
102
|
let inCommandSection = false;
|
|
103
|
+
// Column where the last parsed entry's description starts; anything indented
|
|
104
|
+
// that far is wrapped prose, not a subcommand.
|
|
105
|
+
let descriptionColumn: number | null = null;
|
|
103
106
|
|
|
104
107
|
for (const line of lines) {
|
|
105
108
|
// Check if we're entering a Commands section
|
|
106
109
|
// Match " Commands:" or "│ Commands:"
|
|
107
110
|
if (/^[│\s]*(Commands|Subcommands):\s*$/i.test(line)) {
|
|
108
111
|
inCommandSection = true;
|
|
112
|
+
descriptionColumn = null;
|
|
109
113
|
continue;
|
|
110
114
|
}
|
|
111
115
|
|
|
@@ -134,8 +138,16 @@ export class CommandTreeParser {
|
|
|
134
138
|
// " command-name Description"
|
|
135
139
|
const match = line.match(/^[│\s]*([a-z][a-z0-9-]*)(?:\s{2,}|\s+[<a-z])/);
|
|
136
140
|
|
|
141
|
+
// A wrapped description's continuation lines are indented to the previous
|
|
142
|
+
// entry's description column and match the pattern above (celilo#1409).
|
|
143
|
+
const indent = line.search(/[^│\s]/);
|
|
144
|
+
if (descriptionColumn !== null && indent >= descriptionColumn) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
137
148
|
if (match) {
|
|
138
149
|
const cmdName = match[1];
|
|
150
|
+
descriptionColumn = match[0].length;
|
|
139
151
|
|
|
140
152
|
// Skip examples (lines starting with "celilo")
|
|
141
153
|
if (cmdName === 'celilo') {
|
package/src/cli/commands/api.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import {
|
|
12
|
+
getPrincipalByName,
|
|
12
13
|
grantPrincipal,
|
|
13
14
|
listPrincipals,
|
|
14
15
|
renderAuthorizedKeys,
|
|
@@ -22,7 +23,25 @@ function errMsg(error: unknown): string {
|
|
|
22
23
|
return error instanceof Error ? error.message : String(error);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
function grantList(value: unknown): string[] {
|
|
27
|
+
return typeof value === 'string'
|
|
28
|
+
? value
|
|
29
|
+
.split(',')
|
|
30
|
+
.map((g) => g.trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
: [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,...]>`
|
|
37
|
+
*
|
|
38
|
+
* `--can` REPLACES the grant set; `--add`/`--remove` amend it. Replace-only was
|
|
39
|
+
* the whole surface, so adding one permission meant re-supplying the full list
|
|
40
|
+
* plus the key, and a typo in either silently narrowed the principal or changed
|
|
41
|
+
* its key (celilo#1404). So: `--key` is required only when CREATING a principal,
|
|
42
|
+
* and a `--can` that drops grants an existing principal holds is refused unless
|
|
43
|
+
* `--force` says the narrowing is intended.
|
|
44
|
+
*/
|
|
26
45
|
export async function handleApiGrant(
|
|
27
46
|
args: string[],
|
|
28
47
|
flags: Record<string, boolean | string> = {},
|
|
@@ -32,37 +51,71 @@ export async function handleApiGrant(
|
|
|
32
51
|
if (!name) {
|
|
33
52
|
return {
|
|
34
53
|
success: false,
|
|
35
|
-
error:
|
|
54
|
+
error:
|
|
55
|
+
'Usage: celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,grant...]>',
|
|
36
56
|
};
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
const
|
|
40
|
-
|
|
59
|
+
const replace = grantList(flags.can);
|
|
60
|
+
const added = grantList(flags.add);
|
|
61
|
+
const removed = grantList(flags.remove);
|
|
62
|
+
if (replace.length > 0 && (added.length > 0 || removed.length > 0)) {
|
|
41
63
|
return {
|
|
42
64
|
success: false,
|
|
43
|
-
error: '--
|
|
65
|
+
error: '--can replaces the grant set; use it OR --add/--remove, not both.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (replace.length === 0 && added.length === 0 && removed.length === 0) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error:
|
|
72
|
+
'One of --can <grants> (replace), --add <grants>, or --remove <grants> is required (e.g. --add module:journal).',
|
|
44
73
|
};
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
76
|
+
const existing = await getPrincipalByName(name);
|
|
77
|
+
if (!existing && (added.length > 0 || removed.length > 0)) {
|
|
49
78
|
return {
|
|
50
79
|
success: false,
|
|
51
|
-
error:
|
|
80
|
+
error: `No API principal named "${name}" — --add/--remove amend an existing one. Create it with --key and --can.`,
|
|
52
81
|
};
|
|
53
82
|
}
|
|
54
83
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const keyArg = typeof flags.key === 'string' ? flags.key : '';
|
|
85
|
+
if (!keyArg && !existing) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Keep the enrolled key when none is supplied, so amending grants can never
|
|
92
|
+
// rotate the key by omission.
|
|
93
|
+
const publicKey = keyArg
|
|
94
|
+
? existsSync(keyArg)
|
|
95
|
+
? readFileSync(keyArg, 'utf8').trim()
|
|
96
|
+
: keyArg.trim()
|
|
97
|
+
: (existing?.publicKey ?? '');
|
|
98
|
+
|
|
99
|
+
const current = existing?.grants ?? [];
|
|
100
|
+
let grants: string[];
|
|
101
|
+
if (replace.length > 0) {
|
|
102
|
+
const dropped = current.filter((g) => !replace.includes(g));
|
|
103
|
+
if (dropped.length > 0 && flags.force !== true) {
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
error: `Refusing to narrow "${name}": --can would drop ${dropped.join(', ')}. Use --add/--remove to amend, or --force to replace anyway.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
grants = replace;
|
|
110
|
+
} else {
|
|
111
|
+
grants = [...new Set([...current, ...added])].filter((g) => !removed.includes(g));
|
|
112
|
+
}
|
|
60
113
|
|
|
61
114
|
const { principal, created } = await grantPrincipal({ name, publicKey, grants });
|
|
62
115
|
|
|
63
116
|
return {
|
|
64
117
|
success: true,
|
|
65
|
-
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
118
|
+
message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ') || '(none)'}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
|
|
66
119
|
};
|
|
67
120
|
} catch (error) {
|
|
68
121
|
return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* second tool.
|
|
7
7
|
*
|
|
8
8
|
* Subcommands:
|
|
9
|
-
* status bus.
|
|
9
|
+
* status bus.healthConfirmed() as JSON
|
|
10
10
|
* tail recent events
|
|
11
11
|
* list-subscribers persistent subscribers
|
|
12
12
|
* list-pending pending deliveries (subscriber fan-out — NOT questions)
|
|
@@ -80,7 +80,10 @@ function jsonResult(data: unknown): CommandResult {
|
|
|
80
80
|
export async function handleEventsStatus(): Promise<CommandResult> {
|
|
81
81
|
const bus = openCliBus();
|
|
82
82
|
try {
|
|
83
|
-
|
|
83
|
+
// Confirmed: the ansible task and the fleet check both read this verdict and
|
|
84
|
+
// act on it, so it must not say `wedged` about a dispatcher the reconcile path
|
|
85
|
+
// calls `lagging` (celilo#1399). One threshold, one answer.
|
|
86
|
+
return jsonResult(await bus.healthConfirmed());
|
|
84
87
|
} finally {
|
|
85
88
|
bus.close();
|
|
86
89
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine Reclassify Command
|
|
3
|
+
*
|
|
4
|
+
* Re-runs zone detection over a machine's STORED interface labels and reports
|
|
5
|
+
* old → new. Dry-run by default: an operator sees every relabel before any of
|
|
6
|
+
* them is written. `--apply` writes.
|
|
7
|
+
*
|
|
8
|
+
* See services/machine-interface-zones.ts for why stale labels happen.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { getDb } from '../../db/client';
|
|
13
|
+
import { machines } from '../../db/schema';
|
|
14
|
+
import { applyReclassification, diffInterfaceZones } from '../../services/machine-interface-zones';
|
|
15
|
+
import { getMachineByHostname, getMachineByIp } from '../../services/machine-pool';
|
|
16
|
+
import { detectZoneFromIp } from '../../services/zone-detector';
|
|
17
|
+
import { celiloIntro, celiloOutro } from '../prompts';
|
|
18
|
+
import type { CommandResult } from '../types';
|
|
19
|
+
|
|
20
|
+
const USAGE =
|
|
21
|
+
'Usage:\n celilo machine reclassify <hostname|ip> # show what would change\n celilo machine reclassify <hostname|ip> --apply # write the new labels';
|
|
22
|
+
|
|
23
|
+
export async function handleMachineReclassify(
|
|
24
|
+
args: string[],
|
|
25
|
+
flags: Record<string, boolean | string> = {},
|
|
26
|
+
): Promise<CommandResult> {
|
|
27
|
+
try {
|
|
28
|
+
celiloIntro('Re-classify Machine Interfaces');
|
|
29
|
+
|
|
30
|
+
const identifier = args[0];
|
|
31
|
+
if (!identifier) {
|
|
32
|
+
return { success: false, error: `Machine hostname or IP is required\n\n${USAGE}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(identifier);
|
|
36
|
+
const machine = isIp
|
|
37
|
+
? await getMachineByIp(identifier)
|
|
38
|
+
: await getMachineByHostname(identifier);
|
|
39
|
+
|
|
40
|
+
if (!machine) {
|
|
41
|
+
return { success: false, error: `Machine not found: ${identifier}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const interfaces = machine.interfaces ?? [];
|
|
45
|
+
const changes = await diffInterfaceZones(interfaces, detectZoneFromIp);
|
|
46
|
+
|
|
47
|
+
if (changes.length === 0) {
|
|
48
|
+
celiloOutro(
|
|
49
|
+
`${machine.hostname}: all ${interfaces.length} interface zone label(s) already match the current zone model`,
|
|
50
|
+
);
|
|
51
|
+
return { success: true, message: `${machine.hostname}: no re-classification needed` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lines = changes.map((c) => ` ${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`);
|
|
55
|
+
const summary = `${machine.hostname}: ${changes.length} interface(s) would be re-classified\n${lines.join('\n')}`;
|
|
56
|
+
|
|
57
|
+
if (!flags.apply) {
|
|
58
|
+
celiloOutro(`${summary}\n\nRe-run with --apply to write these labels.`);
|
|
59
|
+
return { success: true, message: summary };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getDb()
|
|
63
|
+
.update(machines)
|
|
64
|
+
.set({ interfaces: applyReclassification(interfaces, changes) })
|
|
65
|
+
.where(eq(machines.id, machine.id))
|
|
66
|
+
.run();
|
|
67
|
+
|
|
68
|
+
celiloOutro(`${summary}\n\nWritten.`);
|
|
69
|
+
return {
|
|
70
|
+
success: true,
|
|
71
|
+
message: `${machine.hostname}: re-classified ${changes.length} interface(s)`,
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: `Failed to re-classify machine interfaces: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -10,7 +10,7 @@ beforeEach(async () => {
|
|
|
10
10
|
dir = await mkdtemp(join(tmpdir(), 'celilo-cs-'));
|
|
11
11
|
await writeFile(
|
|
12
12
|
join(dir, 'manifest.yml'),
|
|
13
|
-
'celilo_contract: "
|
|
13
|
+
'celilo_contract: "2.0"\nid: lunacycle\nversion: 1.0.0\n',
|
|
14
14
|
);
|
|
15
15
|
});
|
|
16
16
|
afterEach(async () => {
|
|
@@ -9,7 +9,7 @@ const DECLARED = join('ansible', 'roles', 'fixture', 'files', 'binary');
|
|
|
9
9
|
|
|
10
10
|
function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
|
|
11
11
|
return {
|
|
12
|
-
celilo_contract: '
|
|
12
|
+
celilo_contract: '2.0',
|
|
13
13
|
id: 'fixture',
|
|
14
14
|
name: 'Fixture',
|
|
15
15
|
version: '1.0.0',
|
|
@@ -42,7 +42,7 @@ describe('missingBuildOutputs', () => {
|
|
|
42
42
|
test('reports nothing for a module with no build block', () => {
|
|
43
43
|
expect(
|
|
44
44
|
missingBuildOutputs(payload, {
|
|
45
|
-
celilo_contract: '
|
|
45
|
+
celilo_contract: '2.0',
|
|
46
46
|
id: 'fixture',
|
|
47
47
|
name: 'Fixture',
|
|
48
48
|
version: '1.0.0',
|
|
@@ -32,7 +32,7 @@ function insertAspectModule(id: string, version: string): void {
|
|
|
32
32
|
id,
|
|
33
33
|
name: id,
|
|
34
34
|
version,
|
|
35
|
-
celilo_contract: '
|
|
35
|
+
celilo_contract: '2.0',
|
|
36
36
|
base_module_aspect: aspect,
|
|
37
37
|
},
|
|
38
38
|
sourcePath: `/tmp/${id}`,
|
|
@@ -102,7 +102,7 @@ describe('handleAspectApprovalAfterImport (non-interactive)', () => {
|
|
|
102
102
|
id: 'caddy',
|
|
103
103
|
name: 'caddy',
|
|
104
104
|
version: '1.0.0',
|
|
105
|
-
manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '
|
|
105
|
+
manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '2.0' },
|
|
106
106
|
sourcePath: '/tmp/caddy',
|
|
107
107
|
})
|
|
108
108
|
.run();
|
|
@@ -95,7 +95,7 @@ describe('updateOne — displayVersion and quiet', () => {
|
|
|
95
95
|
mkdirSync(srcDir, { recursive: true });
|
|
96
96
|
writeFileSync(
|
|
97
97
|
join(srcDir, 'manifest.yml'),
|
|
98
|
-
`celilo_contract: "
|
|
98
|
+
`celilo_contract: "2.0"
|
|
99
99
|
id: testmod
|
|
100
100
|
name: Test Module
|
|
101
101
|
version: 1.0.0
|
|
@@ -117,7 +117,7 @@ description: fixture
|
|
|
117
117
|
version: '1.0.0+5',
|
|
118
118
|
updatedAt: new Date('2026-04-25T16:26:31Z'),
|
|
119
119
|
manifestData: {
|
|
120
|
-
celilo_contract: '
|
|
120
|
+
celilo_contract: '2.0',
|
|
121
121
|
id: 'testmod',
|
|
122
122
|
name: 'Test Module',
|
|
123
123
|
version: '1.0.0',
|
|
@@ -195,7 +195,7 @@ description: fixture
|
|
|
195
195
|
// row's manifestData should reflect the removal.
|
|
196
196
|
writeFileSync(
|
|
197
197
|
join(srcDir, 'manifest.yml'),
|
|
198
|
-
`celilo_contract: "
|
|
198
|
+
`celilo_contract: "2.0"
|
|
199
199
|
id: testmod
|
|
200
200
|
name: Test Module Renamed
|
|
201
201
|
version: 2.0.0
|
|
@@ -210,7 +210,7 @@ variables:
|
|
|
210
210
|
db.update(modules)
|
|
211
211
|
.set({
|
|
212
212
|
manifestData: {
|
|
213
|
-
celilo_contract: '
|
|
213
|
+
celilo_contract: '2.0',
|
|
214
214
|
id: 'testmod',
|
|
215
215
|
name: 'Test Module',
|
|
216
216
|
version: '1.0.0',
|
|
@@ -249,7 +249,7 @@ variables:
|
|
|
249
249
|
try {
|
|
250
250
|
writeFileSync(
|
|
251
251
|
join(srcDir, 'manifest.yml'),
|
|
252
|
-
`celilo_contract: "
|
|
252
|
+
`celilo_contract: "2.0"
|
|
253
253
|
id: testmod
|
|
254
254
|
name: Test Module
|
|
255
255
|
version: 1.1.0
|
|
@@ -302,7 +302,7 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
|
|
|
302
302
|
sourcePath: join(tempDir, 'installed'),
|
|
303
303
|
version: '1.0.2+9',
|
|
304
304
|
manifestData: {
|
|
305
|
-
celilo_contract: '
|
|
305
|
+
celilo_contract: '2.0',
|
|
306
306
|
id: 'iptables',
|
|
307
307
|
name: 'iptables',
|
|
308
308
|
version: '1.0.2',
|
|
@@ -376,7 +376,7 @@ describe("updateOne — pointed at the module's own installed path", () => {
|
|
|
376
376
|
mkdirSync(installedDir, { recursive: true });
|
|
377
377
|
writeFileSync(
|
|
378
378
|
join(installedDir, 'manifest.yml'),
|
|
379
|
-
`celilo_contract: "
|
|
379
|
+
`celilo_contract: "2.0"
|
|
380
380
|
id: selfmod
|
|
381
381
|
name: Self Module
|
|
382
382
|
version: 1.0.0
|
|
@@ -392,7 +392,7 @@ description: fixture
|
|
|
392
392
|
sourcePath: installedDir,
|
|
393
393
|
version: '1.0.0',
|
|
394
394
|
manifestData: {
|
|
395
|
-
celilo_contract: '
|
|
395
|
+
celilo_contract: '2.0',
|
|
396
396
|
id: 'selfmod',
|
|
397
397
|
name: 'Self Module',
|
|
398
398
|
version: '1.0.0',
|
|
@@ -480,7 +480,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
480
480
|
mkdirSync(join(installedDir, 'generated'), { recursive: true });
|
|
481
481
|
writeFileSync(
|
|
482
482
|
join(installedDir, 'manifest.yml'),
|
|
483
|
-
'celilo_contract: "
|
|
483
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
484
484
|
);
|
|
485
485
|
writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n');
|
|
486
486
|
// `derived` — celilo's own output, must survive an update either way.
|
|
@@ -490,7 +490,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
490
490
|
mkdirSync(join(srcDir, 'scripts'), { recursive: true });
|
|
491
491
|
writeFileSync(
|
|
492
492
|
join(srcDir, 'manifest.yml'),
|
|
493
|
-
'celilo_contract: "
|
|
493
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
|
|
494
494
|
);
|
|
495
495
|
writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n');
|
|
496
496
|
// cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first.
|
|
@@ -504,7 +504,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
504
504
|
sourcePath: installedDir,
|
|
505
505
|
version: '1.0.0',
|
|
506
506
|
manifestData: {
|
|
507
|
-
celilo_contract: '
|
|
507
|
+
celilo_contract: '2.0',
|
|
508
508
|
id: 'testmod',
|
|
509
509
|
name: 'Test Module',
|
|
510
510
|
version: '1.0.0',
|
|
@@ -588,7 +588,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
588
588
|
mkdirSync(installedDir, { recursive: true });
|
|
589
589
|
writeFileSync(
|
|
590
590
|
join(installedDir, 'manifest.yml'),
|
|
591
|
-
'celilo_contract: "
|
|
591
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
592
592
|
);
|
|
593
593
|
|
|
594
594
|
db = getDb();
|
|
@@ -599,7 +599,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
599
599
|
sourcePath: installedDir,
|
|
600
600
|
version: '1.0.0+5',
|
|
601
601
|
manifestData: {
|
|
602
|
-
celilo_contract: '
|
|
602
|
+
celilo_contract: '2.0',
|
|
603
603
|
id: 'testmod',
|
|
604
604
|
name: 'Test Module',
|
|
605
605
|
version: '1.0.0',
|
|
@@ -626,7 +626,7 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
626
626
|
mkdirSync(srcDir, { recursive: true });
|
|
627
627
|
writeFileSync(
|
|
628
628
|
join(srcDir, 'manifest.yml'),
|
|
629
|
-
'celilo_contract: "
|
|
629
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
|
|
630
630
|
);
|
|
631
631
|
const pkgPath = join(tempDir, 'testmod-2.0.0.netapp');
|
|
632
632
|
await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
|
|
@@ -45,7 +45,7 @@ async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const MANIFEST = (publicWebVersion: string) => `
|
|
48
|
-
celilo_contract: "
|
|
48
|
+
celilo_contract: "2.0"
|
|
49
49
|
id: celilo-website
|
|
50
50
|
name: Celilo Website
|
|
51
51
|
version: 2.0.0
|
|
@@ -152,3 +152,28 @@ describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
|
|
|
152
152
|
}
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
describe('handleModuleUpgrade — provider AHEAD is terminal (celilo#1414)', () => {
|
|
157
|
+
test('fails, does not defer, when every blocker is major_mismatch_lower', async () => {
|
|
158
|
+
// Consumer requires public_web@3.1.0; the deployed caddy serves 4.0.0.
|
|
159
|
+
// No retry can fix that — the consumer's source has to move.
|
|
160
|
+
seedFleet('4.0.0');
|
|
161
|
+
downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.1.0')));
|
|
162
|
+
|
|
163
|
+
const result = await handleModuleUpgrade(['celilo-website'], {});
|
|
164
|
+
|
|
165
|
+
expect(result.success).toBe(false);
|
|
166
|
+
if (!result.success) {
|
|
167
|
+
expect(result.deferred).toBeUndefined();
|
|
168
|
+
expect(result.error).not.toContain('retries automatically');
|
|
169
|
+
expect(result.error).toContain('public_web');
|
|
170
|
+
}
|
|
171
|
+
// Still no state change: a terminal blocker must not record the version.
|
|
172
|
+
const row = getDb()
|
|
173
|
+
.select()
|
|
174
|
+
.from(modules)
|
|
175
|
+
.all()
|
|
176
|
+
.find((m) => m.id === 'celilo-website');
|
|
177
|
+
expect(row?.version).toBe('1.0.5');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -180,7 +180,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
|
|
|
180
180
|
mkdirSync(installedDir, { recursive: true });
|
|
181
181
|
writeFileSync(
|
|
182
182
|
join(installedDir, 'manifest.yml'),
|
|
183
|
-
'celilo_contract: "
|
|
183
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
184
184
|
);
|
|
185
185
|
|
|
186
186
|
db = getDb();
|
|
@@ -193,7 +193,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
|
|
|
193
193
|
state: 'VERIFIED',
|
|
194
194
|
updatedAt: FROZEN,
|
|
195
195
|
manifestData: {
|
|
196
|
-
celilo_contract: '
|
|
196
|
+
celilo_contract: '2.0',
|
|
197
197
|
id: 'testmod',
|
|
198
198
|
name: 'Test Module',
|
|
199
199
|
version: '1.0.0',
|
|
@@ -208,7 +208,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
|
|
|
208
208
|
mkdirSync(srcDir, { recursive: true });
|
|
209
209
|
writeFileSync(
|
|
210
210
|
join(srcDir, 'manifest.yml'),
|
|
211
|
-
'celilo_contract: "
|
|
211
|
+
'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
|
|
212
212
|
);
|
|
213
213
|
const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
|
|
214
214
|
await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
|
|
@@ -194,6 +194,18 @@ export async function upgradeOneModule(
|
|
|
194
194
|
const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
|
|
195
195
|
if (blockers.length > 0) {
|
|
196
196
|
const why = blockers.map((b) => b.message).join('; ');
|
|
197
|
+
// A provider AHEAD of what the consumer requires is TERMINAL (celilo#1414):
|
|
198
|
+
// no retry can fix it, the consumer's source must move to the provider's
|
|
199
|
+
// current major and republish. Only a provider BEHIND (major_mismatch_higher,
|
|
200
|
+
// the celilo#1361 case) is transient. Deferring the terminal case retried it
|
|
201
|
+
// forever, reported success, and left four consumers silently stuck.
|
|
202
|
+
if (blockers.every((b) => b.reason === 'major_mismatch_lower')) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: `${moduleId} upgrade to ${targetVersion} cannot proceed: the deployed providers are AHEAD of its capability requirements: ${why}. This will not resolve on retry — update ${moduleId}'s source to the provider's current major and republish it.`,
|
|
206
|
+
details: blockers,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
197
209
|
return {
|
|
198
210
|
success: false,
|
|
199
211
|
deferred: true,
|
|
@@ -26,6 +26,7 @@ import { testProxmoxConnection } from '../../api-clients/proxmox';
|
|
|
26
26
|
import { getDb } from '../../db/client';
|
|
27
27
|
import {
|
|
28
28
|
capabilitySecrets,
|
|
29
|
+
machines,
|
|
29
30
|
moduleConfigs as moduleConfigsTbl,
|
|
30
31
|
modules,
|
|
31
32
|
secrets,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
} from '../../services/audit/public-dns-source';
|
|
53
54
|
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
54
55
|
import type { SecretCheckResult } from '../../services/audit/secrets-decryptable';
|
|
56
|
+
import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
|
|
55
57
|
import type { ServiceCredentialsResult } from '../../services/audit/services-credentials';
|
|
56
58
|
import type { ServiceReachableResult } from '../../services/audit/services-reachable';
|
|
57
59
|
import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
@@ -63,6 +65,7 @@ import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
|
63
65
|
import { probeMachines } from '../../services/machine-probe';
|
|
64
66
|
import { parseStoredConfigValue } from '../../services/module-config';
|
|
65
67
|
import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
|
|
68
|
+
import { collectRebootPendingDeps } from '../../services/system-reboot';
|
|
66
69
|
import { buildTerraformEnvForModule } from '../../services/terraform-env';
|
|
67
70
|
import { hasFlag } from '../parser';
|
|
68
71
|
import type { CommandResult } from '../types';
|
|
@@ -368,6 +371,22 @@ async function buildAuditDeps(
|
|
|
368
371
|
probeDiskUsage(db),
|
|
369
372
|
]);
|
|
370
373
|
|
|
374
|
+
// Stale interface zone labels. Reads only what is already stored — the
|
|
375
|
+
// detector matches an address against the declared subnets, so this costs
|
|
376
|
+
// no SSH and cannot be confused by an unreachable machine.
|
|
377
|
+
const { diffInterfaceZones } = await import('../../services/machine-interface-zones');
|
|
378
|
+
const { detectZoneFromIp } = await import('../../services/zone-detector');
|
|
379
|
+
const machineZoneLabels = await Promise.all(
|
|
380
|
+
db
|
|
381
|
+
.select()
|
|
382
|
+
.from(machines)
|
|
383
|
+
.all()
|
|
384
|
+
.map(async (m) => ({
|
|
385
|
+
hostname: m.hostname,
|
|
386
|
+
changes: await diffInterfaceZones(m.interfaces, detectZoneFromIp),
|
|
387
|
+
})),
|
|
388
|
+
);
|
|
389
|
+
|
|
371
390
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
372
391
|
|
|
373
392
|
return {
|
|
@@ -388,6 +407,8 @@ async function buildAuditDeps(
|
|
|
388
407
|
})),
|
|
389
408
|
},
|
|
390
409
|
browserPin: collectBrowserPinDeps(deployedModules),
|
|
410
|
+
serverBunPin: collectServerBunPinDeps(),
|
|
411
|
+
rebootPending: collectRebootPendingDeps(),
|
|
391
412
|
terraformPlan: {
|
|
392
413
|
modules: terraformModules,
|
|
393
414
|
run: realTerraformPlan,
|
|
@@ -429,6 +450,7 @@ async function buildAuditDeps(
|
|
|
429
450
|
secretsDecryptable: { results: secretResults },
|
|
430
451
|
servicesReachable: { results: serviceReachableResults },
|
|
431
452
|
machinesReachable: { results: machineReachableResults },
|
|
453
|
+
machineInterfaceZones: { machines: machineZoneLabels },
|
|
432
454
|
// The only check here whose vantage point is OUTSIDE the fleet. Its
|
|
433
455
|
// undetermined counters are read but not written from this path: an
|
|
434
456
|
// operator-run audit is not a run of a schedule, so counting it towards
|