@celilo/cli 2.2.0 → 2.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 +1 -0
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/schemas/system_config.json +5 -0
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +5 -4
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +5 -4
- package/src/cli/cli.test.ts +3 -2
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +4 -3
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-config.test.ts +2 -1
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +3 -2
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +11 -18
- package/src/cli/commands/module-update.test.ts +28 -12
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +130 -1
- package/src/cli/commands/module-upgrade.ts +77 -3
- package/src/cli/commands/module-verify.test.ts +3 -4
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/index.ts +11 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.ts +1 -1
- package/src/cli/commands/publish/types.ts +7 -0
- package/src/cli/commands/registry-owner.test.ts +3 -2
- package/src/cli/commands/registry-token.test.ts +3 -2
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +143 -6
- package/src/cli/commands/system-doctor.ts +142 -25
- package/src/cli/commands/system-init-deprecation.test.ts +7 -4
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +70 -3
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +5 -4
- package/src/cli/restore-migration-failure.test.ts +4 -3
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +22 -22
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/migrate.test.ts +2 -1
- package/src/db/migrate.ts +16 -16
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +45 -2
- package/src/hooks/executor.test.ts +161 -1
- package/src/hooks/executor.ts +87 -21
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-store.test.ts +6 -0
- package/src/hooks/hook-trespass.test.ts +26 -9
- package/src/hooks/jail.test.ts +152 -5
- package/src/hooks/jail.ts +93 -4
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +60 -0
- package/src/module/packaging/extract.ts +1 -5
- package/src/policy/module-script-scan.ts +51 -107
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -15
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/health.test.ts +97 -2
- package/src/services/audit/health.ts +64 -2
- package/src/services/audit/index.test.ts +22 -0
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -22
- package/src/services/audit/module-integrity.ts +7 -16
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +3 -2
- package/src/services/backup-in-flight-refusal.test.ts +3 -2
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- package/src/services/bus-ensure-flow.test.ts +3 -2
- package/src/services/bus-interview-park.test.ts +4 -3
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +3 -2
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +3 -2
- package/src/services/control-plane-bootstrap.test.ts +2 -1
- package/src/services/cross-module-read.test.ts +4 -3
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-validation.test.ts +3 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +126 -6
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/fleet-key.test.ts +3 -2
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +3 -2
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +3 -2
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-deploy.dns-repoint.test.ts +2 -1
- package/src/services/module-deploy.ts +48 -24
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +4 -3
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +7 -6
- package/src/services/restore-preflight.test.ts +3 -2
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +4 -3
- package/src/services/static-content-converge.test.ts +5 -4
- package/src/services/static-content-converge.ts +33 -10
- package/src/services/system-state-stage.test.ts +4 -3
- package/src/services/trusted-sources.test.ts +3 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/services/zone-detector.test.ts +2 -1
- package/src/test-utils/bus-responder.ts +6 -4
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `celilo module list`'s jail-exemption marker (per-module-jail-policy
|
|
3
|
+
* task 3.1/3.4).
|
|
4
|
+
*
|
|
5
|
+
* A module with a recorded policy weaker than the system's is marked in the
|
|
6
|
+
* human list and carries a `jailExemptions` roster entry in JSON. The negative
|
|
7
|
+
* half is asserted with the same rigour: no row, no marker, empty roster —
|
|
8
|
+
* a query bug that reported phantom exemptions would look exactly like a
|
|
9
|
+
* correctly empty one otherwise.
|
|
10
|
+
*
|
|
11
|
+
* Isolation mirrors module-jail.test.ts: CELILO_DB_PATH / CELILO_DATA_DIR are
|
|
12
|
+
* set before the SUT is imported.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { eq } from 'drizzle-orm';
|
|
20
|
+
|
|
21
|
+
const testRoot = mkdtempSync(join(tmpdir(), 'celilo-module-list-jail-'));
|
|
22
|
+
process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
|
|
23
|
+
process.env.CELILO_DATA_DIR = join(testRoot, 'data');
|
|
24
|
+
|
|
25
|
+
const { getDb, closeDb } = await import('../../db/client');
|
|
26
|
+
const { moduleJailPolicies, modules, systemConfig } = await import('../../db/schema');
|
|
27
|
+
const { handleModuleList } = await import('./module-list');
|
|
28
|
+
import type { CommandResult, CommandSuccess } from '../types';
|
|
29
|
+
|
|
30
|
+
/** Narrow the result union after asserting the branch, so `.message` typechecks. */
|
|
31
|
+
function expectOk(result: CommandResult): asserts result is CommandSuccess {
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function insertModule(id: string): void {
|
|
36
|
+
getDb()
|
|
37
|
+
.insert(modules)
|
|
38
|
+
.values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', manifestData: {} })
|
|
39
|
+
.run();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setModulePolicy(moduleId: string, policy: string): void {
|
|
43
|
+
const typed = policy as 'auto' | 'off' | 'required';
|
|
44
|
+
getDb()
|
|
45
|
+
.insert(moduleJailPolicies)
|
|
46
|
+
.values({ moduleId, policy: typed, updatedAt: new Date() })
|
|
47
|
+
.onConflictDoUpdate({
|
|
48
|
+
target: moduleJailPolicies.moduleId,
|
|
49
|
+
set: { policy: typed, updatedAt: new Date() },
|
|
50
|
+
})
|
|
51
|
+
.run();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function setSystemPolicy(value: string): void {
|
|
55
|
+
getDb()
|
|
56
|
+
.insert(systemConfig)
|
|
57
|
+
.values({ key: 'hooks.jail_policy', value })
|
|
58
|
+
.onConflictDoUpdate({ target: systemConfig.key, set: { value } })
|
|
59
|
+
.run();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearModulePolicy(moduleId: string): void {
|
|
63
|
+
getDb().delete(moduleJailPolicies).where(eq(moduleJailPolicies.moduleId, moduleId)).run();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
closeDb();
|
|
68
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('module list — jail exemption marker (per-module-jail-policy task 3.1)', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
const db = getDb();
|
|
74
|
+
db.delete(moduleJailPolicies).run();
|
|
75
|
+
db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
|
|
76
|
+
db.delete(modules).run();
|
|
77
|
+
insertModule('caddy');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a module weaker than the system is marked, with both policies', async () => {
|
|
81
|
+
setSystemPolicy('auto');
|
|
82
|
+
setModulePolicy('caddy', 'off');
|
|
83
|
+
const result = await handleModuleList({});
|
|
84
|
+
expectOk(result);
|
|
85
|
+
expect(result.message).toContain('caddy');
|
|
86
|
+
expect(result.message).toContain("[jail: off — weaker than the system's auto]");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a module with no per-module row carries no marker (the negative half of task 3.4)', async () => {
|
|
90
|
+
setSystemPolicy('auto');
|
|
91
|
+
const result = await handleModuleList({});
|
|
92
|
+
expectOk(result);
|
|
93
|
+
expect(result.message).not.toContain('jail:');
|
|
94
|
+
expect(result.message).not.toContain('weaker than');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a module whose row is not weaker than the system carries no marker', async () => {
|
|
98
|
+
setSystemPolicy('auto');
|
|
99
|
+
setModulePolicy('caddy', 'required');
|
|
100
|
+
const result = await handleModuleList({});
|
|
101
|
+
expectOk(result);
|
|
102
|
+
expect(result.message).not.toContain('jail:');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a cleared row stops the marker (the exemption is un-set, not hidden)', async () => {
|
|
106
|
+
setSystemPolicy('auto');
|
|
107
|
+
setModulePolicy('caddy', 'off');
|
|
108
|
+
const before = await handleModuleList({});
|
|
109
|
+
expectOk(before);
|
|
110
|
+
expect(before.message).toContain('[jail:');
|
|
111
|
+
clearModulePolicy('caddy');
|
|
112
|
+
const after = await handleModuleList({});
|
|
113
|
+
expectOk(after);
|
|
114
|
+
expect(after.message).not.toContain('[jail:');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the JSON roster carries the exemptions', async () => {
|
|
118
|
+
setSystemPolicy('auto');
|
|
119
|
+
setModulePolicy('caddy', 'off');
|
|
120
|
+
const result = await handleModuleList({ json: true });
|
|
121
|
+
expectOk(result);
|
|
122
|
+
const parsed = JSON.parse(result.message) as {
|
|
123
|
+
jailExemptions: Array<{ moduleId: string; policy: string; systemPolicy: string }>;
|
|
124
|
+
};
|
|
125
|
+
expect(parsed.jailExemptions).toEqual([
|
|
126
|
+
{ moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the JSON roster is empty when nothing is exempted', async () => {
|
|
131
|
+
const result = await handleModuleList({ json: true });
|
|
132
|
+
expectOk(result);
|
|
133
|
+
const parsed = JSON.parse(result.message) as { jailExemptions: unknown[] };
|
|
134
|
+
expect(parsed.jailExemptions).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDb } from '../../db/client';
|
|
2
2
|
import { modules } from '../../db/schema';
|
|
3
3
|
import { loadObservedHealth } from '../../services/alerting/observed-health';
|
|
4
|
+
import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
|
|
4
5
|
import { formatPausedDuration } from '../../services/module-pause';
|
|
5
6
|
import { hasFlag } from '../parser';
|
|
6
7
|
import type { CommandResult } from '../types';
|
|
@@ -20,14 +21,29 @@ export async function handleModuleList(
|
|
|
20
21
|
// Query all modules
|
|
21
22
|
const moduleRows = db.select().from(modules).all();
|
|
22
23
|
|
|
24
|
+
// Per-module jail exemptions (per-module-jail-policy task 3.1), from the
|
|
25
|
+
// shared collector so list, doctor and audit report the same set. A bad
|
|
26
|
+
// stored row throws here rather than silently listing unmarked — the same
|
|
27
|
+
// row will fail that module's hooks at execution time.
|
|
28
|
+
let jailExemptions: JailExemption[];
|
|
29
|
+
try {
|
|
30
|
+
jailExemptions = collectJailExemptions(db);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
error: error instanceof Error ? error.message : String(error),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const exemptedById = new Map(jailExemptions.map((e) => [e.moduleId, e]));
|
|
38
|
+
|
|
23
39
|
// Stable machine-readable roster — the backbone the MCP composite
|
|
24
40
|
// troubleshooting tools correlate audit findings against (ce-77i.5).
|
|
25
41
|
if (hasFlag(flags, 'json')) {
|
|
26
42
|
return {
|
|
27
43
|
success: true,
|
|
28
|
-
message: JSON.stringify({ modules: moduleRows }, null, 2),
|
|
44
|
+
message: JSON.stringify({ modules: moduleRows, jailExemptions }, null, 2),
|
|
29
45
|
rawOutput: true,
|
|
30
|
-
data: moduleRows,
|
|
46
|
+
data: { modules: moduleRows, jailExemptions },
|
|
31
47
|
};
|
|
32
48
|
}
|
|
33
49
|
|
|
@@ -56,7 +72,9 @@ export async function handleModuleList(
|
|
|
56
72
|
module.state === 'PAUSED'
|
|
57
73
|
? `PAUSED (${formatPausedDuration(module.pausedAt)})`
|
|
58
74
|
: module.state;
|
|
59
|
-
lines.push(
|
|
75
|
+
lines.push(
|
|
76
|
+
`${module.id} (v${module.version}) - ${stateCell}${healthNote}${jailNote(exemptedById.get(module.id))}`,
|
|
77
|
+
);
|
|
60
78
|
if (module.state === 'PAUSED' && module.pauseReason) {
|
|
61
79
|
lines.push(` Paused: ${module.pauseReason}`);
|
|
62
80
|
}
|
|
@@ -75,3 +93,12 @@ export async function handleModuleList(
|
|
|
75
93
|
data: moduleRows,
|
|
76
94
|
};
|
|
77
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The list marker for an exempted module, or '' when the module has no row
|
|
99
|
+
* (per-module-jail-policy task 3.4: no row, no output).
|
|
100
|
+
*/
|
|
101
|
+
function jailNote(exemption: JailExemption | undefined): string {
|
|
102
|
+
if (!exemption) return '';
|
|
103
|
+
return ` [jail: ${exemption.policy} — weaker than the system's ${exemption.systemPolicy}]`;
|
|
104
|
+
}
|
|
@@ -7,6 +7,7 @@ import { closeDb, getDb } from '../../db/client';
|
|
|
7
7
|
import { runMigrations } from '../../db/migrate';
|
|
8
8
|
import { moduleOperations } from '../../db/schema';
|
|
9
9
|
import { ABANDONED_RELEASE_MESSAGE, OPERATION_TTL_MS } from '../../services/module-operations';
|
|
10
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
10
11
|
import { handleModuleOperations } from './module-operations';
|
|
11
12
|
|
|
12
13
|
describe('celilo module operations', () => {
|
|
@@ -20,7 +21,7 @@ describe('celilo module operations', () => {
|
|
|
20
21
|
|
|
21
22
|
afterEach(() => {
|
|
22
23
|
closeDb();
|
|
23
|
-
|
|
24
|
+
resetTestDbPath();
|
|
24
25
|
rmSync(dir, { recursive: true, force: true });
|
|
25
26
|
});
|
|
26
27
|
|
|
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
15
15
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import type { IndexEntry } from '../../registry/client';
|
|
18
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
18
19
|
import { handleModulePublish, resolveToken } from './module-publish';
|
|
19
20
|
|
|
20
21
|
const TEST_DIR = `/tmp/test-module-publish-${Date.now()}`;
|
|
@@ -41,9 +42,8 @@ describe('handleModulePublish — validation', () => {
|
|
|
41
42
|
// fallback in resolveToken() may read a real publish token and the test
|
|
42
43
|
// will skip the validation path it's trying to exercise.
|
|
43
44
|
const origToken = process.env.CELILO_PUBLISH_TOKEN;
|
|
44
|
-
const origDbPath = process.env.CELILO_DB_PATH;
|
|
45
45
|
const origDataDir = process.env.CELILO_DATA_DIR;
|
|
46
|
-
process.env.CELILO_PUBLISH_TOKEN
|
|
46
|
+
delete process.env.CELILO_PUBLISH_TOKEN;
|
|
47
47
|
process.env.CELILO_DB_PATH = `/tmp/test-celilo-no-token-${Date.now()}.db`;
|
|
48
48
|
process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-${Date.now()}`;
|
|
49
49
|
try {
|
|
@@ -52,10 +52,9 @@ describe('handleModulePublish — validation', () => {
|
|
|
52
52
|
if (!result.success) expect(result.error).toContain('Publish token required');
|
|
53
53
|
} finally {
|
|
54
54
|
if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken;
|
|
55
|
-
|
|
56
|
-
else process.env.CELILO_DB_PATH = undefined;
|
|
55
|
+
resetTestDbPath();
|
|
57
56
|
if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
|
|
58
|
-
else process.env.CELILO_DATA_DIR
|
|
57
|
+
else delete process.env.CELILO_DATA_DIR;
|
|
59
58
|
}
|
|
60
59
|
});
|
|
61
60
|
|
|
@@ -163,7 +162,6 @@ describe('revision auto-detection algorithm', () => {
|
|
|
163
162
|
describe('resolveToken — precedence', () => {
|
|
164
163
|
test('--token flag wins over env var and secret store', async () => {
|
|
165
164
|
const origEnv = process.env.CELILO_PUBLISH_TOKEN;
|
|
166
|
-
const origDbPath = process.env.CELILO_DB_PATH;
|
|
167
165
|
process.env.CELILO_PUBLISH_TOKEN = 'env-token';
|
|
168
166
|
process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`;
|
|
169
167
|
try {
|
|
@@ -171,15 +169,13 @@ describe('resolveToken — precedence', () => {
|
|
|
171
169
|
expect(t).toBe('flag-token');
|
|
172
170
|
} finally {
|
|
173
171
|
if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
|
|
174
|
-
else process.env.CELILO_PUBLISH_TOKEN
|
|
175
|
-
|
|
176
|
-
else process.env.CELILO_DB_PATH = undefined;
|
|
172
|
+
else delete process.env.CELILO_PUBLISH_TOKEN;
|
|
173
|
+
resetTestDbPath();
|
|
177
174
|
}
|
|
178
175
|
});
|
|
179
176
|
|
|
180
177
|
test('env var used when --token flag is empty', async () => {
|
|
181
178
|
const origEnv = process.env.CELILO_PUBLISH_TOKEN;
|
|
182
|
-
const origDbPath = process.env.CELILO_DB_PATH;
|
|
183
179
|
process.env.CELILO_PUBLISH_TOKEN = 'env-token';
|
|
184
180
|
process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`;
|
|
185
181
|
try {
|
|
@@ -187,9 +183,8 @@ describe('resolveToken — precedence', () => {
|
|
|
187
183
|
expect(t).toBe('env-token');
|
|
188
184
|
} finally {
|
|
189
185
|
if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
|
|
190
|
-
else process.env.CELILO_PUBLISH_TOKEN
|
|
191
|
-
|
|
192
|
-
else process.env.CELILO_DB_PATH = undefined;
|
|
186
|
+
else delete process.env.CELILO_PUBLISH_TOKEN;
|
|
187
|
+
resetTestDbPath();
|
|
193
188
|
}
|
|
194
189
|
});
|
|
195
190
|
|
|
@@ -197,9 +192,8 @@ describe('resolveToken — precedence', () => {
|
|
|
197
192
|
// All three sources empty: secret-store fallback hits an isolated empty
|
|
198
193
|
// DB and returns null gracefully.
|
|
199
194
|
const origEnv = process.env.CELILO_PUBLISH_TOKEN;
|
|
200
|
-
const origDbPath = process.env.CELILO_DB_PATH;
|
|
201
195
|
const origDataDir = process.env.CELILO_DATA_DIR;
|
|
202
|
-
process.env.CELILO_PUBLISH_TOKEN
|
|
196
|
+
delete process.env.CELILO_PUBLISH_TOKEN;
|
|
203
197
|
process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`;
|
|
204
198
|
process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-empty-${Date.now()}`;
|
|
205
199
|
try {
|
|
@@ -207,10 +201,9 @@ describe('resolveToken — precedence', () => {
|
|
|
207
201
|
expect(t).toBe('');
|
|
208
202
|
} finally {
|
|
209
203
|
if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
|
|
210
|
-
|
|
211
|
-
else process.env.CELILO_DB_PATH = undefined;
|
|
204
|
+
resetTestDbPath();
|
|
212
205
|
if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
|
|
213
|
-
else process.env.CELILO_DATA_DIR
|
|
206
|
+
else delete process.env.CELILO_DATA_DIR;
|
|
214
207
|
}
|
|
215
208
|
});
|
|
216
209
|
});
|
|
@@ -21,6 +21,7 @@ import { eq } from 'drizzle-orm';
|
|
|
21
21
|
import { create as tarCreate } from 'tar';
|
|
22
22
|
import { type DbClient, getDb } from '../../db/client';
|
|
23
23
|
import { moduleIntegrity, modules } from '../../db/schema';
|
|
24
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
24
25
|
import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
|
|
25
26
|
|
|
26
27
|
describe('classifyVersionChange', () => {
|
|
@@ -106,12 +107,15 @@ description: fixture
|
|
|
106
107
|
// Seed the modules table with the "currently installed" record.
|
|
107
108
|
// We deliberately put a registry-style version string in here so
|
|
108
109
|
// displayVersion-less upgrades preserve the previousVersion field.
|
|
110
|
+
// celilo#1363: updatedAt is seeded frozen so the success assertions can
|
|
111
|
+
// prove the stamp actually happens whenever the version moves.
|
|
109
112
|
db.insert(modules)
|
|
110
113
|
.values({
|
|
111
114
|
id: 'testmod',
|
|
112
115
|
name: 'Test Module',
|
|
113
116
|
sourcePath: installedDir,
|
|
114
117
|
version: '1.0.0+5',
|
|
118
|
+
updatedAt: new Date('2026-04-25T16:26:31Z'),
|
|
115
119
|
manifestData: {
|
|
116
120
|
celilo_contract: '1.0',
|
|
117
121
|
id: 'testmod',
|
|
@@ -124,8 +128,8 @@ description: fixture
|
|
|
124
128
|
|
|
125
129
|
afterEach(() => {
|
|
126
130
|
rmSync(tempDir, { recursive: true, force: true });
|
|
127
|
-
|
|
128
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
131
|
+
resetTestDbPath();
|
|
132
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
129
133
|
});
|
|
130
134
|
|
|
131
135
|
test('returns previousVersion + newVersion in the success outcome', async () => {
|
|
@@ -152,6 +156,18 @@ description: fixture
|
|
|
152
156
|
expect(row.version).toBe('1.0.0+6');
|
|
153
157
|
});
|
|
154
158
|
|
|
159
|
+
// celilo#1363: the version moved (1.0.5+5 → +6) while updatedAt stayed at
|
|
160
|
+
// 2026-04-25, so the audit read the module as untouched and fine. The stamp
|
|
161
|
+
// must happen in updateOne itself, where every version write lives.
|
|
162
|
+
test('writing a new version stamps updatedAt', async () => {
|
|
163
|
+
const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
|
|
164
|
+
expect(result.status).toBe('success');
|
|
165
|
+
|
|
166
|
+
const row = db.select().from(modules).all()[0];
|
|
167
|
+
expect(row.version).toBe('1.0.0+6');
|
|
168
|
+
expect(row.updatedAt?.getTime()).toBeGreaterThan(new Date('2026-04-25T16:26:31Z').getTime());
|
|
169
|
+
});
|
|
170
|
+
|
|
155
171
|
test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
|
|
156
172
|
// We can't easily intercept the log helpers' output without adding test
|
|
157
173
|
// hooks, so instead we exercise that the call simply succeeds and
|
|
@@ -256,7 +272,7 @@ subscriptions:
|
|
|
256
272
|
bus.close();
|
|
257
273
|
}
|
|
258
274
|
} finally {
|
|
259
|
-
process.env.EVENT_BUS_DB
|
|
275
|
+
delete process.env.EVENT_BUS_DB;
|
|
260
276
|
}
|
|
261
277
|
});
|
|
262
278
|
});
|
|
@@ -313,9 +329,9 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
|
|
|
313
329
|
afterEach(() => {
|
|
314
330
|
server.stop(true);
|
|
315
331
|
rmSync(tempDir, { recursive: true, force: true });
|
|
316
|
-
|
|
317
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
318
|
-
process.env.EVENT_BUS_DB
|
|
332
|
+
resetTestDbPath();
|
|
333
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
334
|
+
delete process.env.EVENT_BUS_DB;
|
|
319
335
|
});
|
|
320
336
|
|
|
321
337
|
test('reports it as unanswered, never as declined, and fails the sweep', async () => {
|
|
@@ -387,8 +403,8 @@ description: fixture
|
|
|
387
403
|
|
|
388
404
|
afterEach(() => {
|
|
389
405
|
rmSync(tempDir, { recursive: true, force: true });
|
|
390
|
-
|
|
391
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
406
|
+
resetTestDbPath();
|
|
407
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
392
408
|
});
|
|
393
409
|
|
|
394
410
|
test('refuses by name, and writes NOTHING', async () => {
|
|
@@ -499,8 +515,8 @@ describe('updateOne — an update that fails partway leaves the install untouche
|
|
|
499
515
|
|
|
500
516
|
afterEach(() => {
|
|
501
517
|
rmSync(tempDir, { recursive: true, force: true });
|
|
502
|
-
|
|
503
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
518
|
+
resetTestDbPath();
|
|
519
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
504
520
|
});
|
|
505
521
|
|
|
506
522
|
test('a failed update leaves the installed tree byte-identical', async () => {
|
|
@@ -594,8 +610,8 @@ describe('updateOne — a .netapp package source', () => {
|
|
|
594
610
|
|
|
595
611
|
afterEach(() => {
|
|
596
612
|
rmSync(tempDir, { recursive: true, force: true });
|
|
597
|
-
|
|
598
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
613
|
+
resetTestDbPath();
|
|
614
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
599
615
|
});
|
|
600
616
|
|
|
601
617
|
// ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and
|
|
@@ -475,12 +475,15 @@ export async function updateOne(
|
|
|
475
475
|
|
|
476
476
|
// Update manifest in database. We persist the display version (with
|
|
477
477
|
// +N when known) so subsequent `module list` / `module update` calls
|
|
478
|
-
// see the same version string the registry reported.
|
|
478
|
+
// see the same version string the registry reported. updatedAt moves with
|
|
479
|
+
// the version: a frozen timestamp beside a new version read as "nothing
|
|
480
|
+
// happened here" to the audit (celilo#1363).
|
|
479
481
|
db.update(modules)
|
|
480
482
|
.set({
|
|
481
483
|
manifestData: newManifest as unknown as Record<string, unknown>,
|
|
482
484
|
version: newVersion,
|
|
483
485
|
name: newManifest.name,
|
|
486
|
+
updatedAt: new Date(),
|
|
484
487
|
})
|
|
485
488
|
.where(eq(modules.id, moduleId))
|
|
486
489
|
.run();
|
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
2
|
+
import type { Mock } from 'bun:test';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
7
|
+
import { create as tarCreate } from 'tar';
|
|
8
|
+
import { type DbClient, getDb } from '../../db/client';
|
|
9
|
+
import { type ModuleState, modules } from '../../db/schema';
|
|
2
10
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
11
|
+
import { RegistryClient } from '../../registry/client';
|
|
12
|
+
import * as moduleDeploy from '../../services/module-deploy';
|
|
13
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
3
14
|
import {
|
|
4
15
|
type PollCandidate,
|
|
5
16
|
isPollInvocation,
|
|
6
17
|
needsPreUpgradeBackup,
|
|
18
|
+
needsSystemStateSnapshot,
|
|
7
19
|
pickAutoUpgrade,
|
|
8
20
|
pickUpgradePolicy,
|
|
9
21
|
selectPollTargets,
|
|
22
|
+
upgradeOneModule,
|
|
10
23
|
} from './module-upgrade';
|
|
11
24
|
|
|
12
25
|
/** Minimal manifest fixture; only `hooks` matters for the backup gate. */
|
|
@@ -69,6 +82,7 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
|
|
|
69
82
|
installed: '1.0.0',
|
|
70
83
|
latest: '1.0.1',
|
|
71
84
|
autoUpgrade: true,
|
|
85
|
+
inError: false,
|
|
72
86
|
...over,
|
|
73
87
|
});
|
|
74
88
|
|
|
@@ -91,6 +105,13 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
|
|
|
91
105
|
expect(selectPollTargets([base({ latest: null })])).toEqual([]);
|
|
92
106
|
});
|
|
93
107
|
|
|
108
|
+
// celilo#1363: the poll burned five attempts redeploying celilo-website
|
|
109
|
+
// against a failure that never went away. A module in ERROR stays skipped
|
|
110
|
+
// until an attended health check clears it.
|
|
111
|
+
test('skips modules in ERROR state (a failed upgrade deploy)', () => {
|
|
112
|
+
expect(selectPollTargets([base({ inError: true })])).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
94
115
|
test('picks only the eligible subset from a mixed set', () => {
|
|
95
116
|
const targets = selectPollTargets([
|
|
96
117
|
base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }),
|
|
@@ -102,6 +123,20 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
|
|
|
102
123
|
});
|
|
103
124
|
});
|
|
104
125
|
|
|
126
|
+
// A 0.x minor/major bump can migrate the celilo DB schema; the system_state
|
|
127
|
+
// snapshot is what makes a later `apt install celilo=<old>` (or dpkg -i from
|
|
128
|
+
// the apt cache) a real rollback instead of a gamble. Patch/revision bumps
|
|
129
|
+
// don't migrate, so they stay fast.
|
|
130
|
+
describe('needsSystemStateSnapshot (safe posture snapshots the celilo DB before mutating)', () => {
|
|
131
|
+
test('safe posture (minor/major) requires the snapshot', () => {
|
|
132
|
+
expect(needsSystemStateSnapshot('safe')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('fast posture (patch/revision) skips the snapshot', () => {
|
|
136
|
+
expect(needsSystemStateSnapshot('fast')).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
105
140
|
describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifest)', () => {
|
|
106
141
|
// The regression: the upgrade that FIRST adds on_backup must still back up.
|
|
107
142
|
// The installed version lacked the hook; the target (passed here) adds it.
|
|
@@ -121,3 +156,97 @@ describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifes
|
|
|
121
156
|
expect(needsPreUpgradeBackup('safe', manifest(undefined))).toBe(false);
|
|
122
157
|
});
|
|
123
158
|
});
|
|
159
|
+
|
|
160
|
+
// celilo#1363: fetchAndUpdate commits the new version to the store BEFORE the
|
|
161
|
+
// deploy runs, so a failed deploy left a new version recorded beside a stale
|
|
162
|
+
// VERIFIED state and a frozen updatedAt, and the audit read the module as
|
|
163
|
+
// fine. Option A: the row must say what happened — state ERROR with the
|
|
164
|
+
// error, and updatedAt moving whenever the version moves.
|
|
165
|
+
describe('upgradeOneModule — a failed deploy records the failure in the row (celilo#1363)', () => {
|
|
166
|
+
const FROZEN = new Date('2026-04-25T16:26:31Z');
|
|
167
|
+
let tempDir: string;
|
|
168
|
+
let installedDir: string;
|
|
169
|
+
let db: DbClient;
|
|
170
|
+
let getIndexSpy: Mock<(name: string) => Promise<unknown[]>>;
|
|
171
|
+
let downloadSpy: Mock<(name: string, vers: string, cksum?: string) => Promise<ArrayBuffer>>;
|
|
172
|
+
let deploySpy: Mock<typeof moduleDeploy.deployModule>;
|
|
173
|
+
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
|
|
176
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
177
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
178
|
+
|
|
179
|
+
installedDir = join(tempDir, 'installed', 'testmod');
|
|
180
|
+
mkdirSync(installedDir, { recursive: true });
|
|
181
|
+
writeFileSync(
|
|
182
|
+
join(installedDir, 'manifest.yml'),
|
|
183
|
+
'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
db = getDb();
|
|
187
|
+
db.insert(modules)
|
|
188
|
+
.values({
|
|
189
|
+
id: 'testmod',
|
|
190
|
+
name: 'Test Module',
|
|
191
|
+
sourcePath: installedDir,
|
|
192
|
+
version: '1.0.0+5',
|
|
193
|
+
state: 'VERIFIED',
|
|
194
|
+
updatedAt: FROZEN,
|
|
195
|
+
manifestData: {
|
|
196
|
+
celilo_contract: '1.0',
|
|
197
|
+
id: 'testmod',
|
|
198
|
+
name: 'Test Module',
|
|
199
|
+
version: '1.0.0',
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
.run();
|
|
203
|
+
|
|
204
|
+
// Build the registry package for a patch bump (1.0.0+5 → 1.0.0+6).
|
|
205
|
+
// Patch/revision → fast posture, so no snapshot and no backup are needed
|
|
206
|
+
// on the path to the deploy.
|
|
207
|
+
const srcDir = join(tempDir, 'pkg-src');
|
|
208
|
+
mkdirSync(srcDir, { recursive: true });
|
|
209
|
+
writeFileSync(
|
|
210
|
+
join(srcDir, 'manifest.yml'),
|
|
211
|
+
'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
|
|
212
|
+
);
|
|
213
|
+
const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
|
|
214
|
+
await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
|
|
215
|
+
const pkgBytes = new Uint8Array(await Bun.file(pkgPath).arrayBuffer());
|
|
216
|
+
|
|
217
|
+
getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([
|
|
218
|
+
{ name: 'testmod', vers: '1.0.0+6', deps: [], cksum: 'abc', yanked: false },
|
|
219
|
+
]);
|
|
220
|
+
downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(pkgBytes.buffer);
|
|
221
|
+
deploySpy = spyOn(moduleDeploy, 'deployModule').mockResolvedValue({
|
|
222
|
+
success: false,
|
|
223
|
+
error: 'ssh: connection refused',
|
|
224
|
+
phases: {},
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
getIndexSpy.mockRestore();
|
|
230
|
+
downloadSpy.mockRestore();
|
|
231
|
+
deploySpy.mockRestore();
|
|
232
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
233
|
+
resetTestDbPath();
|
|
234
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a deploy failure leaves state ERROR, the new version, and a moved updatedAt', async () => {
|
|
238
|
+
const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
|
|
239
|
+
if (!mod) throw new Error('seed row missing');
|
|
240
|
+
|
|
241
|
+
const result = await upgradeOneModule(mod, '1.0.0+6', new RegistryClient(''), db, {});
|
|
242
|
+
|
|
243
|
+
expect(result.success).toBe(false);
|
|
244
|
+
const row = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
|
|
245
|
+
if (!row) throw new Error('row missing after upgrade');
|
|
246
|
+
expect(row.version).toBe('1.0.0+6');
|
|
247
|
+
expect(row.state).toBe<ModuleState>('ERROR');
|
|
248
|
+
expect(row.errorMessage).toContain('1.0.0+6');
|
|
249
|
+
expect(row.errorMessage).toContain('ssh: connection refused');
|
|
250
|
+
expect(row.updatedAt?.getTime()).toBeGreaterThan(FROZEN.getTime());
|
|
251
|
+
});
|
|
252
|
+
});
|