Skip to content

Commit cea178e

Browse files
committed
feat: Add CLI commands for secret vault management
1 parent 68bdd0c commit cea178e

File tree

1 file changed

+159
-0
lines changed

1 file changed

+159
-0
lines changed

caracal/cli/secrets.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Copyright (C) 2026 Garudex Labs. All Rights Reserved.
3+
Caracal, a product of Garudex Labs
4+
5+
CLI commands for secret vault management.
6+
7+
Commands:
8+
caracal secrets list — list secret refs in (org, env)
9+
caracal secrets rotate — rotate the CaracalVault master key (Starter only)
10+
caracal secrets migrate — plan and execute a CaracalVault → AWS SM migration
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import sys
16+
17+
import click
18+
19+
20+
@click.group(name="secrets")
21+
def secrets_group():
22+
"""Manage secrets in the tier-appropriate vault backend."""
23+
24+
25+
@secrets_group.command(name="list")
26+
@click.option("--org-id", required=True, help="Organisation ID.")
27+
@click.option("--env-id", default="default", show_default=True, help="Environment ID.")
28+
@click.option("--tier", required=True,
29+
type=click.Choice(["starter", "growth", "scale", "enterprise"], case_sensitive=False),
30+
help="Subscription tier (determines backend).")
31+
def list_secrets(org_id: str, env_id: str, tier: str) -> None:
32+
"""List secret refs in the vault for (org, env)."""
33+
try:
34+
from caracal.sdk.secrets import SecretsAdapter
35+
adapter = SecretsAdapter(tier=tier, org_id=org_id, env_id=env_id)
36+
refs = adapter.list_refs()
37+
if not refs:
38+
click.echo(f"No secrets found for org={org_id} env={env_id} (backend: {adapter.backend_name}).")
39+
return
40+
click.echo(f"Secrets in {adapter.backend_name} for org={org_id} env={env_id}:\n")
41+
for ref in refs:
42+
click.echo(f" {ref}")
43+
click.echo(f"\nTotal: {len(refs)}")
44+
except Exception as exc:
45+
click.echo(f"Error: {exc}", err=True)
46+
sys.exit(1)
47+
48+
49+
@secrets_group.command(name="rotate")
50+
@click.option("--org-id", required=True, help="Organisation ID.")
51+
@click.option("--env-id", default="default", show_default=True, help="Environment ID.")
52+
@click.option("--confirm", is_flag=True, help="Confirm the rotation without prompting.")
53+
def rotate_key(org_id: str, env_id: str, confirm: bool) -> None:
54+
"""
55+
Rotate the CaracalVault master key for (org, env).
56+
57+
Available only for Starter tier. Re-encrypts all DEKs under a new MEK
58+
version. No secret values are exposed during rotation.
59+
"""
60+
if not confirm:
61+
click.confirm(
62+
f"Rotate master key for org={org_id} env={env_id}? "
63+
"All DEKs will be re-wrapped under a new key version.",
64+
abort=True,
65+
)
66+
try:
67+
from caracal.core.vault import get_vault, gateway_context
68+
vault = get_vault()
69+
with gateway_context():
70+
result = vault.rotate_master_key(org_id, env_id, actor="cli")
71+
click.echo(
72+
f"Key rotation complete.\n"
73+
f" Secrets rotated : {result.secrets_rotated}\n"
74+
f" Secrets failed : {result.secrets_failed}\n"
75+
f" New key version : {result.new_key_version}\n"
76+
f" Duration : {result.duration_seconds}s"
77+
)
78+
if result.secrets_failed > 0:
79+
click.echo("WARNING: Some secrets failed to rotate. Check logs.", err=True)
80+
sys.exit(1)
81+
except Exception as exc:
82+
click.echo(f"Error: {exc}", err=True)
83+
sys.exit(1)
84+
85+
86+
@secrets_group.command(name="migrate")
87+
@click.option("--org-id", required=True, help="Organisation ID.")
88+
@click.option("--env-id", default="default", show_default=True, help="Environment ID.")
89+
@click.option(
90+
"--to-tier",
91+
required=True,
92+
type=click.Choice(["growth", "scale", "enterprise"], case_sensitive=False),
93+
help="Target tier for migration (CaracalVault → AWS SM).",
94+
)
95+
@click.option("--rotate-credentials", is_flag=True, help="Rotate credentials during migration.")
96+
@click.option("--dry-run", is_flag=True, help="Show migration plan without executing.")
97+
@click.option("--confirm", is_flag=True, help="Confirm migration without prompting.")
98+
def migrate_secrets(
99+
org_id: str, env_id: str, to_tier: str,
100+
rotate_credentials: bool, dry_run: bool, confirm: bool,
101+
) -> None:
102+
"""
103+
Migrate secrets from CaracalVault (Starter) to AWS Secrets Manager (Growth+).
104+
105+
Shows cost estimate and impact summary before confirming.
106+
"""
107+
try:
108+
from caracalEnterprise.services.gateway.vault_migration import MigrationOrchestrator
109+
orchestrator = MigrationOrchestrator()
110+
plan = orchestrator.plan_upgrade(
111+
org_id=org_id, env_id=env_id,
112+
source_tier="starter", target_tier=to_tier,
113+
rotate_credentials=rotate_credentials,
114+
)
115+
except Exception as exc:
116+
click.echo(f"Failed to build migration plan: {exc}", err=True)
117+
sys.exit(1)
118+
119+
# Show plan
120+
click.echo(f"\n{'=' * 60}")
121+
click.echo(f" Migration Plan ({plan.plan_id})")
122+
click.echo(f"{'=' * 60}")
123+
click.echo(f" Direction : {plan.source_tier}{plan.target_tier}")
124+
click.echo(f" Secrets : {plan.total_secrets}")
125+
click.echo(f" Rotation : {'yes' if rotate_credentials else 'no'}")
126+
if plan.cost_estimate:
127+
e = plan.cost_estimate
128+
click.echo(f"\n AWS Estimated Cost:")
129+
click.echo(f" Per month : ${e.total_per_month_usd:.4f} USD")
130+
click.echo(f" Per year : ${e.total_per_year_usd:.4f} USD")
131+
click.echo(f" (${e.cost_per_month_usd:.4f}/secret/month + ${e.api_call_cost_per_month_usd:.4f} API calls)")
132+
for k, v in plan.impact_summary.items():
133+
if k not in ("aws_region",):
134+
click.echo(f" {k.replace('_', ' ').title():30s}: {v}")
135+
click.echo(f"{'=' * 60}\n")
136+
137+
if dry_run:
138+
click.echo("Dry run — no changes made.")
139+
return
140+
141+
if not confirm:
142+
click.confirm("Proceed with migration?", abort=True)
143+
144+
try:
145+
result = orchestrator.execute_upgrade(plan, actor="cli")
146+
click.echo(
147+
f"\nMigration {'complete' if result.status.value == 'completed' else 'FAILED'}.\n"
148+
f" Status : {result.status.value}\n"
149+
f" Migrated : {result.secrets_migrated}/{result.secrets_total}\n"
150+
f" Validated : {result.secrets_validated}\n"
151+
f" Failed : {result.secrets_failed}\n"
152+
f" Duration : {result.duration_seconds}s"
153+
)
154+
if result.error:
155+
click.echo(f"\nError: {result.error}", err=True)
156+
sys.exit(1)
157+
except Exception as exc:
158+
click.echo(f"Migration execution failed: {exc}", err=True)
159+
sys.exit(1)

0 commit comments

Comments
 (0)