@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
|
@@ -20,7 +20,8 @@ import { type DbClient, getDb } from '../../db/client';
|
|
|
20
20
|
import { modules } from '../../db/schema';
|
|
21
21
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
22
22
|
import { RegistryClient } from '../../registry/client';
|
|
23
|
-
import { createModuleBackup } from '../../services/backup-create';
|
|
23
|
+
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
24
|
+
import { getDefaultBackupStorage } from '../../services/backup-storage';
|
|
24
25
|
import {
|
|
25
26
|
type DeployPosture,
|
|
26
27
|
type UpgradePolicy,
|
|
@@ -79,12 +80,55 @@ export function needsPreUpgradeBackup(
|
|
|
79
80
|
return posture === 'safe' && Boolean(targetManifest.hooks?.on_backup);
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Pure (Rule 10): does this upgrade snapshot the celilo DB first? Safe posture
|
|
85
|
+
* means a minor/major bump, and a minor bump can migrate the DB schema — the
|
|
86
|
+
* snapshot is what makes a later package downgrade a real rollback instead of
|
|
87
|
+
* a gamble on an older binary reading a newer-migrated database.
|
|
88
|
+
*/
|
|
89
|
+
export function needsSystemStateSnapshot(posture: DeployPosture): boolean {
|
|
90
|
+
return posture === 'safe';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Take the system_state snapshot a safe-posture upgrade requires.
|
|
95
|
+
*
|
|
96
|
+
* A host with no default backup storage keeps today's behavior (warn and
|
|
97
|
+
* proceed): refusing every safe upgrade there would break CD for a config
|
|
98
|
+
* gap, and the module-level backup below still runs when the target manifest
|
|
99
|
+
* declares on_backup. A configured-but-failing snapshot refuses the upgrade,
|
|
100
|
+
* same rule `system update` applies to its own snapshot (D7): the DB snapshot
|
|
101
|
+
* is the safety net, and an upgrade that cannot take it should not mutate.
|
|
102
|
+
*/
|
|
103
|
+
async function snapshotSystemStateBeforeUpgrade(
|
|
104
|
+
moduleId: string,
|
|
105
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
106
|
+
try {
|
|
107
|
+
if (!getDefaultBackupStorage()) {
|
|
108
|
+
log.warn(
|
|
109
|
+
`${moduleId}: no default backup storage configured — skipping pre-upgrade system_state snapshot`,
|
|
110
|
+
);
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
const snapshot = await createSystemStateBackup();
|
|
114
|
+
if (!snapshot.success) {
|
|
115
|
+
return { ok: false, error: snapshot.error ?? 'system_state backup failed' };
|
|
116
|
+
}
|
|
117
|
+
log.success(`System state snapshot taken before upgrading ${moduleId}`);
|
|
118
|
+
return { ok: true };
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
82
124
|
export interface PollCandidate {
|
|
83
125
|
moduleId: string;
|
|
84
126
|
installed: string;
|
|
85
127
|
/** Latest registry version, or null when the module isn't on the registry. */
|
|
86
128
|
latest: string | null;
|
|
87
129
|
autoUpgrade: boolean;
|
|
130
|
+
/** True when the module row carries state ERROR (a failed upgrade, celilo#1363). */
|
|
131
|
+
inError: boolean;
|
|
88
132
|
}
|
|
89
133
|
|
|
90
134
|
export interface PollTarget {
|
|
@@ -102,6 +146,10 @@ export function selectPollTargets(candidates: PollCandidate[]): PollTarget[] {
|
|
|
102
146
|
const targets: PollTarget[] = [];
|
|
103
147
|
for (const c of candidates) {
|
|
104
148
|
if (!c.autoUpgrade || !c.latest) continue;
|
|
149
|
+
// A module in ERROR failed an upgrade already. Retrying it on every poll
|
|
150
|
+
// redeploys the same broken version indefinitely (celilo#1363), so skip it
|
|
151
|
+
// until an attended health check clears the state (healthy → VERIFIED).
|
|
152
|
+
if (c.inError) continue;
|
|
105
153
|
const change = classifyVersionChange(c.installed, c.latest);
|
|
106
154
|
if (change === 'up-to-date' || change === 'ahead') continue;
|
|
107
155
|
targets.push({ moduleId: c.moduleId, from: c.installed, to: c.latest });
|
|
@@ -121,8 +169,10 @@ function resolveAutoUpgrade(moduleId: string): boolean {
|
|
|
121
169
|
* Upgrade ONE module to a known target version: posture → update → backup →
|
|
122
170
|
* deploy → verify. The shared core for both the single-module command and the
|
|
123
171
|
* poll. `mod` is the current DB row; `targetVersion` is the registry latest.
|
|
172
|
+
* Exported for tests: the failure paths here are where the store diverges from
|
|
173
|
+
* reality (celilo#1363).
|
|
124
174
|
*/
|
|
125
|
-
async function upgradeOneModule(
|
|
175
|
+
export async function upgradeOneModule(
|
|
126
176
|
mod: typeof modules.$inferSelect,
|
|
127
177
|
targetVersion: string,
|
|
128
178
|
client: RegistryClient,
|
|
@@ -168,7 +218,17 @@ async function upgradeOneModule(
|
|
|
168
218
|
});
|
|
169
219
|
log.info(`Upgrading ${moduleId} ${mod.version} → ${targetVersion} (${posture} — ${reason})`);
|
|
170
220
|
|
|
171
|
-
// Safe →
|
|
221
|
+
// Safe → snapshot the celilo DB first (the package rollback pair), then
|
|
222
|
+
// back up the module itself, gated on the TARGET manifest's on_backup hook.
|
|
223
|
+
if (needsSystemStateSnapshot(posture)) {
|
|
224
|
+
const snapshot = await snapshotSystemStateBeforeUpgrade(moduleId);
|
|
225
|
+
if (!snapshot.ok) {
|
|
226
|
+
return {
|
|
227
|
+
success: false,
|
|
228
|
+
error: `Pre-upgrade system_state snapshot failed for ${moduleId}: ${snapshot.error}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
172
232
|
if (needsPreUpgradeBackup(posture, targetManifest)) {
|
|
173
233
|
const backup = await createModuleBackup(moduleId);
|
|
174
234
|
if (!backup.success) {
|
|
@@ -185,6 +245,19 @@ async function upgradeOneModule(
|
|
|
185
245
|
// Deploy (idempotent).
|
|
186
246
|
const deployed = await deployModule(moduleId, db, {});
|
|
187
247
|
if (!deployed.success) {
|
|
248
|
+
// fetchAndUpdate already swapped the files and wrote the new version to
|
|
249
|
+
// the store, so the recorded version no longer matches anything verified.
|
|
250
|
+
// Record the failure in the state rather than leaving a stale VERIFIED
|
|
251
|
+
// beside the new version (celilo#1363, option A). An attended health check
|
|
252
|
+
// that passes moves ERROR → VERIFIED and clears the record.
|
|
253
|
+
db.update(modules)
|
|
254
|
+
.set({
|
|
255
|
+
state: 'ERROR',
|
|
256
|
+
errorMessage: `Upgrade to ${targetVersion} failed to deploy: ${deployed.error}`,
|
|
257
|
+
updatedAt: new Date(),
|
|
258
|
+
})
|
|
259
|
+
.where(eq(modules.id, moduleId))
|
|
260
|
+
.run();
|
|
188
261
|
return {
|
|
189
262
|
success: false,
|
|
190
263
|
error: `Deploy failed for ${moduleId} (now at ${targetVersion}): ${deployed.error}`,
|
|
@@ -236,6 +309,7 @@ async function runRegistryPoll(
|
|
|
236
309
|
installed: mod.version,
|
|
237
310
|
latest: await latestRegistryVersion(client, mod.id),
|
|
238
311
|
autoUpgrade: resolveAutoUpgrade(mod.id),
|
|
312
|
+
inError: mod.state === 'ERROR',
|
|
239
313
|
});
|
|
240
314
|
}
|
|
241
315
|
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
5
6
|
import { moduleAudit } from './module-audit';
|
|
6
7
|
import { moduleVerify } from './module-verify';
|
|
7
8
|
|
|
@@ -19,7 +20,6 @@ describe('moduleVerify', () => {
|
|
|
19
20
|
describe('moduleAudit (deprecation alias)', () => {
|
|
20
21
|
let dataDir: string;
|
|
21
22
|
const originalDataDir = process.env.CELILO_DATA_DIR;
|
|
22
|
-
const originalDbPath = process.env.CELILO_DB_PATH;
|
|
23
23
|
|
|
24
24
|
beforeEach(() => {
|
|
25
25
|
dataDir = mkdtempSync(join(tmpdir(), 'celilo-audit-alias-'));
|
|
@@ -29,10 +29,9 @@ describe('moduleAudit (deprecation alias)', () => {
|
|
|
29
29
|
|
|
30
30
|
afterEach(() => {
|
|
31
31
|
rmSync(dataDir, { recursive: true, force: true });
|
|
32
|
-
|
|
32
|
+
resetTestDbPath();
|
|
33
|
+
if (originalDataDir === undefined) delete process.env.CELILO_DATA_DIR;
|
|
33
34
|
else process.env.CELILO_DATA_DIR = originalDataDir;
|
|
34
|
-
if (originalDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
|
|
35
|
-
else process.env.CELILO_DB_PATH = originalDbPath;
|
|
36
35
|
});
|
|
37
36
|
|
|
38
37
|
test('writes a deprecation warning to stderr and delegates to verify', async () => {
|
|
@@ -22,10 +22,10 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { closeDb, getDb } from '../../db/client';
|
|
23
23
|
import { createPerson } from '../../services/alerting/people';
|
|
24
24
|
import { setupTestDatabaseAt } from '../../test-utils/database';
|
|
25
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
25
26
|
import { handlePerson } from './notify-config';
|
|
26
27
|
|
|
27
28
|
let dir: string;
|
|
28
|
-
const previousDbPath = process.env.CELILO_DB_PATH;
|
|
29
29
|
|
|
30
30
|
beforeEach(async () => {
|
|
31
31
|
dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
|
|
@@ -39,8 +39,10 @@ beforeEach(async () => {
|
|
|
39
39
|
|
|
40
40
|
afterEach(() => {
|
|
41
41
|
closeDb();
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Reset to the scratch path, never restore: the saved value may be another
|
|
43
|
+
// suite's temp database or unset — which sends the next var-less reader to
|
|
44
|
+
// the operator's real celilo.db (celilo#1315).
|
|
45
|
+
resetTestDbPath();
|
|
44
46
|
rmSync(dir, { recursive: true, force: true });
|
|
45
47
|
});
|
|
46
48
|
|
|
@@ -63,7 +63,10 @@ export async function executePlan(input: ExecutePlanInput): Promise<PublishResul
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if (plan.modulePublish.length > 0) {
|
|
66
|
-
executeModulePublish(plan.modulePublish, {
|
|
66
|
+
executeModulePublish(plan.modulePublish, {
|
|
67
|
+
allowStale: plan.options.allowStale,
|
|
68
|
+
skippedModules: plan.options.skippedModules,
|
|
69
|
+
});
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
return result;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* celilo publish --release-touch # auto-fix module stale-drift
|
|
21
21
|
* celilo publish --allow-stale # skip stale-version safeguards
|
|
22
22
|
* celilo publish -y / --yes # auto-confirm prompts
|
|
23
|
+
* celilo publish --skip-module <id> # leave a module out of the registry sweep
|
|
23
24
|
* celilo publish --alpha # X.Y.Z-alpha.N to @alpha
|
|
24
25
|
* celilo publish --alpha --track-alpha # also force-pin alphas globally
|
|
25
26
|
* celilo publish --alpha --alpha-modules # also publish module +N
|
|
@@ -120,6 +121,15 @@ export function parseOptions(argv: string[]): ParsedFlags {
|
|
|
120
121
|
const alphaModulesFlag = argv.includes('--alpha-modules');
|
|
121
122
|
const skipChangesets = argv.includes('--skip-changesets');
|
|
122
123
|
const promoteArg = takeFlagValue('--promote');
|
|
124
|
+
const skippedModules: string[] = [];
|
|
125
|
+
for (let i = argv.indexOf('--skip-module'); i >= 0; i = argv.indexOf('--skip-module', i + 1)) {
|
|
126
|
+
const id = argv[i + 1];
|
|
127
|
+
if (id === undefined || id.startsWith('-')) {
|
|
128
|
+
console.error('✗ --skip-module requires a module id (e.g. --skip-module homebridge)');
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
skippedModules.push(id);
|
|
132
|
+
}
|
|
123
133
|
|
|
124
134
|
if (alphaFlag && promoteArg) {
|
|
125
135
|
console.error('✗ --alpha and --promote are mutually exclusive.');
|
|
@@ -152,7 +162,7 @@ export function parseOptions(argv: string[]): ParsedFlags {
|
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
return {
|
|
155
|
-
options: { allowStale, autoYes: yes, mode },
|
|
165
|
+
options: { allowStale, autoYes: yes, mode, skippedModules },
|
|
156
166
|
dryRun,
|
|
157
167
|
releaseTouch,
|
|
158
168
|
skipChangesets,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from 'bun:test';
|
|
11
|
-
import { mapModuleDirsToItems } from './module-registry';
|
|
11
|
+
import { applyModuleSkips, mapModuleDirsToItems } from './module-registry';
|
|
12
12
|
|
|
13
13
|
describe('mapModuleDirsToItems', () => {
|
|
14
14
|
test('empty input → empty plan', () => {
|
|
@@ -38,3 +38,26 @@ describe('mapModuleDirsToItems', () => {
|
|
|
38
38
|
]);
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
describe('applyModuleSkips', () => {
|
|
43
|
+
const items = mapModuleDirsToItems([
|
|
44
|
+
'/repo/modules/caddy',
|
|
45
|
+
'/repo/modules/burner',
|
|
46
|
+
'/repo/modules/homebridge',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
test('no skips leaves the plan unchanged', () => {
|
|
50
|
+
expect(applyModuleSkips(items, [])).toEqual(items);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('drops each named module and keeps the order of the rest', () => {
|
|
54
|
+
expect(applyModuleSkips(items, ['burner']).map((m) => m.moduleDir)).toEqual([
|
|
55
|
+
'/repo/modules/caddy',
|
|
56
|
+
'/repo/modules/homebridge',
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a name that matches no module directory throws and names it', () => {
|
|
61
|
+
expect(() => applyModuleSkips(items, ['burnr'])).toThrow('burnr');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
|
-
import { join, relative } from 'node:path';
|
|
14
|
+
import { basename, join, relative } from 'node:path';
|
|
15
15
|
import { REPO_ROOT, listModuleDirs } from './helpers';
|
|
16
16
|
import type { ModuleItem } from './types';
|
|
17
17
|
|
|
@@ -28,13 +28,31 @@ export function mapModuleDirsToItems(dirs: string[]): ModuleItem[] {
|
|
|
28
28
|
return dirs.map((moduleDir) => ({ moduleDir }));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Leave the modules named by `--skip-module` out of the sweep. A name
|
|
33
|
+
* that matches no module directory throws, so a typo cannot skip
|
|
34
|
+
* nothing and publish the module it meant to hold back.
|
|
35
|
+
*/
|
|
36
|
+
export function applyModuleSkips(items: ModuleItem[], skippedModules: string[]): ModuleItem[] {
|
|
37
|
+
const known = new Set(items.map((m) => basename(m.moduleDir)));
|
|
38
|
+
const unknown = skippedModules.filter((id) => !known.has(id));
|
|
39
|
+
if (unknown.length > 0) {
|
|
40
|
+
throw new Error(`--skip-module names no module directory: ${unknown.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
const skipped = new Set(skippedModules);
|
|
43
|
+
return items.filter((m) => !skipped.has(basename(m.moduleDir)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function planModulePublish(skippedModules: string[]): ModuleItem[] {
|
|
47
|
+
return applyModuleSkips(mapModuleDirsToItems(listModuleDirs()), skippedModules);
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
// ─── Executor ──────────────────────────────────────────────────────
|
|
36
51
|
|
|
37
|
-
export function executeModulePublish(
|
|
52
|
+
export function executeModulePublish(
|
|
53
|
+
items: ModuleItem[],
|
|
54
|
+
opts: { allowStale: boolean; skippedModules: string[] },
|
|
55
|
+
): void {
|
|
38
56
|
if (items.length === 0) {
|
|
39
57
|
console.log('\nNo modules with manifest.yml found — skipping module-publish phase.');
|
|
40
58
|
return;
|
|
@@ -49,6 +67,7 @@ export function executeModulePublish(items: ModuleItem[], opts: { allowStale: bo
|
|
|
49
67
|
console.log('\n──────────────────────────────────────────────');
|
|
50
68
|
console.log(' Publishing modules to celilo registry');
|
|
51
69
|
console.log('──────────────────────────────────────────────');
|
|
70
|
+
for (const id of opts.skippedModules) console.log(` modules/${id} (skipped: --skip-module)`);
|
|
52
71
|
for (const m of items) console.log(` ${relative(REPO_ROOT, m.moduleDir)}`);
|
|
53
72
|
console.log();
|
|
54
73
|
|
|
@@ -76,7 +76,7 @@ export function planPublish(opts: PublishOptions): PublishPlan {
|
|
|
76
76
|
trackAlpha: opts.mode.kind === 'alpha' && opts.mode.trackAlpha,
|
|
77
77
|
})
|
|
78
78
|
: [],
|
|
79
|
-
modulePublish: runPhase4 ? planModulePublish() : [],
|
|
79
|
+
modulePublish: runPhase4 ? planModulePublish(opts.skippedModules) : [],
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -61,6 +61,13 @@ export interface PublishOptions {
|
|
|
61
61
|
allowStale: boolean;
|
|
62
62
|
autoYes: boolean;
|
|
63
63
|
mode: PublishMode;
|
|
64
|
+
/**
|
|
65
|
+
* Module ids to leave out of the module-registry sweep (`--skip-module
|
|
66
|
+
* <id>`, repeatable). For a module that cannot publish yet. The sweep
|
|
67
|
+
* stops at its first failed module, so one such module would otherwise
|
|
68
|
+
* take every module after it, and the release's .deb, down with it.
|
|
69
|
+
*/
|
|
70
|
+
skippedModules: string[];
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
/**
|
|
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { closeDb, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
14
15
|
import {
|
|
15
16
|
handleRegistryOwnerList,
|
|
16
17
|
handleRegistryOwnerSet,
|
|
@@ -44,8 +45,8 @@ describe('registry owner commands', () => {
|
|
|
44
45
|
afterEach(async () => {
|
|
45
46
|
fetchSpy.mockRestore();
|
|
46
47
|
closeDb();
|
|
47
|
-
process.env.CELILO_DATA_DIR
|
|
48
|
-
|
|
48
|
+
delete process.env.CELILO_DATA_DIR;
|
|
49
|
+
resetTestDbPath();
|
|
49
50
|
if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true });
|
|
50
51
|
});
|
|
51
52
|
|
|
@@ -12,6 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { closeDb, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
14
|
import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
|
|
15
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
15
16
|
import { handleRegistryTokenAdd, handleRegistryTokenRm } from './registry-token';
|
|
16
17
|
|
|
17
18
|
/** Read back the stored publish_tokens list via the production read path. */
|
|
@@ -49,8 +50,8 @@ describe('registry token add/rm (F2 clobber fix)', () => {
|
|
|
49
50
|
|
|
50
51
|
afterEach(async () => {
|
|
51
52
|
closeDb();
|
|
52
|
-
process.env.CELILO_DATA_DIR
|
|
53
|
-
|
|
53
|
+
delete process.env.CELILO_DATA_DIR;
|
|
54
|
+
resetTestDbPath();
|
|
54
55
|
if (existsSync(dataDir)) {
|
|
55
56
|
await rm(dataDir, { recursive: true, force: true });
|
|
56
57
|
}
|
|
@@ -30,24 +30,22 @@ import { tmpdir } from 'node:os';
|
|
|
30
30
|
import { join } from 'node:path';
|
|
31
31
|
import { getDb } from '../../db/client';
|
|
32
32
|
import { loadExistingConfiguration } from '../../services/system-init';
|
|
33
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
33
34
|
import { handleSystemApplyConfig } from './system-apply-config';
|
|
34
35
|
import { handleSystemInit } from './system-init';
|
|
35
36
|
|
|
36
37
|
describe('system apply-config equivalence with system init --accept-defaults', () => {
|
|
37
38
|
let tmpDir: string;
|
|
38
|
-
let savedDbPath: string | undefined;
|
|
39
39
|
|
|
40
40
|
beforeEach(() => {
|
|
41
41
|
tmpDir = mkdtempSync(join(tmpdir(), 'celilo-apply-config-test-'));
|
|
42
|
-
savedDbPath = process.env.CELILO_DB_PATH;
|
|
43
42
|
});
|
|
44
43
|
|
|
45
44
|
afterEach(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
45
|
+
// Reset to the scratch path, never restore: the saved value may be
|
|
46
|
+
// another suite's temp database, or unset — which sends the next
|
|
47
|
+
// var-less reader to the operator's real celilo.db (celilo#1315).
|
|
48
|
+
resetTestDbPath();
|
|
51
49
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
52
50
|
});
|
|
53
51
|
|
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
type LatestCliVersionFetcher,
|
|
46
46
|
fetchLatestCliVersion,
|
|
47
47
|
} from '../../services/audit/cli-version';
|
|
48
|
-
import type { MachineReachableResult } from '../../services/audit/machines-reachable';
|
|
49
48
|
import type { ModuleVersionFetcher } from '../../services/audit/module-versions';
|
|
50
49
|
import {
|
|
51
50
|
loadPublicDnsEvidence,
|
|
@@ -57,8 +56,10 @@ import type { ServiceCredentialsResult } from '../../services/audit/services-cre
|
|
|
57
56
|
import type { ServiceReachableResult } from '../../services/audit/services-reachable';
|
|
58
57
|
import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
59
58
|
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
59
|
+
import { probeDiskUsage } from '../../services/disk-probe';
|
|
60
60
|
import { collectFirewallReach } from '../../services/firewall-reach';
|
|
61
61
|
import { runAllHealthChecks } from '../../services/health-runner';
|
|
62
|
+
import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
62
63
|
import { probeMachines } from '../../services/machine-probe';
|
|
63
64
|
import { parseStoredConfigValue } from '../../services/module-config';
|
|
64
65
|
import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
|
|
@@ -341,11 +342,22 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
341
342
|
}),
|
|
342
343
|
);
|
|
343
344
|
|
|
344
|
-
// Machines-reachable
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
345
|
+
// Machines-reachable and disk-space both SSH every system, so they run
|
|
346
|
+
// concurrently here rather than paying each other's latency. The machine
|
|
347
|
+
// probe is shared with the monitor sweep (services/machine-probe.ts) —
|
|
348
|
+
// this block used to be a second copy of the same SSH logic, and both
|
|
349
|
+
// copies reported the local management box as unreachable because celilo
|
|
350
|
+
// does not hold an SSH key for itself.
|
|
351
|
+
//
|
|
352
|
+
// The disk probe measures every system's root filesystem — the machine
|
|
353
|
+
// pool AND every provisioned instance, the systems celilo#1133 filled —
|
|
354
|
+
// in the same pre-computed measurement shape the disk_space monitor
|
|
355
|
+
// sweep consumes, so an audit finding and an alert page carry the same
|
|
356
|
+
// numbers.
|
|
357
|
+
const [machineReachableResults, diskUsageResults] = await Promise.all([
|
|
358
|
+
probeMachines(),
|
|
359
|
+
probeDiskUsage(db),
|
|
360
|
+
]);
|
|
349
361
|
|
|
350
362
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
351
363
|
|
|
@@ -384,6 +396,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
384
396
|
manifest: m.manifestData as ModuleManifest,
|
|
385
397
|
})),
|
|
386
398
|
},
|
|
399
|
+
jailExemptions: { exemptions: collectJailExemptions(db) },
|
|
387
400
|
health: { results: healthResults },
|
|
388
401
|
backups: { modules: installedBackupInfo },
|
|
389
402
|
abandonedOperations: { records: loadAbandonedOperations(db) },
|
|
@@ -417,6 +430,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
417
430
|
probe: createPublicDnsProbe(loadPublicDnsProbeSettings(db)),
|
|
418
431
|
evidence: loadPublicDnsEvidence(db),
|
|
419
432
|
},
|
|
433
|
+
diskSpace: { results: diskUsageResults },
|
|
420
434
|
// Reads the record the poller already writes — this check never performs a
|
|
421
435
|
// read of its own. One that did would drain the queue and eat the
|
|
422
436
|
// acknowledgement it exists to protect (#541).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the set-time interview gate on `system config set`
|
|
3
|
+
* (slice 3 of hook-jail-config-surface, design D5 + peba's --force addition).
|
|
4
|
+
*
|
|
5
|
+
* Writing `hooks.jail_policy off` is a fleet-wide security posture change, so
|
|
6
|
+
* the write asks first over the bus interview and only proceeds on an explicit
|
|
7
|
+
* confirm. `auto` and `required` write directly. `--force` skips the ask per
|
|
8
|
+
* the house flags.force convention.
|
|
9
|
+
*
|
|
10
|
+
* Isolation: CELILO_DB_PATH / CELILO_DATA_DIR / EVENT_BUS_DB are set before the
|
|
11
|
+
* SUT is imported, so nothing touches the production database or event bus.
|
|
12
|
+
* bun test runs non-TTY, so `withInterviewSession` starts no terminal responder
|
|
13
|
+
* and the interview guard probes the isolated bus — a successful direct write
|
|
14
|
+
* with no responder running is itself proof the command never asked.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
|
|
23
|
+
const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jailgate-'));
|
|
24
|
+
const busDbPath = join(testRoot, 'bus.db');
|
|
25
|
+
process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
|
|
26
|
+
process.env.CELILO_DATA_DIR = join(testRoot, 'data');
|
|
27
|
+
process.env.EVENT_BUS_DB = busDbPath;
|
|
28
|
+
|
|
29
|
+
const { getDb, closeDb } = await import('../../db/client');
|
|
30
|
+
const { systemConfig } = await import('../../db/schema');
|
|
31
|
+
const { handleSystemConfigSet } = await import('./system-config');
|
|
32
|
+
const { startProgrammaticResponder } = await import('../../services/programmatic-responder');
|
|
33
|
+
|
|
34
|
+
const KEY = 'hooks.jail_policy';
|
|
35
|
+
const CONFIRM_KEY = 'hooks-jail-policy.set-off';
|
|
36
|
+
|
|
37
|
+
function storedJailPolicy(): string | undefined {
|
|
38
|
+
return getDb().select().from(systemConfig).where(eq(systemConfig.key, KEY)).get()?.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
closeDb();
|
|
43
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('system config set — jail_policy value validation', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a value outside the enum is refused and names the allowed values', async () => {
|
|
52
|
+
const result = await handleSystemConfigSet([KEY, 'alwayssafe']);
|
|
53
|
+
|
|
54
|
+
expect(result.success).toBe(false);
|
|
55
|
+
if (result.success) throw new Error('expected a refusal');
|
|
56
|
+
expect(result.error).toContain('auto');
|
|
57
|
+
expect(result.error).toContain('required');
|
|
58
|
+
expect(storedJailPolicy()).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('system config set — jail_policy values that never ask', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('auto writes directly with no responder listening', async () => {
|
|
68
|
+
const result = await handleSystemConfigSet([KEY, 'auto']);
|
|
69
|
+
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
expect(storedJailPolicy()).toBe('auto');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('required writes directly with no responder listening', async () => {
|
|
75
|
+
const result = await handleSystemConfigSet([KEY, 'required']);
|
|
76
|
+
|
|
77
|
+
expect(result.success).toBe(true);
|
|
78
|
+
expect(storedJailPolicy()).toBe('required');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('system config set — the off interview gate', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('off fails fast with no responder listening and writes nothing', async () => {
|
|
88
|
+
let threw: unknown;
|
|
89
|
+
try {
|
|
90
|
+
await handleSystemConfigSet([KEY, 'off']);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
threw = error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
expect(threw).toBeInstanceOf(Error);
|
|
96
|
+
expect(String((threw as Error).message)).toContain('No responder is listening');
|
|
97
|
+
expect(storedJailPolicy()).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('off writes after a confirming reply over non-TTY, and the question was asked', async () => {
|
|
101
|
+
const responder = startProgrammaticResponder({
|
|
102
|
+
busDbPath,
|
|
103
|
+
db: getDb(),
|
|
104
|
+
onMissing: 'skip',
|
|
105
|
+
values: { interview: { [CONFIRM_KEY]: true } },
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
const result = await handleSystemConfigSet([KEY, 'off']);
|
|
109
|
+
|
|
110
|
+
expect(result.success).toBe(true);
|
|
111
|
+
expect(storedJailPolicy()).toBe('off');
|
|
112
|
+
|
|
113
|
+
const asked = responder.seenInterviewPayloads();
|
|
114
|
+
expect(asked.length).toBe(1);
|
|
115
|
+
expect(asked[0].scope).toBe('hooks-jail-policy');
|
|
116
|
+
expect(asked[0].key).toBe('set-off');
|
|
117
|
+
expect(asked[0].message).toContain('unjailed');
|
|
118
|
+
} finally {
|
|
119
|
+
responder.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('off is cancelled when the reply declines, and writes nothing', async () => {
|
|
124
|
+
const responder = startProgrammaticResponder({
|
|
125
|
+
busDbPath,
|
|
126
|
+
db: getDb(),
|
|
127
|
+
onMissing: 'skip',
|
|
128
|
+
values: { interview: { [CONFIRM_KEY]: false } },
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
const result = await handleSystemConfigSet([KEY, 'off']);
|
|
132
|
+
|
|
133
|
+
expect(result.success).toBe(false);
|
|
134
|
+
if (result.success) throw new Error('expected cancellation');
|
|
135
|
+
expect(result.error).toBe('Cancelled by user');
|
|
136
|
+
expect(storedJailPolicy()).toBeUndefined();
|
|
137
|
+
} finally {
|
|
138
|
+
responder.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('--force skips the ask and writes off with no responder listening', async () => {
|
|
143
|
+
const result = await handleSystemConfigSet([KEY, 'off'], { force: true });
|
|
144
|
+
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
expect(storedJailPolicy()).toBe('off');
|
|
147
|
+
});
|
|
148
|
+
});
|