Skip to main content

seal/cli/
args.rs

1//! CLI argument parsing using clap
2
3use clap::{Parser, Subcommand};
4use std::collections::HashMap;
5
6#[derive(Parser, Debug)]
7#[command(name = "seal")]
8#[command(version)]
9#[command(about = "Create Kubernetes SealedSecrets with a single secure command")]
10#[command(
11    long_about = "Seal simplifies the creation of Kubernetes SealedSecrets by handling temporary file\n\
12                         management, cleanup, and kubeseal integration automatically.\n\n\
13                         Instead of manually creating plaintext secrets and sealing them, Seal does\n\
14                         everything in one secure command with guaranteed cleanup.\n\n\
15                         For more information: https://github.com/yourusername/seal"
16)]
17#[command(author = "Seal Contributors")]
18#[command(after_help = "EXAMPLES:\n  \
19    # Create sealed secret with flags\n  \
20    seal create --name db-creds --namespace prod \\\n    \
21        --from-literal username=admin \\\n    \
22        --from-literal password=secret\n\n  \
23    # Create sealed secret interactively\n  \
24    seal create --interactive\n\n  \
25    # Pipe output to file\n  \
26    seal create --name app-key --namespace default \\\n    \
27        --from-literal token=xyz > sealed-secret.yaml\n\n  \
28    # Apply directly to cluster\n  \
29    seal create -n db-creds -N prod -l user=admin -l pass=secret | kubectl apply -f -")]
30pub struct Cli {
31    #[command(subcommand)]
32    pub command: Commands,
33
34    /// Enable verbose logging (does NOT log secret values)
35    #[arg(short, long, global = true)]
36    pub verbose: bool,
37}
38
39#[derive(Subcommand, Debug)]
40pub enum Commands {
41    /// Create a SealedSecret from key-value pairs
42    Create(CreateCommand),
43}
44
45#[derive(Parser, Debug)]
46#[command(about = "Create a SealedSecret from key-value pairs")]
47#[command(long_about = "Create a SealedSecret from key-value pairs.\n\n\
48                        This command creates a plaintext Kubernetes Secret, seals it using kubeseal,\n\
49                        and outputs the encrypted SealedSecret YAML. All temporary files are automatically\n\
50                        cleaned up with no traces left behind.")]
51#[command(after_help = "EXAMPLES:\n  \
52    # Basic secret creation\n  \
53    seal create -n db-password -N production \\\n    \
54        -l username=admin -l password=secret123\n\n  \
55    # With labels and annotations\n  \
56    seal create -n api-key -N default -l KEY=abc123 \\\n    \
57        -L app=backend -L env=staging \\\n    \
58        -A owner=platform-team\n\n  \
59    # Interactive mode (guided prompts)\n  \
60    seal create --interactive\n\n  \
61    # Save to file\n  \
62    seal create -n app-secret -N prod -l TOKEN=xyz > sealed-secret.yaml\n\n  \
63    # Apply directly to cluster\n  \
64    seal create -n db-creds -N prod -l user=admin | kubectl apply -f -\n\n\
65NOTES:\n  \
66    - The kubeseal binary must be installed and in PATH\n  \
67    - A valid kubeconfig with active context is required\n  \
68    - Temporary files are automatically cleaned up (no traces left)\n  \
69    - Secret values are never logged or persisted\n  \
70    - Cluster context is displayed before sealing (safety feature)")]
71pub struct CreateCommand {
72    /// Secret name (Kubernetes naming rules: lowercase, alphanumeric, hyphens, dots)
73    #[arg(short, long, value_name = "NAME")]
74    pub name: String,
75
76    /// Target namespace for the secret
77    #[arg(short = 'N', long, value_name = "NAMESPACE")]
78    pub namespace: String,
79
80    /// Secret key-value pair (can be specified multiple times, max 50)
81    #[arg(short = 'l', long = "from-literal", value_name = "KEY=VALUE")]
82    pub from_literal: Vec<String>,
83
84    /// Add label to secret metadata (can be specified multiple times)
85    #[arg(short = 'L', long = "label", value_name = "KEY=VALUE")]
86    pub labels: Vec<String>,
87
88    /// Add annotation to secret metadata (can be specified multiple times)
89    #[arg(short = 'A', long = "annotation", value_name = "KEY=VALUE")]
90    pub annotations: Vec<String>,
91
92    /// Output format: yaml (default) or json
93    #[arg(short, long, default_value = "yaml", value_name = "FORMAT")]
94    pub output: String,
95
96    /// Interactive mode - guided prompts for all parameters
97    #[arg(short, long)]
98    pub interactive: bool,
99}
100
101impl CreateCommand {
102    /// Parse key=value strings into HashMap
103    fn parse_key_values(items: &[String]) -> Result<HashMap<String, String>, anyhow::Error> {
104        let mut map = HashMap::new();
105
106        for item in items {
107            let parts: Vec<&str> = item.splitn(2, '=').collect();
108            if parts.len() != 2 {
109                anyhow::bail!("Invalid format '{}': expected KEY=VALUE", item);
110            }
111
112            map.insert(parts[0].to_string(), parts[1].to_string());
113        }
114
115        Ok(map)
116    }
117
118    /// Parse and convert to SecretRequest
119    pub fn to_secret_request(&self) -> Result<crate::models::SecretRequest, anyhow::Error> {
120        // Parse from_literal into key-value pairs
121        let data_map = Self::parse_key_values(&self.from_literal)?;
122
123        if data_map.is_empty() {
124            anyhow::bail!("At least one --from-literal KEY=VALUE pair is required");
125        }
126
127        // Convert to SecretKeyValue vector
128        let mut data = Vec::new();
129        for (key, value) in data_map {
130            data.push(crate::models::SecretKeyValue::new(key, value)?);
131        }
132
133        // Parse labels and annotations
134        let labels = Self::parse_key_values(&self.labels)?;
135        let annotations = Self::parse_key_values(&self.annotations)?;
136
137        // Create and validate SecretRequest
138        crate::models::SecretRequest::new(
139            self.name.clone(),
140            self.namespace.clone(),
141            labels,
142            annotations,
143            data,
144        )
145        .map_err(|e| anyhow::anyhow!(e))
146    }
147
148    /// Validate command arguments
149    pub fn validate(&self) -> Result<(), anyhow::Error> {
150        if !self.interactive {
151            // In non-interactive mode, name and namespace are required
152            if self.name.is_empty() {
153                anyhow::bail!("--name is required (or use --interactive mode)");
154            }
155
156            if self.namespace.is_empty() {
157                anyhow::bail!("--namespace is required (or use --interactive mode)");
158            }
159
160            if self.from_literal.is_empty() {
161                anyhow::bail!(
162                    "At least one --from-literal KEY=VALUE is required (or use --interactive mode)"
163                );
164            }
165        }
166
167        // Validate output format
168        if self.output != "yaml" && self.output != "json" {
169            anyhow::bail!("Output format must be 'yaml' or 'json'");
170        }
171
172        Ok(())
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_parse_key_values() -> anyhow::Result<()> {
182        let items = vec!["key1=value1".to_string(), "key2=value2".to_string()];
183        let map = CreateCommand::parse_key_values(&items)?;
184
185        assert_eq!(map.get("key1"), Some(&"value1".to_string()));
186        assert_eq!(map.get("key2"), Some(&"value2".to_string()));
187        assert_eq!(map.len(), 2);
188
189        Ok(())
190    }
191
192    #[test]
193    fn test_parse_key_values_with_equals_in_value() -> anyhow::Result<()> {
194        let items = vec!["key=value=with=equals".to_string()];
195        let map = CreateCommand::parse_key_values(&items)?;
196
197        assert_eq!(map.get("key"), Some(&"value=with=equals".to_string()));
198
199        Ok(())
200    }
201
202    #[test]
203    fn test_parse_key_values_invalid_format() {
204        let items = vec!["invalid_no_equals".to_string()];
205        let result = CreateCommand::parse_key_values(&items);
206
207        assert!(result.is_err());
208    }
209}