greentic-deployer 0.4.38

Greentic deployer runtime for plan construction and deployment-pack dispatch
Documentation
use std::collections::BTreeSet;
use std::fs;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::str::FromStr;

use anyhow::{Context, Result, bail, ensure};
use greentic_deployer::contract::{
    DeployerContractV1, get_deployer_contract_v1, resolve_deployer_contract_assets,
    set_deployer_contract_v1,
};
use greentic_deployer::pack_introspect::read_manifest_from_gtpack;
use greentic_types::flow::{Flow, FlowHasher, FlowKind, FlowMetadata};
use greentic_types::pack_manifest::{PackFlowEntry, PackKind, PackManifest};
use greentic_types::{FlowId, PackId};
use indexmap::IndexMap;
use semver::Version;
use tar::Builder;

fn main() -> Result<()> {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let fixtures_root = root.join("fixtures/packs");
    let output_root = root.join("dist");

    fs::create_dir_all(&output_root).context("create output directory")?;

    let mut fixture_dirs = fs::read_dir(&fixtures_root)
        .with_context(|| format!("read fixture root {}", fixtures_root.display()))?
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .collect::<Vec<_>>();
    fixture_dirs.sort();

    if fixture_dirs.is_empty() {
        bail!("no fixture packs found under {}", fixtures_root.display());
    }

    for fixture_dir in fixture_dirs {
        let fixture_name = fixture_dir
            .file_name()
            .and_then(|name| name.to_str())
            .context("fixture name missing")?;
        let output_path = output_root.join(format!("{fixture_name}.gtpack"));
        let manifest = build_fixture_gtpack(&fixture_dir, &output_path)?;
        validate_fixture_gtpack(&fixture_dir, &output_path)?;
        println!("built and validated {}", output_path.display());
        let relative_output_path = output_path.strip_prefix(&root).with_context(|| {
            format!("compute relative output path for {}", output_path.display())
        })?;
        println!(
            "PACK\t{}\t{}\t{}",
            manifest.pack_id,
            manifest.version,
            relative_output_path.display()
        );
    }

    Ok(())
}

fn build_fixture_gtpack(fixture_dir: &Path, output_path: &Path) -> Result<PackManifest> {
    let contract = load_contract(fixture_dir)?;
    let manifest = build_manifest(fixture_dir, &contract)?;
    let encoded =
        greentic_types::cbor::encode_pack_manifest(&manifest).context("encode manifest")?;

    let file = File::create(output_path)
        .with_context(|| format!("create output archive {}", output_path.display()))?;
    let mut builder = Builder::new(file);

    append_bytes(&mut builder, Path::new("manifest.cbor"), &encoded)?;
    append_fixture_tree(&mut builder, fixture_dir, fixture_dir)?;
    builder.finish().context("finish gtpack archive")?;

    Ok(manifest)
}

fn validate_fixture_gtpack(fixture_dir: &Path, gtpack_path: &Path) -> Result<()> {
    let manifest = read_manifest_from_gtpack(gtpack_path)
        .with_context(|| format!("read manifest from {}", gtpack_path.display()))?;
    let contract = get_deployer_contract_v1(&manifest)
        .context("decode embedded deployer contract")?
        .context("missing embedded deployer contract")?;
    let resolved = resolve_deployer_contract_assets(&manifest, gtpack_path)
        .with_context(|| format!("resolve contract assets from {}", gtpack_path.display()))?;
    let expected = load_contract(fixture_dir)?;

    ensure!(
        contract == expected,
        "embedded contract mismatch for {}",
        fixture_dir.display()
    );
    ensure!(
        resolved
            .as_ref()
            .context("missing resolved deployer contract")?
            .capabilities
            .len()
            == expected.capabilities.len(),
        "resolved capability count mismatch for {}",
        fixture_dir.display()
    );
    ensure!(
        gtpack_path.is_file(),
        "archive missing after build: {}",
        gtpack_path.display()
    );

    Ok(())
}

