Skip to content

Commit 4f579e9

Browse files
stack72claude
andauthored
feat: add vault migrate command (#1150)
## Summary - Add `swamp vault migrate <vault-name> --to-type <type>` command that migrates a vault's backend in-place, preserving the vault name so all existing vault reference expressions keep working - Extract provider instantiation into shared `vault_provider_factory.ts` used by both `VaultService` and the migrate operation - Support `--dry-run` preview, `--config` for backend-specific settings, and per-secret progress output in both log and JSON modes Fixes swamp-club#37 ## Test Plan - [x] Unit tests for `createVaultProvider` factory (built-in types, unsupported type error, case insensitivity) - [x] Unit tests for `vaultMigrate` generator (secret copying, config swap, vault-not-found error, unknown-type error, empty vault, delete failure tolerance) - [x] Unit tests for `vaultMigratePreview` (preview data, not-found, same-type rejection, unknown-type rejection) - [x] Existing `VaultService` tests still pass after `registerVault()` refactor - [x] `deno check` — no type errors - [x] `deno lint` — clean - [x] `deno fmt` — formatted - [x] Full test suite — 4249 tests pass - [x] `deno run compile` — binary compiles 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6015415 commit 4f579e9

14 files changed

Lines changed: 1328 additions & 108 deletions

File tree

.claude/skills/swamp-vault/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Correct flow: `swamp vault create <type> <name> --json` → edit config if neede
3636
| Store interactive | `swamp vault put <vault> KEY` (prompts for value) |
3737
| Get a secret | `swamp vault get <vault> <key> --json` |
3838
| List secret keys | `swamp vault list-keys <vault> --json` |
39+
| Migrate backend | `swamp vault migrate <vault> --to-type <type>` |
3940

4041
## Repository Structure
4142

design/vaults.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,49 @@ dbHost: ${{ vault.get(my-1p, database/host) }}
388388
sharedCert: ${{ vault.get(my-1p, op://Shared/tls-cert/pem) }}
389389
```
390390

391+
## Vault Migration
392+
393+
The `swamp vault migrate` command migrates a vault to a different backend type
394+
in-place. The vault name stays the same, so all existing vault reference
395+
expressions continue to work without modification.
396+
397+
### Usage
398+
399+
```
400+
swamp vault migrate <vault-name> --to-type <target-type> [--config <json>] [--dry-run]
401+
```
402+
403+
### How It Works
404+
405+
1. Lists all secret keys in the source vault
406+
2. Copies each secret value from the current backend to a new provider instance
407+
3. Updates the vault configuration file to point to the new backend type
408+
(save-new first, then delete-old)
409+
4. The vault name is preserved — all existing `vault.get('name', 'key')`
410+
expressions resolve identically after migration
411+
412+
### Safety Model
413+
414+
- **Secrets are copied, not moved.** The source backend retains its secrets until
415+
the config file is deleted. If anything fails during copy, the original vault
416+
remains fully functional.
417+
- **Config swap ordering.** The new config file is written before the old one is
418+
removed. If the delete fails, an orphaned config file remains but the vault
419+
works correctly on the new backend.
420+
- **Same-type migrations are rejected.** The target type must differ from the
421+
current type.
422+
- **Dry-run support.** Use `--dry-run` to preview the migration (secret count,
423+
type change) without making any changes.
424+
425+
### Provider Factory
426+
427+
Provider instantiation is handled by a shared factory function
428+
(`createVaultProvider` in `src/domain/vaults/vault_provider_factory.ts`) that
429+
supports both built-in types (local_encryption, mock) and extension types
430+
registered in the vault type registry. This factory is used by both
431+
`VaultService.registerVault()` and the migrate operation, ensuring consistent
432+
provider creation behavior.
433+
391434
## Extensibility
392435
393436
The vault system is designed for easy extension to new providers:

src/cli/commands/vault.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { vaultDescribeCommand } from "./vault_describe.ts";
2929
import { vaultEditCommand } from "./vault_edit.ts";
3030
import { vaultPutCommand } from "./vault_put.ts";
3131
import { vaultListKeysCommand } from "./vault_list_keys.ts";
32+
import { vaultMigrateCommand } from "./vault_migrate.ts";
3233
import { unknownCommandErrorHandler } from "../unknown_command_handler.ts";
3334

3435
/**
@@ -67,6 +68,7 @@ export const vaultCommand = new Command()
6768
.command("describe", vaultDescribeCommand)
6869
.command("edit", vaultEditCommand)
6970
.command("put", vaultPutCommand)
71+
.command("migrate", vaultMigrateCommand)
7072
.command("list-keys", vaultListKeysCommand)
7173
.command(
7274
"list",

src/cli/commands/vault_migrate.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Swamp, an Automation Framework
2+
// Copyright (C) 2026 System Initiative, Inc.
3+
//
4+
// This file is part of Swamp.
5+
//
6+
// Swamp is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License version 3
8+
// as published by the Free Software Foundation, with the Swamp
9+
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
10+
// file).
11+
//
12+
// Swamp is distributed in the hope that it will be useful,
13+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
// GNU Affero General Public License for more details.
16+
//
17+
// You should have received a copy of the GNU Affero General Public License
18+
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.
19+
20+
import { Command } from "@cliffy/command";
21+
import {
22+
consumeStream,
23+
createLibSwampContext,
24+
createVaultMigrateDeps,
25+
vaultMigrate,
26+
vaultMigratePreview,
27+
} from "../../libswamp/mod.ts";
28+
import {
29+
createVaultMigrateRenderer,
30+
renderVaultMigrateCancelled,
31+
} from "../../presentation/renderers/vault_migrate.ts";
32+
import { createContext, type GlobalOptions } from "../context.ts";
33+
import { requireInitializedRepo } from "../repo_context.ts";
34+
import { UserError } from "../../domain/errors.ts";
35+
import { getSwampLogger } from "../../infrastructure/logging/logger.ts";
36+
37+
async function promptConfirmation(message: string): Promise<boolean> {
38+
const encoder = new TextEncoder();
39+
const decoder = new TextDecoder();
40+
41+
await Deno.stdout.write(encoder.encode(`${message} [y/N] `));
42+
43+
const buf = new Uint8Array(1024);
44+
const n = await Deno.stdin.read(buf);
45+
if (n === null) return false;
46+
47+
const response = decoder.decode(buf.subarray(0, n)).trim().toLowerCase();
48+
return response === "y" || response === "yes";
49+
}
50+
51+
// deno-lint-ignore no-explicit-any
52+
type AnyOptions = any;
53+
54+
export const vaultMigrateCommand = new Command()
55+
.name("migrate")
56+
.description(
57+
`Migrate a vault to a different backend type.
58+
59+
Copies all secrets from the current backend to a new one, then updates
60+
the vault configuration. The vault name stays the same, so all existing
61+
vault references continue to work without modification.
62+
63+
Both the source and target vaults must be different types.`,
64+
)
65+
.arguments("<vault_name:string>")
66+
.option("--to-type <type:string>", "Target vault type", { required: true })
67+
.option(
68+
"--config <config:string>",
69+
'Provider-specific config as JSON (e.g. \'{"region":"us-east-1"}\')',
70+
)
71+
.option("-f, --force", "Skip confirmation prompt")
72+
.option("--dry-run", "Preview migration without making changes")
73+
.option("--repo-dir <dir:string>", "Repository directory", { default: "." })
74+
.example(
75+
"Migrate to AWS Secrets Manager",
76+
'swamp vault migrate my-vault --to-type @swamp/aws-sm --config \'{"region":"us-east-1"}\'',
77+
)
78+
.example(
79+
"Preview migration (dry run)",
80+
"swamp vault migrate my-vault --to-type @swamp/aws-sm --dry-run",
81+
)
82+
.action(async function (options: AnyOptions, vaultName: string) {
83+
const cliCtx = createContext(options as GlobalOptions, [
84+
"vault",
85+
"migrate",
86+
]);
87+
cliCtx.logger.debug`Migrating vault: ${vaultName}`;
88+
89+
const { repoDir } = await requireInitializedRepo({
90+
repoDir: options.repoDir ?? ".",
91+
outputMode: cliCtx.outputMode,
92+
});
93+
94+
// Parse --config JSON if provided
95+
let targetConfig: Record<string, unknown> | undefined;
96+
if (options.config) {
97+
try {
98+
targetConfig = JSON.parse(options.config);
99+
} catch {
100+
throw new UserError(
101+
`Invalid JSON in --config: ${options.config}`,
102+
);
103+
}
104+
}
105+
106+
const ctx = createLibSwampContext({ logger: cliCtx.logger });
107+
const deps = await createVaultMigrateDeps(repoDir);
108+
109+
// Phase 1: Preview
110+
let preview;
111+
try {
112+
preview = await vaultMigratePreview(ctx, deps, {
113+
vaultName,
114+
targetType: options.toType,
115+
targetConfig,
116+
repoDir,
117+
});
118+
} catch (error) {
119+
if ("code" in (error as Record<string, unknown>)) {
120+
throw new UserError((error as { message: string }).message);
121+
}
122+
throw error;
123+
}
124+
125+
const logger = getSwampLogger(["vault", "migrate"]);
126+
127+
if (cliCtx.outputMode === "log") {
128+
logger
129+
.info`Vault "${preview.vaultName}" (${preview.currentType}) has ${preview.secretCount} secret(s).`;
130+
logger
131+
.info`Target: ${preview.targetTypeName} (${preview.targetType})`;
132+
}
133+
134+
// Phase 2: Dry run or confirmation
135+
if (options.dryRun) {
136+
if (cliCtx.outputMode === "json") {
137+
console.log(JSON.stringify(
138+
{
139+
dryRun: true,
140+
vaultName: preview.vaultName,
141+
currentType: preview.currentType,
142+
currentTypeName: preview.currentTypeName,
143+
targetType: preview.targetType,
144+
targetTypeName: preview.targetTypeName,
145+
secretCount: preview.secretCount,
146+
},
147+
null,
148+
2,
149+
));
150+
} else {
151+
logger.info`Dry run — no changes made.`;
152+
}
153+
return;
154+
}
155+
156+
if (cliCtx.outputMode === "log" && !options.force) {
157+
const confirmed = await promptConfirmation(
158+
`Migrate vault backend from ${preview.currentType} to ${preview.targetType}?`,
159+
);
160+
if (!confirmed) {
161+
renderVaultMigrateCancelled(cliCtx.outputMode);
162+
return;
163+
}
164+
}
165+
166+
// Phase 3: Execute migration
167+
const renderer = createVaultMigrateRenderer(cliCtx.outputMode);
168+
await consumeStream(
169+
vaultMigrate(ctx, deps, {
170+
vaultName,
171+
targetType: options.toType,
172+
targetConfig,
173+
repoDir,
174+
}),
175+
renderer.handlers(),
176+
);
177+
178+
cliCtx.logger.debug("Vault migrate command completed");
179+
});

src/domain/vaults/local_encryption_vault_provider.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,18 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
255255

256256
try {
257257
// createNew: true uses O_CREAT | O_EXCL — atomic exclusive creation
258-
// prevents TOCTOU race where two processes both generate different keys
259-
await Deno.writeTextFile(keyFile, generatedKey, {
258+
// prevents TOCTOU race where two processes both generate different keys.
259+
// Use open + write + close so we control when the handle is released.
260+
const file = await Deno.open(keyFile, {
261+
write: true,
260262
createNew: true,
261263
mode: 0o600,
262264
});
265+
try {
266+
await file.write(new TextEncoder().encode(generatedKey));
267+
} finally {
268+
file.close();
269+
}
263270
return await crypto.subtle.importKey(
264271
"raw",
265272
new TextEncoder().encode(generatedKey),
@@ -271,8 +278,21 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
271278
if (!(writeError instanceof Deno.errors.AlreadyExists)) {
272279
throw writeError;
273280
}
274-
// Another process won the race — read back their key
275-
const winnerKey = await Deno.readTextFile(keyFile);
281+
// Another process won the race — read back their key.
282+
// The winner may still be writing (file created but content not
283+
// flushed), so retry until content is available.
284+
let winnerKey = "";
285+
for (let attempt = 0; attempt < 20; attempt++) {
286+
winnerKey = await Deno.readTextFile(keyFile);
287+
if (winnerKey.length > 0) break;
288+
await new Promise<void>((r) => setTimeout(r, 5));
289+
}
290+
if (!winnerKey) {
291+
throw new Error(
292+
`Key file '${keyFile}' exists but is empty — ` +
293+
`concurrent key generation may have failed`,
294+
);
295+
}
276296
return await crypto.subtle.importKey(
277297
"raw",
278298
new TextEncoder().encode(winnerKey),

0 commit comments

Comments
 (0)