1use std::{collections::HashMap, fmt::Write as _, fs, io, path::PathBuf};
3
4use anyhow::Context as _;
5
6use crate::prelude::{Context, Environment};
7use crate::utils::aws::cli::{
8 secretsmanager_create_empty_secret, secretsmanager_get_secret_string,
9 secretsmanager_list_secret_versions_json, secretsmanager_put_secret_string,
10};
11
12const FALLBACK_EDITOR: &str = "vi";
13
14#[tracel_xtask_macros::declare_command_args(None, SecretsSubCommand)]
15pub struct SecretsCmdArgs {}
16
17impl Default for SecretsSubCommand {
18 fn default() -> Self {
19 SecretsSubCommand::View(SecretsViewSubCmdArgs::default())
20 }
21}
22
23#[derive(clap::Args, Default, Clone, PartialEq)]
24pub struct SecretsCreateSubCmdArgs {
25 #[arg(long)]
27 pub region: String,
28
29 #[arg(value_name = "SECRET_ID")]
31 pub secret_id: String,
32
33 #[arg(long)]
35 pub description: Option<String>,
36}
37
38#[derive(clap::Args, Default, Clone, PartialEq)]
39pub struct SecretsCopySubCmdArgs {
40 #[arg(long)]
42 pub region: String,
43
44 #[arg(long, value_name = "FROM_SECRET_ID")]
46 pub from: String,
47
48 #[arg(long, value_name = "TO_SECRET_ID")]
50 pub to: String,
51}
52
53#[derive(clap::Args, Default, Clone, PartialEq)]
54pub struct SecretsEditSubCmdArgs {
55 #[arg(long)]
57 pub region: String,
58
59 #[arg(value_name = "SECRET_ID")]
61 pub secret_id: String,
62
63 #[arg(short = 'y', long = "yes")]
65 pub yes: bool,
66}
67
68#[derive(clap::Args, Default, Clone, PartialEq)]
69pub struct SecretsEnvFileSubCmdArgs {
70 #[arg(long)]
72 pub output: Option<std::path::PathBuf>,
73
74 #[arg(long)]
76 pub region: String,
77
78 #[arg(value_name = "SECRET_ID", num_args(1..), required = true)]
80 pub secret_ids: Vec<String>,
81}
82
83#[derive(clap::Args, Default, Clone, PartialEq)]
84pub struct SecretsListSubCmdArgs {
85 #[arg(long)]
87 pub region: String,
88
89 #[arg(value_name = "SECRET_ID")]
91 pub secret_id: String,
92}
93
94#[derive(clap::Args, Default, Clone, PartialEq)]
95pub struct SecretsPushSubCmdArgs {
96 #[arg(long)]
98 pub region: String,
99
100 #[arg(long, value_name = "SECRET_ID")]
102 pub secret_id: String,
103
104 #[arg(short = 'y', long = "yes")]
106 pub yes: bool,
107
108 #[arg(value_name = "KEY=VALUE", num_args(1..), required = true)]
110 pub kv: Vec<String>,
111}
112
113#[derive(clap::Args, Default, Clone, PartialEq)]
114pub struct SecretsViewSubCmdArgs {
115 #[arg(long)]
117 pub region: String,
118
119 #[arg(value_name = "SECRET_ID")]
121 pub secret_id: String,
122}
123
124pub fn handle_command(
125 args: SecretsCmdArgs,
126 _env: Environment,
127 _ctx: Context,
128) -> anyhow::Result<()> {
129 match args.get_command() {
130 SecretsSubCommand::Create(create_args) => create(create_args),
131 SecretsSubCommand::Copy(copy_args) => copy(copy_args),
132 SecretsSubCommand::Edit(edit_args) => edit(edit_args),
133 SecretsSubCommand::EnvFile(env_args) => env_file(env_args),
134 SecretsSubCommand::List(list_args) => list(list_args),
135 SecretsSubCommand::Push(push_args) => push(push_args),
136 SecretsSubCommand::View(view_args) => view(view_args),
137 }
138}
139
140fn create(args: SecretsCreateSubCmdArgs) -> anyhow::Result<()> {
142 secretsmanager_create_empty_secret(&args.secret_id, &args.region, args.description.as_deref())?;
144 secretsmanager_put_secret_string(&args.secret_id, &args.region, "{}")?;
146 eprintln!(
147 "✅ Created secret '{}' in region '{}' with an initial empty JSON value.",
148 args.secret_id, args.region
149 );
150 Ok(())
151}
152
153pub fn copy(args: SecretsCopySubCmdArgs) -> anyhow::Result<()> {
155 if args.from == args.to {
156 eprintln!(
157 "Source and target secrets are identical ('{}'), nothing to do.",
158 args.from
159 );
160 return Ok(());
161 }
162
163 eprintln!(
164 "Fetching source secret '{}' in region '{}'...",
165 args.from, args.region
166 );
167 let value = secretsmanager_get_secret_string(&args.from, &args.region, "text")?;
168
169 let target_has_version =
173 secretsmanager_get_secret_string(&args.to, &args.region, "text").is_ok();
174 if target_has_version {
175 eprintln!(
176 "Secret '{}' already has a current version in region '{}'.",
177 args.to, args.region
178 );
179 if !confirm_push()? {
180 eprintln!("Aborting: new secret version was not pushed.");
181 return Ok(());
182 }
183 }
184
185 eprintln!(
186 "Writing target secret '{}' in region '{}'...",
187 args.to, args.region
188 );
189 secretsmanager_put_secret_string(&args.to, &args.region, &value)?;
190 eprintln!(
191 "✅ Copied secret value from '{}' to '{}'.",
192 args.from, args.to
193 );
194
195 Ok(())
196}
197
198fn edit(args: SecretsEditSubCmdArgs) -> anyhow::Result<()> {
206 let original_raw = secretsmanager_get_secret_string(&args.secret_id, &args.region, "text")?;
208 let original_raw_trimmed = original_raw.trim_end_matches('\n');
209 let to_edit =
211 pretty_json(original_raw_trimmed).unwrap_or_else(|| original_raw_trimmed.to_string());
212 let tmp_path = temp_file_path(&args.secret_id);
214 fs::write(&tmp_path, &to_edit)?;
215 eprintln!(
216 "Editing secret '{}' in region '{}' using temporary file:\n {}",
217 args.secret_id,
218 args.region,
219 tmp_path.display()
220 );
221 let editor = detect_editor();
223 let mut parts = editor.split_whitespace();
224 let cmd = parts.next().unwrap_or(FALLBACK_EDITOR);
225 let mut command = std::process::Command::new(cmd);
226 for arg in parts {
227 command.arg(arg);
228 }
229 command.arg(&tmp_path);
230 let status = command
231 .status()
232 .map_err(|e| anyhow::anyhow!("launching editor '{editor}' should succeed: {e}"))?;
233 if !status.success() {
234 fs::remove_file(&tmp_path).ok();
235 return Err(anyhow::anyhow!(
236 "editor '{editor}' should exit successfully (exit status {status})"
237 ));
238 }
239 let edited_raw = fs::read_to_string(&tmp_path)?;
241 fs::remove_file(&tmp_path).ok();
242 let edited_raw_trimmed = edited_raw.trim_end_matches('\n');
243 let original_norm_json = normalize_json(original_raw_trimmed);
245 let edited_norm_json = normalize_json(edited_raw_trimmed);
246 if let (Some(orig_norm), Some(edited_norm)) = (original_norm_json, edited_norm_json) {
248 if orig_norm == edited_norm {
249 eprintln!(
250 "No changes detected (JSON content unchanged), not pushing a new secret version."
251 );
252 return Ok(());
253 }
254 eprintln!("Secret JSON content has changed.");
255 if !args.yes && !confirm_push()? {
256 eprintln!("Aborting: new secret version was not pushed.");
257 return Ok(());
258 }
259 secretsmanager_put_secret_string(&args.secret_id, &args.region, &edited_norm)?;
261 eprintln!(
262 "✅ New JSON version pushed for secret '{}' in region '{}'.",
263 args.secret_id, args.region
264 );
265 return Ok(());
266 }
267 if edited_raw_trimmed == original_raw_trimmed {
269 eprintln!("No changes detected, not pushing a new secret version.");
270 return Ok(());
271 }
272 eprintln!("Secret content has changed.");
273 if !args.yes && !confirm_push()? {
274 eprintln!("Aborting: new secret version was not pushed.");
275 return Ok(());
276 }
277 secretsmanager_put_secret_string(&args.secret_id, &args.region, edited_raw_trimmed)?;
278 eprintln!(
279 "✅ New version pushed for secret '{}' in region '{}'.",
280 args.secret_id, args.region
281 );
282
283 Ok(())
284}
285
286pub fn env_file(args: SecretsEnvFileSubCmdArgs) -> anyhow::Result<()> {
287 if args.secret_ids.is_empty() {
288 eprintln!("No secrets provided.");
289 return Ok(());
290 }
291 let mut merged: HashMap<String, String> = HashMap::new();
293 for id in &args.secret_ids {
294 eprintln!("Fetching secret '{id}'...");
295 let s = secretsmanager_get_secret_string(id, &args.region, "text")?;
296 let s = s.trim();
297 if s.is_empty() {
298 continue;
299 }
300 if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
302 if let Some(obj) = value.as_object() {
303 for (k, v) in obj {
304 let v_str = v
305 .as_str()
306 .map(|x| x.to_string())
307 .unwrap_or_else(|| v.to_string());
308 merged.insert(k.clone(), v_str);
309 }
310 continue;
311 }
312 }
313 for line in s.lines() {
315 let line = line.trim();
316 if line.is_empty() || line.starts_with('#') {
317 continue;
318 }
319 if let Some((k, v)) = line.split_once('=') {
320 merged.insert(k.trim().to_string(), v.trim().to_string());
321 }
322 }
323 }
324
325 let merged = expand_env_map(&merged);
327
328 let mut entries: Vec<(String, String)> = merged.into_iter().collect();
330 entries.sort_by(|a, b| a.0.cmp(&b.0));
331 let mut buf = String::new();
333 for (k, v) in entries {
334 writeln!(&mut buf, "{k}={v}")?;
335 }
336 if let Some(path) = args.output {
337 if let Some(dir) = path.parent() {
338 std::fs::create_dir_all(dir)?;
339 }
340 std::fs::write(&path, buf)?;
341 eprintln!("Wrote env file to {}", path.display());
342 } else {
343 print!("{buf}");
344 }
345
346 Ok(())
347}
348
349fn list(args: SecretsListSubCmdArgs) -> anyhow::Result<()> {
351 eprintln!(
352 "Listing versions for secret '{}' in region '{}'...",
353 args.secret_id, args.region
354 );
355 let json = secretsmanager_list_secret_versions_json(&args.secret_id, &args.region)?;
356 let v: serde_json::Value = serde_json::from_str(&json).context(
357 "Parsing Secrets Manager list-secret-version-ids response as JSON should succeed",
358 )?;
359 let versions = v
360 .get("Versions")
361 .and_then(|v| v.as_array())
362 .ok_or_else(|| {
363 anyhow::anyhow!(
364 "AWS response for secret '{}' should contain a 'Versions' array",
365 args.secret_id
366 )
367 })?;
368
369 if versions.is_empty() {
370 println!("No versions found for secret '{}'.", args.secret_id);
371 return Ok(());
372 }
373
374 struct Row {
375 id: String,
376 created: String,
377 stages: String,
378 }
379
380 let mut rows: Vec<Row> = Vec::new();
381 let mut id_w = "VersionId".len();
382 let mut created_w = "Created".len();
383 let mut stages_w = "Stages".len();
384
385 for ver in versions {
386 let id = ver
387 .get("VersionId")
388 .and_then(|x| x.as_str())
389 .unwrap_or("")
390 .to_string();
391
392 let created = match ver.get("CreatedDate") {
393 Some(serde_json::Value::String(s)) => s.clone(),
394 Some(serde_json::Value::Number(n)) => n.to_string(),
395 Some(other) => other.to_string(),
396 None => "".to_string(),
397 };
398
399 let stages = ver
400 .get("VersionStages")
401 .and_then(|x| x.as_array())
402 .map(|arr| {
403 arr.iter()
404 .filter_map(|v| v.as_str())
405 .collect::<Vec<_>>()
406 .join(",")
407 })
408 .unwrap_or_default();
409
410 id_w = id_w.max(id.len());
411 created_w = created_w.max(created.len());
412 stages_w = stages_w.max(stages.len());
413
414 rows.push(Row {
415 id,
416 created,
417 stages,
418 });
419 }
420
421 println!(
423 "{:<id_w$} {:<created_w$} {:<stages_w$}",
424 "VersionId",
425 "Created",
426 "Stages",
427 id_w = id_w,
428 created_w = created_w,
429 stages_w = stages_w,
430 );
431
432 println!(
434 "{:-<id_w$} {:-<created_w$} {:-<stages_w$}",
435 "",
436 "",
437 "",
438 id_w = id_w,
439 created_w = created_w,
440 stages_w = stages_w,
441 );
442
443 for r in rows {
445 println!(
446 "{:<id_w$} {:<created_w$} {:<stages_w$}",
447 r.id,
448 r.created,
449 r.stages,
450 id_w = id_w,
451 created_w = created_w,
452 stages_w = stages_w,
453 );
454 }
455
456 Ok(())
457}
458
459pub fn push(args: SecretsPushSubCmdArgs) -> anyhow::Result<()> {
462 eprintln!(
464 "Fetching secret '{}' in region '{}'...",
465 args.secret_id, args.region
466 );
467 let original = secretsmanager_get_secret_string(&args.secret_id, &args.region, "text")?;
468 let original_trimmed = original.trim_end_matches('\n');
469 let mut value: serde_json::Value =
470 serde_json::from_str(original_trimmed).with_context(|| {
471 format!(
472 "Parsing secret '{}' as JSON should succeed to use the 'push' subcommand",
473 args.secret_id
474 )
475 })?;
476 let obj = value.as_object_mut().ok_or_else(|| {
477 anyhow::anyhow!(
478 "Secret '{}' should be a JSON object to use the 'push' subcommand",
479 args.secret_id
480 )
481 })?;
482
483 let mut changed = false;
485 eprintln!("Changed entries to update:");
486 for kv in &args.kv {
487 let (key, val) = kv.split_once('=').ok_or_else(|| {
488 anyhow::anyhow!(
489 "Key/value argument '{kv}' should use the KEY=VALUE format for secret '{}'",
490 args.secret_id
491 )
492 })?;
493 let key = key.trim();
494 let val = val.trim();
495 if key.is_empty() {
496 anyhow::bail!(
497 "Key in '{kv}' should not be empty for secret '{}'",
498 args.secret_id
499 );
500 }
501 let existing = obj.get(key);
502 if let Some(existing_val) = existing {
504 if existing_val.is_string() && existing_val.as_str() == Some(val) {
505 continue;
507 }
508 }
509 obj.insert(key.to_string(), serde_json::Value::String(val.to_string()));
510 changed = true;
511 eprintln!(" - {key}");
512 }
513 if !changed {
514 eprintln!("None.");
515 eprintln!(
516 "No changes detected (JSON content unchanged), not pushing a new secret version."
517 );
518 return Ok(());
519 }
520
521 eprintln!("Secret JSON content has changed.");
523 if !args.yes && !confirm_push()? {
524 eprintln!("Aborting: new secret version was not pushed.");
525 return Ok(());
526 }
527
528 let normalized =
530 serde_json::to_string(&value).context("Serializing updated JSON secret should succeed")?;
531 secretsmanager_put_secret_string(&args.secret_id, &args.region, &normalized)?;
532 eprintln!(
533 "✅ Updated secret '{}' in region '{}' with {} key(s).",
534 args.secret_id,
535 args.region,
536 args.kv.len()
537 );
538
539 Ok(())
540}
541
542fn view(args: SecretsViewSubCmdArgs) -> anyhow::Result<()> {
544 let value = secretsmanager_get_secret_string(&args.secret_id, &args.region, "text")?;
545 let trimmed = value.trim_end_matches('\n');
546
547 if let Some(pretty) = pretty_json(trimmed) {
548 println!("{pretty}");
549 } else {
550 println!("{value}");
551 }
552
553 Ok(())
554}
555
556fn temp_file_path(secret_id: &str) -> PathBuf {
558 let mut base: String = secret_id
559 .chars()
560 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
561 .collect();
562 if base.len() > 64 {
563 base.truncate(64);
564 }
565 let pid = std::process::id();
566 let filename = format!("tracel-secret-{base}-{pid}.tmp");
567 std::env::temp_dir().join(filename)
568}
569
570fn detect_editor() -> String {
572 std::env::var("VISUAL")
573 .or_else(|_| std::env::var("EDITOR"))
574 .unwrap_or_else(|_| FALLBACK_EDITOR.to_string())
575}
576
577fn pretty_json(s: &str) -> Option<String> {
579 let value: serde_json::Value = serde_json::from_str(s).ok()?;
580 serde_json::to_string_pretty(&value).ok()
581}
582
583fn normalize_json(s: &str) -> Option<String> {
585 let value: serde_json::Value = serde_json::from_str(s).ok()?;
586 serde_json::to_string(&value).ok()
587}
588
589fn confirm_push() -> anyhow::Result<bool> {
591 use std::io::Write as _;
592
593 print!("Do you want to push a new secret version? [y/N]: ");
594 io::stdout().flush().ok();
595
596 let mut answer = String::new();
597 io::stdin().read_line(&mut answer)?;
598 let answer = answer.trim().to_lowercase();
599 Ok(answer == "y" || answer == "yes")
600}
601
602fn expand_value(input: &str, vars: &HashMap<String, String>) -> String {
604 let mut out = String::new();
605 let mut rest = input;
606
607 while let Some(start) = rest.find("${") {
608 out.push_str(&rest[..start]);
610 let after = &rest[start + 2..];
611 if let Some(end_rel) = after.find('}') {
613 let var_name = &after[..end_rel];
614 if let Some(val) = vars.get(var_name) {
615 out.push_str(val);
617 } else {
618 out.push_str("${");
620 out.push_str(var_name);
621 out.push('}');
622 }
623 rest = &after[end_rel + 1..];
625 } else {
626 out.push_str(&rest[start..]);
628 rest = "";
629 break;
630 }
631 }
632 out.push_str(rest);
634 out
635}
636
637fn expand_env_map(merged: &HashMap<String, String>) -> HashMap<String, String> {
640 let mut expanded = HashMap::new();
641 for (key, value) in merged {
642 let new_val = expand_value(value, merged);
643 expanded.insert(key.clone(), new_val);
644 }
645 expanded
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use serial_test::serial;
652 use std::env;
653
654 #[test]
655 #[serial]
656 fn test_expand_env_map_simple_expansion() {
657 unsafe {
659 env::remove_var("LOG_LEVEL_TEST");
660 env::remove_var("RUST_LOG_TEST");
661 }
662
663 let mut merged: HashMap<String, String> = HashMap::new();
664 merged.insert("LOG_LEVEL_TEST".to_string(), "info".to_string());
665 merged.insert(
666 "RUST_LOG_TEST".to_string(),
667 "xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}".to_string(),
668 );
669
670 let expanded = expand_env_map(&merged);
671
672 let log_level = expanded
673 .get("LOG_LEVEL_TEST")
674 .expect("LOG_LEVEL_TEST should be present after expansion");
675 let rust_log = expanded
676 .get("RUST_LOG_TEST")
677 .expect("RUST_LOG_TEST should be present after expansion");
678
679 assert_eq!(
680 log_level, "info",
681 "LOG_LEVEL_TEST should keep its literal value after expansion"
682 );
683 assert!(
684 !rust_log.contains("${LOG_LEVEL_TEST}"),
685 "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
686 );
687 assert!(
688 rust_log.contains(log_level),
689 "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
690 );
691 }
692
693 #[test]
694 #[serial]
695 fn test_expand_env_map_mixed_values_and_non_expanded_keys() {
696 unsafe {
697 env::remove_var("LOG_LEVEL_TEST");
698 env::remove_var("RUST_LOG_TEST");
699 env::remove_var("PLAIN_KEY_TEST");
700 }
701
702 let mut merged: HashMap<String, String> = HashMap::new();
703 merged.insert("LOG_LEVEL_TEST".to_string(), "debug".to_string());
704 merged.insert(
705 "RUST_LOG_TEST".to_string(),
706 "xtask=${LOG_LEVEL_TEST},other=${LOG_LEVEL_TEST}".to_string(),
707 );
708 merged.insert("PLAIN_KEY_TEST".to_string(), "no_placeholders".to_string());
709
710 let expanded = expand_env_map(&merged);
711
712 let log_level = expanded
713 .get("LOG_LEVEL_TEST")
714 .expect("LOG_LEVEL_TEST should be present after expansion");
715 let rust_log = expanded
716 .get("RUST_LOG_TEST")
717 .expect("RUST_LOG_TEST should be present after expansion");
718 let plain = expanded
719 .get("PLAIN_KEY_TEST")
720 .expect("PLAIN_KEY_TEST should be present after expansion");
721
722 assert_eq!(log_level, "debug");
723 assert!(
724 !rust_log.contains("${LOG_LEVEL_TEST}"),
725 "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
726 );
727 assert!(
728 rust_log.contains(log_level),
729 "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
730 );
731 assert_eq!(
732 plain, "no_placeholders",
733 "PLAIN_KEY_TEST should remain unchanged when there are no placeholders"
734 );
735 }
736
737 #[test]
738 #[serial]
739 fn test_expand_env_map_unknown_placeholder_is_left_intact() {
740 unsafe {
741 env::remove_var("UNKNOWN_PLACEHOLDER_TEST");
742 env::remove_var("USES_UNKNOWN_TEST");
743 }
744
745 let mut merged: HashMap<String, String> = HashMap::new();
746 merged.insert(
747 "USES_UNKNOWN_TEST".to_string(),
748 "value=${UNKNOWN_PLACEHOLDER_TEST}".to_string(),
749 );
750
751 let expanded = expand_env_map(&merged);
752
753 let uses_unknown = expanded
754 .get("USES_UNKNOWN_TEST")
755 .expect("USES_UNKNOWN_TEST should be present after expansion");
756
757 assert_eq!(
759 uses_unknown, "value=${UNKNOWN_PLACEHOLDER_TEST}",
760 "Unknown placeholder should be left intact"
761 );
762 }
763
764 #[test]
765 #[serial]
766 fn test_expand_env_map_preserves_quotes_around_values() {
767 unsafe {
768 env::remove_var("LOG_LEVEL_TEST");
769 env::remove_var("RUST_LOG_QUOTED_TEST");
770 env::remove_var("CRON_TEST");
771 }
772
773 let mut merged: HashMap<String, String> = HashMap::new();
774 merged.insert("LOG_LEVEL_TEST".to_string(), "info".to_string());
775 merged.insert(
777 "RUST_LOG_QUOTED_TEST".to_string(),
778 " \"xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}\" ".to_string(),
779 );
780 merged.insert("CRON_TEST".to_string(), "'0 0 0 * * *'".to_string());
782
783 let expanded = expand_env_map(&merged);
784
785 let rust_log = expanded
786 .get("RUST_LOG_QUOTED_TEST")
787 .expect("RUST_LOG_QUOTED_TEST should be present after expansion");
788 let cron = expanded
789 .get("CRON_TEST")
790 .expect("CRON_TEST should be present after expansion");
791
792 let rust_trimmed = rust_log.trim();
794 assert!(
795 rust_trimmed.starts_with('"') && rust_trimmed.ends_with('"'),
796 "RUST_LOG_QUOTED_TEST should still be double-quoted, got: {rust_log}"
797 );
798 assert!(
799 rust_trimmed.contains("xtask=info"),
800 "RUST_LOG_QUOTED_TEST should contain the expanded value; got: {rust_trimmed}"
801 );
802
803 assert_eq!(
805 cron, "'0 0 0 * * *'",
806 "CRON_TEST should keep its single quotes and content"
807 );
808 }
809}