@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup metadata service - CRUD operations for backup records.
|
|
3
|
+
* Tracks both system state and module data backups.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { desc, eq, like } from 'drizzle-orm';
|
|
8
|
+
import { getDb } from '../db/client';
|
|
9
|
+
import { type Backup, type BackupStatus, type BackupType, backups } from '../db/schema';
|
|
10
|
+
|
|
11
|
+
/** Short ID length used for display and lookup */
|
|
12
|
+
export const SHORT_ID_LENGTH = 8;
|
|
13
|
+
|
|
14
|
+
export interface CreateBackupParams {
|
|
15
|
+
moduleId?: string | null;
|
|
16
|
+
storageId: string;
|
|
17
|
+
storagePath: string;
|
|
18
|
+
backupType: BackupType;
|
|
19
|
+
moduleVersion?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BackupRecord extends Backup {
|
|
23
|
+
id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a new backup record (status: in_progress)
|
|
28
|
+
*/
|
|
29
|
+
export function createBackupRecord(params: CreateBackupParams): BackupRecord {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
const now = new Date();
|
|
33
|
+
|
|
34
|
+
const values = {
|
|
35
|
+
id,
|
|
36
|
+
moduleId: params.moduleId ?? null,
|
|
37
|
+
storageId: params.storageId,
|
|
38
|
+
storagePath: params.storagePath,
|
|
39
|
+
backupType: params.backupType,
|
|
40
|
+
moduleVersion: params.moduleVersion ?? null,
|
|
41
|
+
schemaVersion: null,
|
|
42
|
+
sizeBytes: null,
|
|
43
|
+
metadata: {},
|
|
44
|
+
status: 'in_progress' as BackupStatus,
|
|
45
|
+
errorMessage: null,
|
|
46
|
+
startedAt: now,
|
|
47
|
+
completedAt: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
db.insert(backups).values(values).run();
|
|
51
|
+
|
|
52
|
+
return { ...values, startedAt: now, completedAt: null } as BackupRecord;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mark a backup as completed with final metadata
|
|
57
|
+
*/
|
|
58
|
+
export function completeBackup(
|
|
59
|
+
id: string,
|
|
60
|
+
params: {
|
|
61
|
+
sizeBytes: number;
|
|
62
|
+
metadata?: Record<string, unknown>;
|
|
63
|
+
schemaVersion?: string;
|
|
64
|
+
},
|
|
65
|
+
): void {
|
|
66
|
+
const db = getDb();
|
|
67
|
+
db.update(backups)
|
|
68
|
+
.set({
|
|
69
|
+
status: 'completed' as BackupStatus,
|
|
70
|
+
sizeBytes: params.sizeBytes,
|
|
71
|
+
metadata: params.metadata ?? {},
|
|
72
|
+
schemaVersion: params.schemaVersion ?? null,
|
|
73
|
+
completedAt: new Date(),
|
|
74
|
+
})
|
|
75
|
+
.where(eq(backups.id, id))
|
|
76
|
+
.run();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mark a backup as failed
|
|
81
|
+
*/
|
|
82
|
+
export function failBackup(id: string, errorMessage: string): void {
|
|
83
|
+
const db = getDb();
|
|
84
|
+
db.update(backups)
|
|
85
|
+
.set({
|
|
86
|
+
status: 'failed' as BackupStatus,
|
|
87
|
+
errorMessage,
|
|
88
|
+
completedAt: new Date(),
|
|
89
|
+
})
|
|
90
|
+
.where(eq(backups.id, id))
|
|
91
|
+
.run();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get a backup by ID or short ID prefix.
|
|
96
|
+
* Supports both full UUIDs and 8-char short IDs.
|
|
97
|
+
*/
|
|
98
|
+
export function getBackup(id: string): Backup | null {
|
|
99
|
+
const db = getDb();
|
|
100
|
+
|
|
101
|
+
// Try exact match first
|
|
102
|
+
const exact = db.select().from(backups).where(eq(backups.id, id)).limit(1).all();
|
|
103
|
+
if (exact.length > 0) return exact[0];
|
|
104
|
+
|
|
105
|
+
// Try prefix match for short IDs
|
|
106
|
+
if (id.length < 36) {
|
|
107
|
+
const prefixed = db
|
|
108
|
+
.select()
|
|
109
|
+
.from(backups)
|
|
110
|
+
.where(like(backups.id, `${id}%`))
|
|
111
|
+
.all();
|
|
112
|
+
if (prefixed.length === 1) return prefixed[0];
|
|
113
|
+
if (prefixed.length > 1) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Ambiguous backup ID '${id}' matches ${prefixed.length} backups. Use a longer prefix.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Try name match (case-insensitive)
|
|
121
|
+
const byName = db.select().from(backups).where(like(backups.name, id)).all();
|
|
122
|
+
if (byName.length === 1) return byName[0];
|
|
123
|
+
if (byName.length > 1) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Ambiguous backup name '${id}' matches ${byName.length} backups. Use a unique name or backup ID.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* List backups with optional module filter and limit
|
|
134
|
+
*/
|
|
135
|
+
export function listBackups(options?: {
|
|
136
|
+
moduleId?: string;
|
|
137
|
+
limit?: number;
|
|
138
|
+
}): Backup[] {
|
|
139
|
+
const db = getDb();
|
|
140
|
+
const limit = options?.limit ?? 20;
|
|
141
|
+
|
|
142
|
+
if (options?.moduleId) {
|
|
143
|
+
return db
|
|
144
|
+
.select()
|
|
145
|
+
.from(backups)
|
|
146
|
+
.where(eq(backups.moduleId, options.moduleId))
|
|
147
|
+
.orderBy(desc(backups.startedAt))
|
|
148
|
+
.limit(limit)
|
|
149
|
+
.all();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* List completed backups for a specific module, ordered newest first
|
|
157
|
+
*/
|
|
158
|
+
export function listCompletedBackupsForModule(moduleId: string): Backup[] {
|
|
159
|
+
const db = getDb();
|
|
160
|
+
return db
|
|
161
|
+
.select()
|
|
162
|
+
.from(backups)
|
|
163
|
+
.where(eq(backups.moduleId, moduleId))
|
|
164
|
+
.orderBy(desc(backups.startedAt))
|
|
165
|
+
.all()
|
|
166
|
+
.filter((b) => b.status === 'completed');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Set or clear a human-readable name on a backup
|
|
171
|
+
*/
|
|
172
|
+
export function updateBackupName(id: string, name: string | null): void {
|
|
173
|
+
const db = getDb();
|
|
174
|
+
db.update(backups).set({ name }).where(eq(backups.id, id)).run();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Delete a backup record
|
|
179
|
+
*/
|
|
180
|
+
export function deleteBackupRecord(id: string): void {
|
|
181
|
+
const db = getDb();
|
|
182
|
+
db.delete(backups).where(eq(backups.id, id)).run();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Format bytes into human-readable size
|
|
187
|
+
*/
|
|
188
|
+
export function formatSize(bytes: number | null): string {
|
|
189
|
+
if (bytes === null || bytes === 0) return '0 B';
|
|
190
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
191
|
+
let size = bytes;
|
|
192
|
+
let unitIndex = 0;
|
|
193
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
194
|
+
size /= 1024;
|
|
195
|
+
unitIndex++;
|
|
196
|
+
}
|
|
197
|
+
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
|
198
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup restore service.
|
|
3
|
+
* Downloads, decrypts, extracts backup archives and executes on_restore hooks.
|
|
4
|
+
* For system state backups, restores the Celilo database.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { getDbPath } from '../config/paths';
|
|
13
|
+
import { closeDb, getDb } from '../db/client';
|
|
14
|
+
import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
|
|
15
|
+
import type { Backup } from '../db/schema';
|
|
16
|
+
import { invokeHook } from '../hooks/executor';
|
|
17
|
+
import { createConsoleLogger } from '../hooks/logger';
|
|
18
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
19
|
+
import { decryptSecret } from '../secrets/encryption';
|
|
20
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
21
|
+
import { shellEscape } from '../utils/shell';
|
|
22
|
+
import { createStorageProvider } from './backup-storage';
|
|
23
|
+
|
|
24
|
+
export interface RestoreResult {
|
|
25
|
+
success: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
healthCheckPassed?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Restore a system state backup (replaces the Celilo database)
|
|
32
|
+
*/
|
|
33
|
+
export async function restoreSystemStateBackup(backup: Backup): Promise<RestoreResult> {
|
|
34
|
+
const provider = await createStorageProvider(backup.storageId);
|
|
35
|
+
const tempDir = join(tmpdir(), `celilo-restore-${backup.id}`);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(tempDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
// Download encrypted archive
|
|
41
|
+
const encryptedPath = join(tempDir, 'system.enc');
|
|
42
|
+
await provider.download(backup.storagePath, encryptedPath);
|
|
43
|
+
|
|
44
|
+
// Decrypt
|
|
45
|
+
const encryptedData = JSON.parse(readFileSync(encryptedPath, 'utf-8'));
|
|
46
|
+
const masterKey = await getOrCreateMasterKey();
|
|
47
|
+
const base64Data = decryptSecret(encryptedData, masterKey);
|
|
48
|
+
const dbData = Buffer.from(base64Data, 'base64');
|
|
49
|
+
|
|
50
|
+
// Write restored database to temp file first
|
|
51
|
+
const restoredDbPath = join(tempDir, 'celilo.db');
|
|
52
|
+
writeFileSync(restoredDbPath, dbData);
|
|
53
|
+
|
|
54
|
+
// Close current database connection
|
|
55
|
+
closeDb();
|
|
56
|
+
|
|
57
|
+
// Replace the database file
|
|
58
|
+
const dbPath = getDbPath();
|
|
59
|
+
copyFileSync(restoredDbPath, dbPath);
|
|
60
|
+
|
|
61
|
+
// Remove WAL/SHM files (they're from the old DB state)
|
|
62
|
+
try {
|
|
63
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
64
|
+
} catch {
|
|
65
|
+
// May not exist
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// May not exist
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { success: true };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: `System restore failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
78
|
+
};
|
|
79
|
+
} finally {
|
|
80
|
+
try {
|
|
81
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
82
|
+
} catch {
|
|
83
|
+
// Best effort cleanup
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build config and secret maps for a module
|
|
90
|
+
*/
|
|
91
|
+
async function buildModuleContext(moduleId: string): Promise<{
|
|
92
|
+
configMap: Record<string, unknown>;
|
|
93
|
+
secretMap: Record<string, string>;
|
|
94
|
+
}> {
|
|
95
|
+
const db = getDb();
|
|
96
|
+
|
|
97
|
+
const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
|
|
98
|
+
const configMap: Record<string, unknown> = {};
|
|
99
|
+
for (const c of configs) {
|
|
100
|
+
configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const secretRecords = db
|
|
104
|
+
.select()
|
|
105
|
+
.from(secretsTable)
|
|
106
|
+
.where(eq(secretsTable.moduleId, moduleId))
|
|
107
|
+
.all();
|
|
108
|
+
const masterKey = await getOrCreateMasterKey();
|
|
109
|
+
const secretMap: Record<string, string> = {};
|
|
110
|
+
for (const s of secretRecords) {
|
|
111
|
+
secretMap[s.name] = decryptSecret(
|
|
112
|
+
{ encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
|
|
113
|
+
masterKey,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { configMap, secretMap };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Restore a module data backup.
|
|
122
|
+
* Downloads, decrypts, extracts, then executes the module's on_restore hook.
|
|
123
|
+
* Optionally runs a health check after restore.
|
|
124
|
+
*/
|
|
125
|
+
export async function restoreModuleBackup(
|
|
126
|
+
backup: Backup,
|
|
127
|
+
options: { runHealthCheck?: boolean } = {},
|
|
128
|
+
): Promise<RestoreResult> {
|
|
129
|
+
if (!backup.moduleId) {
|
|
130
|
+
return { success: false, error: 'Backup has no associated module' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const db = getDb();
|
|
134
|
+
const mod = db.select().from(modules).where(eq(modules.id, backup.moduleId)).get();
|
|
135
|
+
if (!mod) {
|
|
136
|
+
return { success: false, error: `Module not found: ${backup.moduleId}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const manifest = mod.manifestData as unknown as ModuleManifest;
|
|
140
|
+
const hookDef = manifest.hooks?.on_restore;
|
|
141
|
+
if (!hookDef) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: `Module '${mod.id}' has no on_restore hook defined`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const provider = await createStorageProvider(backup.storageId);
|
|
149
|
+
const tempDir = join(tmpdir(), `celilo-restore-${backup.id}`);
|
|
150
|
+
const restoreDir = join(tempDir, 'artifacts');
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
mkdirSync(restoreDir, { recursive: true });
|
|
154
|
+
|
|
155
|
+
// Download encrypted archive
|
|
156
|
+
const encryptedPath = join(tempDir, 'backup.tar.enc');
|
|
157
|
+
await provider.download(backup.storagePath, encryptedPath);
|
|
158
|
+
|
|
159
|
+
// Decrypt
|
|
160
|
+
const encryptedData = JSON.parse(readFileSync(encryptedPath, 'utf-8'));
|
|
161
|
+
const masterKey = await getOrCreateMasterKey();
|
|
162
|
+
const base64Data = decryptSecret(encryptedData, masterKey);
|
|
163
|
+
const tarData = Buffer.from(base64Data, 'base64');
|
|
164
|
+
|
|
165
|
+
// Write tar and extract
|
|
166
|
+
const tarPath = join(tempDir, 'backup.tar');
|
|
167
|
+
writeFileSync(tarPath, tarData);
|
|
168
|
+
execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(restoreDir)}`);
|
|
169
|
+
|
|
170
|
+
// Build context for hook execution
|
|
171
|
+
const { configMap, secretMap } = await buildModuleContext(mod.id);
|
|
172
|
+
const logger = createConsoleLogger(mod.id, 'on_restore');
|
|
173
|
+
|
|
174
|
+
// Execute on_restore hook
|
|
175
|
+
const hookResult = await invokeHook(
|
|
176
|
+
mod.sourcePath,
|
|
177
|
+
'on_restore',
|
|
178
|
+
manifest.celilo_contract,
|
|
179
|
+
hookDef,
|
|
180
|
+
{
|
|
181
|
+
restore_dir: restoreDir,
|
|
182
|
+
schema_version: backup.schemaVersion ?? '',
|
|
183
|
+
},
|
|
184
|
+
configMap,
|
|
185
|
+
secretMap,
|
|
186
|
+
logger,
|
|
187
|
+
{
|
|
188
|
+
debug: false,
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (!hookResult.success) {
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
error: hookResult.error ?? 'on_restore hook failed',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Run health check if requested and the module has one
|
|
200
|
+
let healthCheckPassed: boolean | undefined;
|
|
201
|
+
if (options.runHealthCheck && manifest.hooks?.health_check) {
|
|
202
|
+
try {
|
|
203
|
+
const { runModuleHealthCheck } = await import('./health-runner');
|
|
204
|
+
const healthResult = await runModuleHealthCheck(mod.id, db, {
|
|
205
|
+
noInteractive: true,
|
|
206
|
+
});
|
|
207
|
+
healthCheckPassed = healthResult.status === 'healthy';
|
|
208
|
+
} catch {
|
|
209
|
+
healthCheckPassed = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
success: true,
|
|
215
|
+
healthCheckPassed,
|
|
216
|
+
};
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return {
|
|
219
|
+
success: false,
|
|
220
|
+
error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
221
|
+
};
|
|
222
|
+
} finally {
|
|
223
|
+
try {
|
|
224
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
225
|
+
} catch {
|
|
226
|
+
// Best effort cleanup
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup retention service.
|
|
3
|
+
* Enforces retention policies defined in module manifests.
|
|
4
|
+
* Policies are count-based (keep last N) and age-based (delete older than X days).
|
|
5
|
+
* Whichever limit is hit first triggers deletion.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Backup } from '../db/schema';
|
|
9
|
+
import { deleteBackupRecord, listCompletedBackupsForModule } from './backup-metadata';
|
|
10
|
+
import { createStorageProvider } from './backup-storage';
|
|
11
|
+
|
|
12
|
+
export interface RetentionPolicy {
|
|
13
|
+
count: number;
|
|
14
|
+
maxAgeDays: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PruneResult {
|
|
18
|
+
moduleId: string;
|
|
19
|
+
deleted: number;
|
|
20
|
+
deletedPaths: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Identify backups that should be pruned per the retention policy
|
|
25
|
+
*/
|
|
26
|
+
export function identifyExpiredBackups(backupsList: Backup[], policy: RetentionPolicy): Backup[] {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const maxAgeMs = policy.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
29
|
+
const expired: Backup[] = [];
|
|
30
|
+
|
|
31
|
+
// Backups are already sorted newest-first from the query
|
|
32
|
+
for (let i = 0; i < backupsList.length; i++) {
|
|
33
|
+
const backup = backupsList[i];
|
|
34
|
+
const age = now - new Date(backup.startedAt).getTime();
|
|
35
|
+
const exceedsCount = i >= policy.count;
|
|
36
|
+
const exceedsAge = age > maxAgeMs;
|
|
37
|
+
|
|
38
|
+
if (exceedsCount || exceedsAge) {
|
|
39
|
+
expired.push(backup);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return expired;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prune expired backups for a module.
|
|
48
|
+
* Deletes from storage and removes database records.
|
|
49
|
+
*/
|
|
50
|
+
export async function pruneBackupsForModule(
|
|
51
|
+
moduleId: string,
|
|
52
|
+
policy: RetentionPolicy,
|
|
53
|
+
dryRun = false,
|
|
54
|
+
): Promise<PruneResult> {
|
|
55
|
+
const backupsList = listCompletedBackupsForModule(moduleId);
|
|
56
|
+
const expired = identifyExpiredBackups(backupsList, policy);
|
|
57
|
+
|
|
58
|
+
if (dryRun || expired.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
moduleId,
|
|
61
|
+
deleted: expired.length,
|
|
62
|
+
deletedPaths: expired.map((b) => b.storagePath),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const deletedPaths: string[] = [];
|
|
67
|
+
|
|
68
|
+
for (const backup of expired) {
|
|
69
|
+
try {
|
|
70
|
+
const provider = await createStorageProvider(backup.storageId);
|
|
71
|
+
await provider.delete(backup.storagePath);
|
|
72
|
+
} catch {
|
|
73
|
+
// Storage deletion may fail if file already removed — continue
|
|
74
|
+
}
|
|
75
|
+
deleteBackupRecord(backup.id);
|
|
76
|
+
deletedPaths.push(backup.storagePath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
moduleId,
|
|
81
|
+
deleted: deletedPaths.length,
|
|
82
|
+
deletedPaths,
|
|
83
|
+
};
|
|
84
|
+
}
|