greentic-operator 0.4.43

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

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone)]
pub struct BuildOptions {
    pub out_dir: PathBuf,
    pub tenant: Option<String>,
    pub team: Option<String>,
    pub allow_pack_dirs: bool,
    pub only_used_providers: bool,
    pub run_doctor: bool,
}

#[derive(Debug, Deserialize, Serialize)]
struct ResolvedManifest {
    version: String,
    tenant: String,
    team: Option<String>,
    project_root: String,
    providers: BTreeMap<String, Vec<String>>,
    packs: Vec<String>,
    env_passthrough: Vec<String>,
    policy: serde_yaml_bw::Value,
}

pub fn build_bundle(
    project_root: &Path,
    options: BuildOptions,
    pack_command: Option<&Path>,
) -> anyhow::Result<()> {
    if options.run_doctor && std::env::var("GREENTIC_OPERATOR_SKIP_DOCTOR").is_err() {
        let pack_command = pack_command
            .ok_or_else(|| anyhow::anyhow!("greentic-pack command is required for demo doctor"))?;
        crate::doctor::run_doctor(
            project_root,
            crate::doctor::DoctorScope::All,
            crate::doctor::DoctorOptions {
                tenant: options.tenant.clone(),
                team: options.team.clone(),
                strict: false,
                validator_packs: Vec::new(),
            },
            pack_command,
        )?;
    }

    let resolved_dir = project_root.join("state").join("resolved");
    if !resolved_dir.exists() {
        return Err(anyhow::anyhow!(
            "Resolved manifests not found. Run `greentic-operator dev sync` first."
        ));
    }

    let manifests = select_manifests(
        &resolved_dir,
        options.tenant.as_deref(),
        options.team.as_deref(),
    )?;
    if manifests.is_empty() {
        return Err(anyhow::anyhow!(
            "No resolved manifests found for selection."
        ));
    }

    let bundle_root = options.out_dir;
    std::fs::create_dir_all(&bundle_root)?;
    std::fs::create_dir_all(bundle_root.join("providers"))?;
    std::fs::create_dir_all(bundle_root.join("packs"))?;
    std::fs::create_dir_all(bundle_root.join("tenants"))?;
    std::fs::create_dir_all(bundle_root.join("resolved"))?;
    std::fs::create_dir_all(bundle_root.join("state"))?;

    let mut used_provider_paths = BTreeSet::new();
    let mut loaded_manifests = Vec::new();
    for manifest_path in &manifests {
        let manifest = load_manifest(manifest_path)?;
        for packs in manifest.providers.values() {
            for pack in packs {
                used_provider_paths.insert(pack.clone());
            }
        }
        loaded_manifests.push((manifest_path.clone(), manifest));
    }

    if options.only_used_providers {
        for provider_path in &used_provider_paths {
            let from = project_root.join(provider_path);
            let to = bundle_root.join(provider_path);
            copy_file(&from, &to)?;
        }
    } else {
        copy_dir(
            project_root.join("providers"),
            bundle_root.join("providers"),
        )?;
    }

    let mut tenants_to_copy = BTreeSet::new();
    for (manifest_path, mut manifest) in loaded_manifests {
        tenants_to_copy.insert(manifest.tenant.clone());

        let pack_paths = manifest.packs.clone();
        for pack in pack_paths {
            let pack_path = project_root.join(&pack);
            if pack.ends_with(".gtpack") {
                copy_file(&pack_path, &bundle_root.join(&pack))?;
            } else {
                if !options.allow_pack_dirs {
                    return Err(anyhow::anyhow!(
                        "Pack directory not allowed in demo bundle: {} (use --allow-pack-dirs)",
                        pack
                    ));
                }
                eprintln!(
                    "{}",
                    crate::operator_i18n::trf(
                        "demo.build.warn_copying_pack_directory",
                        "Warning: copying pack directory into demo bundle (not portable): {}",
                        &[&pack]
                    )
                );
                copy_dir(pack_path, bundle_root.join(&pack))?;
            }
        }

        manifest.project_root = "./".to_string();
        let filename = manifest_path
            .file_name()
            .ok_or_else(|| anyhow::anyhow!("Invalid manifest filename"))?;
        let out_path = bundle_root.join("resolved").join(filename);
        write_manifest(&out_path, &manifest)?;
    }

    for tenant in tenants_to_copy {
        let tenant_path = project_root.join("tenants").join(&tenant);
        if tenant_path.exists() {
            copy_dir(tenant_path, bundle_root.join("tenants").join(&tenant))?;
        }
    }

    let demo_meta = bundle_root.join("greentic.demo.yaml");
    write_demo_metadata(&demo_meta)?;

    Ok(())
}

fn select_manifests(
    resolved_dir: &Path,
    tenant: Option<&str>,
    team: Option<&str>,
) -> anyhow::Result<Vec<PathBuf>> {
    let mut manifests = Vec::new();
    if let Some(tenant) = tenant {
        let filename = match team {
            Some(team) => format!("{tenant}.{team}.yaml"),
            None => format!("{tenant}.yaml"),
        };
        let path = resolved_dir.join(filename);
        if path.exists() {
            manifests.push(path);
        }
        return Ok(manifests);
    }

    for entry in std::fs::read_dir(resolved_dir)? {
        let entry = entry?;
        if entry.file_type()?.is_file() {
            let path = entry.path();
            if path.extension().and_then(|ext| ext.to_str()) == Some("yaml") {
                manifests.push(path);
            }
        }
    }
    manifests.sort();
    Ok(manifests)
}

fn load_manifest(path: &Path) -> anyhow::Result<ResolvedManifest> {
    let contents = std::fs::read_to_string(path)?;
    let manifest: ResolvedManifest = serde_yaml_bw::from_str(&contents)?;
    Ok(manifest)
}

fn write_manifest(path: &Path, manifest: &ResolvedManifest) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let yaml = serde_yaml_bw::to_string(manifest)?;
    std::fs::write(path, yaml)?;
    Ok(())
}

fn write_demo_metadata(path: &Path) -> anyhow::Result<()> {
    let contents = "version: \"1\"\nproject_root: \"./\"\n";
    std::fs::write(path, contents)?;
    Ok(())
}

fn copy_file(from: &Path, to: &Path) -> anyhow::Result<()> {
    if let Some(parent) = to.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::copy(from, to)?;
    Ok(())
}

fn copy_dir(from: PathBuf, to: PathBuf) -> anyhow::Result<()> {
    if !from.exists() {
        return Ok(());
    }
    std::fs::create_dir_all(&to)?;
    for entry in std::fs::read_dir(&from)? {
        let entry = entry?;
        let path = entry.path();
        let target = to.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_dir(path, target)?;
        } else {
            copy_file(&path, &target)?;
        }
    }
    Ok(())
}