Skip to main content

greentic_setup/
deployment_targets.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use dialoguer::{Confirm, Select};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Serialize, Deserialize)]
8pub struct DeploymentTargetsDocument {
9    pub version: String,
10    pub targets: Vec<DeploymentTargetRecord>,
11}
12
13#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
14pub struct DeploymentTargetRecord {
15    pub target: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub provider_pack: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub default: Option<bool>,
20}
21
22pub fn persist_explicit_deployment_targets(
23    bundle_root: &Path,
24    targets: &[DeploymentTargetRecord],
25) -> anyhow::Result<Option<PathBuf>> {
26    let targets = reconcile_deployment_targets(bundle_root, targets)?;
27    if targets.is_empty() {
28        return Ok(None);
29    }
30
31    let doc = DeploymentTargetsDocument {
32        version: "1".to_string(),
33        targets,
34    };
35
36    let path = bundle_root
37        .join(".greentic")
38        .join("deployment-targets.json");
39    if let Some(parent) = path.parent() {
40        std::fs::create_dir_all(parent)?;
41    }
42    let payload = serde_json::to_string_pretty(&doc).context("serialize deployment targets")?;
43    std::fs::write(&path, payload)
44        .with_context(|| format!("failed to write {}", path.display()))?;
45    Ok(Some(path))
46}
47
48pub fn reconcile_deployment_targets(
49    bundle_root: &Path,
50    explicit_targets: &[DeploymentTargetRecord],
51) -> anyhow::Result<Vec<DeploymentTargetRecord>> {
52    let mut targets = explicit_targets.to_vec();
53    if !targets.iter().any(|record| record.target == "local") {
54        targets.push(DeploymentTargetRecord {
55            target: "local".to_string(),
56            provider_pack: None,
57            default: None,
58        });
59    }
60
61    for candidate in discover_deployer_pack_candidates(bundle_root)? {
62        let provider_pack = candidate.display().to_string();
63        if let Some(existing) = targets
64            .iter_mut()
65            .find(|record| record.provider_pack.as_deref() == Some(provider_pack.as_str()))
66        {
67            if existing.target.trim().is_empty()
68                && let Some(inferred) = infer_deployment_target_from_pack(&candidate)
69            {
70                existing.target = inferred.to_string();
71            }
72            continue;
73        }
74
75        let Some(inferred_target) = infer_deployment_target_from_pack(&candidate) else {
76            continue;
77        };
78
79        if let Some(existing) = targets.iter_mut().find(|record| {
80            record.target == inferred_target
81                && record
82                    .provider_pack
83                    .as_deref()
84                    .is_none_or(|value| value.trim().is_empty())
85        }) {
86            existing.provider_pack = Some(provider_pack);
87            continue;
88        }
89
90        targets.push(DeploymentTargetRecord {
91            target: inferred_target.to_string(),
92            provider_pack: Some(provider_pack),
93            default: None,
94        });
95    }
96
97    Ok(targets)
98}
99
100fn infer_deployment_target_from_pack(path: &Path) -> Option<&'static str> {
101    let normalized = path
102        .file_stem()
103        .or_else(|| path.file_name())
104        .and_then(|value| value.to_str())
105        .unwrap_or_default()
106        .to_ascii_lowercase()
107        .replace('_', "-");
108    if normalized.contains("aws") {
109        Some("aws")
110    } else if normalized.contains("gcp") || normalized.contains("google-cloud") {
111        Some("gcp")
112    } else if normalized.contains("azure") {
113        Some("azure")
114    } else if normalized.contains("single-vm") {
115        Some("single-vm")
116    } else {
117        None
118    }
119}
120
121pub fn prompt_deployment_targets(
122    candidates: &[PathBuf],
123) -> anyhow::Result<Vec<DeploymentTargetRecord>> {
124    let mut targets = Vec::new();
125    for candidate in candidates {
126        let label = candidate.display().to_string();
127        let should_include = Confirm::new()
128            .with_prompt(format!(
129                "Use deployer pack {label} for gtc start deployment?"
130            ))
131            .default(true)
132            .interact()?;
133        if !should_include {
134            continue;
135        }
136        let choices = ["aws", "gcp", "azure", "single-vm"];
137        let index = Select::new()
138            .with_prompt(format!("Which deployment target does {label} implement?"))
139            .items(choices)
140            .default(0)
141            .interact()?;
142        targets.push(DeploymentTargetRecord {
143            target: choices[index].to_string(),
144            provider_pack: Some(label),
145            default: None,
146        });
147    }
148    if targets.len() == 1
149        && let Some(first) = targets.first_mut()
150    {
151        first.default = Some(true);
152    }
153    Ok(targets)
154}
155
156pub fn discover_deployer_pack_candidates(bundle_root: &Path) -> anyhow::Result<Vec<PathBuf>> {
157    let mut candidates = Vec::new();
158    for search_dir in [
159        bundle_root.join("packs"),
160        bundle_root.join("providers").join("deployer"),
161    ] {
162        if !search_dir.exists() {
163            continue;
164        }
165        for entry in std::fs::read_dir(&search_dir)? {
166            let entry = entry?;
167            let path = entry.path();
168            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
169                continue;
170            }
171            let name = path
172                .file_name()
173                .and_then(|value| value.to_str())
174                .unwrap_or_default()
175                .to_ascii_lowercase();
176            if is_deployer_pack_name(&name)
177                && let Ok(relative) = path.strip_prefix(bundle_root)
178            {
179                candidates.push(relative.to_path_buf());
180            }
181        }
182    }
183    candidates.sort();
184    candidates.dedup();
185    Ok(candidates)
186}
187
188pub fn is_deployer_pack_path(path: &Path) -> bool {
189    path.file_name()
190        .and_then(|value| value.to_str())
191        .map(|name| is_deployer_pack_name(&name.to_ascii_lowercase()))
192        .unwrap_or(false)
193}
194
195fn is_deployer_pack_name(name: &str) -> bool {
196    [
197        "terraform",
198        "aws",
199        "gcp",
200        "azure",
201        "single-vm",
202        "single_vm",
203        "helm",
204        "operator",
205        "serverless",
206        "snap",
207        "juju",
208        "k8s",
209    ]
210    .iter()
211    .any(|needle| name.contains(needle))
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn persists_explicit_targets() {
220        let temp = tempfile::tempdir().expect("tempdir");
221        let path = persist_explicit_deployment_targets(
222            temp.path(),
223            &[DeploymentTargetRecord {
224                target: "aws".into(),
225                provider_pack: Some("packs/terraform.gtpack".into()),
226                default: Some(true),
227            }],
228        )
229        .expect("persist")
230        .expect("path");
231        let written = std::fs::read_to_string(path).expect("read");
232        assert!(written.contains("\"target\": \"aws\""));
233        assert!(written.contains("\"provider_pack\": \"packs/terraform.gtpack\""));
234        assert!(written.contains("\"default\": true"));
235        assert!(written.contains("\"target\": \"local\""));
236    }
237
238    #[test]
239    fn reconciles_explicit_targets_with_installed_deployer_packs_and_local() {
240        let temp = tempfile::tempdir().expect("tempdir");
241        let deployer_dir = temp.path().join("providers").join("deployer");
242        std::fs::create_dir_all(&deployer_dir).expect("deployer dir");
243        std::fs::write(deployer_dir.join("aws.gtpack"), "").expect("aws pack");
244        std::fs::write(deployer_dir.join("gcp.gtpack"), "").expect("gcp pack");
245        std::fs::write(deployer_dir.join("azure.gtpack"), "").expect("azure pack");
246
247        let targets = reconcile_deployment_targets(
248            temp.path(),
249            &[
250                DeploymentTargetRecord {
251                    target: "runtime".into(),
252                    provider_pack: None,
253                    default: Some(true),
254                },
255                DeploymentTargetRecord {
256                    target: "aws".into(),
257                    provider_pack: None,
258                    default: None,
259                },
260            ],
261        )
262        .expect("reconcile");
263
264        assert_eq!(targets[0].target, "runtime");
265        assert_eq!(targets[0].default, Some(true));
266        assert!(targets.iter().any(|record| record.target == "local"));
267        assert!(targets.iter().any(|record| {
268            record.target == "aws"
269                && record.provider_pack.as_deref() == Some("providers/deployer/aws.gtpack")
270        }));
271        assert!(targets.iter().any(|record| {
272            record.target == "gcp"
273                && record.provider_pack.as_deref() == Some("providers/deployer/gcp.gtpack")
274        }));
275        assert!(targets.iter().any(|record| {
276            record.target == "azure"
277                && record.provider_pack.as_deref() == Some("providers/deployer/azure.gtpack")
278        }));
279    }
280
281    #[test]
282    fn detects_deployer_pack_paths() {
283        assert!(is_deployer_pack_path(Path::new("packs/aws.gtpack")));
284        assert!(is_deployer_pack_path(Path::new(
285            "providers/deployer/gcp.gtpack"
286        )));
287        assert!(!is_deployer_pack_path(Path::new(
288            "packs/weather-app.gtpack"
289        )));
290    }
291}