use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::client::AppCredentials;
use crate::manifest::SinkConfig;
fn expand(path: &str) -> Result<PathBuf> {
let expanded = shellexpand::tilde(path);
Ok(PathBuf::from(expanded.into_owned()))
}
pub fn write(sink: &SinkConfig, creds: &AppCredentials) -> Result<()> {
match sink {
SinkConfig::Stdout => {
let json = serde_json::to_string_pretty(creds)?;
println!("{json}");
Ok(())
}
SinkConfig::File { path } => {
let p = expand(path)?;
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).ok();
}
let yaml = serde_yaml_ng::to_string(creds)?;
fs::write(&p, yaml).with_context(|| format!("writing {}", p.display()))?;
println!("wrote credentials → {}", p.display());
Ok(())
}
SinkConfig::Sops {
path,
secret_name,
secret_namespace,
} => write_sops(path, secret_name, secret_namespace, creds),
SinkConfig::Akeyless { item_path: _ } => {
bail!("akeyless sink not yet implemented — use sops or file for now")
}
}
}
fn write_sops(
path: &str,
secret_name: &str,
secret_namespace: &str,
creds: &AppCredentials,
) -> Result<()> {
let p = expand(path)?;
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).ok();
}
let installation_id = creds
.installation_id
.map(|id| id.to_string())
.unwrap_or_else(|| "INSTALLATION_ID_PENDING".to_string());
let yaml = render_k8s_secret(
secret_name,
secret_namespace,
creds.id,
&installation_id,
&creds.pem,
);
fs::write(&p, yaml).with_context(|| format!("writing {}", p.display()))?;
println!("wrote plaintext Secret → {}", p.display());
let status = Command::new("sops")
.arg("--encrypt")
.arg("--in-place")
.arg(&p)
.status()
.context("failed to invoke sops (is it on PATH?)")?;
if !status.success() {
bail!(
"sops --encrypt --in-place failed for {}; check that .sops.yaml has a rule covering this path",
p.display()
);
}
println!("sops-encrypted in place → {}", p.display());
Ok(())
}
fn render_k8s_secret(
name: &str,
namespace: &str,
app_id: u64,
installation_id: &str,
private_key_pem: &str,
) -> String {
let indented_pem: String = private_key_pem
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"---\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"
)
}
pub fn load_credentials(path: &Path) -> Result<AppCredentials> {
let content = fs::read_to_string(path)
.with_context(|| format!("reading credentials from {}", path.display()))?;
if content.contains("sops:") || content.contains("ENC[") {
bail!(
"{} appears to be SOPS-encrypted; decrypt with `sops -d` first",
path.display()
);
}
serde_yaml_ng::from_str(&content)
.with_context(|| format!("parsing credentials at {}", path.display()))
}
pub fn parse_cli_sink(spec: &str) -> Result<SinkConfig> {
use anyhow::anyhow;
let (kind, rest) = match spec.split_once(':') {
Some((k, r)) => (k, r),
None => (spec, ""),
};
match kind {
"stdout" => Ok(SinkConfig::Stdout),
"file" => {
if rest.is_empty() {
return Err(anyhow!("--sink file:<path> requires a path"));
}
Ok(SinkConfig::File { path: rest.to_string() })
}
"sops" => {
let mut parts = rest.split(',');
let path = parts
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("--sink sops:<path>,name=...,namespace=... requires path"))?
.to_string();
let mut secret_name = String::new();
let mut secret_namespace = String::new();
for kv in parts {
if let Some((k, v)) = kv.split_once('=') {
match k {
"name" => secret_name = v.to_string(),
"namespace" => secret_namespace = v.to_string(),
other => return Err(anyhow!("unknown sops sink key: {other}")),
}
}
}
if secret_name.is_empty() || secret_namespace.is_empty() {
return Err(anyhow!(
"--sink sops requires both name= and namespace= keys"
));
}
Ok(SinkConfig::Sops {
path,
secret_name,
secret_namespace,
})
}
"akeyless" => {
if rest.is_empty() {
return Err(anyhow!("--sink akeyless:<item-path> requires a path"));
}
Ok(SinkConfig::Akeyless { item_path: rest.to_string() })
}
other => Err(anyhow!("unknown sink kind: {other}")),
}
}
pub fn emit_helmrelease_values(creds: &AppCredentials) {
let installation_id = creds
.installation_id
.map(|id| id.to_string())
.unwrap_or_else(|| "<not-yet-installed>".to_string());
println!(
"# 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: {}",
creds.id, creds.slug
);
}