greentic-setup-dev 1.1.28432494801

End-to-end bundle setup engine for the Greentic platform — pack discovery, QA-driven configuration, secrets persistence, and bundle lifecycle management
Documentation
use std::path::{Path, PathBuf};

use anyhow::Context;
use dialoguer::{Confirm, Select};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct DeploymentTargetsDocument {
    pub version: String,
    pub targets: Vec<DeploymentTargetRecord>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeploymentTargetRecord {
    pub target: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provider_pack: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default: Option<bool>,
}

pub fn persist_explicit_deployment_targets(
    bundle_root: &Path,
    targets: &[DeploymentTargetRecord],
) -> anyhow::Result<Option<PathBuf>> {
    let targets = reconcile_deployment_targets(bundle_root, targets)?;
    if targets.is_empty() {
        return Ok(None);
    }

    let doc = DeploymentTargetsDocument {
        version: "1".to_string(),
        targets,
    };

    let path = bundle_root
        .join(".greentic")
        .join("deployment-targets.json");
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let payload = serde_json::to_string_pretty(&doc).context("serialize deployment targets")?;
    std::fs::write(&path, payload)
        .with_context(|| format!("failed to write {}", path.display()))?;
    Ok(Some(path))
}

pub fn reconcile_deployment_targets(
    bundle_root: &Path,
    explicit_targets: &[DeploymentTargetRecord],
) -> anyhow::Result<Vec<DeploymentTargetRecord>> {
    let mut targets = explicit_targets.to_vec();
    if !targets.iter().any(|record| record.target == "local") {
        targets.push(DeploymentTargetRecord {
            target: "local".to_string(),
            provider_pack: None,
            default: None,
        });
    }

    for candidate in discover_deployer_pack_candidates(bundle_root)? {
        let provider_pack = candidate.display().to_string();
        if let Some(existing) = targets
            .iter_mut()
            .find(|record| record.provider_pack.as_deref() == Some(provider_pack.as_str()))
        {
            if existing.target.trim().is_empty()
                && let Some(inferred) = infer_deployment_target_from_pack(&candidate)
            {
                existing.target = inferred.to_string();
            }
            continue;
        }

        let Some(inferred_target) = infer_deployment_target_from_pack(&candidate) else {
            continue;
        };

        if let Some(existing) = targets.iter_mut().find(|record| {
            record.target == inferred_target
                && record
                    .provider_pack
                    .as_deref()
                    .is_none_or(|value| value.trim().is_empty())
        }) {
            existing.provider_pack = Some(provider_pack);
            continue;
        }

        targets.push(DeploymentTargetRecord {
            target: inferred_target.to_string(),
            provider_pack: Some(provider_pack),
            default: None,
        });
    }

    Ok(targets)
}

fn infer_deployment_target_from_pack(path: &Path) -> Option<&'static str> {
    let normalized = path
        .file_stem()
        .or_else(|| path.file_name())
        .and_then(|value| value.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase()
        .replace('_', "-");
    if normalized.contains("aws") {
        Some("aws")
    } else if normalized.contains("gcp") || normalized.contains("google-cloud") {
        Some("gcp")
    } else if normalized.contains("azure") {
        Some("azure")
    } else if normalized.contains("single-vm") {
        Some("single-vm")
    } else {
        None
    }
}

pub fn prompt_deployment_targets(
    candidates: &[PathBuf],
) -> anyhow::Result<Vec<DeploymentTargetRecord>> {
    let mut targets = Vec::new();
    for candidate in candidates {
        let label = candidate.display().to_string();
        let should_include = Confirm::new()
            .with_prompt(format!(
                "Use deployer pack {label} for gtc start deployment?"
            ))
            .default(true)
            .interact()?;
        if !should_include {
            continue;
        }
        let choices = ["aws", "gcp", "azure", "single-vm"];
        let index = Select::new()
            .with_prompt(format!("Which deployment target does {label} implement?"))
            .items(choices)
            .default(0)
            .interact()?;
        targets.push(DeploymentTargetRecord {
            target: choices[index].to_string(),
            provider_pack: Some(label),
            default: None,
        });
    }
    if targets.len() == 1
        && let Some(first) = targets.first_mut()
    {
        first.default = Some(true);
    }
    Ok(targets)
}

pub fn discover_deployer_pack_candidates(bundle_root: &Path) -> anyhow::Result<Vec<PathBuf>> {
    let mut candidates = Vec::new();
    for search_dir in [
        bundle_root.join("packs"),
        bundle_root.join("providers").join("deployer"),
    ] {
        if !search_dir.exists() {
            continue;
        }
        for entry in std::fs::read_dir(&search_dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
                continue;
            }
            let name = path
                .file_name()
                .and_then(|value| value.to_str())
                .unwrap_or_default()
                .to_ascii_lowercase();
            if is_deployer_pack_name(&name)
                && let Ok(relative) = path.strip_prefix(bundle_root)
            {
                candidates.push(relative.to_path_buf());
            }
        }
    }
    candidates.sort();
    candidates.dedup();
    Ok(candidates)
}

