@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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System Initialization Service
|
|
3
|
+
*
|
|
4
|
+
* Handles first-time system configuration with sensible defaults.
|
|
5
|
+
* Supports both interactive and non-interactive (--accept-defaults) modes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import type { DbClient } from '../db/client';
|
|
13
|
+
import { systemConfig } from '../db/schema';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* System configuration schema interface
|
|
17
|
+
*/
|
|
18
|
+
interface SystemConfigSchema {
|
|
19
|
+
properties: Record<
|
|
20
|
+
string,
|
|
21
|
+
{
|
|
22
|
+
type: string;
|
|
23
|
+
default?: string | number;
|
|
24
|
+
description?: string;
|
|
25
|
+
pattern?: string;
|
|
26
|
+
minimum?: number;
|
|
27
|
+
maximum?: number;
|
|
28
|
+
format?: string;
|
|
29
|
+
}
|
|
30
|
+
>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load system config schema from JSON file
|
|
35
|
+
*/
|
|
36
|
+
function loadSchema(): SystemConfigSchema {
|
|
37
|
+
// Try common locations (relative to this file's directory and cwd)
|
|
38
|
+
const thisDir = dirname(new URL(import.meta.url).pathname);
|
|
39
|
+
const candidates = [
|
|
40
|
+
join(thisDir, '..', '..', 'schemas', 'system_config.json'), // Relative to src/services/ → apps/celilo/schemas/
|
|
41
|
+
'./schemas/system_config.json', // From cwd (apps/celilo)
|
|
42
|
+
join(process.cwd(), 'schemas', 'system_config.json'),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
if (existsSync(candidate)) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(candidate, 'utf-8'));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Failed to parse system_config.json schema: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error('Could not find system_config.json schema file');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get default configuration values from schema
|
|
62
|
+
*/
|
|
63
|
+
export function getDefaultConfiguration(): Record<string, string | number> {
|
|
64
|
+
const schema = loadSchema();
|
|
65
|
+
const defaults: Record<string, string | number> = {};
|
|
66
|
+
|
|
67
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
68
|
+
if (property.default !== undefined) {
|
|
69
|
+
defaults[key] = property.default;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return defaults;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compute gateway IP from subnet CIDR
|
|
78
|
+
* Returns the first usable IP address in the subnet
|
|
79
|
+
*
|
|
80
|
+
* @param subnet - CIDR notation (e.g., "10.0.10.0/24")
|
|
81
|
+
* @returns Gateway IP (e.g., "10.0.10.1")
|
|
82
|
+
*/
|
|
83
|
+
function computeGateway(subnet: string): string {
|
|
84
|
+
const [network, _bits] = subnet.split('/');
|
|
85
|
+
const octets = network.split('.').map(Number);
|
|
86
|
+
|
|
87
|
+
// First usable IP (network address + 1)
|
|
88
|
+
octets[3] += 1;
|
|
89
|
+
|
|
90
|
+
return octets.join('.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Auto-detect SSH public key from common locations
|
|
95
|
+
*
|
|
96
|
+
* Checks ~/.ssh/ for common key files:
|
|
97
|
+
* - id_ed25519.pub (preferred)
|
|
98
|
+
* - id_rsa.pub
|
|
99
|
+
* - id_ecdsa.pub
|
|
100
|
+
*
|
|
101
|
+
* @returns SSH public key content or null if none found
|
|
102
|
+
*/
|
|
103
|
+
export interface DetectedSSHKey {
|
|
104
|
+
filename: string;
|
|
105
|
+
keyType: string;
|
|
106
|
+
content: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function autoDetectSSHKeys(): DetectedSSHKey[] {
|
|
110
|
+
const sshDir = join(homedir(), '.ssh');
|
|
111
|
+
const keyFiles = ['id_ed25519.pub', 'id_rsa.pub', 'id_ecdsa.pub'];
|
|
112
|
+
const keys: DetectedSSHKey[] = [];
|
|
113
|
+
|
|
114
|
+
for (const keyFile of keyFiles) {
|
|
115
|
+
const keyPath = join(sshDir, keyFile);
|
|
116
|
+
if (existsSync(keyPath)) {
|
|
117
|
+
try {
|
|
118
|
+
const keyContent = readFileSync(keyPath, 'utf-8').trim();
|
|
119
|
+
if (keyContent) {
|
|
120
|
+
const keyType = keyContent.split(' ')[0] || keyFile;
|
|
121
|
+
keys.push({ filename: keyFile, keyType, content: keyContent });
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return keys;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @deprecated Use autoDetectSSHKeys() instead */
|
|
131
|
+
export function autoDetectSSHKey(): string | null {
|
|
132
|
+
const keys = autoDetectSSHKeys();
|
|
133
|
+
return keys.length > 0 ? keys[0].content : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Initialize system configuration with defaults
|
|
138
|
+
*
|
|
139
|
+
* This function does all the "smart" work:
|
|
140
|
+
* - Loads defaults from schema
|
|
141
|
+
* - Computes gateway IPs from subnets
|
|
142
|
+
* - Derives admin email from primary domain
|
|
143
|
+
* - Auto-detects SSH keys
|
|
144
|
+
* - Applies user overrides
|
|
145
|
+
* - Stores everything in the database
|
|
146
|
+
*
|
|
147
|
+
* @param db - Database instance
|
|
148
|
+
* @param overrides - Optional overrides for specific keys (used for interactive mode)
|
|
149
|
+
* @returns Configuration that was applied (for summary display)
|
|
150
|
+
*/
|
|
151
|
+
export function initializeSystem(
|
|
152
|
+
db: DbClient,
|
|
153
|
+
overrides: Partial<Record<string, string | number>> = {},
|
|
154
|
+
): Record<string, string | number> {
|
|
155
|
+
const defaults = getDefaultConfiguration();
|
|
156
|
+
|
|
157
|
+
// Start with defaults
|
|
158
|
+
let config = { ...defaults };
|
|
159
|
+
|
|
160
|
+
// Apply user overrides (filter out undefined values)
|
|
161
|
+
const definedOverrides = Object.fromEntries(
|
|
162
|
+
Object.entries(overrides).filter(([_, v]) => v !== undefined),
|
|
163
|
+
) as Record<string, string | number>;
|
|
164
|
+
config = { ...config, ...definedOverrides };
|
|
165
|
+
|
|
166
|
+
// Auto-detect SSH key if not provided
|
|
167
|
+
if (!config['ssh.public_key']) {
|
|
168
|
+
const detectedKey = autoDetectSSHKey();
|
|
169
|
+
if (detectedKey) {
|
|
170
|
+
config['ssh.public_key'] = detectedKey;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Compute gateway IPs from subnets (if subnet was provided but not gateway)
|
|
175
|
+
const zones = ['dmz', 'app', 'secure', 'internal'];
|
|
176
|
+
for (const zone of zones) {
|
|
177
|
+
const subnetKey = `network.${zone}.subnet`;
|
|
178
|
+
const gatewayKey = `network.${zone}.gateway`;
|
|
179
|
+
|
|
180
|
+
// If user provided a subnet, compute gateway from it
|
|
181
|
+
if (config[subnetKey] && !overrides[gatewayKey]) {
|
|
182
|
+
config[gatewayKey] = computeGateway(String(config[subnetKey]));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// primary_domain and admin.email are no longer system config — they live
|
|
187
|
+
// in the dns_registrar capability and in the authentik/caddy modules
|
|
188
|
+
// respectively as of MANIFEST_V2 Phase 2 (D9).
|
|
189
|
+
|
|
190
|
+
// Store all configuration in database
|
|
191
|
+
for (const [key, value] of Object.entries(config)) {
|
|
192
|
+
if (value === undefined || value === null) {
|
|
193
|
+
continue; // Skip undefined/null values
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const valueStr = String(value);
|
|
197
|
+
|
|
198
|
+
db.insert(systemConfig)
|
|
199
|
+
.values({ key, value: valueStr })
|
|
200
|
+
.onConflictDoUpdate({
|
|
201
|
+
target: systemConfig.key,
|
|
202
|
+
set: { value: valueStr },
|
|
203
|
+
})
|
|
204
|
+
.run();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return config;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load existing system configuration from database
|
|
212
|
+
*
|
|
213
|
+
* @param db - Database instance
|
|
214
|
+
* @returns Map of existing configuration values
|
|
215
|
+
*/
|
|
216
|
+
export function loadExistingConfiguration(db: DbClient): Record<string, string> {
|
|
217
|
+
const existing: Record<string, string> = {};
|
|
218
|
+
|
|
219
|
+
const rows = db.select().from(systemConfig).all();
|
|
220
|
+
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
existing[row.key] = row.value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return existing;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if system is already initialized
|
|
230
|
+
*
|
|
231
|
+
* System is considered initialized if critical configuration exists:
|
|
232
|
+
* - At least one network zone subnet
|
|
233
|
+
* - ssh.public_key
|
|
234
|
+
*
|
|
235
|
+
* @param db - Database instance
|
|
236
|
+
* @returns true if system appears to be initialized
|
|
237
|
+
*/
|
|
238
|
+
export function isSystemInitialized(db: DbClient): boolean {
|
|
239
|
+
const criticalKeys = ['network.dmz.subnet', 'ssh.public_key'];
|
|
240
|
+
|
|
241
|
+
let foundKeys = 0;
|
|
242
|
+
|
|
243
|
+
for (const key of criticalKeys) {
|
|
244
|
+
const result = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
|
|
245
|
+
|
|
246
|
+
if (result) {
|
|
247
|
+
foundKeys++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Consider initialized if at least 2 of 3 critical keys exist
|
|
252
|
+
return foundKeys >= 2;
|
|
253
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terraform Plan Safety Validation
|
|
3
|
+
*
|
|
4
|
+
* Parses Terraform plan output and validates that only safe operations are planned.
|
|
5
|
+
*
|
|
6
|
+
* Safety Rules:
|
|
7
|
+
* - Only CREATE operations allowed
|
|
8
|
+
* - Only whitelisted provider resources allowed:
|
|
9
|
+
* - Proxmox: proxmox_lxc, proxmox_vm
|
|
10
|
+
* - Digital Ocean: digitalocean_droplet, digitalocean_*
|
|
11
|
+
* - No UPDATE operations (coming in future phase)
|
|
12
|
+
* - No DELETE operations
|
|
13
|
+
* - No REPLACE operations
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Allowed Terraform resource type prefixes
|
|
18
|
+
* Only resources from these providers can be created
|
|
19
|
+
*/
|
|
20
|
+
const ALLOWED_RESOURCE_PREFIXES = ['proxmox_lxc', 'proxmox_vm', 'digitalocean_'];
|
|
21
|
+
|
|
22
|
+
export interface PlanSafetyResult {
|
|
23
|
+
safe: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
actions: TerraformAction[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TerraformAction {
|
|
29
|
+
resourceType: string;
|
|
30
|
+
resourceName: string;
|
|
31
|
+
action: 'create' | 'update' | 'delete' | 'replace';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse and validate Terraform plan for safety
|
|
36
|
+
* Policy function - validates plan adheres to safety rules
|
|
37
|
+
*
|
|
38
|
+
* @param planOutput - Terraform plan output (from terraform plan -no-color)
|
|
39
|
+
* @returns Safety validation result
|
|
40
|
+
*/
|
|
41
|
+
export function validateTerraformPlanSafety(planOutput: string): PlanSafetyResult {
|
|
42
|
+
const actions = parseTerraformPlan(planOutput);
|
|
43
|
+
|
|
44
|
+
// Check each action against safety rules
|
|
45
|
+
for (const action of actions) {
|
|
46
|
+
// Rule 1: Only whitelisted provider resources allowed
|
|
47
|
+
const isAllowed = ALLOWED_RESOURCE_PREFIXES.some((prefix) =>
|
|
48
|
+
action.resourceType.startsWith(prefix),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (!isAllowed) {
|
|
52
|
+
const allowedList = ALLOWED_RESOURCE_PREFIXES.join(', ');
|
|
53
|
+
return {
|
|
54
|
+
safe: false,
|
|
55
|
+
error: `Unexpected resource type: ${action.resourceType}\nOnly resources from approved providers are allowed: ${allowedList}`,
|
|
56
|
+
actions,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Rule 2: Only CREATE operations allowed
|
|
61
|
+
if (action.action !== 'create') {
|
|
62
|
+
return {
|
|
63
|
+
safe: false,
|
|
64
|
+
error: formatUnsafeOperationError(action),
|
|
65
|
+
actions,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { safe: true, actions };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse Terraform plan output to extract resource actions
|
|
75
|
+
* Policy function - pure parsing logic
|
|
76
|
+
*
|
|
77
|
+
* Terraform plan format examples:
|
|
78
|
+
* # proxmox_lxc.caddy will be created
|
|
79
|
+
* # proxmox_lxc.caddy will be updated in-place
|
|
80
|
+
* # proxmox_lxc.caddy will be updated in-place (some changes)
|
|
81
|
+
* # proxmox_lxc.caddy must be replaced
|
|
82
|
+
* # proxmox_lxc.caddy will be destroyed
|
|
83
|
+
* # proxmox_lxc.caddy must be replaced (forces new resource)
|
|
84
|
+
*
|
|
85
|
+
* @param planOutput - Terraform plan output text
|
|
86
|
+
* @returns Array of parsed actions
|
|
87
|
+
*/
|
|
88
|
+
export function parseTerraformPlan(planOutput: string): TerraformAction[] {
|
|
89
|
+
const actions: TerraformAction[] = [];
|
|
90
|
+
const lines = planOutput.split('\n');
|
|
91
|
+
|
|
92
|
+
// Regex patterns for Terraform plan actions
|
|
93
|
+
const createPattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be created$/;
|
|
94
|
+
const updatePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be updated/;
|
|
95
|
+
const replacePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+must be replaced/;
|
|
96
|
+
const deletePattern = /^#\s+([\w_]+)\.([\w_-]+)\s+will be destroyed$/;
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
|
|
101
|
+
// Try each pattern in order (replace must come before update since replace contains "must be")
|
|
102
|
+
let match = trimmed.match(replacePattern);
|
|
103
|
+
if (match) {
|
|
104
|
+
actions.push({
|
|
105
|
+
resourceType: match[1],
|
|
106
|
+
resourceName: match[2],
|
|
107
|
+
action: 'replace',
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
match = trimmed.match(createPattern);
|
|
113
|
+
if (match) {
|
|
114
|
+
actions.push({
|
|
115
|
+
resourceType: match[1],
|
|
116
|
+
resourceName: match[2],
|
|
117
|
+
action: 'create',
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
match = trimmed.match(updatePattern);
|
|
123
|
+
if (match) {
|
|
124
|
+
actions.push({
|
|
125
|
+
resourceType: match[1],
|
|
126
|
+
resourceName: match[2],
|
|
127
|
+
action: 'update',
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
match = trimmed.match(deletePattern);
|
|
133
|
+
if (match) {
|
|
134
|
+
actions.push({
|
|
135
|
+
resourceType: match[1],
|
|
136
|
+
resourceName: match[2],
|
|
137
|
+
action: 'delete',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return actions;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Format error message for unsafe operations
|
|
147
|
+
* Presentation function - formats output for user
|
|
148
|
+
*
|
|
149
|
+
* @param action - Terraform action that failed validation
|
|
150
|
+
* @returns Formatted error message
|
|
151
|
+
*/
|
|
152
|
+
function formatUnsafeOperationError(action: TerraformAction): string {
|
|
153
|
+
const lines = [
|
|
154
|
+
`Unsafe operation: ${action.action} on ${action.resourceType}.${action.resourceName}`,
|
|
155
|
+
'',
|
|
156
|
+
'Only CREATE operations are currently allowed.',
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
if (action.action === 'update') {
|
|
160
|
+
lines.push('UPDATE operations require explicit approval.');
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push('To fix: Review the changes and manually apply if safe.');
|
|
163
|
+
} else if (action.action === 'delete') {
|
|
164
|
+
lines.push('DELETE operations are never auto-approved.');
|
|
165
|
+
lines.push('');
|
|
166
|
+
lines.push('To fix: Remove the resource manually or use terraform destroy.');
|
|
167
|
+
} else if (action.action === 'replace') {
|
|
168
|
+
lines.push('REPLACE operations require explicit approval.');
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('To fix: Review what triggered the replacement and decide if it should proceed.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return lines.join('\n');
|
|
174
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for zone detector
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
6
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { closeDb, createDbClient } from '../db/client';
|
|
10
|
+
import { runMigrations } from '../db/migrate';
|
|
11
|
+
import { systemConfig } from '../db/schema';
|
|
12
|
+
import { detectZoneFromIp, isValidIp } from './zone-detector';
|
|
13
|
+
|
|
14
|
+
describe('zone-detector', () => {
|
|
15
|
+
let testDbPath: string;
|
|
16
|
+
let testDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
// Create temp directory for test database
|
|
20
|
+
testDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
|
|
21
|
+
testDbPath = join(testDir, 'test.db');
|
|
22
|
+
|
|
23
|
+
// Set environment variable for database path
|
|
24
|
+
process.env.CELILO_DB_PATH = testDbPath;
|
|
25
|
+
|
|
26
|
+
// Initialize database and run migrations
|
|
27
|
+
await runMigrations(testDbPath);
|
|
28
|
+
|
|
29
|
+
// Insert test network configuration
|
|
30
|
+
const db = createDbClient({ path: testDbPath });
|
|
31
|
+
await db.insert(systemConfig).values([
|
|
32
|
+
{ key: 'network.internal.subnet', value: '192.168.0.0/24' },
|
|
33
|
+
{ key: 'network.dmz.subnet', value: '10.0.10.0/24' },
|
|
34
|
+
{ key: 'network.app.subnet', value: '10.0.20.0/24' },
|
|
35
|
+
{ key: 'network.secure.subnet', value: '10.0.30.0/24' },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
// Close database connection
|
|
41
|
+
closeDb();
|
|
42
|
+
|
|
43
|
+
// Clean up test directory
|
|
44
|
+
if (testDir) {
|
|
45
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clear environment variables
|
|
49
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('detectZoneFromIp', () => {
|
|
53
|
+
it('detects internal zone', async () => {
|
|
54
|
+
const zone = await detectZoneFromIp('192.168.0.100');
|
|
55
|
+
expect(zone).toBe('internal');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('detects dmz zone', async () => {
|
|
59
|
+
const zone = await detectZoneFromIp('10.0.10.50');
|
|
60
|
+
expect(zone).toBe('dmz');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('detects app zone', async () => {
|
|
64
|
+
const zone = await detectZoneFromIp('10.0.20.100');
|
|
65
|
+
expect(zone).toBe('app');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('detects secure zone', async () => {
|
|
69
|
+
const zone = await detectZoneFromIp('10.0.30.25');
|
|
70
|
+
expect(zone).toBe('secure');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns external for non-matching IP', async () => {
|
|
74
|
+
const zone = await detectZoneFromIp('167.99.123.45');
|
|
75
|
+
expect(zone).toBe('external');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('matches first IP in subnet', async () => {
|
|
79
|
+
const zone = await detectZoneFromIp('192.168.0.1');
|
|
80
|
+
expect(zone).toBe('internal');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('matches last IP in subnet', async () => {
|
|
84
|
+
const zone = await detectZoneFromIp('192.168.0.254');
|
|
85
|
+
expect(zone).toBe('internal');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not match IP outside subnet', async () => {
|
|
89
|
+
const zone = await detectZoneFromIp('192.168.1.100');
|
|
90
|
+
expect(zone).toBe('external');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('isValidIp', () => {
|
|
95
|
+
it('validates correct IPv4 addresses', () => {
|
|
96
|
+
expect(isValidIp('192.168.1.1')).toBe(true);
|
|
97
|
+
expect(isValidIp('10.0.0.1')).toBe(true);
|
|
98
|
+
expect(isValidIp('255.255.255.255')).toBe(true);
|
|
99
|
+
expect(isValidIp('0.0.0.0')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects invalid IPv4 addresses', () => {
|
|
103
|
+
expect(isValidIp('192.168.1.256')).toBe(false); // Number > 255
|
|
104
|
+
expect(isValidIp('192.168.1')).toBe(false); // Too few octets
|
|
105
|
+
expect(isValidIp('192.168.1.1.1')).toBe(false); // Too many octets
|
|
106
|
+
expect(isValidIp('not.an.ip.address')).toBe(false); // Non-numeric
|
|
107
|
+
expect(isValidIp('')).toBe(false); // Empty string
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone Detector
|
|
3
|
+
* Auto-detects network zone from IP address by matching against system subnets
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
7
|
+
import { getDb } from '../db/client';
|
|
8
|
+
import { type NetworkZone, systemConfig } from '../db/schema';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse CIDR notation to get network address and prefix length
|
|
12
|
+
*/
|
|
13
|
+
function parseCIDR(cidr: string): { network: string; prefixLength: number } {
|
|
14
|
+
const [ip, prefix] = cidr.split('/');
|
|
15
|
+
return {
|
|
16
|
+
network: ip,
|
|
17
|
+
prefixLength: Number.parseInt(prefix, 10),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert IP address string to 32-bit integer
|
|
23
|
+
*/
|
|
24
|
+
function ipToInt(ip: string): number {
|
|
25
|
+
const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
|
|
26
|
+
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if an IP address is in a CIDR subnet
|
|
31
|
+
*/
|
|
32
|
+
function ipInSubnet(ip: string, cidr: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const { network, prefixLength } = parseCIDR(cidr);
|
|
35
|
+
const ipInt = ipToInt(ip);
|
|
36
|
+
const networkInt = ipToInt(network);
|
|
37
|
+
|
|
38
|
+
// Create subnet mask
|
|
39
|
+
const mask = ~((1 << (32 - prefixLength)) - 1);
|
|
40
|
+
|
|
41
|
+
// Check if IP is in subnet
|
|
42
|
+
return (ipInt & mask) === (networkInt & mask);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get system network configuration for a zone
|
|
50
|
+
*/
|
|
51
|
+
async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
|
|
52
|
+
const db = getDb();
|
|
53
|
+
|
|
54
|
+
// Try to get subnet from system config
|
|
55
|
+
// Format: network.{zone}.subnet (e.g., network.dmz.subnet)
|
|
56
|
+
const key = `network.${zone}.subnet`;
|
|
57
|
+
|
|
58
|
+
const result = await db.select().from(systemConfig).where(eq(systemConfig.key, key)).limit(1);
|
|
59
|
+
|
|
60
|
+
if (result.length === 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result[0].value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detect network zone from IP address
|
|
69
|
+
* Matches IP against system network configuration
|
|
70
|
+
*
|
|
71
|
+
* @param ip - IP address to check
|
|
72
|
+
* @returns Detected network zone, or 'external' if no match
|
|
73
|
+
*/
|
|
74
|
+
export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
|
|
75
|
+
// Check each zone's subnet
|
|
76
|
+
const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure'];
|
|
77
|
+
|
|
78
|
+
for (const zone of zones) {
|
|
79
|
+
const subnet = await getZoneSubnet(zone);
|
|
80
|
+
if (subnet && ipInSubnet(ip, subnet)) {
|
|
81
|
+
return zone;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No match found - must be external
|
|
86
|
+
return 'external';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate IP address format
|
|
91
|
+
*/
|
|
92
|
+
export function isValidIp(ip: string): boolean {
|
|
93
|
+
const parts = ip.split('.');
|
|
94
|
+
if (parts.length !== 4) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parts.every((part) => {
|
|
99
|
+
const num = Number.parseInt(part, 10);
|
|
100
|
+
return !Number.isNaN(num) && num >= 0 && num <= 255;
|
|
101
|
+
});
|
|
102
|
+
}
|