tencrypt-cli 0.1.1

Command-line interface for tencrypt certificate issuance and reconcile flows
use std::fs;
use std::path::PathBuf;

use clap::{Parser, Subcommand};
use tencrypt_core::{
    acme_cert_present, advance_record, cert_state_from_str, cert_state_names, dry_run_issue,
    generate_router_labels, new_request, reconcile_step, render_traefik_static_yaml,
    validate_hostname, write_audit_jsonl, write_events_jsonl, BackoffConfig, CertState,
    IssuanceReport, StateStore, StateTransition, TraefikRouterConfig, TraefikStaticConfigInput,
};

#[derive(Debug, Parser)]
#[command(name = "tencrypt")]
#[command(about = "Traefik-backed certificate issuing service CLI")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    /// Simulate a full issuance dry-run for one hostname and emit evidence.
    DryRun {
        #[arg(long)]
        hostname: String,
        #[arg(long, default_value = "operator")]
        actor: String,
        #[arg(long, default_value = "evidence")]
        evidence_dir: PathBuf,
    },
    /// Render a Traefik static config YAML file.
    RenderStaticConfig {
        #[arg(long)]
        email: String,
        #[arg(long, default_value = "cloudflare")]
        resolver_name: String,
        #[arg(long, default_value = "CF_DNS_API_TOKEN")]
        cloudflare_token_env: String,
        #[arg(long, default_value = "/var/traefik/certs/acme.json")]
        cert_storage_file: String,
        #[arg(long, default_value = "INFO")]
        log_level: String,
        #[arg(long)]
        output: PathBuf,
    },
    /// Print Traefik Docker router labels for a service to stdout.
    RenderRouterLabels {
        #[arg(long)]
        service_name: String,
        #[arg(long)]
        hostname: String,
        #[arg(long)]
        service_port: u16,
        #[arg(long, default_value = "cloudflare")]
        resolver_name: String,
    },
    /// Print the reconcile decision for a single certificate state (M3).
    ReconcileDryRun {
        #[arg(long)]
        state: String,
    },
    /// Advance all certificates in a state file by one reconcile step.
    ///
    /// Reads state-file, applies one reconcile step to each cert, writes
    /// updated state, emits audit + event JSONL, then exits.
    /// Exit 0: all certs Done or Wait. Exit 1: any cert in Failed state.
    Reconcile {
        /// Path to the JSON state file (created if absent).
        #[arg(long)]
        state_file: PathBuf,
        /// Directory for audit.jsonl and events.cloudevents.jsonl output.
        #[arg(long, default_value = "evidence")]
        evidence_dir: PathBuf,
        /// Path to Traefik's acme.json. When provided, certificates in
        /// dns_challenge_propagating are advanced to issuing once the cert
        /// appears in acme.json.
        #[arg(long)]
        acme_json: Option<PathBuf>,
        /// ACME resolver name to look up in acme.json (default: cloudflare).
        #[arg(long, default_value = "cloudflare")]
        resolver: String,
    },
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::DryRun {
            hostname,
            actor,
            evidence_dir,
        } => {
            fs::create_dir_all(&evidence_dir)?;
            let request = new_request(hostname, actor);
            let report = dry_run_issue(&request)?;

            let audit_path = evidence_dir.join("audit.jsonl");
            let events_path = evidence_dir.join("events.cloudevents.jsonl");

            write_audit_jsonl(&audit_path, &report)?;
            write_events_jsonl(&events_path, &report)?;

            println!("{}", serde_json::to_string_pretty(&report)?);
            println!("audit: {}", audit_path.display());
            println!("events: {}", events_path.display());
        }

        Commands::RenderStaticConfig {
            email,
            resolver_name,
            cloudflare_token_env,
            cert_storage_file,
            log_level,
            output,
        } => {
            if let Some(parent) = output.parent() {
                fs::create_dir_all(parent)?;
            }
            let config = TraefikStaticConfigInput {
                email,
                resolver_name,
                cert_storage_file,
                cloudflare_token_env,
                log_level,
            };
            let yaml = render_traefik_static_yaml(&config);
            fs::write(&output, yaml)?;
            println!("wrote {}", output.display());
        }

        Commands::RenderRouterLabels {
            service_name,
            hostname,
            service_port,
            resolver_name,
        } => {
            validate_hostname(&hostname)?;
            let config = TraefikRouterConfig {
                service_name,
                hostname,
                service_port,
                resolver_name,
            };
            for label in generate_router_labels(&config) {
                println!("{label}");
            }
        }

        Commands::ReconcileDryRun { state } => {
            let cert_state = cert_state_from_str(&state).ok_or_else(|| {
                let valid = cert_state_names().join(", ");
                anyhow::anyhow!("unknown state {:?}; valid states: {}", state, valid)
            })?;
            let decision = reconcile_step(cert_state);
            println!("{}", serde_json::to_string_pretty(&decision)?);
        }

        Commands::Reconcile {
            state_file,
            evidence_dir,
            acme_json,
            resolver,
        } => {
            fs::create_dir_all(&evidence_dir)?;
            let mut store = StateStore::load(&state_file)?;
            let config = BackoffConfig::default();

            let mut transitions: Vec<StateTransition> = Vec::new();
            let mut evidence_reports: Vec<IssuanceReport> = Vec::new();
            let mut any_failed = false;

            for record in &mut store.certs {
                // For dns_challenge_propagating: check acme.json before deciding.
                // If the cert is now present, manually advance to issuing so the
                // next advance_record call can proceed to issued.
                if record.state == CertState::DnsChallengePropagating {
                    if let Some(ref acme_path) = acme_json {
                        match acme_cert_present(acme_path, &resolver, &record.hostname) {
                            Ok(true) => {
                                let from = record.state;
                                record.state = CertState::Issuing;
                                record.attempt = 0;
                                record.last_updated = chrono::Utc::now();
                                let t = StateTransition {
                                    from,
                                    to: CertState::Issuing,
                                    reason: "acme cert detected in acme.json".to_string(),
                                    at: record.last_updated,
                                };
                                evidence_reports.push(report_from_transition(&record.hostname, &t));
                                transitions.push(t);
                            }
                            Ok(false) => {}
                            Err(e) => {
                                eprintln!(
                                    "warn: acme.json check failed for {}: {e}",
                                    record.hostname
                                );
                            }
                        }
                    }
                }
                if let Some(t) = advance_record(record, &config) {
                    evidence_reports.push(report_from_transition(&record.hostname, &t));
                    transitions.push(t);
                }
                if record.state == tencrypt_core::CertState::Failed {
                    any_failed = true;
                }
            }

            // Emit evidence for all transitions that occurred this run.
            if !transitions.is_empty() {
                let audit_path = evidence_dir.join("audit.jsonl");
                let events_path = evidence_dir.join("events.cloudevents.jsonl");

                for report in &evidence_reports {
                    write_audit_jsonl(&audit_path, report)?;
                    write_events_jsonl(&events_path, report)?;
                }

                println!(
                    "reconcile: {} transition(s) — state saved to {}",
                    transitions.len(),
                    state_file.display()
                );
            } else {
                println!("reconcile: no transitions this run");
            }

            store.save(&state_file)?;

            if any_failed {
                std::process::exit(1);
            }
        }
    }

    Ok(())
}

fn report_from_transition(hostname: &str, transition: &StateTransition) -> IssuanceReport {
    IssuanceReport {
        request_id: uuid::Uuid::new_v4().to_string(),
        hostname: hostname.to_string(),
        actor: "reconciler".to_string(),
        status: serde_json::to_string(&transition.to)
            .expect("cert state should serialize")
            .trim_matches('"')
            .to_string(),
        started_at: transition.at,
        completed_at: transition.at,
        transitions: vec![transition.clone()],
    }
}

#[cfg(test)]
mod tests {
    use chrono::Utc;
    use tencrypt_core::{CertState, StateTransition};

    use super::report_from_transition;

    #[test]
    fn report_from_transition_preserves_hostname_and_snake_case_status() {
        let transition = StateTransition {
            from: CertState::Requested,
            to: CertState::PolicyValidated,
            reason: "reconcile: proceed".to_string(),
            at: Utc::now(),
        };

        let report = report_from_transition("api.example.com", &transition);
        assert_eq!(report.hostname, "api.example.com");
        assert_eq!(report.status, "policy_validated");
        assert_eq!(report.transitions.len(), 1);
    }
}