Skip to main content

greentic_setup/
platform_setup.rs

1//! Bundle-level platform setup types and static routes policy handling.
2
3use std::net::IpAddr;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use dialoguer::{Confirm, Input, Select};
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::deployment_targets::DeploymentTargetRecord;
12
13const STATIC_ROUTES_VERSION: u32 = 1;
14const PACK_DECLARED_POLICY: &str = "pack_declared";
15const SURFACE_ENABLED: &str = "enabled";
16const SURFACE_DISABLED: &str = "disabled";
17
18#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
19pub struct PlatformSetupAnswers {
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub static_routes: Option<StaticRoutesAnswers>,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub deployment_targets: Vec<DeploymentTargetRecord>,
24}
25
26#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
27pub struct StaticRoutesAnswers {
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub public_web_enabled: Option<bool>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub public_base_url: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub public_surface_policy: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub default_route_prefix_policy: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub tenant_path_policy: Option<String>,
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
41pub struct StaticRoutesPolicy {
42    pub version: u32,
43    pub public_web_enabled: bool,
44    pub public_base_url: Option<String>,
45    pub public_surface_policy: String,
46    pub default_route_prefix_policy: String,
47    pub tenant_path_policy: String,
48}
49
50impl Default for StaticRoutesPolicy {
51    fn default() -> Self {
52        Self::disabled()
53    }
54}
55
56impl StaticRoutesPolicy {
57    pub fn disabled() -> Self {
58        Self {
59            version: STATIC_ROUTES_VERSION,
60            public_web_enabled: false,
61            public_base_url: None,
62            public_surface_policy: SURFACE_DISABLED.to_string(),
63            default_route_prefix_policy: PACK_DECLARED_POLICY.to_string(),
64            tenant_path_policy: PACK_DECLARED_POLICY.to_string(),
65        }
66    }
67
68    pub fn to_answers(&self) -> StaticRoutesAnswers {
69        StaticRoutesAnswers {
70            public_web_enabled: Some(self.public_web_enabled),
71            public_base_url: self.public_base_url.clone(),
72            public_surface_policy: Some(self.public_surface_policy.clone()),
73            default_route_prefix_policy: Some(self.default_route_prefix_policy.clone()),
74            tenant_path_policy: Some(self.tenant_path_policy.clone()),
75        }
76    }
77
78    pub fn normalize(input: Option<&StaticRoutesAnswers>, env: &str) -> Result<Self> {
79        let Some(input) = input else {
80            return Ok(Self::disabled());
81        };
82
83        let public_web_enabled = input.public_web_enabled.unwrap_or(false);
84        let public_surface_policy = input
85            .public_surface_policy
86            .as_deref()
87            .map(str::trim)
88            .filter(|v| !v.is_empty())
89            .map(str::to_string)
90            .unwrap_or_else(|| {
91                if public_web_enabled {
92                    SURFACE_ENABLED.to_string()
93                } else {
94                    SURFACE_DISABLED.to_string()
95                }
96            });
97
98        if public_surface_policy != SURFACE_ENABLED && public_surface_policy != SURFACE_DISABLED {
99            bail!(
100                "public_surface_policy must be one of: {}, {}",
101                SURFACE_ENABLED,
102                SURFACE_DISABLED
103            );
104        }
105
106        let default_route_prefix_policy = normalize_pack_declared_policy(
107            "default_route_prefix_policy",
108            input.default_route_prefix_policy.as_deref(),
109        )?;
110        let tenant_path_policy = normalize_pack_declared_policy(
111            "tenant_path_policy",
112            input.tenant_path_policy.as_deref(),
113        )?;
114
115        let public_base_url = match input.public_base_url.as_deref().map(str::trim) {
116            Some("") | None => None,
117            Some(url) => Some(normalize_public_base_url(url, env)?),
118        };
119
120        if public_web_enabled && public_base_url.is_none() {
121            bail!("public_base_url is required when public_web_enabled=true");
122        }
123
124        if public_web_enabled && public_surface_policy == SURFACE_DISABLED {
125            bail!("public_surface_policy=disabled is incompatible with public_web_enabled=true");
126        }
127
128        Ok(Self {
129            version: STATIC_ROUTES_VERSION,
130            public_web_enabled,
131            public_base_url,
132            public_surface_policy,
133            default_route_prefix_policy,
134            tenant_path_policy,
135        })
136    }
137}
138
139pub fn prompt_static_routes_policy(
140    env: &str,
141    current: Option<&StaticRoutesPolicy>,
142) -> Result<StaticRoutesPolicy> {
143    let current = current.cloned().unwrap_or_default();
144    let public_web_enabled = Confirm::new()
145        .with_prompt("Enable public web/static hosting for this bundle?")
146        .default(current.public_web_enabled)
147        .interact()?;
148
149    if !public_web_enabled {
150        return Ok(StaticRoutesPolicy::disabled());
151    }
152
153    let base_default = current.public_base_url.unwrap_or_default();
154    let public_base_url: String = Input::new()
155        .with_prompt("Public base URL")
156        .with_initial_text(base_default)
157        .interact_text()?;
158
159    let policies = [SURFACE_ENABLED, SURFACE_DISABLED];
160    let surface_index = policies
161        .iter()
162        .position(|value| *value == current.public_surface_policy)
163        .unwrap_or(0);
164    let public_surface_policy = policies[Select::new()
165        .with_prompt("Public surface policy")
166        .items(policies)
167        .default(surface_index)
168        .interact()?]
169    .to_string();
170
171    StaticRoutesPolicy::normalize(
172        Some(&StaticRoutesAnswers {
173            public_web_enabled: Some(public_web_enabled),
174            public_base_url: Some(public_base_url),
175            public_surface_policy: Some(public_surface_policy),
176            default_route_prefix_policy: Some(current.default_route_prefix_policy),
177            tenant_path_policy: Some(current.tenant_path_policy),
178        }),
179        env,
180    )
181}
182
183pub fn static_routes_artifact_path(bundle_root: &Path) -> PathBuf {
184    bundle_root
185        .join("state")
186        .join("config")
187        .join("platform")
188        .join("static-routes.json")
189}
190
191pub fn load_static_routes_artifact(bundle_root: &Path) -> Result<Option<StaticRoutesPolicy>> {
192    let path = static_routes_artifact_path(bundle_root);
193    if !path.exists() {
194        return Ok(None);
195    }
196    let raw = std::fs::read_to_string(&path)
197        .with_context(|| format!("failed to read {}", path.display()))?;
198    let policy = serde_json::from_str(&raw)
199        .or_else(|_| serde_yaml_bw::from_str(&raw))
200        .with_context(|| format!("failed to parse {}", path.display()))?;
201    Ok(Some(policy))
202}
203
204#[derive(Debug, Deserialize)]
205struct RuntimeEndpoints {
206    #[allow(dead_code)]
207    tenant: Option<String>,
208    #[allow(dead_code)]
209    team: Option<String>,
210    public_base_url: Option<String>,
211}
212
213pub fn load_runtime_public_base_url(
214    bundle_root: &Path,
215    tenant: &str,
216    team: Option<&str>,
217) -> Result<Option<String>> {
218    let team = team.unwrap_or("default");
219    let path = bundle_root
220        .join("state")
221        .join("runtime")
222        .join(format!("{tenant}.{team}"))
223        .join("endpoints.json");
224    if !path.exists() {
225        return Ok(None);
226    }
227    let raw = std::fs::read_to_string(&path)
228        .with_context(|| format!("failed to read {}", path.display()))?;
229    let endpoints: RuntimeEndpoints = serde_json::from_str(&raw)
230        .with_context(|| format!("failed to parse {}", path.display()))?;
231    Ok(endpoints
232        .public_base_url
233        .as_deref()
234        .map(str::trim)
235        .filter(|value| !value.is_empty())
236        .map(ToString::to_string))
237}
238
239pub fn load_effective_static_routes_defaults(
240    bundle_root: &Path,
241    tenant: &str,
242    team: Option<&str>,
243) -> Result<Option<StaticRoutesPolicy>> {
244    let mut policy = load_static_routes_artifact(bundle_root)?.unwrap_or_default();
245    if policy.public_base_url.is_none()
246        && let Some(runtime_url) = load_runtime_public_base_url(bundle_root, tenant, team)?
247    {
248        policy.public_base_url = Some(runtime_url);
249    }
250    if policy == StaticRoutesPolicy::disabled() {
251        return Ok(None);
252    }
253    Ok(Some(policy))
254}
255
256pub fn persist_static_routes_artifact(
257    bundle_root: &Path,
258    policy: &StaticRoutesPolicy,
259) -> Result<PathBuf> {
260    let path = static_routes_artifact_path(bundle_root);
261    if let Some(parent) = path.parent() {
262        std::fs::create_dir_all(parent)?;
263    }
264    let payload = serde_json::to_string_pretty(policy).context("serialize static routes policy")?;
265    std::fs::write(&path, payload)
266        .with_context(|| format!("failed to write {}", path.display()))?;
267    Ok(path)
268}
269
270fn normalize_pack_declared_policy(field: &str, value: Option<&str>) -> Result<String> {
271    let value = value
272        .map(str::trim)
273        .filter(|v| !v.is_empty())
274        .unwrap_or(PACK_DECLARED_POLICY);
275    if value != PACK_DECLARED_POLICY {
276        bail!("{field} must be '{}'", PACK_DECLARED_POLICY);
277    }
278    Ok(value.to_string())
279}
280
281fn normalize_public_base_url(value: &str, env: &str) -> Result<String> {
282    let url = Url::parse(value).map_err(|err| anyhow!("invalid public_base_url: {err}"))?;
283    match url.scheme() {
284        "https" => {}
285        "http" if is_local_http_origin(&url) => {}
286        "http" => bail!("public_base_url must use https unless it targets localhost/loopback"),
287        _ => bail!("public_base_url must use http or https"),
288    }
289
290    if url.host_str().is_none() {
291        bail!("public_base_url must include a host");
292    }
293    if url.query().is_some() {
294        bail!("public_base_url must not include a query string");
295    }
296    if url.fragment().is_some() {
297        bail!("public_base_url must not include a fragment");
298    }
299    if env != "dev" && url.scheme() == "http" {
300        bail!("public_base_url may only use http for localhost/loopback origins in dev");
301    }
302
303    let mut normalized = url.to_string();
304    while normalized.ends_with('/') && normalized.len() > scheme_host_floor(&url) {
305        normalized.pop();
306    }
307    if normalized.ends_with('/') && url.path() == "/" {
308        normalized.pop();
309    }
310    Ok(normalized)
311}
312
313fn scheme_host_floor(url: &Url) -> usize {
314    let host = url.host_str().unwrap_or_default();
315    let mut floor = url.scheme().len() + 3 + host.len();
316    if let Some(port) = url.port() {
317        floor += 1 + port.to_string().len();
318    }
319    floor
320}
321
322fn is_local_http_origin(url: &Url) -> bool {
323    let Some(host) = url.host_str() else {
324        return false;
325    };
326    if host.eq_ignore_ascii_case("localhost") {
327        return true;
328    }
329    host.parse::<IpAddr>()
330        .map(|addr| addr.is_loopback())
331        .unwrap_or(false)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn disabled_is_default() {
340        let policy = StaticRoutesPolicy::normalize(None, "dev").unwrap();
341        assert_eq!(policy, StaticRoutesPolicy::disabled());
342    }
343
344    #[test]
345    fn enabled_requires_base_url() {
346        let err = StaticRoutesPolicy::normalize(
347            Some(&StaticRoutesAnswers {
348                public_web_enabled: Some(true),
349                ..Default::default()
350            }),
351            "dev",
352        )
353        .unwrap_err();
354        assert!(err.to_string().contains("public_base_url is required"));
355    }
356
357    #[test]
358    fn normalizes_public_base_url() {
359        let policy = StaticRoutesPolicy::normalize(
360            Some(&StaticRoutesAnswers {
361                public_web_enabled: Some(true),
362                public_base_url: Some("https://example.com/base/".into()),
363                ..Default::default()
364            }),
365            "prod",
366        )
367        .unwrap();
368        assert_eq!(
369            policy.public_base_url.as_deref(),
370            Some("https://example.com/base")
371        );
372        assert_eq!(policy.public_surface_policy, SURFACE_ENABLED);
373        assert_eq!(policy.default_route_prefix_policy, PACK_DECLARED_POLICY);
374        assert_eq!(policy.tenant_path_policy, PACK_DECLARED_POLICY);
375    }
376
377    #[test]
378    fn rejects_query_and_fragment() {
379        let err = StaticRoutesPolicy::normalize(
380            Some(&StaticRoutesAnswers {
381                public_web_enabled: Some(true),
382                public_base_url: Some("https://example.com?x=1".into()),
383                ..Default::default()
384            }),
385            "prod",
386        )
387        .unwrap_err();
388        assert!(err.to_string().contains("query string"));
389
390        let err = StaticRoutesPolicy::normalize(
391            Some(&StaticRoutesAnswers {
392                public_web_enabled: Some(true),
393                public_base_url: Some("https://example.com#frag".into()),
394                ..Default::default()
395            }),
396            "prod",
397        )
398        .unwrap_err();
399        assert!(err.to_string().contains("fragment"));
400    }
401
402    #[test]
403    fn allows_http_loopback_in_dev_only() {
404        let policy = StaticRoutesPolicy::normalize(
405            Some(&StaticRoutesAnswers {
406                public_web_enabled: Some(true),
407                public_base_url: Some("http://127.0.0.1:3000/".into()),
408                ..Default::default()
409            }),
410            "dev",
411        )
412        .unwrap();
413        assert_eq!(
414            policy.public_base_url.as_deref(),
415            Some("http://127.0.0.1:3000")
416        );
417
418        let err = StaticRoutesPolicy::normalize(
419            Some(&StaticRoutesAnswers {
420                public_web_enabled: Some(true),
421                public_base_url: Some("http://127.0.0.1:3000".into()),
422                ..Default::default()
423            }),
424            "prod",
425        )
426        .unwrap_err();
427        assert!(err.to_string().contains("dev"));
428    }
429
430    #[test]
431    fn rejects_enabled_with_disabled_surface_policy() {
432        let err = StaticRoutesPolicy::normalize(
433            Some(&StaticRoutesAnswers {
434                public_web_enabled: Some(true),
435                public_base_url: Some("https://example.com".into()),
436                public_surface_policy: Some("disabled".into()),
437                ..Default::default()
438            }),
439            "prod",
440        )
441        .unwrap_err();
442        assert!(err.to_string().contains("incompatible"));
443    }
444
445    #[test]
446    fn persists_and_loads_artifact() {
447        let temp = tempfile::tempdir().unwrap();
448        let policy = StaticRoutesPolicy::normalize(
449            Some(&StaticRoutesAnswers {
450                public_web_enabled: Some(true),
451                public_base_url: Some("https://example.com".into()),
452                ..Default::default()
453            }),
454            "prod",
455        )
456        .unwrap();
457        let path = persist_static_routes_artifact(temp.path(), &policy).unwrap();
458        assert!(path.exists());
459        let loaded = load_static_routes_artifact(temp.path()).unwrap().unwrap();
460        assert_eq!(loaded, policy);
461    }
462
463    #[test]
464    fn effective_defaults_fall_back_to_runtime_endpoint() {
465        let temp = tempfile::tempdir().unwrap();
466        let runtime_dir = temp
467            .path()
468            .join("state")
469            .join("runtime")
470            .join("demo.default");
471        std::fs::create_dir_all(&runtime_dir).unwrap();
472        std::fs::write(
473            runtime_dir.join("endpoints.json"),
474            r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
475        )
476        .unwrap();
477
478        let loaded =
479            load_effective_static_routes_defaults(temp.path(), "demo", Some("default")).unwrap();
480        assert_eq!(
481            loaded.and_then(|policy| policy.public_base_url),
482            Some("https://runtime.example.com".to_string())
483        );
484    }
485}