github-app-forge 0.1.1

Declarative GitHub App lifecycle management via Manifest flow
Documentation
//! Credential sinks — where the App credentials get written after creation.
//!
//! `Stdout` and `File` exist for testing/dry-run. `Sops` is the canonical sink
//! for pleme-io GitOps clusters: renders a K8s Secret YAML matching the
//! `pleme-arc-controller` chart's expected shape, then runs `sops --encrypt
//! --in-place` on it. `Akeyless` is a stub for the future migration.

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;

/// Resolve a `~`-prefixed path against $HOME.
fn expand(path: &str) -> Result<PathBuf> {
    let expanded = shellexpand::tilde(path);
    Ok(PathBuf::from(expanded.into_owned()))
}

/// Write credentials to the configured sink.
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());

    // Run sops --encrypt --in-place
    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 {
    // Indent each line of the PEM by 4 spaces to fit the YAML block scalar.
    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"
    )
}

/// Load credentials from a file (plaintext YAML — only useful for re-loading
/// after a `File` sink). For SOPS files, decrypt with `sops -d` before reading.
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()))
}

/// Parse the CLI's `--sink "kind:..."` shorthand into a SinkConfig.
///
/// Forms:
///   stdout
///   file:./creds.yaml
///   sops:./secret.yaml,name=arc-github-app-secret,namespace=actions-runner-controller
///   akeyless:/path/to/item
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" => {
            // path,name=X,namespace=Y
            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}")),
    }
}

/// Print a HelmRelease values stub for `pleme-arc-controller` consuming these
/// credentials. Useful for double-checking the chart wiring.
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
    );
}