greentic-operator 0.4.43

Greentic operator CLI for local dev and demo orchestration.
Documentation
use std::path::{Path, PathBuf};

use crate::domains::{self, Domain};

#[derive(Clone, Debug)]
pub struct DoctorOptions {
    pub tenant: Option<String>,
    pub team: Option<String>,
    pub strict: bool,
    pub validator_packs: Vec<PathBuf>,
}

#[derive(Clone, Copy, Debug)]
pub enum DoctorScope {
    One(Domain),
    All,
}

#[derive(Clone, Debug)]
pub struct DoctorRun {
    pub pack_path: PathBuf,
    pub status: std::process::ExitStatus,
}

pub fn run_doctor(
    root: &Path,
    scope: DoctorScope,
    options: DoctorOptions,
    pack_command: &Path,
) -> anyhow::Result<Vec<DoctorRun>> {
    let base_dir = doctor_root(root)?;
    std::fs::create_dir_all(&base_dir)?;

    let domains = match scope {
        DoctorScope::One(domain) => vec![domain],
        DoctorScope::All => vec![
            Domain::Messaging,
            Domain::Events,
            Domain::Secrets,
            Domain::OAuth,
        ],
    };

    let mut runs = Vec::new();

    for domain in domains {
        let provider_packs = domains::discover_provider_packs(root, domain)?;
        let validators = if !options.validator_packs.is_empty() {
            options.validator_packs.clone()
        } else {
            domains::validator_pack_path(root, domain)
                .map(|path| vec![path])
                .unwrap_or_default()
        };

        for pack in provider_packs {
            let run = run_doctor_for_pack(
                root,
                &base_dir,
                domain,
                &pack.path,
                &pack.pack_id,
                &validators,
                options.strict,
                pack_command,
            )?;
            runs.push(run);
        }
    }

    if let Some(selection) = demo_packs(root, &options)? {
        for pack in selection.packs {
            let run = run_doctor_for_pack(
                root,
                &base_dir,
                Domain::Messaging,
                &pack,
                pack.file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or("pack"),
                &options.validator_packs,
                options.strict,
                pack_command,
            )?;
            if !run.status.success() {
                let _ = write_summary(
                    &base_dir,
                    "demo",
                    &format!("doctor failed for demo pack {:?}", pack.display()),
                );
            }
        }
        let summary = format!(
            "demo packs validated for tenant={} team={}\n",
            selection.tenant,
            selection.team.unwrap_or_else(|| "none".to_string())
        );
        write_summary(&base_dir, "demo", &summary)?;
    }

    Ok(runs)
}

pub fn build_doctor_args(
    pack_path: &Path,
    validator_packs: &[PathBuf],
    strict: bool,
) -> Vec<String> {
    let mut args = vec!["doctor".to_string(), pack_path.display().to_string()];
    if strict {
        args.push("--strict".to_string());
    }
    for validator in validator_packs {
        args.push("--validator-pack".to_string());
        args.push(validator.display().to_string());
    }
    args
}

#[allow(clippy::too_many_arguments)]
fn run_doctor_for_pack(
    _root: &Path,
    base_dir: &Path,
    domain: Domain,
    pack_path: &Path,
    pack_label: &str,
    validator_packs: &[PathBuf],
    strict: bool,
    pack_command: &Path,
) -> anyhow::Result<DoctorRun> {
    let run_dir = base_dir.join(domain_name(domain)).join(pack_label);
    std::fs::create_dir_all(&run_dir)?;
    let stdout_path = run_dir.join("stdout.txt");
    let stderr_path = run_dir.join("stderr.txt");

    let stdout = std::fs::File::create(&stdout_path)?;
    let stderr = std::fs::File::create(&stderr_path)?;

    let args = build_doctor_args(pack_path, validator_packs, strict);
    let status = std::process::Command::new(pack_command)
        .args(&args)
        .stdout(stdout)
        .stderr(stderr)
        .status()?;

    let summary = format!("pack: {}\nstatus: {}\n", pack_path.display(), status);
    write_summary(
        base_dir,
        &format!("{}-{}", domain_name(domain), pack_label),
        &summary,
    )?;

    if !status.success() {
        return Err(anyhow::anyhow!(
            "greentic-pack doctor failed for {}",
            pack_path.display()
        ));
    }

    Ok(DoctorRun {
        pack_path: pack_path.to_path_buf(),
        status,
    })
}

struct DemoPackSelection {
    packs: Vec<PathBuf>,
    tenant: String,
    team: Option<String>,
}

fn demo_packs(root: &Path, options: &DoctorOptions) -> anyhow::Result<Option<DemoPackSelection>> {
    let Some(tenant) = options.tenant.clone() else {
        return Ok(None);
    };
    let manifest = resolved_manifest_path(root, &tenant, options.team.as_deref());
    if !manifest.exists() {
        return Ok(None);
    }
    let contents = std::fs::read_to_string(manifest)?;
    let manifest: ResolvedManifest = serde_yaml_bw::from_str(&contents)?;
    let mut packs = Vec::new();
    for pack in manifest.packs {
        if pack.ends_with(".gtpack") {
            packs.push(root.join(pack));
        } else {
            eprintln!("Warning: skipping non-gtpack demo pack {}", pack);
        }
    }
    Ok(Some(DemoPackSelection {
        packs,
        tenant,
        team: options.team.clone(),
    }))
}

fn resolved_manifest_path(root: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
    let filename = match team {
        Some(team) => format!("{tenant}.{team}.yaml"),
        None => format!("{tenant}.yaml"),
    };
    root.join("state").join("resolved").join(filename)
}

fn doctor_root(root: &Path) -> anyhow::Result<PathBuf> {
    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|err| anyhow::anyhow!("timestamp error: {err}"))?
        .as_secs();
    Ok(root
        .join("state")
        .join("doctor")
        .join(format!("{timestamp}")))
}

fn write_summary(base_dir: &Path, name: &str, contents: &str) -> anyhow::Result<()> {
    let summary_path = base_dir.join(format!("{name}-summary.txt"));
    std::fs::write(summary_path, contents)?;
    Ok(())
}

fn domain_name(domain: Domain) -> &'static str {
    match domain {
        Domain::Messaging => "messaging",
        Domain::Events => "events",
        Domain::Secrets => "secrets",
        Domain::OAuth => "oauth",
    }
}

#[derive(Debug, serde::Deserialize)]
struct ResolvedManifest {
    packs: Vec<String>,
}