@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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema validation service
|
|
3
|
+
* Validates module configuration values against JSON Schema definitions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import Ajv, { type ValidateFunction } from 'ajv';
|
|
10
|
+
import { eq } from 'drizzle-orm';
|
|
11
|
+
import { type DbClient, getDb } from '../db/client';
|
|
12
|
+
import { modules } from '../db/schema';
|
|
13
|
+
|
|
14
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
15
|
+
|
|
16
|
+
interface ValidationResult {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
errors?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get module source path from database
|
|
23
|
+
*/
|
|
24
|
+
function getModuleSourcePath(moduleId: string, db: DbClient = getDb()): string | null {
|
|
25
|
+
try {
|
|
26
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
27
|
+
return module?.sourcePath || null;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
// If database access fails, validation will be skipped (schema not found)
|
|
30
|
+
console.error(`Failed to get module source path for ${moduleId}:`, error);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load JSON Schema for a module
|
|
37
|
+
* Looks for schema/user_config.json in the module directory
|
|
38
|
+
*/
|
|
39
|
+
async function loadModuleSchema(
|
|
40
|
+
moduleId: string,
|
|
41
|
+
db: DbClient = getDb(),
|
|
42
|
+
): Promise<Record<string, unknown> | null> {
|
|
43
|
+
const sourcePath = getModuleSourcePath(moduleId, db);
|
|
44
|
+
if (!sourcePath) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const schemaPath = join(sourcePath, 'schema', 'user_config.json');
|
|
49
|
+
if (!existsSync(schemaPath)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const schemaContent = await readFile(schemaPath, 'utf-8');
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(schemaContent) as Record<string, unknown>;
|
|
57
|
+
} catch (parseError) {
|
|
58
|
+
console.error(
|
|
59
|
+
`Failed to parse user_config.json schema for ${moduleId}:`,
|
|
60
|
+
parseError instanceof Error ? parseError.message : parseError,
|
|
61
|
+
);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Failed to load schema for ${moduleId}:`, error);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get JSON Schema for a specific property
|
|
72
|
+
* Extracts the property schema from the module's user_config.json
|
|
73
|
+
*/
|
|
74
|
+
async function getPropertySchema(
|
|
75
|
+
moduleId: string,
|
|
76
|
+
propertyName: string,
|
|
77
|
+
db: DbClient = getDb(),
|
|
78
|
+
): Promise<Record<string, unknown> | null> {
|
|
79
|
+
const schema = await loadModuleSchema(moduleId, db);
|
|
80
|
+
if (!schema) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const properties = schema.properties as Record<string, unknown> | undefined;
|
|
85
|
+
if (!properties) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (properties[propertyName] as Record<string, unknown>) || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate a configuration value against its schema
|
|
94
|
+
*
|
|
95
|
+
* @param moduleId - Module ID
|
|
96
|
+
* @param key - Configuration key
|
|
97
|
+
* @param value - Value to validate (already parsed)
|
|
98
|
+
* @param db - Database client (defaults to singleton)
|
|
99
|
+
* @returns Validation result with errors if invalid
|
|
100
|
+
*/
|
|
101
|
+
export async function validateConfigValue(
|
|
102
|
+
moduleId: string,
|
|
103
|
+
key: string,
|
|
104
|
+
value: unknown,
|
|
105
|
+
db: DbClient = getDb(),
|
|
106
|
+
): Promise<ValidationResult> {
|
|
107
|
+
// Load property schema
|
|
108
|
+
const propertySchema = await getPropertySchema(moduleId, key, db);
|
|
109
|
+
|
|
110
|
+
// If no schema exists, validation passes (schema is optional)
|
|
111
|
+
if (!propertySchema) {
|
|
112
|
+
return { valid: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Compile and validate
|
|
116
|
+
let validate: ValidateFunction;
|
|
117
|
+
try {
|
|
118
|
+
validate = ajv.compile(propertySchema);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// Schema compilation error - invalid schema definition
|
|
121
|
+
return {
|
|
122
|
+
valid: false,
|
|
123
|
+
errors: [
|
|
124
|
+
`Invalid schema for property ${key}: ${error instanceof Error ? error.message : String(error)}`,
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const valid = validate(value);
|
|
130
|
+
|
|
131
|
+
if (!valid && validate.errors) {
|
|
132
|
+
const errors = validate.errors.map((err) => {
|
|
133
|
+
const path = err.instancePath ? `${err.instancePath} ` : '';
|
|
134
|
+
return `${path}${err.message}`;
|
|
135
|
+
});
|
|
136
|
+
return { valid: false, errors };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { valid: true };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Format validation errors for display
|
|
144
|
+
*/
|
|
145
|
+
export function formatValidationErrors(errors: string[]): string {
|
|
146
|
+
if (errors.length === 0) {
|
|
147
|
+
return 'Validation failed';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (errors.length === 1) {
|
|
151
|
+
return `Validation error: ${errors[0]}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `Validation errors:\n${errors.map((err) => ` - ${err}`).join('\n')}`;
|
|
155
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { type DbClient, getDb } from '../db/client';
|
|
6
|
+
import { modules } from '../db/schema';
|
|
7
|
+
import {
|
|
8
|
+
type SecretsSchema,
|
|
9
|
+
getAllSecretMetadata,
|
|
10
|
+
getSecretMetadata,
|
|
11
|
+
loadSecretsSchema,
|
|
12
|
+
validateDerivationGraph,
|
|
13
|
+
} from './secret-schema-loader';
|
|
14
|
+
|
|
15
|
+
describe('secret-schema-loader', () => {
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
let testDb: DbClient;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// Create temporary directory for test module
|
|
21
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
|
|
22
|
+
|
|
23
|
+
// Set up test database
|
|
24
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
25
|
+
testDb = getDb();
|
|
26
|
+
|
|
27
|
+
// Create test module in database
|
|
28
|
+
testDb
|
|
29
|
+
.insert(modules)
|
|
30
|
+
.values({
|
|
31
|
+
id: 'test-module',
|
|
32
|
+
name: 'Test Module',
|
|
33
|
+
sourcePath: tempDir,
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
manifestData: {},
|
|
36
|
+
})
|
|
37
|
+
.run();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
// Clean up temp directory
|
|
42
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
43
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('loadSecretsSchema', () => {
|
|
47
|
+
test('loads valid secrets schema', async () => {
|
|
48
|
+
// Create schema directory and file
|
|
49
|
+
const schemaDir = join(tempDir, 'schema');
|
|
50
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const schema = {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
api_key: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
source: 'user_provided',
|
|
58
|
+
description: 'API key for service',
|
|
59
|
+
},
|
|
60
|
+
tsig_secret: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
source: 'generated',
|
|
63
|
+
format: 'tsig-key',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
|
|
69
|
+
|
|
70
|
+
const result = await loadSecretsSchema('test-module', testDb);
|
|
71
|
+
|
|
72
|
+
expect(result).not.toBeNull();
|
|
73
|
+
expect(result?.properties.api_key).toBeDefined();
|
|
74
|
+
expect(result?.properties.api_key.source).toBe('user_provided');
|
|
75
|
+
expect(result?.properties.tsig_secret.source).toBe('generated');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('returns null when schema file does not exist', async () => {
|
|
79
|
+
const result = await loadSecretsSchema('test-module', testDb);
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('returns null for invalid schema structure', async () => {
|
|
84
|
+
const schemaDir = join(tempDir, 'schema');
|
|
85
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
// Invalid schema (missing properties)
|
|
88
|
+
const schema = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
|
|
93
|
+
|
|
94
|
+
const result = await loadSecretsSchema('test-module', testDb);
|
|
95
|
+
expect(result).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('returns null for malformed JSON', async () => {
|
|
99
|
+
const schemaDir = join(tempDir, 'schema');
|
|
100
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
101
|
+
|
|
102
|
+
writeFileSync(join(schemaDir, 'secrets.json'), 'not valid json{');
|
|
103
|
+
|
|
104
|
+
const result = await loadSecretsSchema('test-module', testDb);
|
|
105
|
+
expect(result).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('getSecretMetadata', () => {
|
|
110
|
+
test('returns metadata for specific secret', async () => {
|
|
111
|
+
const schemaDir = join(tempDir, 'schema');
|
|
112
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
113
|
+
|
|
114
|
+
const schema = {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
wireguard_private_key: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
source: 'generated',
|
|
120
|
+
format: 'wireguard-key',
|
|
121
|
+
length: 32,
|
|
122
|
+
title: 'WireGuard Private Key',
|
|
123
|
+
description: 'Private key for WireGuard tunnel',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
|
|
129
|
+
|
|
130
|
+
const metadata = await getSecretMetadata('test-module', 'wireguard_private_key', testDb);
|
|
131
|
+
|
|
132
|
+
expect(metadata).not.toBeNull();
|
|
133
|
+
expect(metadata?.name).toBe('wireguard_private_key');
|
|
134
|
+
expect(metadata?.source).toBe('generated');
|
|
135
|
+
expect(metadata?.format).toBe('wireguard-key');
|
|
136
|
+
expect(metadata?.length).toBe(32);
|
|
137
|
+
expect(metadata?.title).toBe('WireGuard Private Key');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('returns null for non-existent secret', async () => {
|
|
141
|
+
const schemaDir = join(tempDir, 'schema');
|
|
142
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
143
|
+
|
|
144
|
+
const schema = {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
api_key: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
source: 'user_provided',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
|
|
155
|
+
|
|
156
|
+
const metadata = await getSecretMetadata('test-module', 'non_existent', testDb);
|
|
157
|
+
expect(metadata).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('defaults to user_provided when source not specified', async () => {
|
|
161
|
+
const schemaDir = join(tempDir, 'schema');
|
|
162
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const schema = {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
api_key: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: 'API key',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
|
|
175
|
+
|
|
176
|
+
const metadata = await getSecretMetadata('test-module', 'api_key', testDb);
|
|
177
|
+
|
|
178
|
+
expect(metadata?.source).toBe('user_provided');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('getAllSecretMetadata', () => {
|
|
183
|
+
test('returns all secret metadata', async () => {
|
|
184
|
+
const schemaDir = join(tempDir, 'schema');
|
|
185
|
+
mkdirSync(schemaDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
const schema = {
|
|
188
|
+
type: 'object',
|
|
189
|
+
properties: {
|
|
190
|
+
api_key: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
source: 'user_provided',
|
|
193
|
+
},
|
|
194
|
+
tsig_secret: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
source: 'generated',
|
|
197
|
+
format: 'tsig-key',
|
|
198
|
+
},
|
|
199
|
+
admin_password: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
source: 'generated_optional',
|
|
202
|
+
format: 'base64',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
|
|
208
|
+
|
|
209
|
+
const allMetadata = await getAllSecretMetadata('test-module', testDb);
|
|
210
|
+
|
|
211
|
+
expect(allMetadata).toHaveLength(3);
|
|
212
|
+
expect(allMetadata.map((m) => m.name)).toContain('api_key');
|
|
213
|
+
expect(allMetadata.map((m) => m.name)).toContain('tsig_secret');
|
|
214
|
+
expect(allMetadata.map((m) => m.name)).toContain('admin_password');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('returns empty array when no schema exists', async () => {
|
|
218
|
+
const allMetadata = await getAllSecretMetadata('test-module', testDb);
|
|
219
|
+
expect(allMetadata).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('validateDerivationGraph', () => {
|
|
224
|
+
test('validates schema without derivation', () => {
|
|
225
|
+
const schema: SecretsSchema = {
|
|
226
|
+
type: 'object',
|
|
227
|
+
properties: {
|
|
228
|
+
api_key: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
source: 'user_provided',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const error = validateDerivationGraph(schema);
|
|
236
|
+
expect(error).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('validates schema with linear derivation', () => {
|
|
240
|
+
const schema: SecretsSchema = {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
private_key: {
|
|
244
|
+
type: 'string',
|
|
245
|
+
source: 'generated',
|
|
246
|
+
format: 'wireguard-key',
|
|
247
|
+
},
|
|
248
|
+
public_key: {
|
|
249
|
+
type: 'string',
|
|
250
|
+
source: 'generated',
|
|
251
|
+
format: 'wireguard-key',
|
|
252
|
+
derive_from: 'private_key',
|
|
253
|
+
derive_method: 'wireguard-pubkey',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const error = validateDerivationGraph(schema);
|
|
259
|
+
expect(error).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('detects circular derivation (direct cycle)', () => {
|
|
263
|
+
const schema: SecretsSchema = {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
secret_a: {
|
|
267
|
+
type: 'string',
|
|
268
|
+
derive_from: 'secret_b',
|
|
269
|
+
derive_method: 'test',
|
|
270
|
+
},
|
|
271
|
+
secret_b: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
derive_from: 'secret_a',
|
|
274
|
+
derive_method: 'test',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const error = validateDerivationGraph(schema);
|
|
280
|
+
expect(error).not.toBeNull();
|
|
281
|
+
expect(error).toContain('Circular derivation detected');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('detects circular derivation (indirect cycle)', () => {
|
|
285
|
+
const schema: SecretsSchema = {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
secret_a: {
|
|
289
|
+
type: 'string',
|
|
290
|
+
derive_from: 'secret_b',
|
|
291
|
+
derive_method: 'test',
|
|
292
|
+
},
|
|
293
|
+
secret_b: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
derive_from: 'secret_c',
|
|
296
|
+
derive_method: 'test',
|
|
297
|
+
},
|
|
298
|
+
secret_c: {
|
|
299
|
+
type: 'string',
|
|
300
|
+
derive_from: 'secret_a',
|
|
301
|
+
derive_method: 'test',
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const error = validateDerivationGraph(schema);
|
|
307
|
+
expect(error).not.toBeNull();
|
|
308
|
+
expect(error).toContain('Circular derivation detected');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret Schema Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads and parses schema/secrets.json files for modules
|
|
5
|
+
* Provides metadata about secret generation behavior
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { type DbClient, getDb } from '../db/client';
|
|
13
|
+
import { modules } from '../db/schema';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Secret metadata from schema
|
|
17
|
+
*/
|
|
18
|
+
export interface SecretMetadata {
|
|
19
|
+
name: string;
|
|
20
|
+
source: 'generated' | 'user_provided' | 'user_password' | 'generated_optional';
|
|
21
|
+
format?: string;
|
|
22
|
+
length?: number;
|
|
23
|
+
deriveFrom?: string;
|
|
24
|
+
deriveMethod?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Secrets schema structure
|
|
31
|
+
*/
|
|
32
|
+
export interface SecretsSchema {
|
|
33
|
+
$schema?: string;
|
|
34
|
+
type: 'object';
|
|
35
|
+
title?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
required?: string[];
|
|
38
|
+
properties: Record<string, SecretPropertySchema>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Individual secret property schema
|
|
43
|
+
*/
|
|
44
|
+
export interface SecretPropertySchema {
|
|
45
|
+
type: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
source?: 'generated' | 'user_provided' | 'user_password' | 'generated_optional';
|
|
49
|
+
format?: string;
|
|
50
|
+
length?: number;
|
|
51
|
+
derive_from?: string;
|
|
52
|
+
derive_method?: string;
|
|
53
|
+
minLength?: number;
|
|
54
|
+
maxLength?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get module source path from database
|
|
59
|
+
*/
|
|
60
|
+
function getModuleSourcePath(moduleId: string, db: DbClient = getDb()): string | null {
|
|
61
|
+
try {
|
|
62
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
63
|
+
return module?.sourcePath || null;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Failed to get module source path for ${moduleId}:`, error);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load secrets schema for a module
|
|
72
|
+
*
|
|
73
|
+
* Looks for schema/secrets.json in the module directory
|
|
74
|
+
*
|
|
75
|
+
* @param moduleId - Module identifier
|
|
76
|
+
* @param db - Database client
|
|
77
|
+
* @returns Secrets schema or null if not found
|
|
78
|
+
*/
|
|
79
|
+
export async function loadSecretsSchema(
|
|
80
|
+
moduleId: string,
|
|
81
|
+
db: DbClient = getDb(),
|
|
82
|
+
): Promise<SecretsSchema | null> {
|
|
83
|
+
const sourcePath = getModuleSourcePath(moduleId, db);
|
|
84
|
+
if (!sourcePath) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const schemaPath = join(sourcePath, 'schema', 'secrets.json');
|
|
89
|
+
if (!existsSync(schemaPath)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const schemaContent = await readFile(schemaPath, 'utf-8');
|
|
95
|
+
let schema: SecretsSchema;
|
|
96
|
+
try {
|
|
97
|
+
schema = JSON.parse(schemaContent) as SecretsSchema;
|
|
98
|
+
} catch (parseError) {
|
|
99
|
+
console.error(
|
|
100
|
+
`Failed to parse secrets schema JSON for ${moduleId}:`,
|
|
101
|
+
parseError instanceof Error ? parseError.message : parseError,
|
|
102
|
+
);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validate basic structure
|
|
107
|
+
if (!schema.properties || typeof schema.properties !== 'object') {
|
|
108
|
+
console.error(`Invalid secrets schema for ${moduleId}: missing properties`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return schema;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(`Failed to load secrets schema for ${moduleId}:`, error);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get metadata for a specific secret
|
|
121
|
+
*
|
|
122
|
+
* @param moduleId - Module identifier
|
|
123
|
+
* @param secretName - Secret name
|
|
124
|
+
* @param db - Database client
|
|
125
|
+
* @returns Secret metadata or null if not found
|
|
126
|
+
*/
|
|
127
|
+
export async function getSecretMetadata(
|
|
128
|
+
moduleId: string,
|
|
129
|
+
secretName: string,
|
|
130
|
+
db: DbClient = getDb(),
|
|
131
|
+
): Promise<SecretMetadata | null> {
|
|
132
|
+
const schema = await loadSecretsSchema(moduleId, db);
|
|
133
|
+
if (!schema) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const propertySchema = schema.properties[secretName];
|
|
138
|
+
if (!propertySchema) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name: secretName,
|
|
144
|
+
source: propertySchema.source || 'user_provided', // Safe default
|
|
145
|
+
format: propertySchema.format,
|
|
146
|
+
length: propertySchema.length,
|
|
147
|
+
deriveFrom: propertySchema.derive_from,
|
|
148
|
+
deriveMethod: propertySchema.derive_method,
|
|
149
|
+
title: propertySchema.title,
|
|
150
|
+
description: propertySchema.description,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get all secret metadata for a module
|
|
156
|
+
*
|
|
157
|
+
* @param moduleId - Module identifier
|
|
158
|
+
* @param db - Database client
|
|
159
|
+
* @returns Array of secret metadata
|
|
160
|
+
*/
|
|
161
|
+
export async function getAllSecretMetadata(
|
|
162
|
+
moduleId: string,
|
|
163
|
+
db: DbClient = getDb(),
|
|
164
|
+
): Promise<SecretMetadata[]> {
|
|
165
|
+
const schema = await loadSecretsSchema(moduleId, db);
|
|
166
|
+
if (!schema) {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const metadata: SecretMetadata[] = [];
|
|
171
|
+
|
|
172
|
+
for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
|
|
173
|
+
metadata.push({
|
|
174
|
+
name: secretName,
|
|
175
|
+
source: propertySchema.source || 'user_provided', // Safe default
|
|
176
|
+
format: propertySchema.format,
|
|
177
|
+
length: propertySchema.length,
|
|
178
|
+
deriveFrom: propertySchema.derive_from,
|
|
179
|
+
deriveMethod: propertySchema.derive_method,
|
|
180
|
+
title: propertySchema.title,
|
|
181
|
+
description: propertySchema.description,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return metadata;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Validate secrets schema for circular derivation
|
|
190
|
+
*
|
|
191
|
+
* Builds derivation graph and checks for cycles using DFS
|
|
192
|
+
*
|
|
193
|
+
* @param schema - Secrets schema
|
|
194
|
+
* @returns Error message if circular derivation detected, null otherwise
|
|
195
|
+
*/
|
|
196
|
+
export function validateDerivationGraph(schema: SecretsSchema): string | null {
|
|
197
|
+
const graph: Record<string, string> = {};
|
|
198
|
+
|
|
199
|
+
// Build derivation graph (secret -> derive_from)
|
|
200
|
+
for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
|
|
201
|
+
if (propertySchema.derive_from) {
|
|
202
|
+
graph[secretName] = propertySchema.derive_from;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check for cycles using DFS
|
|
207
|
+
const visited = new Set<string>();
|
|
208
|
+
const recursionStack = new Set<string>();
|
|
209
|
+
|
|
210
|
+
function hasCycle(node: string): boolean {
|
|
211
|
+
if (recursionStack.has(node)) {
|
|
212
|
+
return true; // Cycle detected
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (visited.has(node)) {
|
|
216
|
+
return false; // Already processed
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
visited.add(node);
|
|
220
|
+
recursionStack.add(node);
|
|
221
|
+
|
|
222
|
+
const parent = graph[node];
|
|
223
|
+
if (parent && hasCycle(parent)) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
recursionStack.delete(node);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check each node in the graph
|
|
232
|
+
for (const secretName of Object.keys(graph)) {
|
|
233
|
+
if (hasCycle(secretName)) {
|
|
234
|
+
return `Circular derivation detected involving: ${secretName}`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return null;
|
|
239
|
+
}
|