fn load_contract(fixture_dir: &Path) -> Result<DeployerContractV1> {
    let path = fixture_dir.join("contract.greentic.deployer.v1.json");
    let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
    serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))
}

fn build_manifest(fixture_dir: &Path, contract: &DeployerContractV1) -> Result<PackManifest> {
    let fixture_name = fixture_dir
        .file_name()
        .and_then(|name| name.to_str())
        .context("fixture name missing")?;
    let package_version =
        Version::parse(env!("CARGO_PKG_VERSION")).context("parse package version")?;
    let mut manifest = PackManifest {
        schema_version: "pack-v1".to_string(),
        pack_id: fixture_pack_id(fixture_name)?,
        name: Some(format!("Fixture {}", fixture_name)),
        version: package_version,
        kind: PackKind::Application,
        publisher: "greentic".to_string(),
        secret_requirements: Vec::new(),
        components: Vec::new(),
        flows: contract_flow_entries(contract)?,
        dependencies: Vec::new(),
        capabilities: Vec::new(),
        signatures: Default::default(),
        bootstrap: None,
        extensions: None,
    };
    set_deployer_contract_v1(&mut manifest, contract.clone()).context("embed deployer contract")?;
    Ok(manifest)
}

fn fixture_pack_id(fixture_name: &str) -> Result<PackId> {
    let pack_id = fixture_name.replace('-', ".");
    PackId::from_str(&format!("greentic.fixture.{pack_id}.gtpack")).context("build pack id")
}

fn contract_flow_entries(contract: &DeployerContractV1) -> Result<Vec<PackFlowEntry>> {
    let mut ids = BTreeSet::new();
    ids.insert(contract.planner.flow_id.clone());
    for capability in &contract.capabilities {
        ids.insert(capability.flow_id.clone());
    }

    ids.into_iter()
        .map(|id| {
            let flow_id = FlowId::from_str(&id)
                .with_context(|| format!("invalid flow id in contract: {id}"))?;
            Ok(PackFlowEntry {
                id: flow_id.clone(),
                kind: FlowKind::Messaging,
                flow: Flow {
                    schema_version: "flowir-v1".to_string(),
                    id: flow_id,
                    kind: FlowKind::Messaging,
                    entrypoints: Default::default(),
                    nodes: IndexMap::<_, _, FlowHasher>::default(),
                    metadata: FlowMetadata::default(),
                },
                tags: Vec::new(),
                entrypoints: Vec::new(),
            })
        })
        .collect()
}

fn append_fixture_tree(builder: &mut Builder<File>, root: &Path, current: &Path) -> Result<()> {
    let mut entries = fs::read_dir(current)
        .with_context(|| format!("read directory {}", current.display()))?
        .flatten()
        .map(|entry| entry.path())
        .collect::<Vec<_>>();
    entries.sort();

    for path in entries {
        if path.is_dir() {
            append_fixture_tree(builder, root, &path)?;
        } else if path.is_file() {
            let relative = path
                .strip_prefix(root)
                .with_context(|| format!("compute relative path for {}", path.display()))?;
            let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
            append_bytes(builder, relative, &bytes)?;
        }
    }

    Ok(())
}

fn append_bytes(builder: &mut Builder<File>, path: &Path, bytes: &[u8]) -> Result<()> {
    let mut header = tar::Header::new_gnu();
    header.set_size(bytes.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    builder
        .append_data(&mut header, path, bytes)
        .with_context(|| format!("append {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::fixture_pack_id;

    #[test]
    fn fixture_pack_ids_keep_gtpack_suffix() {
        assert_eq!(
            fixture_pack_id("helm").unwrap().to_string(),
            "greentic.fixture.helm.gtpack"
        );
        assert_eq!(
            fixture_pack_id("k8s-raw").unwrap().to_string(),
            "greentic.fixture.k8s.raw.gtpack"
        );
        assert_eq!(
            fixture_pack_id("juju-machine").unwrap().to_string(),
            "greentic.fixture.juju.machine.gtpack"
        );
    }
}