pub fn is_deployer_pack_path(path: &Path) -> bool {
    path.file_name()
        .and_then(|value| value.to_str())
        .map(|name| is_deployer_pack_name(&name.to_ascii_lowercase()))
        .unwrap_or(false)
}

fn is_deployer_pack_name(name: &str) -> bool {
    [
        "terraform",
        "aws",
        "gcp",
        "azure",
        "single-vm",
        "single_vm",
        "helm",
        "operator",
        "serverless",
        "snap",
        "juju",
        "k8s",
    ]
    .iter()
    .any(|needle| name.contains(needle))
}

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

    #[test]
    fn persists_explicit_targets() {
        let temp = tempfile::tempdir().expect("tempdir");
        let path = persist_explicit_deployment_targets(
            temp.path(),
            &[DeploymentTargetRecord {
                target: "aws".into(),
                provider_pack: Some("packs/terraform.gtpack".into()),
                default: Some(true),
            }],
        )
        .expect("persist")
        .expect("path");
        let written = std::fs::read_to_string(path).expect("read");
        assert!(written.contains("\"target\": \"aws\""));
        assert!(written.contains("\"provider_pack\": \"packs/terraform.gtpack\""));
        assert!(written.contains("\"default\": true"));
        assert!(written.contains("\"target\": \"local\""));
    }

    #[test]
    fn reconciles_explicit_targets_with_installed_deployer_packs_and_local() {
        let temp = tempfile::tempdir().expect("tempdir");
        let deployer_dir = temp.path().join("providers").join("deployer");
        std::fs::create_dir_all(&deployer_dir).expect("deployer dir");
        std::fs::write(deployer_dir.join("aws.gtpack"), "").expect("aws pack");
        std::fs::write(deployer_dir.join("gcp.gtpack"), "").expect("gcp pack");
        std::fs::write(deployer_dir.join("azure.gtpack"), "").expect("azure pack");

        let targets = reconcile_deployment_targets(
            temp.path(),
            &[
                DeploymentTargetRecord {
                    target: "runtime".into(),
                    provider_pack: None,
                    default: Some(true),
                },
                DeploymentTargetRecord {
                    target: "aws".into(),
                    provider_pack: None,
                    default: None,
                },
            ],
        )
        .expect("reconcile");

        assert_eq!(targets[0].target, "runtime");
        assert_eq!(targets[0].default, Some(true));
        assert!(targets.iter().any(|record| record.target == "local"));
        assert!(targets.iter().any(|record| {
            record.target == "aws"
                && record.provider_pack.as_deref() == Some("providers/deployer/aws.gtpack")
        }));
        assert!(targets.iter().any(|record| {
            record.target == "gcp"
                && record.provider_pack.as_deref() == Some("providers/deployer/gcp.gtpack")
        }));
        assert!(targets.iter().any(|record| {
            record.target == "azure"
                && record.provider_pack.as_deref() == Some("providers/deployer/azure.gtpack")
        }));
    }

    #[test]
    fn detects_deployer_pack_paths() {
        assert!(is_deployer_pack_path(Path::new("packs/aws.gtpack")));
        assert!(is_deployer_pack_path(Path::new(
            "providers/deployer/gcp.gtpack"
        )));
        assert!(!is_deployer_pack_path(Path::new(
            "packs/weather-app.gtpack"
        )));
    }
}