greentic_setup/
deployment_targets.rs1use 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}