Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/swamp-vault/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Correct flow: `swamp vault create <type> <name> --json` → edit config if neede
| Store interactive | `swamp vault put <vault> KEY` (prompts for value) |
| Get a secret | `swamp vault get <vault> <key> --json` |
| List secret keys | `swamp vault list-keys <vault> --json` |
| Migrate backend | `swamp vault migrate <vault> --to-type <type>` |

## Repository Structure

Expand Down
43 changes: 43 additions & 0 deletions design/vaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <vault-name> --to-type <target-type> [--config <json>] [--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:
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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",
Expand Down
179 changes: 179 additions & 0 deletions src/cli/commands/vault_migrate.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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<boolean> {
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("<vault_name:string>")
.option("--to-type <type:string>", "Target vault type", { required: true })
.option(
"--config <config:string>",
'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 <dir:string>", "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<string, unknown> | 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<string, unknown>)) {
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");
});
28 changes: 24 additions & 4 deletions src/domain/vaults/local_encryption_vault_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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<void>((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),
Expand Down
Loading
Loading