From 99be6ea7ecef0f723e16c4832690d5d8d05e83ae Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 9 Apr 2026 11:44:41 +0100 Subject: [PATCH 1/5] feat: add vault migrate command to change vault backend in-place Add `swamp vault migrate --to-type ` command that migrates a vault to a different backend provider while preserving the vault name. All existing vault reference expressions continue to work without modification. The command copies all secrets from the current backend to the new one, then swaps the vault configuration (save-new before delete-old for safety). Supports --dry-run for previewing and --config for providing backend-specific configuration. Also extracts provider instantiation into a shared factory function (vault_provider_factory.ts) used by both VaultService and the migrate operation, ensuring consistent provider creation behavior. Fixes swamp-club#37 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/swamp-vault/SKILL.md | 1 + design/vaults.md | 43 +++ src/cli/commands/vault.ts | 2 + src/cli/commands/vault_migrate.ts | 176 ++++++++++ src/domain/vaults/vault_provider_factory.ts | 130 +++++++ .../vaults/vault_provider_factory_test.ts | 47 +++ src/domain/vaults/vault_service.ts | 98 +----- src/libswamp/mod.ts | 12 + src/libswamp/vaults/migrate.ts | 326 +++++++++++++++++ src/libswamp/vaults/migrate_test.ts | 329 ++++++++++++++++++ src/presentation/renderers/vault_migrate.ts | 83 +++++ 11 files changed, 1156 insertions(+), 91 deletions(-) create mode 100644 src/cli/commands/vault_migrate.ts create mode 100644 src/domain/vaults/vault_provider_factory.ts create mode 100644 src/domain/vaults/vault_provider_factory_test.ts create mode 100644 src/libswamp/vaults/migrate.ts create mode 100644 src/libswamp/vaults/migrate_test.ts create mode 100644 src/presentation/renderers/vault_migrate.ts 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..fe46bd03 --- /dev/null +++ b/src/cli/commands/vault_migrate.ts @@ -0,0 +1,176 @@ +// 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("--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 for --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.currentTypeName}) 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, + targetType: preview.targetType, + secretCount: preview.secretCount, + }, + null, + 2, + )); + } else { + logger.info`Dry run — no changes made.`; + } + return; + } + + if (cliCtx.outputMode === "log") { + 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/vault_provider_factory.ts b/src/domain/vaults/vault_provider_factory.ts new file mode 100644 index 00000000..fb04b8f8 --- /dev/null +++ b/src/domain/vaults/vault_provider_factory.ts @@ -0,0 +1,130 @@ +// 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 } from "./vault_types.ts"; + +/** + * Known renamed vault types and their current names. + */ +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", +}; + +/** + * 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..b04b3b3f 100644 --- a/src/domain/vaults/vault_service.ts +++ b/src/domain/vaults/vault_service.ts @@ -23,13 +23,10 @@ import { getVaultTypes } 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); } @@ -272,35 +231,6 @@ export class VaultService { } } -/** - * 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. */ @@ -311,17 +241,3 @@ export const RENAMED_VAULT_TYPES: Record = { "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/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/migrate.ts b/src/libswamp/vaults/migrate.ts new file mode 100644 index 00000000..7d8997c7 --- /dev/null +++ b/src/libswamp/vaults/migrate.ts @@ -0,0 +1,326 @@ +// 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_service.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) { + 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; + } + + // 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); + + 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}`; + } + + 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..62075ccf --- /dev/null +++ b/src/libswamp/vaults/migrate_test.ts @@ -0,0 +1,329 @@ +// 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", async () => { + let savedConfig: VaultConfig | null = null; + + const deps = makeDeps({ + loadSourceVaultService: async () => { + const { VaultService } = await import( + "../../domain/vaults/vault_service.ts" + ); + const svc = new VaultService(); + // Register a mock vault that returns empty list + svc.registerVault({ + name: "empty-vault", + type: "mock", + config: {}, + }); + return svc; + }, + 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"); + // MockVaultProvider has default secrets, so count may be > 0 + assertEquals(typeof completed.data.secretsMigrated, "number"); + + // Config should still be updated even with no user 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"); +}); 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."); + } +} From 1cd47418def43e56c7e19591c02e9992170f92be Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 9 Apr 2026 13:23:51 +0100 Subject: [PATCH 2/5] fix: address PR feedback for vault migrate command - Add -f/--force flag to skip confirmation prompt (matches data_gc, vault_put) - Fix --config error message wording for consistency with vault_create - Include type display names in dry-run JSON output - Use consistent format for source/target log lines - Extract RENAMED_VAULT_TYPES to vault_types.ts (single source of truth) - Fix empty vault test to actually test zero secrets path Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/vault_migrate.ts | 9 +++-- src/domain/vaults/vault_provider_factory.ts | 13 +------ src/domain/vaults/vault_service.ts | 13 +------ src/domain/vaults/vault_types.ts | 13 +++++++ src/libswamp/vaults/create.ts | 2 +- src/libswamp/vaults/migrate.ts | 2 +- src/libswamp/vaults/migrate_test.ts | 38 +++++++++++++-------- 7 files changed, 46 insertions(+), 44 deletions(-) diff --git a/src/cli/commands/vault_migrate.ts b/src/cli/commands/vault_migrate.ts index fe46bd03..03cc08da 100644 --- a/src/cli/commands/vault_migrate.ts +++ b/src/cli/commands/vault_migrate.ts @@ -68,6 +68,7 @@ Both the source and target vaults must be different types.`, "--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( @@ -97,7 +98,7 @@ Both the source and target vaults must be different types.`, targetConfig = JSON.parse(options.config); } catch { throw new UserError( - `Invalid JSON for --config: ${options.config}`, + `Invalid JSON in --config: ${options.config}`, ); } } @@ -125,7 +126,7 @@ Both the source and target vaults must be different types.`, if (cliCtx.outputMode === "log") { logger - .info`Vault "${preview.vaultName}" (${preview.currentTypeName}) has ${preview.secretCount} secret(s).`; + .info`Vault "${preview.vaultName}" (${preview.currentType}) has ${preview.secretCount} secret(s).`; logger .info`Target: ${preview.targetTypeName} (${preview.targetType})`; } @@ -138,7 +139,9 @@ Both the source and target vaults must be different types.`, dryRun: true, vaultName: preview.vaultName, currentType: preview.currentType, + currentTypeName: preview.currentTypeName, targetType: preview.targetType, + targetTypeName: preview.targetTypeName, secretCount: preview.secretCount, }, null, @@ -150,7 +153,7 @@ Both the source and target vaults must be different types.`, return; } - if (cliCtx.outputMode === "log") { + if (cliCtx.outputMode === "log" && !options.force) { const confirmed = await promptConfirmation( `Migrate vault backend from ${preview.currentType} to ${preview.targetType}?`, ); diff --git a/src/domain/vaults/vault_provider_factory.ts b/src/domain/vaults/vault_provider_factory.ts index fb04b8f8..ec824519 100644 --- a/src/domain/vaults/vault_provider_factory.ts +++ b/src/domain/vaults/vault_provider_factory.ts @@ -24,18 +24,7 @@ import { type LocalEncryptionConfig, LocalEncryptionVaultProvider, } from "./local_encryption_vault_provider.ts"; -import { getVaultTypes } from "./vault_types.ts"; - -/** - * Known renamed vault types and their current names. - */ -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", -}; +import { getVaultTypes, RENAMED_VAULT_TYPES } from "./vault_types.ts"; /** * Creates a VaultProvider instance for the given type, name, and config. diff --git a/src/domain/vaults/vault_service.ts b/src/domain/vaults/vault_service.ts index b04b3b3f..cb939a4e 100644 --- a/src/domain/vaults/vault_service.ts +++ b/src/domain/vaults/vault_service.ts @@ -19,7 +19,7 @@ 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"; @@ -230,14 +230,3 @@ export class VaultService { // Left in place to avoid breaking the fromRepository() call site. } } - -/** - * 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", -}; 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/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 index 7d8997c7..99ace9d0 100644 --- a/src/libswamp/vaults/migrate.ts +++ b/src/libswamp/vaults/migrate.ts @@ -23,7 +23,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 { 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"; diff --git a/src/libswamp/vaults/migrate_test.ts b/src/libswamp/vaults/migrate_test.ts index 62075ccf..a1436ce0 100644 --- a/src/libswamp/vaults/migrate_test.ts +++ b/src/libswamp/vaults/migrate_test.ts @@ -261,23 +261,32 @@ Deno.test("vaultMigrate: yields error when target type unknown", async () => { assertEquals(last.error.code, "validation_failed"); }); -Deno.test("vaultMigrate: handles empty vault", async () => { +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: async () => { - const { VaultService } = await import( - "../../domain/vaults/vault_service.ts" + 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, ); - const svc = new VaultService(); - // Register a mock vault that returns empty list - svc.registerVault({ - name: "empty-vault", - type: "mock", - config: {}, - }); - return svc; }, + createProvider: () => emptyProvider, findVaultConfig: () => Promise.resolve( VaultConfig.create("vault-empty", "empty-vault", "mock", {}), @@ -301,10 +310,9 @@ Deno.test("vaultMigrate: handles empty vault", async () => { { kind: "completed" } >; assertEquals(completed.kind, "completed"); - // MockVaultProvider has default secrets, so count may be > 0 - assertEquals(typeof completed.data.secretsMigrated, "number"); + assertEquals(completed.data.secretsMigrated, 0); - // Config should still be updated even with no user secrets + // Config should still be updated even with zero secrets assertEquals(savedConfig!.type, "local_encryption"); }); From 9acdaf70ab6aa49c469f6fabf42907369f9c1125 Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 9 Apr 2026 13:38:21 +0100 Subject: [PATCH 3/5] fix: case-insensitive built-in config resolution in vault migrate resolveBuiltInProviderConfig used strict case matching while the rest of the system normalizes to lowercase. A mixed-case --to-type like Local_Encryption would silently get empty config instead of the correct auto_generate + base_dir defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libswamp/vaults/migrate.ts | 2 +- src/libswamp/vaults/migrate_test.ts | 46 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/libswamp/vaults/migrate.ts b/src/libswamp/vaults/migrate.ts index 99ace9d0..ee7f739c 100644 --- a/src/libswamp/vaults/migrate.ts +++ b/src/libswamp/vaults/migrate.ts @@ -115,7 +115,7 @@ function resolveBuiltInProviderConfig( vaultType: string, repoDir: string, ): Record { - switch (vaultType) { + switch (vaultType.toLowerCase()) { case "local_encryption": return { auto_generate: true, diff --git a/src/libswamp/vaults/migrate_test.ts b/src/libswamp/vaults/migrate_test.ts index a1436ce0..dbf0339a 100644 --- a/src/libswamp/vaults/migrate_test.ts +++ b/src/libswamp/vaults/migrate_test.ts @@ -335,3 +335,49 @@ Deno.test("vaultMigrate: tolerates delete failure", async () => { 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"); +}); From 3d813ed74366b8fb7e39ec51646bfd1195cb57b1 Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 9 Apr 2026 14:11:43 +0100 Subject: [PATCH 4/5] fix: race condition in concurrent vault key generation Deno.writeTextFile with createNew:true creates the file (O_CREAT|O_EXCL) before writing content. A concurrent reader in the AlreadyExists catch path could read an empty file and import empty key material, causing decryption failures when vaults share a key file. Fix: use Deno.open + write + close for explicit control over the write, and add a bounded retry in the loser path to wait for content to appear. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vaults/local_encryption_vault_provider.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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), From 5954b932d728451da53c3bab6f9b264d773d61d1 Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 9 Apr 2026 14:20:18 +0100 Subject: [PATCH 5/5] fix: guard vaultMigrate against same-type and mid-copy failures vaultMigrate was independently callable without vaultMigratePreview, but lacked the same-type guard. When sourceType === targetType, saveConfig then deleteConfig target the same file path, deleting the vault config. Also wraps the secret copy loop + config swap in try/catch so failures (network drops, permission errors, quota exceeded) yield structured error events instead of unhandled exceptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libswamp/vaults/migrate.ts | 80 +++++++++++++++++++---------- src/libswamp/vaults/migrate_test.ts | 61 ++++++++++++++++++++++ 2 files changed, 114 insertions(+), 27 deletions(-) diff --git a/src/libswamp/vaults/migrate.ts b/src/libswamp/vaults/migrate.ts index ee7f739c..a3aa49ef 100644 --- a/src/libswamp/vaults/migrate.ts +++ b/src/libswamp/vaults/migrate.ts @@ -248,6 +248,20 @@ export async function* vaultMigrate( 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); @@ -279,35 +293,47 @@ export async function* vaultMigrate( const vaultService = await deps.loadSourceVaultService(); const keys = await vaultService.list(input.vaultName); - for (let i = 0; i < keys.length; i++) { + 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: "copying_secret", - index: i + 1, - total: keys.length, - key: keys[i], + kind: "error", + error: validationFailed( + `Migration failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ), }; - 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}`; + return; } yield { diff --git a/src/libswamp/vaults/migrate_test.ts b/src/libswamp/vaults/migrate_test.ts index dbf0339a..8242d154 100644 --- a/src/libswamp/vaults/migrate_test.ts +++ b/src/libswamp/vaults/migrate_test.ts @@ -381,3 +381,64 @@ Deno.test("vaultMigrate: case-insensitive target type resolves correct config", 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"); +});