@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +5 -4
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxmox reconciliation (v2/CELILO_BASE.md D5).
|
|
3
|
+
*
|
|
4
|
+
* When a base-module aspect declares `proxmox_reconcile.tfvars` and
|
|
5
|
+
* its fan-out plan touches Proxmox-provisioned LXCs, the running
|
|
6
|
+
* config (Ansible writes /etc/resolv.conf) is only half the story:
|
|
7
|
+
* the LXC's persisted Proxmox terraform config also needs the new
|
|
8
|
+
* value, otherwise a re-provision would revert to a stale resolver
|
|
9
|
+
* and break the fleet.
|
|
10
|
+
*
|
|
11
|
+
* SC5 SCOPE (this file):
|
|
12
|
+
* - Plan: pure function that takes the aspect + fan-out targets
|
|
13
|
+
* + resolution context, returns one action per affected LXC.
|
|
14
|
+
* - Execute: surfaces a clear WARN per action, documenting which
|
|
15
|
+
* tfvar would be updated on which owning-module's terraform
|
|
16
|
+
* state.
|
|
17
|
+
*
|
|
18
|
+
* DEFERRED to a follow-up (Phase 1c / Phase 2):
|
|
19
|
+
* - Actually running terraform apply per LXC owning-module. The
|
|
20
|
+
* value needs to flow through celilo's persistent state
|
|
21
|
+
* (system_config / module_config) AND be picked up by the
|
|
22
|
+
* owning module's terraform template on next regular deploy,
|
|
23
|
+
* not just injected as a one-off -var. Doing that right
|
|
24
|
+
* requires (a) a celilo system-config key with a clear
|
|
25
|
+
* semantic, (b) updates to every Proxmox-using module's
|
|
26
|
+
* terraform template to read from it, and (c) reconciliation
|
|
27
|
+
* against existing tfvars files so the next deploy doesn't
|
|
28
|
+
* overwrite.
|
|
29
|
+
*
|
|
30
|
+
* That's meaningful design work and the simulator's Phase 1
|
|
31
|
+
* e2e doesn't have a real Proxmox to validate against. The
|
|
32
|
+
* responsible move is to ship the SHAPE (manifest schema +
|
|
33
|
+
* planning + observable warn-on-skip), defer the persistence
|
|
34
|
+
* layer, and pick it up when production rollout actually
|
|
35
|
+
* touches Proxmox LXCs.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { log } from '../cli/prompts';
|
|
39
|
+
import type { getDb } from '../db/client';
|
|
40
|
+
import type { BaseModuleAspect } from '../manifest/schema';
|
|
41
|
+
import { resolveAspectTemplateRecord } from './aspect-template-resolver';
|
|
42
|
+
import { type ContainerSystem, getContainerSystemsByZone } from './machine-pool';
|
|
43
|
+
|
|
44
|
+
type DbClient = ReturnType<typeof getDb>;
|
|
45
|
+
|
|
46
|
+
export interface ProxmoxReconcileAction {
|
|
47
|
+
/** The owning module of the affected LXC — its terraform state holds the var. */
|
|
48
|
+
moduleId: string;
|
|
49
|
+
/** The container_service this LXC was provisioned through. */
|
|
50
|
+
serviceId: string;
|
|
51
|
+
/** Resolved tfvar updates, mapping tfvar name → final string value. */
|
|
52
|
+
tfvarUpdates: Record<string, string>;
|
|
53
|
+
/** Source LXC for logging / observability. */
|
|
54
|
+
containerSystem: ContainerSystem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProxmoxReconcilePlan {
|
|
58
|
+
actions: ProxmoxReconcileAction[];
|
|
59
|
+
/** Containers that matched the zones but were skipped (non-Proxmox provider). */
|
|
60
|
+
skipped: Array<{ system: ContainerSystem; reason: string }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Planning phase: figure out which Proxmox LXCs in the aspect's
|
|
65
|
+
* zone scope would need a terraform-config update. Pure aside from
|
|
66
|
+
* the DB reads (container_service rows + value resolution).
|
|
67
|
+
*
|
|
68
|
+
* The `providerModuleId` is the module whose aspect we're acting
|
|
69
|
+
* on (e.g., knot-unbound-internal). Its config + capability data
|
|
70
|
+
* is what tfvar value templates resolve against — the aspect knows
|
|
71
|
+
* its own service's IP, the resolution context just makes that
|
|
72
|
+
* available.
|
|
73
|
+
*/
|
|
74
|
+
export async function planProxmoxReconcile(args: {
|
|
75
|
+
aspect: BaseModuleAspect;
|
|
76
|
+
providerModuleId: string;
|
|
77
|
+
db: DbClient;
|
|
78
|
+
}): Promise<ProxmoxReconcilePlan> {
|
|
79
|
+
const { aspect, providerModuleId, db } = args;
|
|
80
|
+
if (!aspect.proxmox_reconcile) {
|
|
81
|
+
return { actions: [], skipped: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const systems = await getContainerSystemsByZone(aspect.applicable_zones);
|
|
85
|
+
|
|
86
|
+
const actions: ProxmoxReconcileAction[] = [];
|
|
87
|
+
const skipped: ProxmoxReconcilePlan['skipped'] = [];
|
|
88
|
+
|
|
89
|
+
for (const sys of systems) {
|
|
90
|
+
if (sys.providerName !== 'proxmox') {
|
|
91
|
+
// DO droplets / future providers don't ride the proxmox_lxc
|
|
92
|
+
// terraform path. The aspect's Ansible run still hit them;
|
|
93
|
+
// they just don't get a tfvar-overlay update from this
|
|
94
|
+
// mechanism.
|
|
95
|
+
skipped.push({ system: sys, reason: `non-proxmox provider (${sys.providerName})` });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve every tfvar template against the provider module's
|
|
100
|
+
// context. Any resolution failure surfaces as a thrown error
|
|
101
|
+
// up to the caller — aspect authors are expected to declare
|
|
102
|
+
// resolvable templates.
|
|
103
|
+
const tfvarUpdates = await resolveAspectTemplateRecord(
|
|
104
|
+
aspect.proxmox_reconcile.tfvars,
|
|
105
|
+
providerModuleId,
|
|
106
|
+
db,
|
|
107
|
+
'proxmox_reconcile.tfvars',
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
actions.push({
|
|
111
|
+
moduleId: sys.moduleId,
|
|
112
|
+
serviceId: sys.serviceId,
|
|
113
|
+
tfvarUpdates,
|
|
114
|
+
containerSystem: sys,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { actions, skipped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Execution phase. **Currently surfaces operator warnings instead
|
|
123
|
+
* of running terraform** — see file header for why.
|
|
124
|
+
*
|
|
125
|
+
* The warning is structured so operators see exactly which
|
|
126
|
+
* Proxmox LXC has drifted from its desired tfvar state and can
|
|
127
|
+
* manually reconcile via:
|
|
128
|
+
*
|
|
129
|
+
* celilo module deploy <owning-module>
|
|
130
|
+
*
|
|
131
|
+
* (which re-runs that module's terraform with whatever the
|
|
132
|
+
* current celilo state says the tfvars should be).
|
|
133
|
+
*
|
|
134
|
+
* When the persistence layer lands, this function gains a real
|
|
135
|
+
* terraform-apply path; the planning function above stays
|
|
136
|
+
* unchanged.
|
|
137
|
+
*/
|
|
138
|
+
export function executeProxmoxReconcile(plan: ProxmoxReconcilePlan): void {
|
|
139
|
+
if (plan.actions.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
log.warn(
|
|
143
|
+
`Proxmox reconciliation pending (${plan.actions.length} LXC${plan.actions.length === 1 ? '' : 's'} affected). The aspect's Ansible run updated the running config on each, but the persisted Proxmox terraform config has NOT been changed. Re-provisioning would revert. Affected:`,
|
|
144
|
+
);
|
|
145
|
+
for (const action of plan.actions) {
|
|
146
|
+
const tfvarSummary = Object.entries(action.tfvarUpdates)
|
|
147
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
148
|
+
.join(', ');
|
|
149
|
+
log.warn(
|
|
150
|
+
` - LXC owned by module '${action.moduleId}' (service ${action.serviceId}, vmid ${(action.containerSystem.containerMetadata as { vmid?: string | number } | null)?.vmid ?? '?'}): would set ${tfvarSummary}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
log.warn(
|
|
154
|
+
'To persist these values, re-deploy the owning modules after the underlying celilo state catches up. Tracked as Phase 1c in v2/CELILO_BASE.md.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm';
|
|
|
11
11
|
import { log } from '../cli/prompts';
|
|
12
12
|
import type { DbClient } from '../db/client';
|
|
13
13
|
import { moduleConfigs } from '../db/schema';
|
|
14
|
+
import { upsertModuleConfig } from './module-config';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Terraform state file structure (subset we care about)
|
|
@@ -107,33 +108,11 @@ export async function ensureProxmoxConfigFromState(
|
|
|
107
108
|
log.warn(' Recovering vmid and target_ip from Terraform state...');
|
|
108
109
|
|
|
109
110
|
if (!existingVmid) {
|
|
110
|
-
|
|
111
|
-
.insert(moduleConfigs)
|
|
112
|
-
.values({
|
|
113
|
-
moduleId,
|
|
114
|
-
key: 'vmid',
|
|
115
|
-
value: vmid.toString(),
|
|
116
|
-
})
|
|
117
|
-
.onConflictDoUpdate({
|
|
118
|
-
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
119
|
-
set: { value: vmid.toString() },
|
|
120
|
-
})
|
|
121
|
-
.run();
|
|
111
|
+
upsertModuleConfig(db, moduleId, 'vmid', vmid);
|
|
122
112
|
}
|
|
123
113
|
|
|
124
114
|
if (!existingContainerIp) {
|
|
125
|
-
|
|
126
|
-
.insert(moduleConfigs)
|
|
127
|
-
.values({
|
|
128
|
-
moduleId,
|
|
129
|
-
key: 'target_ip',
|
|
130
|
-
value: containerIp,
|
|
131
|
-
})
|
|
132
|
-
.onConflictDoUpdate({
|
|
133
|
-
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
134
|
-
set: { value: containerIp },
|
|
135
|
-
})
|
|
136
|
-
.run();
|
|
115
|
+
upsertModuleConfig(db, moduleId, 'target_ip', containerIp);
|
|
137
116
|
}
|
|
138
117
|
|
|
139
118
|
log.success(` Recovered: vmid=${vmid}, target_ip=${containerIp}`);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the file-direct restore service (Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Focused on the apply-staged-system-files step + restoreFromArtifactFile's
|
|
5
|
+
* error-handling for malformed / mismatched / missing artifacts.
|
|
6
|
+
*
|
|
7
|
+
* Full round-trip (create artifact via celilo-mgmt backup, then restore
|
|
8
|
+
* via this service) is an e2e concern, not exercised at the unit level.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { closeDb } from '../db/client';
|
|
24
|
+
import { runMigrations } from '../db/migrate';
|
|
25
|
+
import { applyStagedSystemFiles, restoreFromArtifactFile } from './restore-from-file';
|
|
26
|
+
|
|
27
|
+
describe('applyStagedSystemFiles', () => {
|
|
28
|
+
let dir: string;
|
|
29
|
+
let stagingDir: string;
|
|
30
|
+
let livePath: string;
|
|
31
|
+
let keyPath: string;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-staged-apply-test-'));
|
|
35
|
+
stagingDir = join(dir, 'system');
|
|
36
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
37
|
+
livePath = join(dir, 'celilo.db');
|
|
38
|
+
keyPath = join(dir, 'master.key');
|
|
39
|
+
process.env.CELILO_DB_PATH = livePath;
|
|
40
|
+
process.env.CELILO_MASTER_KEY_PATH = keyPath;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
45
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
46
|
+
try {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('copies celilo.db + master.key into their live paths', () => {
|
|
54
|
+
writeFileSync(join(stagingDir, 'celilo.db'), 'restored-db');
|
|
55
|
+
writeFileSync(join(stagingDir, 'master.key'), 'restored-key');
|
|
56
|
+
|
|
57
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
58
|
+
expect(result.dbApplied).toBe(true);
|
|
59
|
+
expect(result.keyApplied).toBe(true);
|
|
60
|
+
expect(readFileSync(livePath, 'utf-8')).toBe('restored-db');
|
|
61
|
+
expect(readFileSync(keyPath, 'utf-8')).toBe('restored-key');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('overwrites pre-existing files at the live paths', () => {
|
|
65
|
+
writeFileSync(livePath, 'old-db');
|
|
66
|
+
writeFileSync(keyPath, 'old-key');
|
|
67
|
+
writeFileSync(join(stagingDir, 'celilo.db'), 'new-db');
|
|
68
|
+
writeFileSync(join(stagingDir, 'master.key'), 'new-key');
|
|
69
|
+
|
|
70
|
+
applyStagedSystemFiles(stagingDir);
|
|
71
|
+
expect(readFileSync(livePath, 'utf-8')).toBe('new-db');
|
|
72
|
+
expect(readFileSync(keyPath, 'utf-8')).toBe('new-key');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('cleans up stale WAL/SHM siblings of the restored DB', () => {
|
|
76
|
+
writeFileSync(`${livePath}-wal`, 'stale-wal');
|
|
77
|
+
writeFileSync(`${livePath}-shm`, 'stale-shm');
|
|
78
|
+
writeFileSync(join(stagingDir, 'celilo.db'), 'fresh-db');
|
|
79
|
+
|
|
80
|
+
applyStagedSystemFiles(stagingDir);
|
|
81
|
+
expect(existsSync(`${livePath}-wal`)).toBe(false);
|
|
82
|
+
expect(existsSync(`${livePath}-shm`)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handles partial staging (only master.key, no DB)', () => {
|
|
86
|
+
writeFileSync(join(stagingDir, 'master.key'), 'just-the-key');
|
|
87
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
88
|
+
expect(result.dbApplied).toBe(false);
|
|
89
|
+
expect(result.keyApplied).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('handles partial staging (only DB, no master.key)', () => {
|
|
93
|
+
writeFileSync(join(stagingDir, 'celilo.db'), 'just-the-db');
|
|
94
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
95
|
+
expect(result.dbApplied).toBe(true);
|
|
96
|
+
expect(result.keyApplied).toBe(false);
|
|
97
|
+
expect(result.sshApplied).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('restores the fleet SSH keypair next to the DB with strict perms', () => {
|
|
101
|
+
const stagedSsh = join(stagingDir, 'ssh');
|
|
102
|
+
mkdirSync(stagedSsh, { recursive: true });
|
|
103
|
+
writeFileSync(join(stagedSsh, 'id_ed25519'), 'PRIVATE-KEY-BYTES');
|
|
104
|
+
writeFileSync(join(stagedSsh, 'id_ed25519.pub'), 'ssh-ed25519 AAAA... celilo-fleet');
|
|
105
|
+
|
|
106
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
107
|
+
expect(result.sshApplied).toBe(true);
|
|
108
|
+
|
|
109
|
+
// Lands at dirname(getDbPath())/.ssh — which is `dir` here (CELILO_DB_PATH=dir/celilo.db).
|
|
110
|
+
const liveSshDir = join(dir, '.ssh');
|
|
111
|
+
expect(readFileSync(join(liveSshDir, 'id_ed25519'), 'utf-8')).toBe('PRIVATE-KEY-BYTES');
|
|
112
|
+
expect(readFileSync(join(liveSshDir, 'id_ed25519.pub'), 'utf-8')).toBe(
|
|
113
|
+
'ssh-ed25519 AAAA... celilo-fleet',
|
|
114
|
+
);
|
|
115
|
+
// Private key 0600, public 0644, dir 0700.
|
|
116
|
+
expect(statSync(join(liveSshDir, 'id_ed25519')).mode & 0o777).toBe(0o600);
|
|
117
|
+
expect(statSync(join(liveSshDir, 'id_ed25519.pub')).mode & 0o777).toBe(0o644);
|
|
118
|
+
expect(statSync(liveSshDir).mode & 0o777).toBe(0o700);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('is a no-op when staging dir does not exist', () => {
|
|
122
|
+
rmSync(stagingDir, { recursive: true });
|
|
123
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
124
|
+
expect(result.dbApplied).toBe(false);
|
|
125
|
+
expect(result.keyApplied).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('restoreFromArtifactFile error paths', () => {
|
|
130
|
+
let dir: string;
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-restore-from-file-test-'));
|
|
134
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
135
|
+
process.env.CELILO_DATA_DIR = dir;
|
|
136
|
+
process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
|
|
137
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
closeDb();
|
|
142
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
143
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
144
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
145
|
+
try {
|
|
146
|
+
rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns an error for a non-existent file', async () => {
|
|
153
|
+
const result = await restoreFromArtifactFile('/tmp/definitely-does-not-exist.backup');
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
expect(result.error).toContain('not found');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns an error for a file that is not valid JSON', async () => {
|
|
159
|
+
const bogus = join(dir, 'bogus.backup');
|
|
160
|
+
writeFileSync(bogus, 'this is not JSON');
|
|
161
|
+
const result = await restoreFromArtifactFile(bogus);
|
|
162
|
+
expect(result.success).toBe(false);
|
|
163
|
+
expect(result.error).toContain('JSON');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('returns an error for valid JSON that fails decryption', async () => {
|
|
167
|
+
// Valid JSON shape that decryptSecret will fail on.
|
|
168
|
+
const bogus = join(dir, 'malformed.backup');
|
|
169
|
+
writeFileSync(
|
|
170
|
+
bogus,
|
|
171
|
+
JSON.stringify({ encryptedValue: 'not-real', iv: 'not-real', authTag: 'not-real' }),
|
|
172
|
+
);
|
|
173
|
+
const result = await restoreFromArtifactFile(bogus);
|
|
174
|
+
expect(result.success).toBe(false);
|
|
175
|
+
expect((result.error ?? '').toLowerCase()).toMatch(/decrypt|json|artifact/);
|
|
176
|
+
});
|
|
177
|
+
});
|