diff --git a/.claude/skills/swamp-vault/SKILL.md b/.claude/skills/swamp-vault/SKILL.md index 24d7abfc..e7b7a174 100644 --- a/.claude/skills/swamp-vault/SKILL.md +++ b/.claude/skills/swamp-vault/SKILL.md @@ -36,6 +36,7 @@ Correct flow: `swamp vault create --json` → edit config if neede | Store interactive | `swamp vault put KEY` (prompts for value) | | Get a secret | `swamp vault get --json` | | List secret keys | `swamp vault list-keys --json` | +| Migrate backend | `swamp vault migrate --to-type ` | ## Repository Structure diff --git a/design/vaults.md b/design/vaults.md index c87cff9a..9c586fc9 100644 --- a/design/vaults.md +++ b/design/vaults.md @@ -388,6 +388,49 @@ dbHost: ${{ vault.get(my-1p, database/host) }} sharedCert: ${{ vault.get(my-1p, op://Shared/tls-cert/pem) }} ``` +## Vault Migration + +The `swamp vault migrate` command migrates a vault to a different backend type +in-place. The vault name stays the same, so all existing vault reference +expressions continue to work without modification. + +### Usage + +``` +swamp vault migrate --to-type [--config ] [--dry-run] +``` + +### How It Works + +1. Lists all secret keys in the source vault +2. Copies each secret value from the current backend to a new provider instance +3. Updates the vault configuration file to point to the new backend type + (save-new first, then delete-old) +4. The vault name is preserved — all existing `vault.get('name', 'key')` + expressions resolve identically after migration + +### Safety Model + +- **Secrets are copied, not moved.** The source backend retains its secrets until + the config file is deleted. If anything fails during copy, the original vault + remains fully functional. +- **Config swap ordering.** The new config file is written before the old one is + removed. If the delete fails, an orphaned config file remains but the vault + works correctly on the new backend. +- **Same-type migrations are rejected.** The target type must differ from the + current type. +- **Dry-run support.** Use `--dry-run` to preview the migration (secret count, + type change) without making any changes. + +### Provider Factory + +Provider instantiation is handled by a shared factory function +(`createVaultProvider` in `src/domain/vaults/vault_provider_factory.ts`) that +supports both built-in types (local_encryption, mock) and extension types +registered in the vault type registry. This factory is used by both +`VaultService.registerVault()` and the migrate operation, ensuring consistent +provider creation behavior. + ## Extensibility The vault system is designed for easy extension to new providers: diff --git a/src/cli/commands/vault.ts b/src/cli/commands/vault.ts index af68a691..0e920992 100644 --- a/src/cli/commands/vault.ts +++ b/src/cli/commands/vault.ts @@ -29,6 +29,7 @@ import { vaultDescribeCommand } from "./vault_describe.ts"; import { vaultEditCommand } from "./vault_edit.ts"; import { vaultPutCommand } from "./vault_put.ts"; import { vaultListKeysCommand } from "./vault_list_keys.ts"; +import { vaultMigrateCommand } from "./vault_migrate.ts"; import { unknownCommandErrorHandler } from "../unknown_command_handler.ts"; /** @@ -67,6 +68,7 @@ export const vaultCommand = new Command() .command("describe", vaultDescribeCommand) .command("edit", vaultEditCommand) .command("put", vaultPutCommand) + .command("migrate", vaultMigrateCommand) .command("list-keys", vaultListKeysCommand) .command( "list", diff --git a/src/cli/commands/vault_migrate.ts b/src/cli/commands/vault_migrate.ts new file mode 100644 index 00000000..03cc08da --- /dev/null +++ b/src/cli/commands/vault_migrate.ts @@ -0,0 +1,179 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { Command } from "@cliffy/command"; +import { + consumeStream, + createLibSwampContext, + createVaultMigrateDeps, + vaultMigrate, + vaultMigratePreview, +} from "../../libswamp/mod.ts"; +import { + createVaultMigrateRenderer, + renderVaultMigrateCancelled, +} from "../../presentation/renderers/vault_migrate.ts"; +import { createContext, type GlobalOptions } from "../context.ts"; +import { requireInitializedRepo } from "../repo_context.ts"; +import { UserError } from "../../domain/errors.ts"; +import { getSwampLogger } from "../../infrastructure/logging/logger.ts"; + +async function promptConfirmation(message: string): Promise { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + await Deno.stdout.write(encoder.encode(`${message} [y/N] `)); + + const buf = new Uint8Array(1024); + const n = await Deno.stdin.read(buf); + if (n === null) return false; + + const response = decoder.decode(buf.subarray(0, n)).trim().toLowerCase(); + return response === "y" || response === "yes"; +} + +// deno-lint-ignore no-explicit-any +type AnyOptions = any; + +export const vaultMigrateCommand = new Command() + .name("migrate") + .description( + `Migrate a vault to a different backend type. + +Copies all secrets from the current backend to a new one, then updates +the vault configuration. The vault name stays the same, so all existing +vault references continue to work without modification. + +Both the source and target vaults must be different types.`, + ) + .arguments("") + .option("--to-type ", "Target vault type", { required: true }) + .option( + "--config ", + 'Provider-specific config as JSON (e.g. \'{"region":"us-east-1"}\')', + ) + .option("-f, --force", "Skip confirmation prompt") + .option("--dry-run", "Preview migration without making changes") + .option("--repo-dir ", "Repository directory", { default: "." }) + .example( + "Migrate to AWS Secrets Manager", + 'swamp vault migrate my-vault --to-type @swamp/aws-sm --config \'{"region":"us-east-1"}\'', + ) + .example( + "Preview migration (dry run)", + "swamp vault migrate my-vault --to-type @swamp/aws-sm --dry-run", + ) + .action(async function (options: AnyOptions, vaultName: string) { + const cliCtx = createContext(options as GlobalOptions, [ + "vault", + "migrate", + ]); + cliCtx.logger.debug`Migrating vault: ${vaultName}`; + + const { repoDir } = await requireInitializedRepo({ + repoDir: options.repoDir ?? ".", + outputMode: cliCtx.outputMode, + }); + + // Parse --config JSON if provided + let targetConfig: Record | undefined; + if (options.config) { + try { + targetConfig = JSON.parse(options.config); + } catch { + throw new UserError( + `Invalid JSON in --config: ${options.config}`, + ); + } + } + + const ctx = createLibSwampContext({ logger: cliCtx.logger }); + const deps = await createVaultMigrateDeps(repoDir); + + // Phase 1: Preview + let preview; + try { + preview = await vaultMigratePreview(ctx, deps, { + vaultName, + targetType: options.toType, + targetConfig, + repoDir, + }); + } catch (error) { + if ("code" in (error as Record)) { + throw new UserError((error as { message: string }).message); + } + throw error; + } + + const logger = getSwampLogger(["vault", "migrate"]); + + if (cliCtx.outputMode === "log") { + logger + .info`Vault "${preview.vaultName}" (${preview.currentType}) has ${preview.secretCount} secret(s).`; + logger + .info`Target: ${preview.targetTypeName} (${preview.targetType})`; + } + + // Phase 2: Dry run or confirmation + if (options.dryRun) { + if (cliCtx.outputMode === "json") { + console.log(JSON.stringify( + { + dryRun: true, + vaultName: preview.vaultName, + currentType: preview.currentType, + currentTypeName: preview.currentTypeName, + targetType: preview.targetType, + targetTypeName: preview.targetTypeName, + secretCount: preview.secretCount, + }, + null, + 2, + )); + } else { + logger.info`Dry run — no changes made.`; + } + return; + } + + if (cliCtx.outputMode === "log" && !options.force) { + const confirmed = await promptConfirmation( + `Migrate vault backend from ${preview.currentType} to ${preview.targetType}?`, + ); + if (!confirmed) { + renderVaultMigrateCancelled(cliCtx.outputMode); + return; + } + } + + // Phase 3: Execute migration + const renderer = createVaultMigrateRenderer(cliCtx.outputMode); + await consumeStream( + vaultMigrate(ctx, deps, { + vaultName, + targetType: options.toType, + targetConfig, + repoDir, + }), + renderer.handlers(), + ); + + cliCtx.logger.debug("Vault migrate command completed"); + }); diff --git a/src/domain/vaults/local_encryption_vault_provider.ts b/src/domain/vaults/local_encryption_vault_provider.ts index b22dfd52..38632b53 100644 --- a/src/domain/vaults/local_encryption_vault_provider.ts +++ b/src/domain/vaults/local_encryption_vault_provider.ts @@ -255,11 +255,18 @@ export class LocalEncryptionVaultProvider implements VaultProvider { try { // createNew: true uses O_CREAT | O_EXCL — atomic exclusive creation - // prevents TOCTOU race where two processes both generate different keys - await Deno.writeTextFile(keyFile, generatedKey, { + // prevents TOCTOU race where two processes both generate different keys. + // Use open + write + close so we control when the handle is released. + const file = await Deno.open(keyFile, { + write: true, createNew: true, mode: 0o600, }); + try { + await file.write(new TextEncoder().encode(generatedKey)); + } finally { + file.close(); + } return await crypto.subtle.importKey( "raw", new TextEncoder().encode(generatedKey), @@ -271,8 +278,21 @@ export class LocalEncryptionVaultProvider implements VaultProvider { if (!(writeError instanceof Deno.errors.AlreadyExists)) { throw writeError; } - // Another process won the race — read back their key - const winnerKey = await Deno.readTextFile(keyFile); + // Another process won the race — read back their key. + // The winner may still be writing (file created but content not + // flushed), so retry until content is available. + let winnerKey = ""; + for (let attempt = 0; attempt < 20; attempt++) { + winnerKey = await Deno.readTextFile(keyFile); + if (winnerKey.length > 0) break; + await new Promise((r) => setTimeout(r, 5)); + } + if (!winnerKey) { + throw new Error( + `Key file '${keyFile}' exists but is empty — ` + + `concurrent key generation may have failed`, + ); + } return await crypto.subtle.importKey( "raw", new TextEncoder().encode(winnerKey), diff --git a/src/domain/vaults/vault_provider_factory.ts b/src/domain/vaults/vault_provider_factory.ts new file mode 100644 index 00000000..ec824519 --- /dev/null +++ b/src/domain/vaults/vault_provider_factory.ts @@ -0,0 +1,119 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import type { VaultProvider } from "./vault_provider.ts"; +import { vaultTypeRegistry } from "./vault_type_registry.ts"; +import { MockVaultProvider } from "./mock_vault_provider.ts"; +import { + type LocalEncryptionConfig, + LocalEncryptionVaultProvider, +} from "./local_encryption_vault_provider.ts"; +import { getVaultTypes, RENAMED_VAULT_TYPES } from "./vault_types.ts"; + +/** + * Creates a VaultProvider instance for the given type, name, and config. + * + * Handles both built-in types (local_encryption, mock) and extension types + * registered in the vault type registry. This factory is the single source + * of truth for provider instantiation — used by VaultService.registerVault() + * and the vault migrate operation. + * + * @throws Error if the type is unsupported or config validation fails + */ +export function createVaultProvider( + type: string, + name: string, + config: Record, +): VaultProvider { + // Check registry for user-defined types with a createProvider factory + const registeredType = vaultTypeRegistry.get(type); + if (registeredType?.createProvider && !registeredType.isBuiltIn) { + if (registeredType.configSchema) { + const result = registeredType.configSchema.safeParse(config); + if (!result.success) { + throw new Error( + `Invalid config for vault type '${type}' (vault '${name}'): ${result.error.message}`, + ); + } + } + const provider = registeredType.createProvider(name, config); + assertVaultProvider(provider, type, name); + return provider; + } + + // Built-in types + switch (type.toLowerCase()) { + case "mock": + return new MockVaultProvider( + name, + config as Record, + ); + case "local_encryption": + return new LocalEncryptionVaultProvider( + name, + config as LocalEncryptionConfig, + ); + default: { + const allTypes = vaultTypeRegistry.getAll().map((v) => v.type); + throw new Error( + `Unsupported vault type: '${type}' (vault '${name}').` + + suggestVaultType(type, allTypes), + ); + } + } +} + +/** + * Validates that an object returned by a user-defined createProvider implements + * the VaultProvider interface. + */ +function assertVaultProvider( + obj: unknown, + vaultType: string, + vaultName: string, +): asserts obj is VaultProvider { + const required: (keyof VaultProvider)[] = ["get", "put", "list", "getName"]; + const missing: string[] = []; + for (const method of required) { + if ( + typeof obj !== "object" || obj === null || + typeof (obj as Record)[method] !== "function" + ) { + missing.push(method); + } + } + if (missing.length > 0) { + throw new Error( + `createProvider for vault type '${vaultType}' (vault '${vaultName}') returned an invalid provider: ` + + `missing methods: ${missing.join(", ")}. ` + + `A VaultProvider must implement get, put, list, and getName.`, + ); + } +} + +function suggestVaultType(type: string, allTypes?: string[]): string { + const normalized = type.toLowerCase(); + const renamed = RENAMED_VAULT_TYPES[normalized]; + if (renamed) { + return ` The type '${type}' has been renamed to '${renamed}'. Update your vault configuration to use type: ${renamed}`; + } + const available = allTypes?.join(", ") ?? + getVaultTypes().map((v) => v.type).join(", "); + return ` Available vault types: ${available}`; +} diff --git a/src/domain/vaults/vault_provider_factory_test.ts b/src/domain/vaults/vault_provider_factory_test.ts new file mode 100644 index 00000000..fc463da4 --- /dev/null +++ b/src/domain/vaults/vault_provider_factory_test.ts @@ -0,0 +1,47 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals, assertThrows } from "@std/assert"; +import { createVaultProvider } from "./vault_provider_factory.ts"; + +Deno.test("createVaultProvider: creates mock provider", () => { + const provider = createVaultProvider("mock", "test-vault", {}); + assertEquals(provider.getName(), "test-vault"); +}); + +Deno.test("createVaultProvider: creates local_encryption provider", () => { + const provider = createVaultProvider("local_encryption", "test-vault", { + auto_generate: true, + base_dir: "/tmp", + }); + assertEquals(provider.getName(), "test-vault"); +}); + +Deno.test("createVaultProvider: throws for unsupported type", () => { + assertThrows( + () => createVaultProvider("nonexistent", "test-vault", {}), + Error, + "Unsupported vault type", + ); +}); + +Deno.test("createVaultProvider: is case insensitive for built-in types", () => { + const provider = createVaultProvider("Mock", "test-vault", {}); + assertEquals(provider.getName(), "test-vault"); +}); diff --git a/src/domain/vaults/vault_service.ts b/src/domain/vaults/vault_service.ts index 53e2bc7e..cb939a4e 100644 --- a/src/domain/vaults/vault_service.ts +++ b/src/domain/vaults/vault_service.ts @@ -19,17 +19,14 @@ import { getLogger } from "@logtape/logtape"; import type { VaultConfiguration, VaultProvider } from "./vault_provider.ts"; -import { getVaultTypes } from "./vault_types.ts"; +import { getVaultTypes, RENAMED_VAULT_TYPES } from "./vault_types.ts"; import { vaultTypeRegistry } from "./vault_type_registry.ts"; import { resolveVaultType } from "../extensions/extension_auto_resolver.ts"; import { getAutoResolver } from "../extensions/auto_resolver_context.ts"; -import { MockVaultProvider } from "./mock_vault_provider.ts"; -import { - type LocalEncryptionConfig, - LocalEncryptionVaultProvider, -} from "./local_encryption_vault_provider.ts"; +import type { LocalEncryptionConfig } from "./local_encryption_vault_provider.ts"; import { join } from "@std/path"; import { YamlVaultConfigRepository } from "../../infrastructure/persistence/yaml_vault_config_repository.ts"; +import { createVaultProvider } from "./vault_provider_factory.ts"; /** * Service for managing vault providers and resolving vault operations. @@ -114,49 +111,11 @@ export class VaultService { * Registers a vault provider with the given configuration. */ registerVault(config: VaultConfiguration): void { - let provider: VaultProvider; - - // Check registry for user-defined types with a createProvider factory - const registeredType = vaultTypeRegistry.get(config.type); - if (registeredType?.createProvider && !registeredType.isBuiltIn) { - // Validate config against schema if provided - if (registeredType.configSchema) { - const result = registeredType.configSchema.safeParse(config.config); - if (!result.success) { - throw new Error( - `Invalid config for vault type '${config.type}' (vault '${config.name}'): ${result.error.message}`, - ); - } - } - provider = registeredType.createProvider(config.name, config.config); - assertVaultProvider(provider, config.type, config.name); - this.providers.set(config.name, provider); - return; - } - - // Built-in types - switch (config.type.toLowerCase()) { - case "mock": - provider = new MockVaultProvider( - config.name, - config.config as Record, - ); - break; - case "local_encryption": - provider = new LocalEncryptionVaultProvider( - config.name, - config.config as LocalEncryptionConfig, - ); - break; - default: { - const allTypes = vaultTypeRegistry.getAll().map((v) => v.type); - throw new Error( - `Unsupported vault type: '${config.type}' (vault '${config.name}').` + - suggestVaultType(config.type, allTypes), - ); - } - } - + const provider = createVaultProvider( + config.type, + config.name, + config.config, + ); this.providers.set(config.name, provider); } @@ -271,57 +230,3 @@ export class VaultService { // Left in place to avoid breaking the fromRepository() call site. } } - -/** - * Validates that an object returned by a user-defined createProvider implements - * the VaultProvider interface. Throws a descriptive error if any required method - * is missing or not a function. - */ -function assertVaultProvider( - obj: unknown, - vaultType: string, - vaultName: string, -): asserts obj is VaultProvider { - const required: (keyof VaultProvider)[] = ["get", "put", "list", "getName"]; - const missing: string[] = []; - for (const method of required) { - if ( - typeof obj !== "object" || obj === null || - typeof (obj as Record)[method] !== "function" - ) { - missing.push(method); - } - } - if (missing.length > 0) { - throw new Error( - `createProvider for vault type '${vaultType}' (vault '${vaultName}') returned an invalid provider: ` + - `missing methods: ${missing.join(", ")}. ` + - `A VaultProvider must implement get, put, list, and getName.`, - ); - } -} - -/** - * Known renamed vault types and their current names. - */ -export const RENAMED_VAULT_TYPES: Record = { - "aws": "@swamp/aws-sm", - "aws-sm": "@swamp/aws-sm", - "azure": "@swamp/azure-kv", - "azure-kv": "@swamp/azure-kv", - "1password": "@swamp/1password", -}; - -/** - * Suggests the correct vault type name if the user provided a renamed or similar type. - */ -function suggestVaultType(type: string, allTypes?: string[]): string { - const normalized = type.toLowerCase(); - const renamed = RENAMED_VAULT_TYPES[normalized]; - if (renamed) { - return ` The type '${type}' has been renamed to '${renamed}'. Update your vault configuration to use type: ${renamed}`; - } - const available = allTypes?.join(", ") ?? - getVaultTypes().map((v) => v.type).join(", "); - return ` Available vault types: ${available}`; -} diff --git a/src/domain/vaults/vault_types.ts b/src/domain/vaults/vault_types.ts index 1ee50211..cc06c545 100644 --- a/src/domain/vaults/vault_types.ts +++ b/src/domain/vaults/vault_types.ts @@ -24,6 +24,19 @@ import { export type { VaultTypeInfo } from "./vault_type_registry.ts"; +/** + * Known renamed vault types and their current names. + * Used by VaultService (for auto-remapping on load), the provider factory + * (for helpful error messages), and libswamp operations (for early rejection). + */ +export const RENAMED_VAULT_TYPES: Record = { + "aws": "@swamp/aws-sm", + "aws-sm": "@swamp/aws-sm", + "azure": "@swamp/azure-kv", + "azure-kv": "@swamp/azure-kv", + "1password": "@swamp/1password", +}; + /** * Built-in vault type definitions. * Note: mock vault is intentionally excluded as it's for internal testing only. diff --git a/src/libswamp/mod.ts b/src/libswamp/mod.ts index 825b3a15..27041f62 100644 --- a/src/libswamp/mod.ts +++ b/src/libswamp/mod.ts @@ -496,6 +496,18 @@ export { type VaultListKeysInput, } from "./vaults/list_keys.ts"; +// Vault migrate operations +export { + createVaultMigrateDeps, + vaultMigrate, + type VaultMigrateData, + type VaultMigrateDeps, + type VaultMigrateEvent, + type VaultMigrateInput, + type VaultMigratePreview, + vaultMigratePreview, +} from "./vaults/migrate.ts"; + // Extension search operations export { extensionSearch, diff --git a/src/libswamp/vaults/create.ts b/src/libswamp/vaults/create.ts index 278f1d08..154b1e7c 100644 --- a/src/libswamp/vaults/create.ts +++ b/src/libswamp/vaults/create.ts @@ -25,7 +25,7 @@ import { type VaultTypeInfo, vaultTypeRegistry, } from "../../domain/vaults/vault_type_registry.ts"; -import { RENAMED_VAULT_TYPES } from "../../domain/vaults/vault_service.ts"; +import { RENAMED_VAULT_TYPES } from "../../domain/vaults/vault_types.ts"; import { resolveVaultType } from "../../domain/extensions/extension_auto_resolver.ts"; import { getAutoResolver } from "../../domain/extensions/auto_resolver_context.ts"; import { YamlVaultConfigRepository } from "../../infrastructure/persistence/yaml_vault_config_repository.ts"; diff --git a/src/libswamp/vaults/migrate.ts b/src/libswamp/vaults/migrate.ts new file mode 100644 index 00000000..a3aa49ef --- /dev/null +++ b/src/libswamp/vaults/migrate.ts @@ -0,0 +1,352 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { VaultConfig } from "../../domain/vaults/vault_config.ts"; +import type { VaultProvider } from "../../domain/vaults/vault_provider.ts"; +import { + type VaultTypeInfo, + vaultTypeRegistry, +} from "../../domain/vaults/vault_type_registry.ts"; +import { RENAMED_VAULT_TYPES } from "../../domain/vaults/vault_types.ts"; +import { createVaultProvider } from "../../domain/vaults/vault_provider_factory.ts"; +import { resolveVaultType } from "../../domain/extensions/extension_auto_resolver.ts"; +import { getAutoResolver } from "../../domain/extensions/auto_resolver_context.ts"; +import { YamlVaultConfigRepository } from "../../infrastructure/persistence/yaml_vault_config_repository.ts"; +import { VaultService } from "../../domain/vaults/vault_service.ts"; +import type { LibSwampContext } from "../context.ts"; +import type { SwampError } from "../errors.ts"; +import { notFound, validationFailed } from "../errors.ts"; + +import { withGeneratorSpan } from "../../infrastructure/tracing/mod.ts"; + +/** Data returned by the preview step. */ +export interface VaultMigratePreview { + vaultName: string; + currentType: string; + currentTypeName: string; + targetType: string; + targetTypeName: string; + secretCount: number; +} + +/** Data emitted on successful migration. */ +export interface VaultMigrateData { + vaultName: string; + previousType: string; + newType: string; + newTypeName: string; + secretsMigrated: number; + timestamp: string; +} + +export type VaultMigrateEvent = + | { kind: "copying_secret"; index: number; total: number; key: string } + | { kind: "updating_config" } + | { kind: "completed"; data: VaultMigrateData } + | { kind: "error"; error: SwampError }; + +/** Input for the vault migrate operation. */ +export interface VaultMigrateInput { + vaultName: string; + targetType: string; + targetConfig?: Record; + repoDir: string; +} + +/** Dependencies for the vault migrate operation. */ +export interface VaultMigrateDeps { + findVaultConfig: (name: string) => Promise; + resolveExtensionVaultType: (type: string) => Promise; + getVaultTypeInfo: (type: string) => VaultTypeInfo | undefined; + createProvider: ( + type: string, + name: string, + config: Record, + ) => VaultProvider; + loadSourceVaultService: () => Promise; + saveConfig: (config: VaultConfig) => Promise; + deleteConfig: (config: VaultConfig) => Promise; + listAvailableTypes: () => string[]; +} + +/** Wires real infrastructure into VaultMigrateDeps. */ +export async function createVaultMigrateDeps( + repoDir: string, +): Promise { + await vaultTypeRegistry.ensureLoaded(); + const repo = new YamlVaultConfigRepository(repoDir); + return { + findVaultConfig: (name) => repo.findByName(name), + resolveExtensionVaultType: async (type) => { + await vaultTypeRegistry.ensureTypeLoaded(type); + if (!vaultTypeRegistry.has(type) && type.startsWith("@")) { + await resolveVaultType(type, getAutoResolver()); + } + }, + getVaultTypeInfo: (type) => vaultTypeRegistry.get(type), + createProvider: createVaultProvider, + loadSourceVaultService: () => VaultService.fromRepository(repoDir), + saveConfig: (config) => repo.save(config), + deleteConfig: (config) => repo.delete(config), + listAvailableTypes: () => vaultTypeRegistry.getAll().map((v) => v.type), + }; +} + +/** + * Resolves provider-specific configuration for built-in vault types. + */ +function resolveBuiltInProviderConfig( + vaultType: string, + repoDir: string, +): Record { + switch (vaultType.toLowerCase()) { + case "local_encryption": + return { + auto_generate: true, + base_dir: repoDir, + }; + default: + return {}; + } +} + +/** Gathers preview info for the vault migrate operation. */ +export async function vaultMigratePreview( + ctx: LibSwampContext, + deps: VaultMigrateDeps, + input: VaultMigrateInput, +): Promise { + ctx.logger.debug`Previewing vault migration: ${input.vaultName}`; + + const config = await deps.findVaultConfig(input.vaultName); + if (!config) { + throw notFound( + "Vault", + `${input.vaultName}. Use 'swamp vault search' to see available vaults.`, + ); + } + + // Reject same-type migrations + if (config.type.toLowerCase() === input.targetType.toLowerCase()) { + throw validationFailed( + `Vault '${input.vaultName}' is already using type '${config.type}'. ` + + `Cannot migrate to the same type.`, + ); + } + + // Check for renamed types + const renamed = RENAMED_VAULT_TYPES[input.targetType.toLowerCase()]; + if (renamed) { + throw validationFailed( + `The type '${input.targetType}' has been renamed to '${renamed}'. Use type '${renamed}' instead.`, + ); + } + + // Resolve and validate target type + await deps.resolveExtensionVaultType(input.targetType); + const targetTypeInfo = deps.getVaultTypeInfo(input.targetType); + if (!targetTypeInfo) { + const availableTypes = deps.listAvailableTypes().join(", "); + throw validationFailed( + `Unknown vault type: ${input.targetType}. Available types: ${availableTypes}. ` + + `Use 'swamp vault type search' to see available types.`, + ); + } + + // Validate target config + const targetConfig = resolveTargetConfig( + input.targetType, + input.targetConfig, + targetTypeInfo, + input.repoDir, + ); + + // Verify we can create a provider for the target type (catches config issues early) + deps.createProvider(input.targetType, input.vaultName, targetConfig); + + // Get current type info + const currentTypeInfo = deps.getVaultTypeInfo(config.type); + const currentTypeName = currentTypeInfo?.name ?? config.type; + + // Count secrets in source vault + const vaultService = await deps.loadSourceVaultService(); + const keys = await vaultService.list(input.vaultName); + + return { + vaultName: input.vaultName, + currentType: config.type, + currentTypeName, + targetType: input.targetType, + targetTypeName: targetTypeInfo.name, + secretCount: keys.length, + }; +} + +/** Resolves and validates target config for the migration. */ +function resolveTargetConfig( + targetType: string, + providedConfig: Record | undefined, + typeInfo: VaultTypeInfo, + repoDir: string, +): Record { + if (!typeInfo.isBuiltIn && typeInfo.createProvider) { + const config = providedConfig ?? {}; + if (typeInfo.configSchema) { + const result = typeInfo.configSchema.safeParse(config); + if (!result.success) { + throw validationFailed( + `Invalid config for vault type '${targetType}': ${result.error.message}`, + ); + } + } + return config; + } + + if (providedConfig) { + return providedConfig; + } + + return resolveBuiltInProviderConfig(targetType, repoDir); +} + +/** Migrates a vault to a new backend type in-place. */ +export async function* vaultMigrate( + ctx: LibSwampContext, + deps: VaultMigrateDeps, + input: VaultMigrateInput, +): AsyncIterable { + yield* withGeneratorSpan( + "swamp.vault.migrate", + {}, + (async function* () { + ctx.logger + .debug`Migrating vault: ${input.vaultName} to ${input.targetType}`; + + // Load source vault config + const sourceConfig = await deps.findVaultConfig(input.vaultName); + if (!sourceConfig) { + yield { + kind: "error", + error: notFound("Vault", input.vaultName), + }; + return; + } + + // Same-type guard — prevent config deletion when source and target + // paths are identical (saveConfig then deleteConfig on the same file). + if ( + sourceConfig.type.toLowerCase() === input.targetType.toLowerCase() + ) { + yield { + kind: "error", + error: validationFailed( + `Cannot migrate to the same type. Vault '${input.vaultName}' is already type '${sourceConfig.type}'.`, + ), + }; + return; + } + + // Resolve target config + await deps.resolveExtensionVaultType(input.targetType); + const targetTypeInfo = deps.getVaultTypeInfo(input.targetType); + if (!targetTypeInfo) { + yield { + kind: "error", + error: validationFailed( + `Unknown vault type: ${input.targetType}`, + ), + }; + return; + } + + const targetConfig = resolveTargetConfig( + input.targetType, + input.targetConfig, + targetTypeInfo, + input.repoDir, + ); + + // Create target provider + const targetProvider = deps.createProvider( + input.targetType, + input.vaultName, + targetConfig, + ); + + // Load source vault service and copy secrets + const vaultService = await deps.loadSourceVaultService(); + const keys = await vaultService.list(input.vaultName); + + try { + for (let i = 0; i < keys.length; i++) { + yield { + kind: "copying_secret", + index: i + 1, + total: keys.length, + key: keys[i], + }; + const value = await vaultService.get(input.vaultName, keys[i]); + await targetProvider.put(keys[i], value); + ctx.logger.debug`Copied secret ${i + 1}/${keys.length}`; + } + + // Swap config: save new first, then delete old + yield { kind: "updating_config" }; + const newConfig = VaultConfig.create( + sourceConfig.id, + sourceConfig.name, + input.targetType, + targetConfig, + ); + await deps.saveConfig(newConfig); + ctx.logger.debug`Saved new vault config`; + + try { + await deps.deleteConfig(sourceConfig); + ctx.logger.debug`Deleted old vault config`; + } catch (deleteErr) { + ctx.logger + .warn`Failed to delete old vault config file (vault still works): ${deleteErr}`; + } + } catch (err) { + yield { + kind: "error", + error: validationFailed( + `Migration failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + }; + return; + } + + yield { + kind: "completed", + data: { + vaultName: input.vaultName, + previousType: sourceConfig.type, + newType: input.targetType, + newTypeName: targetTypeInfo.name, + secretsMigrated: keys.length, + timestamp: new Date().toISOString(), + }, + }; + })(), + ); +} diff --git a/src/libswamp/vaults/migrate_test.ts b/src/libswamp/vaults/migrate_test.ts new file mode 100644 index 00000000..8242d154 --- /dev/null +++ b/src/libswamp/vaults/migrate_test.ts @@ -0,0 +1,444 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals, assertStringIncludes, unreachable } from "@std/assert"; +import { collect } from "../testing.ts"; +import { createLibSwampContext } from "../context.ts"; +import { VaultConfig } from "../../domain/vaults/vault_config.ts"; +import { MockVaultProvider } from "../../domain/vaults/mock_vault_provider.ts"; +import { + vaultMigrate, + type VaultMigrateDeps, + type VaultMigrateEvent, + vaultMigratePreview, +} from "./migrate.ts"; + +const SOURCE_CONFIG = VaultConfig.create( + "vault-1", + "my-vault", + "mock", + {}, +); + +function makeDeps( + overrides: Partial = {}, +): VaultMigrateDeps { + const targetSecrets = new Map(); + return { + findVaultConfig: () => Promise.resolve(SOURCE_CONFIG), + resolveExtensionVaultType: () => Promise.resolve(), + getVaultTypeInfo: (type) => { + if (type === "mock" || type === "local_encryption") { + return { + type, + name: type === "mock" ? "Mock" : "Local Encryption", + description: `${type} vault`, + isBuiltIn: true, + }; + } + return undefined; + }, + createProvider: (_type, name) => + new MockVaultProvider(name, Object.fromEntries(targetSecrets)), + loadSourceVaultService: async () => { + // Create a minimal vault service with the source provider + const { VaultService } = await import( + "../../domain/vaults/vault_service.ts" + ); + const svc = new VaultService(); + svc.registerVault({ + name: "my-vault", + type: "mock", + config: {}, + }); + return svc; + }, + saveConfig: () => Promise.resolve(), + deleteConfig: () => Promise.resolve(), + listAvailableTypes: () => ["mock", "local_encryption"], + ...overrides, + }; +} + +Deno.test("vaultMigratePreview: returns preview with secret count", async () => { + const deps = makeDeps(); + const preview = await vaultMigratePreview( + createLibSwampContext(), + deps, + { + vaultName: "my-vault", + targetType: "local_encryption", + repoDir: "/tmp", + }, + ); + + assertEquals(preview.vaultName, "my-vault"); + assertEquals(preview.currentType, "mock"); + assertEquals(preview.targetType, "local_encryption"); + // MockVaultProvider has default secrets + assertEquals(typeof preview.secretCount, "number"); +}); + +Deno.test("vaultMigratePreview: throws not_found for missing vault", async () => { + const deps = makeDeps({ + findVaultConfig: () => Promise.resolve(null), + }); + + try { + await vaultMigratePreview( + createLibSwampContext(), + deps, + { + vaultName: "missing", + targetType: "local_encryption", + repoDir: "/tmp", + }, + ); + unreachable(); + } catch (err) { + assertEquals((err as { code: string }).code, "not_found"); + } +}); + +Deno.test("vaultMigratePreview: rejects same-type migration", async () => { + const deps = makeDeps(); + + try { + await vaultMigratePreview( + createLibSwampContext(), + deps, + { + vaultName: "my-vault", + targetType: "mock", + repoDir: "/tmp", + }, + ); + unreachable(); + } catch (err) { + assertEquals((err as { code: string }).code, "validation_failed"); + assertStringIncludes( + (err as { message: string }).message, + "Cannot migrate to the same type", + ); + } +}); + +Deno.test("vaultMigratePreview: rejects unknown target type", async () => { + const deps = makeDeps({ + getVaultTypeInfo: () => undefined, + }); + + try { + await vaultMigratePreview( + createLibSwampContext(), + deps, + { + vaultName: "my-vault", + targetType: "nonexistent", + repoDir: "/tmp", + }, + ); + unreachable(); + } catch (err) { + assertEquals((err as { code: string }).code, "validation_failed"); + assertStringIncludes( + (err as { message: string }).message, + "Unknown vault type", + ); + } +}); + +Deno.test("vaultMigrate: copies secrets and updates config", async () => { + const copiedSecrets = new Map(); + let savedConfig: VaultConfig | null = null; + let deletedConfig: VaultConfig | null = null; + + const deps = makeDeps({ + createProvider: (_type, name) => { + return { + get: (key: string) => { + const val = copiedSecrets.get(key); + if (!val) throw new Error(`Not found: ${key}`); + return Promise.resolve(val); + }, + put: (key: string, value: string) => { + copiedSecrets.set(key, value); + return Promise.resolve(); + }, + list: () => Promise.resolve(Array.from(copiedSecrets.keys())), + getName: () => name, + }; + }, + saveConfig: (config) => { + savedConfig = config; + return Promise.resolve(); + }, + deleteConfig: (config) => { + deletedConfig = config; + return Promise.resolve(); + }, + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "my-vault", + targetType: "local_encryption", + repoDir: "/tmp", + }), + ); + + // Should have copying events, updating_config, and completed + const kinds = events.map((e) => e.kind); + assertEquals(kinds.includes("updating_config"), true); + assertEquals(kinds[kinds.length - 1], "completed"); + + // Secrets should have been copied + assertEquals(copiedSecrets.size > 0, true); + + // Config should have been saved with new type + assertEquals(savedConfig!.type, "local_encryption"); + assertEquals(savedConfig!.name, "my-vault"); + + // Old config should have been deleted + assertEquals(deletedConfig!.type, "mock"); +}); + +Deno.test("vaultMigrate: yields error when vault not found", async () => { + const deps = makeDeps({ + findVaultConfig: () => Promise.resolve(null), + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "missing", + targetType: "local_encryption", + repoDir: "/tmp", + }), + ); + + const last = events[events.length - 1] as Extract< + VaultMigrateEvent, + { kind: "error" } + >; + assertEquals(last.kind, "error"); + assertEquals(last.error.code, "not_found"); +}); + +Deno.test("vaultMigrate: yields error when target type unknown", async () => { + const deps = makeDeps({ + getVaultTypeInfo: () => undefined, + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "my-vault", + targetType: "nonexistent", + repoDir: "/tmp", + }), + ); + + const last = events[events.length - 1] as Extract< + VaultMigrateEvent, + { kind: "error" } + >; + assertEquals(last.kind, "error"); + assertEquals(last.error.code, "validation_failed"); +}); + +Deno.test("vaultMigrate: handles empty vault with zero secrets", async () => { + let savedConfig: VaultConfig | null = null; + + const emptyProvider = { + get: (_key: string): Promise => { + throw new Error("No secrets"); + }, + put: (_key: string, _value: string) => Promise.resolve(), + list: () => Promise.resolve([] as string[]), + getName: () => "empty-vault", + }; + + const deps = makeDeps({ + loadSourceVaultService: () => { + return Promise.resolve( + { + get: () => { + throw new Error("No secrets"); + }, + put: () => Promise.resolve(), + list: () => Promise.resolve([]), + getVaultNames: () => ["empty-vault"], + } as unknown as import("../../domain/vaults/vault_service.ts").VaultService, + ); + }, + createProvider: () => emptyProvider, + findVaultConfig: () => + Promise.resolve( + VaultConfig.create("vault-empty", "empty-vault", "mock", {}), + ), + saveConfig: (config) => { + savedConfig = config; + return Promise.resolve(); + }, + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "empty-vault", + targetType: "local_encryption", + repoDir: "/tmp", + }), + ); + + const completed = events[events.length - 1] as Extract< + VaultMigrateEvent, + { kind: "completed" } + >; + assertEquals(completed.kind, "completed"); + assertEquals(completed.data.secretsMigrated, 0); + + // Config should still be updated even with zero secrets + assertEquals(savedConfig!.type, "local_encryption"); +}); + +Deno.test("vaultMigrate: tolerates delete failure", async () => { + const deps = makeDeps({ + deleteConfig: () => { + throw new Error("Permission denied"); + }, + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "my-vault", + targetType: "local_encryption", + repoDir: "/tmp", + }), + ); + + // Should still complete despite delete failure + const last = events[events.length - 1]; + assertEquals(last.kind, "completed"); +}); + +Deno.test("vaultMigrate: case-insensitive target type resolves correct config", async () => { + let savedConfig: VaultConfig | null = null; + let createdProviderConfig: Record | undefined; + + const deps = makeDeps({ + getVaultTypeInfo: (type) => { + if ( + type.toLowerCase() === "mock" || + type.toLowerCase() === "local_encryption" + ) { + return { + type, + name: type.toLowerCase() === "mock" ? "Mock" : "Local Encryption", + description: `${type} vault`, + isBuiltIn: true, + }; + } + return undefined; + }, + createProvider: (_type, name, config) => { + createdProviderConfig = config as Record; + return new MockVaultProvider(name); + }, + saveConfig: (config) => { + savedConfig = config; + return Promise.resolve(); + }, + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "my-vault", + targetType: "Local_Encryption", + repoDir: "/tmp/test-repo", + }), + ); + + const last = events[events.length - 1]; + assertEquals(last.kind, "completed"); + assertEquals(savedConfig!.type, "Local_Encryption"); + // The key assertion: config should have auto_generate and base_dir, + // not an empty object from the default branch + assertEquals(createdProviderConfig?.auto_generate, true); + assertEquals(createdProviderConfig?.base_dir, "/tmp/test-repo"); +}); + +Deno.test("vaultMigrate: rejects same-type migration", async () => { + let deleteCalled = false; + const deps = makeDeps({ + deleteConfig: () => { + deleteCalled = true; + return Promise.resolve(); + }, + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "my-vault", + targetType: "mock", // same as source type + repoDir: "/tmp", + }), + ); + + const last = events[events.length - 1] as Extract< + VaultMigrateEvent, + { kind: "error" } + >; + assertEquals(last.kind, "error"); + assertEquals(last.error.code, "validation_failed"); + assertStringIncludes(last.error.message, "Cannot migrate to the same type"); + // Config must NOT be deleted — that would destroy the vault + assertEquals(deleteCalled, false); +}); + +Deno.test("vaultMigrate: yields error event on secret copy failure", async () => { + let copyCount = 0; + const deps = makeDeps({ + createProvider: (_type, name) => ({ + get: () => Promise.reject(new Error("Not found")), + put: (_key: string, _value: string) => { + copyCount++; + if (copyCount >= 2) { + return Promise.reject(new Error("Network timeout")); + } + return Promise.resolve(); + }, + list: () => Promise.resolve([]), + getName: () => name, + }), + }); + + const events = await collect( + vaultMigrate(createLibSwampContext(), deps, { + vaultName: "my-vault", + targetType: "local_encryption", + repoDir: "/tmp", + }), + ); + + const last = events[events.length - 1] as Extract< + VaultMigrateEvent, + { kind: "error" } + >; + assertEquals(last.kind, "error"); + assertStringIncludes(last.error.message, "Network timeout"); +}); diff --git a/src/presentation/renderers/vault_migrate.ts b/src/presentation/renderers/vault_migrate.ts new file mode 100644 index 00000000..2301d4ae --- /dev/null +++ b/src/presentation/renderers/vault_migrate.ts @@ -0,0 +1,83 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import type { EventHandlers, VaultMigrateEvent } from "../../libswamp/mod.ts"; +import type { Renderer } from "../renderer.ts"; +import type { OutputMode } from "../output/output.ts"; +import { getSwampLogger } from "../../infrastructure/logging/logger.ts"; +import { UserError } from "../../domain/errors.ts"; + +class LogVaultMigrateRenderer implements Renderer { + handlers(): EventHandlers { + const logger = getSwampLogger(["vault", "migrate"]); + return { + copying_secret: (e) => { + logger.info`Copying secret ${e.index}/${e.total}: ${e.key}`; + }, + updating_config: () => { + logger.info`Updating vault configuration...`; + }, + completed: (e) => { + logger + .info`Migrated vault ${e.data.vaultName} from ${e.data.previousType} to ${e.data.newType} (${e.data.secretsMigrated} secrets)`; + logger + .info`All existing vault references continue to work — no changes needed.`; + }, + error: (e) => { + throw new UserError(e.error.message); + }, + }; + } +} + +class JsonVaultMigrateRenderer implements Renderer { + handlers(): EventHandlers { + return { + copying_secret: () => {}, + updating_config: () => {}, + completed: (e) => { + console.log(JSON.stringify(e.data, null, 2)); + }, + error: (e) => { + throw new UserError(e.error.message); + }, + }; + } +} + +export function createVaultMigrateRenderer( + mode: OutputMode, +): Renderer { + switch (mode) { + case "json": + return new JsonVaultMigrateRenderer(); + case "log": + return new LogVaultMigrateRenderer(); + } +} + +/** Renders cancellation when user declines the prompt. */ +export function renderVaultMigrateCancelled(mode: OutputMode): void { + if (mode === "json") { + console.log(JSON.stringify({ cancelled: true }, null, 2)); + } else { + const logger = getSwampLogger(["vault", "migrate"]); + logger.info("Operation cancelled."); + } +}