1use anyhow::{bail, Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::client::AppCredentials;
14use crate::manifest::SinkConfig;
15
16fn expand(path: &str) -> Result<PathBuf> {
18 let expanded = shellexpand::tilde(path);
19 Ok(PathBuf::from(expanded.into_owned()))
20}
21
22pub fn write(sink: &SinkConfig, creds: &AppCredentials) -> Result<()> {
24 match sink {
25 SinkConfig::Stdout => {
26 let json = serde_json::to_string_pretty(creds)?;
27 println!("{json}");
28 Ok(())
29 }
30 SinkConfig::File { path } => {
31 let p = expand(path)?;
32 if let Some(parent) = p.parent() {
33 fs::create_dir_all(parent).ok();
34 }
35 let yaml = serde_yaml_ng::to_string(creds)?;
36 fs::write(&p, yaml).with_context(|| format!("writing {}", p.display()))?;
37 println!("wrote credentials → {}", p.display());
38 Ok(())
39 }
40 SinkConfig::Sops {
41 path,
42 secret_name,
43 secret_namespace,
44 } => write_sops(path, secret_name, secret_namespace, creds),
45 SinkConfig::Akeyless { item_path: _ } => {
46 bail!("akeyless sink not yet implemented — use sops or file for now")
47 }
48 }
49}
50
51fn write_sops(
52 path: &str,
53 secret_name: &str,
54 secret_namespace: &str,
55 creds: &AppCredentials,
56) -> Result<()> {
57 let p = expand(path)?;
58 if let Some(parent) = p.parent() {
59 fs::create_dir_all(parent).ok();
60 }
61
62 let installation_id = creds
63 .installation_id
64 .map(|id| id.to_string())
65 .unwrap_or_else(|| "INSTALLATION_ID_PENDING".to_string());
66
67 let yaml = render_k8s_secret(
68 secret_name,
69 secret_namespace,
70 creds.id,
71 &installation_id,
72 &creds.pem,
73 );
74
75 fs::write(&p, yaml).with_context(|| format!("writing {}", p.display()))?;
76 println!("wrote plaintext Secret → {}", p.display());
77
78 let status = Command::new("sops")
80 .arg("--encrypt")
81 .arg("--in-place")
82 .arg(&p)
83 .status()
84 .context("failed to invoke sops (is it on PATH?)")?;
85 if !status.success() {
86 bail!(
87 "sops --encrypt --in-place failed for {}; check that .sops.yaml has a rule covering this path",
88 p.display()
89 );
90 }
91 println!("sops-encrypted in place → {}", p.display());
92 Ok(())
93}
94
95fn render_k8s_secret(
96 name: &str,
97 namespace: &str,
98 app_id: u64,
99 installation_id: &str,
100 private_key_pem: &str,
101) -> String {
102 let indented_pem: String = private_key_pem
104 .lines()
105 .map(|line| format!(" {line}"))
106 .collect::<Vec<_>>()
107 .join("\n");
108 format!(
109 "---\n# Generated by github-app-forge.\n# Do NOT hand-edit — re-run `github-app-forge create` to regenerate.\napiVersion: v1\nkind: Secret\nmetadata:\n name: {name}\n namespace: {namespace}\ntype: Opaque\nstringData:\n github_app_id: \"{app_id}\"\n github_app_installation_id: \"{installation_id}\"\n github_app_private_key: |\n{indented_pem}\n"
110 )
111}
112
113pub fn load_credentials(path: &Path) -> Result<AppCredentials> {
116 let content = fs::read_to_string(path)
117 .with_context(|| format!("reading credentials from {}", path.display()))?;
118 if content.contains("sops:") || content.contains("ENC[") {
119 bail!(
120 "{} appears to be SOPS-encrypted; decrypt with `sops -d` first",
121 path.display()
122 );
123 }
124 serde_yaml_ng::from_str(&content)
125 .with_context(|| format!("parsing credentials at {}", path.display()))
126}
127
128pub fn parse_cli_sink(spec: &str) -> Result<SinkConfig> {
136 use anyhow::anyhow;
137 let (kind, rest) = match spec.split_once(':') {
138 Some((k, r)) => (k, r),
139 None => (spec, ""),
140 };
141 match kind {
142 "stdout" => Ok(SinkConfig::Stdout),
143 "file" => {
144 if rest.is_empty() {
145 return Err(anyhow!("--sink file:<path> requires a path"));
146 }
147 Ok(SinkConfig::File { path: rest.to_string() })
148 }
149 "sops" => {
150 let mut parts = rest.split(',');
152 let path = parts
153 .next()
154 .filter(|s| !s.is_empty())
155 .ok_or_else(|| anyhow!("--sink sops:<path>,name=...,namespace=... requires path"))?
156 .to_string();
157 let mut secret_name = String::new();
158 let mut secret_namespace = String::new();
159 for kv in parts {
160 if let Some((k, v)) = kv.split_once('=') {
161 match k {
162 "name" => secret_name = v.to_string(),
163 "namespace" => secret_namespace = v.to_string(),
164 other => return Err(anyhow!("unknown sops sink key: {other}")),
165 }
166 }
167 }
168 if secret_name.is_empty() || secret_namespace.is_empty() {
169 return Err(anyhow!(
170 "--sink sops requires both name= and namespace= keys"
171 ));
172 }
173 Ok(SinkConfig::Sops {
174 path,
175 secret_name,
176 secret_namespace,
177 })
178 }
179 "akeyless" => {
180 if rest.is_empty() {
181 return Err(anyhow!("--sink akeyless:<item-path> requires a path"));
182 }
183 Ok(SinkConfig::Akeyless { item_path: rest.to_string() })
184 }
185 other => Err(anyhow!("unknown sink kind: {other}")),
186 }
187}
188
189pub fn emit_helmrelease_values(creds: &AppCredentials) {
192 let installation_id = creds
193 .installation_id
194 .map(|id| id.to_string())
195 .unwrap_or_else(|| "<not-yet-installed>".to_string());
196 println!(
197 "# HelmRelease values stub for pleme-arc-controller\n# (operator wires the Secret into the values bundle separately)\nexternalSecret:\n secrets: {{}} # rio uses SOPS-direct seeding, not ESO\ngha-runner-scale-set-controller:\n replicaCount: 1\n# App ID: {}\n# Installation ID: {installation_id}\n# Slug: {}",
198 creds.id, creds.slug
199 );
200}