@celilo/cli 0.1.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/README.md +1566 -0
- package/bin/celilo +16 -0
- package/drizzle/0000_complex_puma.sql +179 -0
- package/drizzle/0001_dizzy_wolfpack.sql +2 -0
- package/drizzle/0002_web_routes.sql +16 -0
- package/drizzle/0003_backup_storage.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +1151 -0
- package/drizzle/meta/0001_snapshot.json +1167 -0
- package/drizzle/meta/0002_snapshot.json +1257 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +64 -0
- package/schemas/system_config.json +106 -0
- package/src/__integration__/container-services-cli.integration.test.ts +246 -0
- package/src/ansible/dependencies.test.ts +309 -0
- package/src/ansible/dependencies.ts +896 -0
- package/src/ansible/inventory.test.ts +463 -0
- package/src/ansible/inventory.ts +445 -0
- package/src/ansible/secrets.ts +222 -0
- package/src/ansible/validation.test.ts +92 -0
- package/src/ansible/validation.ts +272 -0
- package/src/api-clients/digitalocean.ts +94 -0
- package/src/api-clients/proxmox.ts +655 -0
- package/src/capabilities/logging-wrapper.test.ts +217 -0
- package/src/capabilities/lookup.test.ts +149 -0
- package/src/capabilities/lookup.ts +89 -0
- package/src/capabilities/public-web-helpers.test.ts +198 -0
- package/src/capabilities/public-web-publish.test.ts +458 -0
- package/src/capabilities/registration.test.ts +395 -0
- package/src/capabilities/registration.ts +200 -0
- package/src/capabilities/route-validation.test.ts +121 -0
- package/src/capabilities/route-validation.ts +96 -0
- package/src/capabilities/secret-ref.test.ts +313 -0
- package/src/capabilities/secret-validation.ts +157 -0
- package/src/capabilities/secrets.test.ts +750 -0
- package/src/capabilities/secrets.ts +244 -0
- package/src/capabilities/validation.test.ts +613 -0
- package/src/capabilities/validation.ts +160 -0
- package/src/capabilities/well-known.test.ts +238 -0
- package/src/capabilities/well-known.ts +222 -0
- package/src/cli/cli.test.ts +654 -0
- package/src/cli/command-registry.ts +742 -0
- package/src/cli/command-tree-parser.test.ts +180 -0
- package/src/cli/command-tree-parser.ts +193 -0
- package/src/cli/commands/backup-create.ts +137 -0
- package/src/cli/commands/backup-delete.ts +74 -0
- package/src/cli/commands/backup-import.ts +97 -0
- package/src/cli/commands/backup-list.ts +132 -0
- package/src/cli/commands/backup-name.ts +73 -0
- package/src/cli/commands/backup-prune.ts +98 -0
- package/src/cli/commands/backup-restore.ts +122 -0
- package/src/cli/commands/capability-info.ts +121 -0
- package/src/cli/commands/capability-list.ts +47 -0
- package/src/cli/commands/completion.ts +87 -0
- package/src/cli/commands/hook-run.ts +176 -0
- package/src/cli/commands/ipam.ts +607 -0
- package/src/cli/commands/machine-add.ts +235 -0
- package/src/cli/commands/machine-earmark.ts +82 -0
- package/src/cli/commands/machine-list.ts +77 -0
- package/src/cli/commands/machine-remove.ts +90 -0
- package/src/cli/commands/machine-status.ts +131 -0
- package/src/cli/commands/module-audit.ts +51 -0
- package/src/cli/commands/module-build.ts +60 -0
- package/src/cli/commands/module-config.ts +170 -0
- package/src/cli/commands/module-deploy.ts +71 -0
- package/src/cli/commands/module-generate.ts +236 -0
- package/src/cli/commands/module-health.ts +108 -0
- package/src/cli/commands/module-import.ts +80 -0
- package/src/cli/commands/module-list.ts +43 -0
- package/src/cli/commands/module-logs.ts +73 -0
- package/src/cli/commands/module-remove.ts +162 -0
- package/src/cli/commands/module-show.ts +208 -0
- package/src/cli/commands/module-status.ts +131 -0
- package/src/cli/commands/module-types.ts +189 -0
- package/src/cli/commands/module-upgrade.ts +192 -0
- package/src/cli/commands/package.ts +68 -0
- package/src/cli/commands/secret-list.ts +99 -0
- package/src/cli/commands/secret-set.ts +134 -0
- package/src/cli/commands/service-add-digitalocean.ts +133 -0
- package/src/cli/commands/service-add-proxmox.ts +342 -0
- package/src/cli/commands/service-config-get.ts +83 -0
- package/src/cli/commands/service-config-set.ts +145 -0
- package/src/cli/commands/service-list.ts +74 -0
- package/src/cli/commands/service-reconfigure.ts +230 -0
- package/src/cli/commands/service-remove.ts +103 -0
- package/src/cli/commands/service-verify.ts +240 -0
- package/src/cli/commands/status.ts +216 -0
- package/src/cli/commands/storage-add-local.ts +106 -0
- package/src/cli/commands/storage-add-s3.ts +114 -0
- package/src/cli/commands/storage-list.ts +72 -0
- package/src/cli/commands/storage-remove.ts +54 -0
- package/src/cli/commands/storage-set-default.ts +44 -0
- package/src/cli/commands/storage-verify.ts +54 -0
- package/src/cli/commands/system-config.ts +168 -0
- package/src/cli/commands/system-init.ts +314 -0
- package/src/cli/commands/system-secret-get.ts +98 -0
- package/src/cli/commands/system-secret-set.ts +76 -0
- package/src/cli/commands/system-vault-password.ts +34 -0
- package/src/cli/completion.test.ts +37 -0
- package/src/cli/completion.ts +482 -0
- package/src/cli/fuel-gauge.test.ts +208 -0
- package/src/cli/fuel-gauge.ts +405 -0
- package/src/cli/generate-zsh-completion.test.ts +95 -0
- package/src/cli/generate-zsh-completion.ts +497 -0
- package/src/cli/index.ts +1583 -0
- package/src/cli/interactive-config.test.ts +201 -0
- package/src/cli/interactive-config.ts +62 -0
- package/src/cli/parser.test.ts +227 -0
- package/src/cli/parser.ts +244 -0
- package/src/cli/prompts.test.ts +33 -0
- package/src/cli/prompts.ts +121 -0
- package/src/cli/types.ts +38 -0
- package/src/cli/validators.test.ts +235 -0
- package/src/cli/validators.ts +188 -0
- package/src/config/env.ts +41 -0
- package/src/config/paths.test.ts +172 -0
- package/src/config/paths.ts +108 -0
- package/src/db/client.ts +190 -0
- package/src/db/migrate.ts +30 -0
- package/src/db/schema.test.ts +221 -0
- package/src/db/schema.ts +434 -0
- package/src/hooks/capability-loader-firewall.test.ts +246 -0
- package/src/hooks/capability-loader.test.ts +100 -0
- package/src/hooks/capability-loader.ts +520 -0
- package/src/hooks/define-hook.test.ts +488 -0
- package/src/hooks/executor.test.ts +462 -0
- package/src/hooks/executor.ts +469 -0
- package/src/hooks/logger.test.ts +54 -0
- package/src/hooks/logger.ts +95 -0
- package/src/hooks/test-fixtures/failing-hook.ts +13 -0
- package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
- package/src/hooks/test-fixtures/success-hook.ts +20 -0
- package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
- package/src/hooks/test-fixtures/void-hook.ts +13 -0
- package/src/hooks/types.ts +89 -0
- package/src/infrastructure/property-extractor.test.ts +194 -0
- package/src/infrastructure/property-extractor.ts +151 -0
- package/src/ipam/allocator.test.ts +442 -0
- package/src/ipam/allocator.ts +369 -0
- package/src/ipam/auto-allocator.test.ts +247 -0
- package/src/ipam/auto-allocator.ts +270 -0
- package/src/ipam/subnet-parser.test.ts +107 -0
- package/src/ipam/subnet-parser.ts +136 -0
- package/src/manifest/contracts/index.ts +61 -0
- package/src/manifest/contracts/v1.ts +118 -0
- package/src/manifest/json-schema-roundtrip.test.ts +99 -0
- package/src/manifest/schema.ts +367 -0
- package/src/manifest/template-validator.test.ts +231 -0
- package/src/manifest/template-validator.ts +322 -0
- package/src/manifest/validate.test.ts +1180 -0
- package/src/manifest/validate.ts +415 -0
- package/src/module/import.test.ts +355 -0
- package/src/module/import.ts +676 -0
- package/src/module/packaging/audit.ts +169 -0
- package/src/module/packaging/build.ts +228 -0
- package/src/module/packaging/checksum.ts +41 -0
- package/src/module/packaging/extract.ts +234 -0
- package/src/module/packaging/signature.ts +47 -0
- package/src/secrets/encryption.test.ts +284 -0
- package/src/secrets/encryption.ts +162 -0
- package/src/secrets/generators.test.ts +112 -0
- package/src/secrets/generators.ts +127 -0
- package/src/secrets/master-key.test.ts +159 -0
- package/src/secrets/master-key.ts +114 -0
- package/src/secrets/storage.test.ts +115 -0
- package/src/secrets/storage.ts +106 -0
- package/src/secrets/vault.test.ts +35 -0
- package/src/secrets/vault.ts +42 -0
- package/src/services/backup-create.ts +532 -0
- package/src/services/backup-metadata.ts +198 -0
- package/src/services/backup-restore.ts +229 -0
- package/src/services/backup-retention.ts +84 -0
- package/src/services/backup-storage.ts +281 -0
- package/src/services/build-stream.test.ts +122 -0
- package/src/services/build-stream.ts +201 -0
- package/src/services/config-interview.ts +694 -0
- package/src/services/container-service.test.ts +298 -0
- package/src/services/container-service.ts +401 -0
- package/src/services/cross-module-data-manager.test.ts +405 -0
- package/src/services/cross-module-data-manager.ts +412 -0
- package/src/services/deploy-ansible.ts +88 -0
- package/src/services/deploy-planner.ts +153 -0
- package/src/services/deploy-preflight.ts +274 -0
- package/src/services/deploy-ssh.ts +131 -0
- package/src/services/deploy-terraform.test.ts +55 -0
- package/src/services/deploy-terraform.ts +445 -0
- package/src/services/deploy-validation.ts +311 -0
- package/src/services/dns-auto-register.ts +211 -0
- package/src/services/health-runner.ts +184 -0
- package/src/services/infrastructure-selector.test.ts +485 -0
- package/src/services/infrastructure-selector.ts +245 -0
- package/src/services/infrastructure-variable-resolver.test.ts +751 -0
- package/src/services/infrastructure-variable-resolver.ts +234 -0
- package/src/services/machine-detector.ts +328 -0
- package/src/services/machine-pool.test.ts +405 -0
- package/src/services/machine-pool.ts +316 -0
- package/src/services/manifest-validation.ts +120 -0
- package/src/services/module-build.test.ts +290 -0
- package/src/services/module-build.ts +431 -0
- package/src/services/module-config.test.ts +237 -0
- package/src/services/module-config.ts +298 -0
- package/src/services/module-deploy.ts +862 -0
- package/src/services/module-types-drift.test.ts +73 -0
- package/src/services/module-types-generator.test.ts +288 -0
- package/src/services/module-types-generator.ts +189 -0
- package/src/services/proxmox-state-recovery.ts +140 -0
- package/src/services/schema-validation.ts +155 -0
- package/src/services/secret-schema-loader.test.ts +311 -0
- package/src/services/secret-schema-loader.ts +239 -0
- package/src/services/ssh-key-manager.test.ts +283 -0
- package/src/services/ssh-key-manager.ts +193 -0
- package/src/services/storage-providers/local.ts +105 -0
- package/src/services/storage-providers/s3.ts +182 -0
- package/src/services/storage-providers/types.ts +24 -0
- package/src/services/system-config-schema-types.ts +25 -0
- package/src/services/system-config-validator.test.ts +160 -0
- package/src/services/system-config-validator.ts +74 -0
- package/src/services/system-init.test.ts +153 -0
- package/src/services/system-init.ts +253 -0
- package/src/services/terraform-safety.ts +174 -0
- package/src/services/zone-detector.test.ts +110 -0
- package/src/services/zone-detector.ts +102 -0
- package/src/services/zone-policy.test.ts +97 -0
- package/src/services/zone-policy.ts +126 -0
- package/src/templates/generator.test.ts +645 -0
- package/src/templates/generator.ts +1119 -0
- package/src/templates/types.ts +62 -0
- package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
- package/src/test-utils/cli-context-interactive.test.ts +152 -0
- package/src/test-utils/cli-context-server.test.ts +66 -0
- package/src/test-utils/cli-context.test.ts +273 -0
- package/src/test-utils/cli-context.ts +677 -0
- package/src/test-utils/cli-result.test.ts +282 -0
- package/src/test-utils/cli-result.ts +241 -0
- package/src/test-utils/cli.ts +55 -0
- package/src/test-utils/completion-harness.test.ts +126 -0
- package/src/test-utils/completion-harness.ts +82 -0
- package/src/test-utils/database.test.ts +182 -0
- package/src/test-utils/database.ts +126 -0
- package/src/test-utils/filesystem.test.ts +208 -0
- package/src/test-utils/filesystem.ts +142 -0
- package/src/test-utils/fixtures.test.ts +123 -0
- package/src/test-utils/fixtures.ts +160 -0
- package/src/test-utils/golden-diff.ts +197 -0
- package/src/test-utils/index.ts +77 -0
- package/src/test-utils/integration.ts +81 -0
- package/src/test-utils/module-fixtures.ts +468 -0
- package/src/test-utils/modules.test.ts +144 -0
- package/src/test-utils/modules.ts +183 -0
- package/src/test-utils/setup-test-db.ts +90 -0
- package/src/test-utils/value-extractor.test.ts +231 -0
- package/src/test-utils/value-extractor.ts +228 -0
- package/src/types/infrastructure.ts +157 -0
- package/src/utils/shell.test.ts +365 -0
- package/src/utils/shell.ts +159 -0
- package/src/validation/schemas.ts +166 -0
- package/src/variables/ansible-resolver.test.ts +142 -0
- package/src/variables/ansible-resolver.ts +69 -0
- package/src/variables/capability-self-ref.test.ts +220 -0
- package/src/variables/context.test.ts +1265 -0
- package/src/variables/context.ts +624 -0
- package/src/variables/declarative-derivation.test.ts +743 -0
- package/src/variables/declarative-derivation.ts +200 -0
- package/src/variables/parser.test.ts +231 -0
- package/src/variables/parser.ts +76 -0
- package/src/variables/resolver.test.ts +458 -0
- package/src/variables/resolver.ts +282 -0
- package/src/variables/types.ts +59 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SSH key manager
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { closeDb } from '../db/client';
|
|
10
|
+
import { runMigrations } from '../db/migrate';
|
|
11
|
+
import { addMachine } from './machine-pool';
|
|
12
|
+
import {
|
|
13
|
+
ManagedSshKey,
|
|
14
|
+
cleanupTemporarySshKeys,
|
|
15
|
+
deleteTemporarySshKey,
|
|
16
|
+
writeTemporarySshKey,
|
|
17
|
+
} from './ssh-key-manager';
|
|
18
|
+
|
|
19
|
+
describe('ssh-key-manager', () => {
|
|
20
|
+
let testDbPath: string;
|
|
21
|
+
let testDir: string;
|
|
22
|
+
let dataDir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
// Create temp directory for test database and data
|
|
26
|
+
testDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
|
|
27
|
+
testDbPath = join(testDir, 'test.db');
|
|
28
|
+
dataDir = join(testDir, 'data');
|
|
29
|
+
|
|
30
|
+
// Set environment variables
|
|
31
|
+
process.env.CELILO_DB_PATH = testDbPath;
|
|
32
|
+
process.env.CELILO_DATA_DIR = dataDir;
|
|
33
|
+
|
|
34
|
+
// Initialize database and run migrations
|
|
35
|
+
await runMigrations(testDbPath);
|
|
36
|
+
|
|
37
|
+
// Create a dummy master key for encryption
|
|
38
|
+
const masterKeyPath = join(dataDir, 'master.key');
|
|
39
|
+
process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
|
|
40
|
+
const fs = await import('node:fs/promises');
|
|
41
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
42
|
+
await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
// Close database connection
|
|
47
|
+
closeDb();
|
|
48
|
+
|
|
49
|
+
// Clean up test directory
|
|
50
|
+
if (testDir) {
|
|
51
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Clear environment variables
|
|
55
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
56
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
57
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('writeTemporarySshKey', () => {
|
|
61
|
+
it('creates temp directory and writes key file', async () => {
|
|
62
|
+
const machine = await addMachine({
|
|
63
|
+
hostname: 'test-machine',
|
|
64
|
+
zone: 'internal',
|
|
65
|
+
ipAddress: '192.168.1.100',
|
|
66
|
+
sshUser: 'root',
|
|
67
|
+
sshKey: 'test-ssh-key-content',
|
|
68
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
69
|
+
role: 'host',
|
|
70
|
+
interfaces: [],
|
|
71
|
+
assignedModuleIds: [],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const keyPath = await writeTemporarySshKey(machine.id);
|
|
75
|
+
|
|
76
|
+
// Check file exists
|
|
77
|
+
expect(existsSync(keyPath)).toBe(true);
|
|
78
|
+
|
|
79
|
+
// Check file content is decrypted
|
|
80
|
+
const content = readFileSync(keyPath, 'utf8');
|
|
81
|
+
expect(content).toBe('test-ssh-key-content');
|
|
82
|
+
|
|
83
|
+
// Check file permissions (should be 0o600)
|
|
84
|
+
const stats = statSync(keyPath);
|
|
85
|
+
const mode = stats.mode & 0o777;
|
|
86
|
+
expect(mode).toBe(0o600);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns correct temp key path', async () => {
|
|
90
|
+
const machine = await addMachine({
|
|
91
|
+
hostname: 'test-machine',
|
|
92
|
+
zone: 'internal',
|
|
93
|
+
ipAddress: '192.168.1.100',
|
|
94
|
+
sshUser: 'root',
|
|
95
|
+
sshKey: 'test-ssh-key-content',
|
|
96
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
97
|
+
role: 'host',
|
|
98
|
+
interfaces: [],
|
|
99
|
+
assignedModuleIds: [],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const keyPath = await writeTemporarySshKey(machine.id);
|
|
103
|
+
|
|
104
|
+
expect(keyPath).toContain('tmp/ansible-keys');
|
|
105
|
+
expect(keyPath).toContain(`machine-${machine.id}.key`);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('deleteTemporarySshKey', () => {
|
|
110
|
+
it('removes temp key file', async () => {
|
|
111
|
+
const machine = await addMachine({
|
|
112
|
+
hostname: 'test-machine',
|
|
113
|
+
zone: 'internal',
|
|
114
|
+
ipAddress: '192.168.1.100',
|
|
115
|
+
sshUser: 'root',
|
|
116
|
+
sshKey: 'test-ssh-key-content',
|
|
117
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
118
|
+
role: 'host',
|
|
119
|
+
interfaces: [],
|
|
120
|
+
assignedModuleIds: [],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const keyPath = await writeTemporarySshKey(machine.id);
|
|
124
|
+
expect(existsSync(keyPath)).toBe(true);
|
|
125
|
+
|
|
126
|
+
deleteTemporarySshKey(machine.id);
|
|
127
|
+
expect(existsSync(keyPath)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not throw if key file does not exist', () => {
|
|
131
|
+
expect(() => deleteTemporarySshKey('non-existent-machine')).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('cleanupTemporarySshKeys', () => {
|
|
136
|
+
it('removes all temp key files', async () => {
|
|
137
|
+
const machine1 = await addMachine({
|
|
138
|
+
hostname: 'machine1',
|
|
139
|
+
zone: 'internal',
|
|
140
|
+
ipAddress: '192.168.1.100',
|
|
141
|
+
sshUser: 'root',
|
|
142
|
+
sshKey: 'key1',
|
|
143
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
144
|
+
role: 'host',
|
|
145
|
+
interfaces: [],
|
|
146
|
+
assignedModuleIds: [],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const machine2 = await addMachine({
|
|
150
|
+
hostname: 'machine2',
|
|
151
|
+
zone: 'internal',
|
|
152
|
+
ipAddress: '192.168.1.101',
|
|
153
|
+
sshUser: 'root',
|
|
154
|
+
sshKey: 'key2',
|
|
155
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
156
|
+
role: 'host',
|
|
157
|
+
interfaces: [],
|
|
158
|
+
assignedModuleIds: [],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const keyPath1 = await writeTemporarySshKey(machine1.id);
|
|
162
|
+
const keyPath2 = await writeTemporarySshKey(machine2.id);
|
|
163
|
+
|
|
164
|
+
expect(existsSync(keyPath1)).toBe(true);
|
|
165
|
+
expect(existsSync(keyPath2)).toBe(true);
|
|
166
|
+
|
|
167
|
+
cleanupTemporarySshKeys();
|
|
168
|
+
|
|
169
|
+
expect(existsSync(keyPath1)).toBe(false);
|
|
170
|
+
expect(existsSync(keyPath2)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('does not throw if temp directory does not exist', () => {
|
|
174
|
+
expect(() => cleanupTemporarySshKeys()).not.toThrow();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('ManagedSshKey', () => {
|
|
179
|
+
it('writes key and provides path', async () => {
|
|
180
|
+
const machine = await addMachine({
|
|
181
|
+
hostname: 'test-machine',
|
|
182
|
+
zone: 'internal',
|
|
183
|
+
ipAddress: '192.168.1.100',
|
|
184
|
+
sshUser: 'root',
|
|
185
|
+
sshKey: 'test-key',
|
|
186
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
187
|
+
role: 'host',
|
|
188
|
+
interfaces: [],
|
|
189
|
+
assignedModuleIds: [],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const managedKey = new ManagedSshKey(machine.id);
|
|
193
|
+
const keyPath = await managedKey.write();
|
|
194
|
+
|
|
195
|
+
expect(existsSync(keyPath)).toBe(true);
|
|
196
|
+
expect(managedKey.getPath()).toBe(keyPath);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('cleans up key file', async () => {
|
|
200
|
+
const machine = await addMachine({
|
|
201
|
+
hostname: 'test-machine',
|
|
202
|
+
zone: 'internal',
|
|
203
|
+
ipAddress: '192.168.1.100',
|
|
204
|
+
sshUser: 'root',
|
|
205
|
+
sshKey: 'test-key',
|
|
206
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
207
|
+
role: 'host',
|
|
208
|
+
interfaces: [],
|
|
209
|
+
assignedModuleIds: [],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const managedKey = new ManagedSshKey(machine.id);
|
|
213
|
+
const keyPath = await managedKey.write();
|
|
214
|
+
|
|
215
|
+
expect(existsSync(keyPath)).toBe(true);
|
|
216
|
+
|
|
217
|
+
managedKey.cleanup();
|
|
218
|
+
expect(existsSync(keyPath)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('use() method handles automatic cleanup', async () => {
|
|
222
|
+
const machine = await addMachine({
|
|
223
|
+
hostname: 'test-machine',
|
|
224
|
+
zone: 'internal',
|
|
225
|
+
ipAddress: '192.168.1.100',
|
|
226
|
+
sshUser: 'root',
|
|
227
|
+
sshKey: 'test-key',
|
|
228
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
229
|
+
role: 'host',
|
|
230
|
+
interfaces: [],
|
|
231
|
+
assignedModuleIds: [],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const managedKey = new ManagedSshKey(machine.id);
|
|
235
|
+
let capturedPath: string | null = null;
|
|
236
|
+
|
|
237
|
+
await managedKey.use(async (keyPath) => {
|
|
238
|
+
capturedPath = keyPath;
|
|
239
|
+
expect(existsSync(keyPath)).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Key should be cleaned up after callback
|
|
243
|
+
expect(capturedPath).not.toBeNull();
|
|
244
|
+
// biome-ignore lint/style/noNonNullAssertion: checked with not.toBeNull() above
|
|
245
|
+
expect(existsSync(capturedPath!)).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('use() method cleans up even if callback throws', async () => {
|
|
249
|
+
const machine = await addMachine({
|
|
250
|
+
hostname: 'test-machine',
|
|
251
|
+
zone: 'internal',
|
|
252
|
+
ipAddress: '192.168.1.100',
|
|
253
|
+
sshUser: 'root',
|
|
254
|
+
sshKey: 'test-key',
|
|
255
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
256
|
+
role: 'host',
|
|
257
|
+
interfaces: [],
|
|
258
|
+
assignedModuleIds: [],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const managedKey = new ManagedSshKey(machine.id);
|
|
262
|
+
let capturedPath: string | null = null;
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
managedKey.use(async (keyPath) => {
|
|
266
|
+
capturedPath = keyPath;
|
|
267
|
+
throw new Error('Test error');
|
|
268
|
+
}),
|
|
269
|
+
).rejects.toThrow('Test error');
|
|
270
|
+
|
|
271
|
+
// Key should still be cleaned up despite error
|
|
272
|
+
expect(capturedPath).not.toBeNull();
|
|
273
|
+
// biome-ignore lint/style/noNonNullAssertion: checked with not.toBeNull() above
|
|
274
|
+
expect(existsSync(capturedPath!)).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('throws if getPath() called before write()', () => {
|
|
278
|
+
const managedKey = new ManagedSshKey('test-machine-id');
|
|
279
|
+
|
|
280
|
+
expect(() => managedKey.getPath()).toThrow('SSH key not written yet');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Key Manager
|
|
3
|
+
* Handles temporary SSH key files for Ansible execution
|
|
4
|
+
* Keys are stored encrypted in database, written temporarily to filesystem for Ansible
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { getDataDir } from '../config/paths';
|
|
10
|
+
import { getMachineSshKey } from './machine-pool';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the temporary SSH keys directory
|
|
14
|
+
*/
|
|
15
|
+
function getTempKeysDir(): string {
|
|
16
|
+
return join(getDataDir(), 'tmp', 'ansible-keys');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the temp keys directory exists
|
|
21
|
+
*/
|
|
22
|
+
function ensureTempKeysDir(): void {
|
|
23
|
+
const dir = getTempKeysDir();
|
|
24
|
+
if (!existsSync(dir)) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the temporary key file path for a machine
|
|
31
|
+
*/
|
|
32
|
+
export function getTempKeyPath(machineId: string): string {
|
|
33
|
+
return join(getTempKeysDir(), `machine-${machineId}.key`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Write a temporary SSH key file for Ansible
|
|
38
|
+
* The key is decrypted from the database and written to a temporary file
|
|
39
|
+
*
|
|
40
|
+
* @param machineId - Machine ID
|
|
41
|
+
* @returns Absolute path to the temporary key file
|
|
42
|
+
*/
|
|
43
|
+
export async function writeTemporarySshKey(machineId: string): Promise<string> {
|
|
44
|
+
ensureTempKeysDir();
|
|
45
|
+
|
|
46
|
+
// Get decrypted SSH key from database
|
|
47
|
+
const keyContent = await getMachineSshKey(machineId);
|
|
48
|
+
|
|
49
|
+
// Write to temporary file with restrictive permissions
|
|
50
|
+
const keyPath = getTempKeyPath(machineId);
|
|
51
|
+
writeFileSync(keyPath, keyContent, { mode: 0o600 });
|
|
52
|
+
|
|
53
|
+
return keyPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Delete a temporary SSH key file
|
|
58
|
+
*
|
|
59
|
+
* @param machineId - Machine ID
|
|
60
|
+
*/
|
|
61
|
+
export function deleteTemporarySshKey(machineId: string): void {
|
|
62
|
+
const keyPath = getTempKeyPath(machineId);
|
|
63
|
+
if (existsSync(keyPath)) {
|
|
64
|
+
rmSync(keyPath, { force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clean up all temporary SSH key files
|
|
70
|
+
* Should be called after Ansible execution or on process exit
|
|
71
|
+
*/
|
|
72
|
+
export function cleanupTemporarySshKeys(): void {
|
|
73
|
+
const dir = getTempKeysDir();
|
|
74
|
+
if (!existsSync(dir)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Remove all .key files
|
|
79
|
+
const files = readdirSync(dir);
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
if (file.endsWith('.key')) {
|
|
82
|
+
const filePath = join(dir, file);
|
|
83
|
+
rmSync(filePath, { force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register cleanup handlers for process exit
|
|
90
|
+
* Ensures temporary SSH keys are always cleaned up
|
|
91
|
+
*/
|
|
92
|
+
export function registerCleanupHandlers(): void {
|
|
93
|
+
// Clean up on normal exit
|
|
94
|
+
process.on('exit', () => {
|
|
95
|
+
try {
|
|
96
|
+
cleanupTemporarySshKeys();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('Failed to cleanup SSH keys on exit:', error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Clean up on SIGINT (Ctrl+C)
|
|
103
|
+
process.on('SIGINT', () => {
|
|
104
|
+
try {
|
|
105
|
+
cleanupTemporarySshKeys();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Failed to cleanup SSH keys on SIGINT:', error);
|
|
108
|
+
}
|
|
109
|
+
process.exit(130); // Standard exit code for SIGINT
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Clean up on SIGTERM
|
|
113
|
+
process.on('SIGTERM', () => {
|
|
114
|
+
try {
|
|
115
|
+
cleanupTemporarySshKeys();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error('Failed to cleanup SSH keys on SIGTERM:', error);
|
|
118
|
+
}
|
|
119
|
+
process.exit(143); // Standard exit code for SIGTERM
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Clean up on uncaught exception
|
|
123
|
+
process.on('uncaughtException', (error) => {
|
|
124
|
+
console.error('Uncaught exception:', error);
|
|
125
|
+
try {
|
|
126
|
+
cleanupTemporarySshKeys();
|
|
127
|
+
} catch (cleanupError) {
|
|
128
|
+
console.error('Failed to cleanup SSH keys on uncaught exception:', cleanupError);
|
|
129
|
+
}
|
|
130
|
+
process.exit(1);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Managed SSH key context for safe cleanup
|
|
136
|
+
* Use this for automatic cleanup in try-finally patterns
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* const keyPath = await writeTemporarySshKey(machineId);
|
|
140
|
+
* try {
|
|
141
|
+
* await runAnsible(keyPath);
|
|
142
|
+
* } finally {
|
|
143
|
+
* deleteTemporarySshKey(machineId);
|
|
144
|
+
* }
|
|
145
|
+
*/
|
|
146
|
+
export class ManagedSshKey {
|
|
147
|
+
private machineId: string;
|
|
148
|
+
private keyPath: string | null = null;
|
|
149
|
+
|
|
150
|
+
constructor(machineId: string) {
|
|
151
|
+
this.machineId = machineId;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Write the SSH key and return the path
|
|
156
|
+
*/
|
|
157
|
+
async write(): Promise<string> {
|
|
158
|
+
this.keyPath = await writeTemporarySshKey(this.machineId);
|
|
159
|
+
return this.keyPath;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the key path (throws if not written yet)
|
|
164
|
+
*/
|
|
165
|
+
getPath(): string {
|
|
166
|
+
if (!this.keyPath) {
|
|
167
|
+
throw new Error('SSH key not written yet. Call write() first.');
|
|
168
|
+
}
|
|
169
|
+
return this.keyPath;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clean up the temporary key file
|
|
174
|
+
*/
|
|
175
|
+
cleanup(): void {
|
|
176
|
+
if (this.keyPath) {
|
|
177
|
+
deleteTemporarySshKey(this.machineId);
|
|
178
|
+
this.keyPath = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Use the key in a callback with automatic cleanup
|
|
184
|
+
*/
|
|
185
|
+
async use<T>(callback: (keyPath: string) => Promise<T>): Promise<T> {
|
|
186
|
+
await this.write();
|
|
187
|
+
try {
|
|
188
|
+
return await callback(this.getPath());
|
|
189
|
+
} finally {
|
|
190
|
+
this.cleanup();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem storage provider.
|
|
3
|
+
* Stores backup archives in a local directory (or NAS-mounted path).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
copyFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { dirname, join, relative } from 'node:path';
|
|
15
|
+
import type { StorageProvider, StorageVerifyResult } from './types';
|
|
16
|
+
|
|
17
|
+
const BACKUP_PREFIX = 'celilo-backups';
|
|
18
|
+
|
|
19
|
+
export interface LocalStorageConfig {
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createLocalStorageProvider(config: LocalStorageConfig): StorageProvider {
|
|
24
|
+
const basePath = join(config.path, BACKUP_PREFIX);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async upload(localPath: string, remotePath: string): Promise<void> {
|
|
28
|
+
const destPath = join(basePath, remotePath);
|
|
29
|
+
const destDir = dirname(destPath);
|
|
30
|
+
mkdirSync(destDir, { recursive: true });
|
|
31
|
+
copyFileSync(localPath, destPath);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async download(remotePath: string, localPath: string): Promise<void> {
|
|
35
|
+
const srcPath = join(basePath, remotePath);
|
|
36
|
+
if (!existsSync(srcPath)) {
|
|
37
|
+
throw new Error(`Backup file not found: ${remotePath}`);
|
|
38
|
+
}
|
|
39
|
+
const destDir = dirname(localPath);
|
|
40
|
+
mkdirSync(destDir, { recursive: true });
|
|
41
|
+
copyFileSync(srcPath, localPath);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async delete(remotePath: string): Promise<void> {
|
|
45
|
+
const fullPath = join(basePath, remotePath);
|
|
46
|
+
if (existsSync(fullPath)) {
|
|
47
|
+
unlinkSync(fullPath);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async list(prefix: string): Promise<string[]> {
|
|
52
|
+
const searchDir = join(basePath, prefix);
|
|
53
|
+
if (!existsSync(searchDir)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return collectFiles(searchDir).map((f) => relative(basePath, f));
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async verify(): Promise<StorageVerifyResult> {
|
|
60
|
+
try {
|
|
61
|
+
// Check base directory is writable
|
|
62
|
+
mkdirSync(basePath, { recursive: true });
|
|
63
|
+
|
|
64
|
+
// Write test file
|
|
65
|
+
const testFile = join(basePath, '.celilo-verify-test');
|
|
66
|
+
writeFileSync(testFile, 'verify');
|
|
67
|
+
|
|
68
|
+
// Read it back
|
|
69
|
+
const content = Bun.file(testFile);
|
|
70
|
+
const text = await content.text();
|
|
71
|
+
if (text !== 'verify') {
|
|
72
|
+
return { success: false, message: 'Read-back verification failed' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Delete test file
|
|
76
|
+
unlinkSync(testFile);
|
|
77
|
+
|
|
78
|
+
return { success: true, message: `Write test passed at ${basePath}` };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
message: `Storage verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async initialize(): Promise<void> {
|
|
88
|
+
mkdirSync(basePath, { recursive: true });
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectFiles(dir: string): string[] {
|
|
94
|
+
const results: string[] = [];
|
|
95
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const fullPath = join(dir, entry.name);
|
|
98
|
+
if (entry.isDirectory()) {
|
|
99
|
+
results.push(...collectFiles(fullPath));
|
|
100
|
+
} else {
|
|
101
|
+
results.push(fullPath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|