Skip to main content

github_app_forge/
sink.rs

1//! Credential sinks — where the App credentials get written after creation.
2//!
3//! `Stdout` and `File` exist for testing/dry-run. `Sops` is the canonical sink
4//! for pleme-io GitOps clusters: renders a K8s Secret YAML matching the
5//! `pleme-arc-controller` chart's expected shape, then runs `sops --encrypt
6//! --in-place` on it. `Akeyless` is a stub for the future migration.
7
8use 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
16/// Resolve a `~`-prefixed path against $HOME.
17fn expand(path: &str) -> Result<PathBuf> {
18    let expanded = shellexpand::tilde(path);
19    Ok(PathBuf::from(expanded.into_owned()))
20}
21
22/// Write credentials to the configured sink.
23pub 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    // Run sops --encrypt --in-place
79    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    // Indent each line of the PEM by 4 spaces to fit the YAML block scalar.
103    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
113/// Load credentials from a file (plaintext YAML — only useful for re-loading
114/// after a `File` sink). For SOPS files, decrypt with `sops -d` before reading.
115pub 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
128/// Parse the CLI's `--sink "kind:..."` shorthand into a SinkConfig.
129///
130/// Forms:
131///   stdout
132///   file:./creds.yaml
133///   sops:./secret.yaml,name=arc-github-app-secret,namespace=actions-runner-controller
134///   akeyless:/path/to/item
135pub 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            // path,name=X,namespace=Y
151            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
189/// Print a HelmRelease values stub for `pleme-arc-controller` consuming these
190/// credentials. Useful for double-checking the chart wiring.
191pub 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}