@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,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup creation service.
|
|
3
|
+
* Orchestrates the backup workflow: create temp files, encrypt, upload to storage.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
copyFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { eq } from 'drizzle-orm';
|
|
18
|
+
import { getDbPath } from '../config/paths';
|
|
19
|
+
import { getDb } from '../db/client';
|
|
20
|
+
import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
|
|
21
|
+
import { invokeHook } from '../hooks/executor';
|
|
22
|
+
import { createConsoleLogger } from '../hooks/logger';
|
|
23
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
24
|
+
import { decryptSecret, encryptSecret } from '../secrets/encryption';
|
|
25
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
26
|
+
import { shellEscape } from '../utils/shell';
|
|
27
|
+
import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
|
|
28
|
+
import { createStorageProvider, getBackupStorage, getDefaultBackupStorage } from './backup-storage';
|
|
29
|
+
|
|
30
|
+
export interface BackupCreateOptions {
|
|
31
|
+
storageId?: string;
|
|
32
|
+
force?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BackupCreateResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
backupId?: string;
|
|
38
|
+
storagePath?: string;
|
|
39
|
+
sizeBytes?: number;
|
|
40
|
+
schemaVersion?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the target storage destination
|
|
48
|
+
*/
|
|
49
|
+
export function resolveStorage(storageId?: string) {
|
|
50
|
+
if (storageId) {
|
|
51
|
+
const storage = getBackupStorage(storageId);
|
|
52
|
+
if (!storage) {
|
|
53
|
+
throw new Error(`Storage not found: ${storageId}`);
|
|
54
|
+
}
|
|
55
|
+
if (!storage.verified) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Storage '${storage.storageId}' is not verified. Run: celilo storage verify ${storage.storageId}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return storage;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultStorage = getDefaultBackupStorage();
|
|
64
|
+
if (!defaultStorage) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'No default backup storage configured.\n\nAdd storage first: celilo storage add local',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!defaultStorage.verified) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Default storage '${defaultStorage.storageId}' is not verified. Run: celilo storage verify ${defaultStorage.storageId}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return defaultStorage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a system state backup (Celilo database)
|
|
79
|
+
*/
|
|
80
|
+
export async function createSystemStateBackup(
|
|
81
|
+
options: BackupCreateOptions = {},
|
|
82
|
+
): Promise<BackupCreateResult> {
|
|
83
|
+
const storage = resolveStorage(options.storageId);
|
|
84
|
+
const provider = await createStorageProvider(storage.id);
|
|
85
|
+
|
|
86
|
+
const now = new Date();
|
|
87
|
+
const dateDir = now.toISOString().slice(0, 10);
|
|
88
|
+
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
|
89
|
+
const storagePath = `${dateDir}/system-${timestamp}.backup`;
|
|
90
|
+
|
|
91
|
+
const record = createBackupRecord({
|
|
92
|
+
moduleId: null,
|
|
93
|
+
storageId: storage.id,
|
|
94
|
+
storagePath,
|
|
95
|
+
backupType: 'system_state',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(tempDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
// Copy the SQLite database to temp (safe point-in-time copy)
|
|
104
|
+
const dbPath = getDbPath();
|
|
105
|
+
const tempDbPath = join(tempDir, 'celilo.db');
|
|
106
|
+
copyFileSync(dbPath, tempDbPath);
|
|
107
|
+
|
|
108
|
+
// Also copy WAL file if it exists (for consistency)
|
|
109
|
+
const walPath = `${dbPath}-wal`;
|
|
110
|
+
try {
|
|
111
|
+
copyFileSync(walPath, join(tempDir, 'celilo.db-wal'));
|
|
112
|
+
} catch {
|
|
113
|
+
// WAL may not exist — that's fine
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Read the database file and encrypt it
|
|
117
|
+
const dbData = readFileSync(tempDbPath);
|
|
118
|
+
const masterKey = await getOrCreateMasterKey();
|
|
119
|
+
const encrypted = encryptSecret(dbData.toString('base64'), masterKey);
|
|
120
|
+
|
|
121
|
+
// Write encrypted payload to temp file
|
|
122
|
+
const encryptedPath = join(tempDir, 'system.enc');
|
|
123
|
+
writeFileSync(encryptedPath, JSON.stringify(encrypted));
|
|
124
|
+
|
|
125
|
+
const encryptedSize = statSync(encryptedPath).size;
|
|
126
|
+
|
|
127
|
+
// Upload to storage
|
|
128
|
+
await provider.upload(encryptedPath, storagePath);
|
|
129
|
+
|
|
130
|
+
// Mark backup as completed
|
|
131
|
+
completeBackup(record.id, {
|
|
132
|
+
sizeBytes: encryptedSize,
|
|
133
|
+
metadata: {
|
|
134
|
+
originalSizeBytes: dbData.length,
|
|
135
|
+
dbPath,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
success: true,
|
|
141
|
+
backupId: record.id,
|
|
142
|
+
storagePath,
|
|
143
|
+
sizeBytes: encryptedSize,
|
|
144
|
+
};
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
failBackup(record.id, message);
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
backupId: record.id,
|
|
151
|
+
error: message,
|
|
152
|
+
};
|
|
153
|
+
} finally {
|
|
154
|
+
// Clean up temp directory
|
|
155
|
+
try {
|
|
156
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
157
|
+
} catch {
|
|
158
|
+
// Best effort cleanup
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Schedule intervals in milliseconds
|
|
165
|
+
*/
|
|
166
|
+
const SCHEDULE_INTERVALS: Record<BackupSchedule, number> = {
|
|
167
|
+
hourly: 60 * 60 * 1000,
|
|
168
|
+
daily: 24 * 60 * 60 * 1000,
|
|
169
|
+
weekly: 7 * 24 * 60 * 60 * 1000,
|
|
170
|
+
monthly: 30 * 24 * 60 * 60 * 1000,
|
|
171
|
+
manual: Number.POSITIVE_INFINITY,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if a module is due for backup based on its schedule
|
|
176
|
+
*/
|
|
177
|
+
export function isBackupDue(moduleId: string, schedule: BackupSchedule): boolean {
|
|
178
|
+
if (schedule === 'manual') return false;
|
|
179
|
+
|
|
180
|
+
const existing = listBackups({ moduleId, limit: 1 });
|
|
181
|
+
const lastBackup = existing.find(
|
|
182
|
+
(b) => b.moduleId === moduleId && b.status === 'completed' && b.backupType === 'module_data',
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (!lastBackup) return true;
|
|
186
|
+
|
|
187
|
+
const elapsed = Date.now() - new Date(lastBackup.startedAt).getTime();
|
|
188
|
+
return elapsed >= SCHEDULE_INTERVALS[schedule];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find all installed modules that have an on_backup hook
|
|
193
|
+
*/
|
|
194
|
+
export function findBackupEligibleModules(): Array<{
|
|
195
|
+
module: typeof modules.$inferSelect;
|
|
196
|
+
manifest: ModuleManifest;
|
|
197
|
+
}> {
|
|
198
|
+
const db = getDb();
|
|
199
|
+
const allModules = db.select().from(modules).all();
|
|
200
|
+
|
|
201
|
+
const eligible: Array<{
|
|
202
|
+
module: typeof modules.$inferSelect;
|
|
203
|
+
manifest: ModuleManifest;
|
|
204
|
+
}> = [];
|
|
205
|
+
|
|
206
|
+
for (const mod of allModules) {
|
|
207
|
+
if (mod.state !== 'INSTALLED' && mod.state !== 'VERIFIED') continue;
|
|
208
|
+
|
|
209
|
+
const manifest = mod.manifestData as unknown as ModuleManifest;
|
|
210
|
+
if (!manifest.hooks?.on_backup) continue;
|
|
211
|
+
|
|
212
|
+
eligible.push({ module: mod, manifest });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return eligible;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build config and secret maps for a module (same pattern as health-runner.ts)
|
|
220
|
+
*/
|
|
221
|
+
async function buildModuleContext(moduleId: string): Promise<{
|
|
222
|
+
configMap: Record<string, unknown>;
|
|
223
|
+
secretMap: Record<string, string>;
|
|
224
|
+
}> {
|
|
225
|
+
const db = getDb();
|
|
226
|
+
|
|
227
|
+
const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
|
|
228
|
+
const configMap: Record<string, unknown> = {};
|
|
229
|
+
for (const c of configs) {
|
|
230
|
+
configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const secretRecords = db
|
|
234
|
+
.select()
|
|
235
|
+
.from(secretsTable)
|
|
236
|
+
.where(eq(secretsTable.moduleId, moduleId))
|
|
237
|
+
.all();
|
|
238
|
+
const masterKey = await getOrCreateMasterKey();
|
|
239
|
+
const secretMap: Record<string, string> = {};
|
|
240
|
+
for (const s of secretRecords) {
|
|
241
|
+
secretMap[s.name] = decryptSecret(
|
|
242
|
+
{ encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
|
|
243
|
+
masterKey,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { configMap, secretMap };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Create a module data backup by executing its on_backup hook
|
|
252
|
+
*/
|
|
253
|
+
export async function createModuleBackup(
|
|
254
|
+
moduleId: string,
|
|
255
|
+
options: BackupCreateOptions = {},
|
|
256
|
+
): Promise<BackupCreateResult> {
|
|
257
|
+
const db = getDb();
|
|
258
|
+
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
259
|
+
if (!mod) {
|
|
260
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const manifest = mod.manifestData as unknown as ModuleManifest;
|
|
264
|
+
const hookDef = manifest.hooks?.on_backup;
|
|
265
|
+
if (!hookDef) {
|
|
266
|
+
return { success: false, error: `Module '${moduleId}' has no on_backup hook` };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const storage = resolveStorage(options.storageId);
|
|
270
|
+
const provider = await createStorageProvider(storage.id);
|
|
271
|
+
|
|
272
|
+
const now = new Date();
|
|
273
|
+
const dateDir = now.toISOString().slice(0, 10);
|
|
274
|
+
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
|
275
|
+
const storagePath = `${dateDir}/${moduleId}-${timestamp}.backup`;
|
|
276
|
+
|
|
277
|
+
const record = createBackupRecord({
|
|
278
|
+
moduleId,
|
|
279
|
+
storageId: storage.id,
|
|
280
|
+
storagePath,
|
|
281
|
+
backupType: 'module_data',
|
|
282
|
+
moduleVersion: manifest.version,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
|
|
286
|
+
const backupDir = join(tempDir, 'artifacts');
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
mkdirSync(backupDir, { recursive: true });
|
|
290
|
+
|
|
291
|
+
// Build context for hook execution
|
|
292
|
+
const { configMap, secretMap } = await buildModuleContext(moduleId);
|
|
293
|
+
const logger = createConsoleLogger(moduleId, 'on_backup');
|
|
294
|
+
|
|
295
|
+
// Execute on_backup hook — it writes artifacts to backupDir
|
|
296
|
+
const hookResult = await invokeHook(
|
|
297
|
+
mod.sourcePath,
|
|
298
|
+
'on_backup',
|
|
299
|
+
manifest.celilo_contract,
|
|
300
|
+
hookDef,
|
|
301
|
+
{ backup_dir: backupDir },
|
|
302
|
+
configMap,
|
|
303
|
+
secretMap,
|
|
304
|
+
logger,
|
|
305
|
+
{
|
|
306
|
+
debug: false,
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (!hookResult.success) {
|
|
311
|
+
failBackup(record.id, hookResult.error ?? 'on_backup hook failed');
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
backupId: record.id,
|
|
315
|
+
error: hookResult.error ?? 'on_backup hook failed',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Tar the backup artifacts
|
|
320
|
+
const tarPath = join(tempDir, 'backup.tar');
|
|
321
|
+
const { execSync } = await import('node:child_process');
|
|
322
|
+
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(backupDir)} .`);
|
|
323
|
+
|
|
324
|
+
// Encrypt the tar
|
|
325
|
+
const tarData = readFileSync(tarPath);
|
|
326
|
+
const masterKey = await getOrCreateMasterKey();
|
|
327
|
+
const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
|
|
328
|
+
|
|
329
|
+
const encryptedPath = join(tempDir, 'backup.tar.enc');
|
|
330
|
+
writeFileSync(encryptedPath, JSON.stringify(encrypted));
|
|
331
|
+
|
|
332
|
+
const encryptedSize = statSync(encryptedPath).size;
|
|
333
|
+
|
|
334
|
+
// Upload to storage
|
|
335
|
+
await provider.upload(encryptedPath, storagePath);
|
|
336
|
+
|
|
337
|
+
// Extract schema_version from hook outputs (optional)
|
|
338
|
+
const schemaVersion =
|
|
339
|
+
typeof hookResult.outputs.schema_version === 'string'
|
|
340
|
+
? hookResult.outputs.schema_version
|
|
341
|
+
: undefined;
|
|
342
|
+
|
|
343
|
+
// Mark backup as completed
|
|
344
|
+
completeBackup(record.id, {
|
|
345
|
+
sizeBytes: encryptedSize,
|
|
346
|
+
metadata: {
|
|
347
|
+
artifactCount: hookResult.outputs.artifact_count,
|
|
348
|
+
originalSizeBytes: tarData.length,
|
|
349
|
+
},
|
|
350
|
+
schemaVersion,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
success: true,
|
|
355
|
+
backupId: record.id,
|
|
356
|
+
storagePath,
|
|
357
|
+
sizeBytes: encryptedSize,
|
|
358
|
+
schemaVersion,
|
|
359
|
+
};
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
362
|
+
failBackup(record.id, message);
|
|
363
|
+
return {
|
|
364
|
+
success: false,
|
|
365
|
+
backupId: record.id,
|
|
366
|
+
error: message,
|
|
367
|
+
};
|
|
368
|
+
} finally {
|
|
369
|
+
try {
|
|
370
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
371
|
+
} catch {
|
|
372
|
+
// Best effort cleanup
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface ImportBackupOptions {
|
|
378
|
+
storageId?: string;
|
|
379
|
+
schemaVersion?: string;
|
|
380
|
+
name?: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Import a local file as a module backup (bypasses on_backup hook).
|
|
385
|
+
* The file is placed into the backup archive as `db.sqlite`, matching
|
|
386
|
+
* the artifact name produced by the on_backup hook.
|
|
387
|
+
*/
|
|
388
|
+
export async function importModuleBackup(
|
|
389
|
+
filePath: string,
|
|
390
|
+
moduleId: string,
|
|
391
|
+
options: ImportBackupOptions = {},
|
|
392
|
+
): Promise<BackupCreateResult> {
|
|
393
|
+
const db = getDb();
|
|
394
|
+
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
395
|
+
if (!mod) {
|
|
396
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!existsSync(filePath)) {
|
|
400
|
+
return { success: false, error: `File not found: ${filePath}` };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const manifest = mod.manifestData as unknown as ModuleManifest;
|
|
404
|
+
const storage = resolveStorage(options.storageId);
|
|
405
|
+
const provider = await createStorageProvider(storage.id);
|
|
406
|
+
|
|
407
|
+
const now = new Date();
|
|
408
|
+
const dateDir = now.toISOString().slice(0, 10);
|
|
409
|
+
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
|
410
|
+
const storagePath = `${dateDir}/${moduleId}-${timestamp}.backup`;
|
|
411
|
+
|
|
412
|
+
const record = createBackupRecord({
|
|
413
|
+
moduleId,
|
|
414
|
+
storageId: storage.id,
|
|
415
|
+
storagePath,
|
|
416
|
+
backupType: 'module_data',
|
|
417
|
+
moduleVersion: manifest.version,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const tempDir = join(tmpdir(), `celilo-backup-${record.id}`);
|
|
421
|
+
const artifactDir = join(tempDir, 'artifacts');
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
425
|
+
|
|
426
|
+
// Copy the file as db.sqlite (matching on_backup hook output)
|
|
427
|
+
const destPath = join(artifactDir, 'db.sqlite');
|
|
428
|
+
copyFileSync(filePath, destPath);
|
|
429
|
+
|
|
430
|
+
// If the module has an on_backup_analyze hook, invoke it to extract metadata
|
|
431
|
+
let analyzedSchemaVersion: string | undefined = options.schemaVersion;
|
|
432
|
+
let analyzedMetadata: Record<string, unknown> = {
|
|
433
|
+
artifactCount: '1',
|
|
434
|
+
originalSizeBytes: 0,
|
|
435
|
+
importedFrom: filePath,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const analyzeHook = manifest.hooks?.on_backup_analyze;
|
|
439
|
+
if (analyzeHook) {
|
|
440
|
+
const { configMap, secretMap } = await buildModuleContext(moduleId);
|
|
441
|
+
const logger = createConsoleLogger(moduleId, 'on_backup_analyze');
|
|
442
|
+
|
|
443
|
+
const analyzeResult = await invokeHook(
|
|
444
|
+
mod.sourcePath,
|
|
445
|
+
'on_backup_analyze',
|
|
446
|
+
manifest.celilo_contract,
|
|
447
|
+
analyzeHook,
|
|
448
|
+
{ artifact_path: destPath },
|
|
449
|
+
configMap,
|
|
450
|
+
secretMap,
|
|
451
|
+
logger,
|
|
452
|
+
{
|
|
453
|
+
debug: false,
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
if (analyzeResult.success) {
|
|
458
|
+
// Extract schema_version from hook outputs if available
|
|
459
|
+
if (typeof analyzeResult.outputs.schema_version === 'string') {
|
|
460
|
+
analyzedSchemaVersion = analyzeResult.outputs.schema_version;
|
|
461
|
+
}
|
|
462
|
+
// Merge all hook outputs into metadata
|
|
463
|
+
analyzedMetadata = {
|
|
464
|
+
...analyzedMetadata,
|
|
465
|
+
...analyzeResult.outputs,
|
|
466
|
+
importedFrom: filePath,
|
|
467
|
+
};
|
|
468
|
+
} else {
|
|
469
|
+
// Hook failed - log warning but continue with import
|
|
470
|
+
console.warn(
|
|
471
|
+
`Warning: on_backup_analyze hook failed: ${analyzeResult.error}. Continuing with manual metadata.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Tar the artifacts
|
|
477
|
+
const tarPath = join(tempDir, 'backup.tar');
|
|
478
|
+
const { execSync } = await import('node:child_process');
|
|
479
|
+
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(artifactDir)} .`);
|
|
480
|
+
|
|
481
|
+
// Encrypt the tar
|
|
482
|
+
const tarData = readFileSync(tarPath);
|
|
483
|
+
const masterKey = await getOrCreateMasterKey();
|
|
484
|
+
const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
|
|
485
|
+
|
|
486
|
+
const encryptedPath = join(tempDir, 'backup.tar.enc');
|
|
487
|
+
writeFileSync(encryptedPath, JSON.stringify(encrypted));
|
|
488
|
+
|
|
489
|
+
const encryptedSize = statSync(encryptedPath).size;
|
|
490
|
+
|
|
491
|
+
// Upload to storage
|
|
492
|
+
await provider.upload(encryptedPath, storagePath);
|
|
493
|
+
|
|
494
|
+
// Mark backup as completed
|
|
495
|
+
completeBackup(record.id, {
|
|
496
|
+
sizeBytes: encryptedSize,
|
|
497
|
+
metadata: {
|
|
498
|
+
...analyzedMetadata,
|
|
499
|
+
originalSizeBytes: tarData.length,
|
|
500
|
+
},
|
|
501
|
+
schemaVersion: analyzedSchemaVersion,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Set human-readable name if provided
|
|
505
|
+
if (options.name) {
|
|
506
|
+
const { updateBackupName } = await import('./backup-metadata');
|
|
507
|
+
updateBackupName(record.id, options.name);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
success: true,
|
|
512
|
+
backupId: record.id,
|
|
513
|
+
storagePath,
|
|
514
|
+
sizeBytes: encryptedSize,
|
|
515
|
+
schemaVersion: analyzedSchemaVersion,
|
|
516
|
+
};
|
|
517
|
+
} catch (error) {
|
|
518
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
519
|
+
failBackup(record.id, message);
|
|
520
|
+
return {
|
|
521
|
+
success: false,
|
|
522
|
+
backupId: record.id,
|
|
523
|
+
error: message,
|
|
524
|
+
};
|
|
525
|
+
} finally {
|
|
526
|
+
try {
|
|
527
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
528
|
+
} catch {
|
|
529
|
+
// Best effort cleanup
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|