Skip to main content

syncable_cli/wizard/
service_endpoints.rs

1//! Service endpoint discovery and env var matching for inter-service linking
2//!
3//! When deploying service A that calls service B, this module discovers
4//! already-deployed services, shows their public URLs, and offers to inject
5//! them as environment variables.
6
7use crate::platform::api::types::{CloudRunnerNetwork, DeployedService, DeploymentSecretInput};
8use crate::wizard::render::wizard_render_config;
9use colored::Colorize;
10use inquire::{Confirm, InquireError, MultiSelect, Text};
11
12/// A deployed service with a reachable URL (public or private network).
13#[derive(Debug, Clone)]
14pub struct AvailableServiceEndpoint {
15    pub service_name: String,
16    /// The URL to use for connecting — either public URL or private IP.
17    pub url: String,
18    /// Whether this endpoint is a private network address (no public URL).
19    pub is_private: bool,
20    /// Cloud provider this service runs on (e.g. "hetzner", "gcp", "azure").
21    pub cloud_provider: Option<String>,
22    pub status: String,
23}
24
25/// Confidence level for an env-var-to-service match.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum MatchConfidence {
28    Low,
29    Medium,
30    High,
31}
32
33/// A suggested mapping: env var -> deployed service URL.
34#[derive(Debug, Clone)]
35pub struct EndpointSuggestion {
36    pub env_var_name: String,
37    pub service: AvailableServiceEndpoint,
38    pub confidence: MatchConfidence,
39    pub reason: String,
40}
41
42// ---------------------------------------------------------------------------
43// Suffixes that indicate a URL-like env var
44// ---------------------------------------------------------------------------
45
46const URL_SUFFIXES: &[&str] = &[
47    "_URL",
48    "_SERVICE_URL",
49    "_ENDPOINT",
50    "_HOST",
51    "_BASE",
52    "_BASE_URL",
53    "_API_URL",
54    "_URI",
55];
56
57// ---------------------------------------------------------------------------
58// Core functions
59// ---------------------------------------------------------------------------
60
61/// Filter deployments down to services that have a reachable URL (public or
62/// private) and are not in a known-bad state.
63///
64/// The `list_deployments` API may return multiple records per service (one per
65/// deploy attempt). We deduplicate by `service_name`, keeping the most recent
66/// record (the API returns most-recent-first).
67///
68/// A service is included if it has a `public_url` OR a `private_ip` (for
69/// internal services deployed on a private network without public access).
70pub fn get_available_endpoints(deployments: &[DeployedService]) -> Vec<AvailableServiceEndpoint> {
71    const EXCLUDED_STATUSES: &[&str] = &[
72        "failed",
73        "cancelled",
74        "canceled",
75        "pending",
76        "processing",
77        "building",
78        "deploying",
79        "generating",
80        "deleting",
81        "deleted",
82    ];
83
84    let mut seen_services = std::collections::HashSet::new();
85
86    deployments
87        .iter()
88        .filter_map(|d| {
89            // Deduplicate: keep only the first (most recent) record per service
90            if !seen_services.insert(d.service_name.clone()) {
91                return None;
92            }
93
94            let status_lower = d.status.to_lowercase();
95            if EXCLUDED_STATUSES.iter().any(|&s| status_lower == s) {
96                log::debug!(
97                    "Skipping service '{}' (status: {}): excluded status",
98                    d.service_name,
99                    d.status
100                );
101                return None;
102            }
103
104            // Prefer public URL; fall back to private IP
105            let public_url = d.public_url.as_deref().unwrap_or("").trim();
106            let private_ip = d.private_ip.as_deref().unwrap_or("").trim();
107
108            if !public_url.is_empty() {
109                log::debug!(
110                    "Available endpoint: '{}' -> {} (public, status: {})",
111                    d.service_name,
112                    public_url,
113                    d.status
114                );
115                Some(AvailableServiceEndpoint {
116                    service_name: d.service_name.clone(),
117                    url: public_url.to_string(),
118                    is_private: false,
119                    cloud_provider: d.cloud_provider.clone(),
120                    status: d.status.clone(),
121                })
122            } else if !private_ip.is_empty() {
123                // Build a usable URL from the private IP.
124                // Services on Hetzner private networks are reachable by IP from
125                // other services on the same network.
126                let url = format!("http://{}", private_ip);
127                log::debug!(
128                    "Available endpoint: '{}' -> {} (private, status: {})",
129                    d.service_name,
130                    url,
131                    d.status
132                );
133                Some(AvailableServiceEndpoint {
134                    service_name: d.service_name.clone(),
135                    url,
136                    is_private: true,
137                    cloud_provider: d.cloud_provider.clone(),
138                    status: d.status.clone(),
139                })
140            } else {
141                log::debug!(
142                    "Skipping service '{}' (status: {}): no public_url or private_ip",
143                    d.service_name,
144                    d.status
145                );
146                None
147            }
148        })
149        .collect()
150}
151
152/// Filter endpoints so that private-network endpoints only appear when they
153/// share the same cloud provider as the service being deployed.
154///
155/// Public endpoints are always kept regardless of provider — they're reachable
156/// from anywhere. Private endpoints are only reachable within the same provider
157/// network.
158pub fn filter_endpoints_for_provider(
159    endpoints: Vec<AvailableServiceEndpoint>,
160    target_provider: &str,
161) -> Vec<AvailableServiceEndpoint> {
162    let target = target_provider.to_lowercase();
163    endpoints
164        .into_iter()
165        .filter(|ep| {
166            if !ep.is_private {
167                // Public URLs are reachable from any provider
168                return true;
169            }
170            // Private IPs are only useful on the same provider network
171            ep.cloud_provider
172                .as_ref()
173                .map(|p| p.to_lowercase() == target)
174                .unwrap_or(false)
175        })
176        .collect()
177}
178
179/// Check whether an env var name looks like it holds a URL.
180pub fn is_url_env_var(name: &str) -> bool {
181    let upper = name.to_uppercase();
182    URL_SUFFIXES.iter().any(|suffix| upper.ends_with(suffix))
183}
184
185/// Strip the URL-like suffix from an env var name to extract a service hint.
186///
187/// `SENTIMENT_SERVICE_URL` -> `"sentiment"`
188/// `API_BASE` -> `"api"`
189/// `NODE_ENV` -> `None`
190pub fn extract_service_hint(env_var_name: &str) -> Option<String> {
191    let upper = env_var_name.to_uppercase();
192
193    // Try suffixes longest-first so _SERVICE_URL is tried before _URL
194    let mut suffixes: Vec<&&str> = URL_SUFFIXES.iter().collect();
195    suffixes.sort_by_key(|b| std::cmp::Reverse(b.len()));
196
197    for suffix in suffixes {
198        if upper.ends_with(suffix) {
199            let prefix = &upper[..upper.len() - suffix.len()];
200            if prefix.is_empty() {
201                return None;
202            }
203            return Some(prefix.to_lowercase());
204        }
205    }
206    None
207}
208
209/// Normalize a name for matching: lowercase, strip `-` and `_`.
210fn normalize(s: &str) -> String {
211    s.to_lowercase().replace(['-', '_'], "")
212}
213
214/// Split a name into tokens on `_` and `-`.
215fn tokenize(s: &str) -> Vec<String> {
216    s.to_lowercase()
217        .split(['_', '-'])
218        .filter(|t| !t.is_empty())
219        .map(String::from)
220        .collect()
221}
222
223/// Match a service hint against a service name.
224///
225/// Returns `None` if there is no meaningful overlap.
226pub fn match_hint_to_service(hint: &str, service_name: &str) -> Option<MatchConfidence> {
227    let nh = normalize(hint);
228    let ns = normalize(service_name);
229
230    if nh.is_empty() || ns.is_empty() {
231        return None;
232    }
233
234    // Exact match or hint is prefix of service (normalized)
235    if nh == ns || ns.starts_with(&nh) {
236        return Some(MatchConfidence::High);
237    }
238
239    // One contains the other (normalized, no separators)
240    if ns.contains(&nh) || nh.contains(&ns) {
241        return Some(MatchConfidence::Medium);
242    }
243
244    // Check if either normalized form is a prefix of the other
245    // (catches "contacts" ~ "contactintelligence" via shared stem)
246    if nh.starts_with(&ns) || ns.starts_with(&nh) {
247        return Some(MatchConfidence::Medium);
248    }
249
250    // Token overlap: exact or prefix match between tokens
251    let hint_tokens = tokenize(hint);
252    let svc_tokens = tokenize(service_name);
253    let overlap = hint_tokens
254        .iter()
255        .filter(|ht| {
256            svc_tokens
257                .iter()
258                .any(|st| st == *ht || st.starts_with(ht.as_str()) || ht.starts_with(st.as_str()))
259        })
260        .count();
261
262    if overlap == 0 {
263        return None;
264    }
265
266    let max_tokens = hint_tokens.len().max(svc_tokens.len());
267    if overlap * 2 >= max_tokens {
268        Some(MatchConfidence::Medium)
269    } else {
270        Some(MatchConfidence::Low)
271    }
272}
273
274/// For each URL-like env var, find the best matching deployed service.
275///
276/// Returns suggestions sorted by confidence (highest first).
277pub fn match_env_vars_to_services(
278    env_var_names: &[String],
279    endpoints: &[AvailableServiceEndpoint],
280) -> Vec<EndpointSuggestion> {
281    let mut suggestions = Vec::new();
282
283    for var_name in env_var_names {
284        if !is_url_env_var(var_name) {
285            continue;
286        }
287        let hint = match extract_service_hint(var_name) {
288            Some(h) => h,
289            None => continue,
290        };
291
292        // Find best match
293        let mut best: Option<(MatchConfidence, &AvailableServiceEndpoint)> = None;
294        for ep in endpoints {
295            if let Some(conf) = match_hint_to_service(&hint, &ep.service_name) {
296                if best.as_ref().is_none_or(|(bc, _)| conf > *bc) {
297                    best = Some((conf, ep));
298                }
299            }
300        }
301
302        if let Some((confidence, ep)) = best {
303            suggestions.push(EndpointSuggestion {
304                env_var_name: var_name.clone(),
305                service: ep.clone(),
306                confidence,
307                reason: format!(
308                    "Env var '{}' (hint '{}') matches service '{}' ({:?})",
309                    var_name, hint, ep.service_name, confidence
310                ),
311            });
312        }
313    }
314
315    suggestions.sort_by(|a, b| b.confidence.cmp(&a.confidence));
316    suggestions
317}
318
319/// Generate a default env var name for a service.
320///
321/// `"sentiment-analysis"` -> `"SENTIMENT_ANALYSIS_URL"`
322pub fn suggest_env_var_name(service_name: &str) -> String {
323    let base = service_name.to_uppercase().replace('-', "_");
324    format!("{}_URL", base)
325}
326
327// ---------------------------------------------------------------------------
328// Wizard UI
329// ---------------------------------------------------------------------------
330
331/// Interactive prompt to link deployed service URLs as env vars.
332///
333/// Shows available endpoints, lets the user select which to link, and
334/// prompts for each env var name. Returns `DeploymentSecretInput` entries
335/// with `is_secret: false` (URLs are not secrets).
336pub fn collect_service_endpoint_env_vars(
337    endpoints: &[AvailableServiceEndpoint],
338) -> Vec<DeploymentSecretInput> {
339    if endpoints.is_empty() {
340        return Vec::new();
341    }
342
343    println!();
344    println!(
345        "{}",
346        "─── Deployed Service Endpoints ────────────────────".dimmed()
347    );
348    println!(
349        "  Found {} running service(s) with reachable URLs:",
350        endpoints.len().to_string().cyan()
351    );
352    for ep in endpoints {
353        let access_label = if ep.is_private {
354            " (private network)"
355        } else {
356            ""
357        };
358        println!(
359            "    {} {:<30} {}{}",
360            "●".green(),
361            ep.service_name.cyan(),
362            ep.url.dimmed(),
363            access_label.yellow()
364        );
365    }
366    println!();
367
368    // Ask if user wants to link any
369    let wants_link = match Confirm::new("Link any deployed service URLs as env vars?")
370        .with_default(true)
371        .with_help_message("Inject deployed service URLs as environment variables")
372        .prompt()
373    {
374        Ok(v) => v,
375        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
376            return Vec::new();
377        }
378        Err(_) => return Vec::new(),
379    };
380
381    if !wants_link {
382        return Vec::new();
383    }
384
385    // Build labels for multi-select
386    let labels: Vec<String> = endpoints
387        .iter()
388        .map(|ep| {
389            let suffix = if ep.is_private { " [private]" } else { "" };
390            format!("{} ({}){}", ep.service_name, ep.url, suffix)
391        })
392        .collect();
393
394    let selected = match MultiSelect::new("Select services to link:", labels.clone())
395        .with_render_config(wizard_render_config())
396        .with_help_message("Space to toggle, Enter to confirm")
397        .prompt()
398    {
399        Ok(s) if !s.is_empty() => s,
400        Ok(_) => return Vec::new(),
401        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
402            return Vec::new();
403        }
404        Err(_) => return Vec::new(),
405    };
406
407    // Map selected labels back to endpoints
408    let mut result = Vec::new();
409    for sel_label in &selected {
410        let idx = match labels.iter().position(|l| l == sel_label) {
411            Some(i) => i,
412            None => continue,
413        };
414        let ep = &endpoints[idx];
415        let default_name = suggest_env_var_name(&ep.service_name);
416
417        let var_name = match Text::new(&format!("Env var name for '{}':", ep.service_name))
418            .with_default(&default_name)
419            .with_help_message("Environment variable name to hold this service URL")
420            .prompt()
421        {
422            Ok(name) => name.trim().to_uppercase(),
423            Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
424                break;
425            }
426            Err(_) => break,
427        };
428
429        if var_name.is_empty() {
430            continue;
431        }
432
433        let private_note = if ep.is_private {
434            " (private network)"
435        } else {
436            ""
437        };
438        println!(
439            "  {} {} = {}{}",
440            "✓".green(),
441            var_name.cyan(),
442            ep.url.dimmed(),
443            private_note.yellow()
444        );
445
446        result.push(DeploymentSecretInput {
447            key: var_name,
448            value: ep.url.clone(),
449            is_secret: false,
450        });
451    }
452
453    result
454}
455
456// ---------------------------------------------------------------------------
457// Network endpoint discovery
458// ---------------------------------------------------------------------------
459
460/// A network resource with its connection-relevant details.
461///
462/// Extracted from `CloudRunnerNetwork` records, filtered for the target
463/// provider and environment. Contains key-value pairs of useful connection
464/// info (VPC_ID, DEFAULT_DOMAIN, etc.) that can be injected as env vars.
465#[derive(Debug, Clone)]
466pub struct NetworkEndpointInfo {
467    pub network_id: String,
468    pub cloud_provider: String,
469    pub region: String,
470    pub status: String,
471    pub environment_id: Option<String>,
472    /// Key-value pairs of useful connection info for this network
473    /// e.g., ("NETWORK_VPC_ID", "12345"), ("NETWORK_DEFAULT_DOMAIN", "my-app.azurecontainerapps.io")
474    pub connection_details: Vec<(String, String)>,
475}
476
477/// Extract useful connection details from cloud runner networks.
478///
479/// Returns only networks that are "ready" and on the target provider.
480/// Optionally filters by environment ID (shared/default networks with no
481/// environment_id are always included).
482pub fn extract_network_endpoints(
483    networks: &[CloudRunnerNetwork],
484    target_provider: &str,
485    target_environment_id: Option<&str>,
486) -> Vec<NetworkEndpointInfo> {
487    networks
488        .iter()
489        .filter(|n| {
490            n.status == "ready"
491                && n.cloud_provider.eq_ignore_ascii_case(target_provider)
492                && (target_environment_id.is_none()
493                    || n.environment_id.as_deref() == target_environment_id
494                    || n.environment_id.is_none()) // shared/default networks
495        })
496        .map(|n| {
497            let mut details = Vec::new();
498
499            // Provider-generic connection details
500            if let Some(ref vpc_id) = n.vpc_id {
501                details.push(("NETWORK_VPC_ID".to_string(), vpc_id.clone()));
502            }
503            if let Some(ref vpc_name) = n.vpc_name {
504                details.push(("NETWORK_VPC_NAME".to_string(), vpc_name.clone()));
505            }
506            if let Some(ref subnet_id) = n.subnet_id {
507                details.push(("NETWORK_SUBNET_ID".to_string(), subnet_id.clone()));
508            }
509            // Azure-specific
510            if let Some(ref cae_name) = n.container_app_environment_name {
511                details.push(("AZURE_CONTAINER_APP_ENV_NAME".to_string(), cae_name.clone()));
512            }
513            if let Some(ref domain) = n.default_domain {
514                details.push(("NETWORK_DEFAULT_DOMAIN".to_string(), domain.clone()));
515            }
516            if let Some(ref rg) = n.resource_group_name {
517                details.push(("AZURE_RESOURCE_GROUP".to_string(), rg.clone()));
518            }
519            // GCP-specific
520            if let Some(ref connector_name) = n.vpc_connector_name {
521                details.push(("GCP_VPC_CONNECTOR".to_string(), connector_name.clone()));
522            }
523
524            NetworkEndpointInfo {
525                network_id: n.id.clone(),
526                cloud_provider: n.cloud_provider.clone(),
527                region: n.region.clone(),
528                status: n.status.clone(),
529                environment_id: n.environment_id.clone(),
530                connection_details: details,
531            }
532        })
533        .collect()
534}
535
536/// Interactive prompt to offer network connection details as env vars.
537///
538/// Shows discovered network info and lets the user select which to inject.
539/// Returns `DeploymentSecretInput` entries with `is_secret: false` (network
540/// identifiers are infrastructure metadata, not secrets).
541pub fn collect_network_endpoint_env_vars(
542    network_endpoints: &[NetworkEndpointInfo],
543) -> Vec<DeploymentSecretInput> {
544    if network_endpoints.is_empty() {
545        return Vec::new();
546    }
547
548    // Flatten all connection details across networks
549    let all_details: Vec<(&NetworkEndpointInfo, &str, &str)> = network_endpoints
550        .iter()
551        .flat_map(|ne| {
552            ne.connection_details
553                .iter()
554                .map(move |(k, v)| (ne, k.as_str(), v.as_str()))
555        })
556        .collect();
557
558    if all_details.is_empty() {
559        return Vec::new();
560    }
561
562    println!();
563    println!(
564        "{}",
565        "─── Private Network Resources ────────────────────".dimmed()
566    );
567    for ne in network_endpoints {
568        println!(
569            "  {} {} network in {} ({})",
570            "●".green(),
571            ne.cloud_provider.cyan(),
572            ne.region,
573            ne.status,
574        );
575        for (k, v) in &ne.connection_details {
576            println!("    {} = {}", k.dimmed(), v);
577        }
578    }
579    println!();
580
581    let wants_inject = match Confirm::new("Inject any network details as env vars?")
582        .with_default(false)
583        .with_help_message("Add network identifiers like VPC_ID, DEFAULT_DOMAIN as env vars")
584        .prompt()
585    {
586        Ok(v) => v,
587        Err(_) => return Vec::new(),
588    };
589
590    if !wants_inject {
591        return Vec::new();
592    }
593
594    let labels: Vec<String> = all_details
595        .iter()
596        .map(|(ne, k, v)| format!("{} = {} [{}]", k, v, ne.cloud_provider))
597        .collect();
598
599    let selected = match MultiSelect::new("Select network details to inject:", labels.clone())
600        .with_render_config(wizard_render_config())
601        .with_help_message("Space to toggle, Enter to confirm")
602        .prompt()
603    {
604        Ok(s) if !s.is_empty() => s,
605        _ => return Vec::new(),
606    };
607
608    selected
609        .iter()
610        .filter_map(|label| {
611            let idx = labels.iter().position(|l| l == label)?;
612            let (_, key, value) = &all_details[idx];
613            Some(DeploymentSecretInput {
614                key: key.to_string(),
615                value: value.to_string(),
616                is_secret: false,
617            })
618        })
619        .collect()
620}
621
622// ---------------------------------------------------------------------------
623// Tests
624// ---------------------------------------------------------------------------
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_extract_service_hint() {
632        assert_eq!(
633            extract_service_hint("SENTIMENT_SERVICE_URL"),
634            Some("sentiment".to_string())
635        );
636        assert_eq!(extract_service_hint("API_BASE"), Some("api".to_string()));
637        assert_eq!(extract_service_hint("NODE_ENV"), None);
638        assert_eq!(
639            extract_service_hint("CONTACTS_API_URL"),
640            Some("contacts".to_string())
641        );
642        assert_eq!(
643            extract_service_hint("BACKEND_ENDPOINT"),
644            Some("backend".to_string())
645        );
646    }
647
648    #[test]
649    fn test_match_hint_exact() {
650        assert_eq!(
651            match_hint_to_service("sentiment", "sentiment"),
652            Some(MatchConfidence::High)
653        );
654    }
655
656    #[test]
657    fn test_match_hint_prefix() {
658        assert_eq!(
659            match_hint_to_service("sentiment", "sentiment-analysis"),
660            Some(MatchConfidence::High)
661        );
662    }
663
664    #[test]
665    fn test_match_hint_containment() {
666        assert_eq!(
667            match_hint_to_service("contacts", "contact-intelligence"),
668            Some(MatchConfidence::Medium)
669        );
670    }
671
672    #[test]
673    fn test_no_match() {
674        assert_eq!(
675            match_hint_to_service("database", "sentiment-analysis"),
676            None
677        );
678    }
679
680    #[test]
681    fn test_is_url_env_var() {
682        assert!(is_url_env_var("DATABASE_URL"));
683        assert!(is_url_env_var("BACKEND_SERVICE_URL"));
684        assert!(is_url_env_var("API_ENDPOINT"));
685        assert!(is_url_env_var("SERVICE_HOST"));
686        assert!(is_url_env_var("API_BASE"));
687        assert!(is_url_env_var("APP_BASE_URL"));
688        assert!(is_url_env_var("BACKEND_API_URL"));
689        assert!(is_url_env_var("SERVICE_URI"));
690        assert!(!is_url_env_var("NODE_ENV"));
691        assert!(!is_url_env_var("PORT"));
692        assert!(!is_url_env_var("DEBUG"));
693    }
694
695    #[test]
696    fn test_suggest_env_var_name() {
697        assert_eq!(
698            suggest_env_var_name("sentiment-analysis"),
699            "SENTIMENT_ANALYSIS_URL"
700        );
701        assert_eq!(suggest_env_var_name("backend"), "BACKEND_URL");
702        assert_eq!(
703            suggest_env_var_name("contact-intelligence"),
704            "CONTACT_INTELLIGENCE_URL"
705        );
706    }
707
708    #[test]
709    fn test_match_env_vars_to_services() {
710        let endpoints = vec![
711            AvailableServiceEndpoint {
712                service_name: "sentiment-analysis".to_string(),
713                url: "https://sentiment-abc.syncable.dev".to_string(),
714                is_private: false,
715                cloud_provider: Some("hetzner".to_string()),
716                status: "running".to_string(),
717            },
718            AvailableServiceEndpoint {
719                service_name: "contact-intelligence".to_string(),
720                url: "https://contact-def.syncable.dev".to_string(),
721                is_private: false,
722                cloud_provider: Some("hetzner".to_string()),
723                status: "running".to_string(),
724            },
725        ];
726
727        let env_vars = vec![
728            "SENTIMENT_SERVICE_URL".to_string(),
729            "CONTACTS_API_URL".to_string(),
730            "NODE_ENV".to_string(),     // not a URL var
731            "DATABASE_URL".to_string(), // no matching service
732        ];
733
734        let suggestions = match_env_vars_to_services(&env_vars, &endpoints);
735
736        // SENTIMENT_SERVICE_URL should match sentiment-analysis
737        let sent = suggestions
738            .iter()
739            .find(|s| s.env_var_name == "SENTIMENT_SERVICE_URL");
740        assert!(sent.is_some());
741        assert_eq!(sent.unwrap().service.service_name, "sentiment-analysis");
742        assert_eq!(sent.unwrap().confidence, MatchConfidence::High);
743
744        // CONTACTS_API_URL should match contact-intelligence
745        let cont = suggestions
746            .iter()
747            .find(|s| s.env_var_name == "CONTACTS_API_URL");
748        assert!(cont.is_some());
749        assert_eq!(cont.unwrap().service.service_name, "contact-intelligence");
750
751        // NODE_ENV should not be in suggestions (not a URL var)
752        assert!(suggestions.iter().all(|s| s.env_var_name != "NODE_ENV"));
753    }
754
755    #[test]
756    fn test_get_available_endpoints() {
757        use crate::platform::api::types::DeployedService;
758        use chrono::Utc;
759
760        let deployments = vec![
761            DeployedService {
762                id: "1".to_string(),
763                project_id: "p1".to_string(),
764                service_name: "running-svc".to_string(),
765                repository_full_name: "org/repo".to_string(),
766                status: "running".to_string(),
767                backstage_task_id: None,
768                commit_sha: None,
769                public_url: Some("https://running.example.com".to_string()),
770                private_ip: None,
771                cloud_provider: None,
772                created_at: Utc::now(),
773            },
774            DeployedService {
775                id: "2".to_string(),
776                project_id: "p1".to_string(),
777                service_name: "no-url-svc".to_string(),
778                repository_full_name: "org/repo".to_string(),
779                status: "running".to_string(),
780                backstage_task_id: None,
781                commit_sha: None,
782                public_url: None,
783                private_ip: None,
784                cloud_provider: None,
785                created_at: Utc::now(),
786            },
787            DeployedService {
788                id: "3".to_string(),
789                project_id: "p1".to_string(),
790                service_name: "failed-svc".to_string(),
791                repository_full_name: "org/repo".to_string(),
792                status: "failed".to_string(),
793                backstage_task_id: None,
794                commit_sha: None,
795                public_url: Some("https://failed.example.com".to_string()),
796                private_ip: None,
797                cloud_provider: None,
798                created_at: Utc::now(),
799            },
800            DeployedService {
801                id: "4".to_string(),
802                project_id: "p1".to_string(),
803                service_name: "healthy-svc".to_string(),
804                repository_full_name: "org/repo".to_string(),
805                status: "healthy".to_string(),
806                backstage_task_id: None,
807                commit_sha: None,
808                public_url: Some("https://healthy.example.com".to_string()),
809                private_ip: None,
810                cloud_provider: None,
811                created_at: Utc::now(),
812            },
813        ];
814
815        let endpoints = get_available_endpoints(&deployments);
816        assert_eq!(endpoints.len(), 2);
817        assert_eq!(endpoints[0].service_name, "running-svc");
818        assert_eq!(endpoints[1].service_name, "healthy-svc");
819    }
820
821    #[test]
822    fn test_get_available_endpoints_includes_private_ip() {
823        use crate::platform::api::types::DeployedService;
824        use chrono::Utc;
825
826        let deployments = vec![
827            DeployedService {
828                id: "1".to_string(),
829                project_id: "p1".to_string(),
830                service_name: "public-svc".to_string(),
831                repository_full_name: "org/repo".to_string(),
832                status: "healthy".to_string(),
833                backstage_task_id: None,
834                commit_sha: None,
835                public_url: Some("https://public.example.com".to_string()),
836                private_ip: Some("10.0.0.2".to_string()),
837                cloud_provider: Some("hetzner".to_string()),
838                created_at: Utc::now(),
839            },
840            DeployedService {
841                id: "2".to_string(),
842                project_id: "p1".to_string(),
843                service_name: "internal-svc".to_string(),
844                repository_full_name: "org/repo".to_string(),
845                status: "healthy".to_string(),
846                backstage_task_id: None,
847                commit_sha: None,
848                public_url: None,
849                private_ip: Some("10.0.0.3".to_string()),
850                cloud_provider: Some("hetzner".to_string()),
851                created_at: Utc::now(),
852            },
853            DeployedService {
854                id: "3".to_string(),
855                project_id: "p1".to_string(),
856                service_name: "ghost-svc".to_string(),
857                repository_full_name: "org/repo".to_string(),
858                status: "healthy".to_string(),
859                backstage_task_id: None,
860                commit_sha: None,
861                public_url: None,
862                private_ip: None,
863                cloud_provider: None,
864                created_at: Utc::now(),
865            },
866        ];
867
868        let endpoints = get_available_endpoints(&deployments);
869        assert_eq!(endpoints.len(), 2);
870
871        // Public service uses public URL, not private IP
872        assert_eq!(endpoints[0].service_name, "public-svc");
873        assert_eq!(endpoints[0].url, "https://public.example.com");
874        assert!(!endpoints[0].is_private);
875
876        // Internal service uses private IP
877        assert_eq!(endpoints[1].service_name, "internal-svc");
878        assert_eq!(endpoints[1].url, "http://10.0.0.3");
879        assert!(endpoints[1].is_private);
880    }
881
882    #[test]
883    fn test_get_available_endpoints_deduplicates() {
884        use crate::platform::api::types::DeployedService;
885        use chrono::Utc;
886
887        // Simulate API returning two records for same service (most recent first)
888        let deployments = vec![
889            DeployedService {
890                id: "2".to_string(),
891                project_id: "p1".to_string(),
892                service_name: "backend".to_string(),
893                repository_full_name: "org/repo".to_string(),
894                status: "running".to_string(),
895                backstage_task_id: None,
896                commit_sha: None,
897                public_url: Some("https://backend.example.com".to_string()),
898                private_ip: None,
899                cloud_provider: None,
900                created_at: Utc::now(),
901            },
902            DeployedService {
903                id: "1".to_string(),
904                project_id: "p1".to_string(),
905                service_name: "backend".to_string(),
906                repository_full_name: "org/repo".to_string(),
907                status: "failed".to_string(),
908                backstage_task_id: None,
909                commit_sha: None,
910                public_url: Some("https://backend-old.example.com".to_string()),
911                private_ip: None,
912                cloud_provider: None,
913                created_at: Utc::now(),
914            },
915        ];
916
917        let endpoints = get_available_endpoints(&deployments);
918        assert_eq!(endpoints.len(), 1);
919        assert_eq!(endpoints[0].url, "https://backend.example.com");
920    }
921
922    #[test]
923    fn test_get_available_endpoints_accepts_unknown_statuses() {
924        use crate::platform::api::types::DeployedService;
925        use chrono::Utc;
926
927        // A service with an unexpected status but a public URL should be included
928        let deployments = vec![DeployedService {
929            id: "1".to_string(),
930            project_id: "p1".to_string(),
931            service_name: "api-svc".to_string(),
932            repository_full_name: "org/repo".to_string(),
933            status: "succeeded".to_string(),
934            backstage_task_id: None,
935            commit_sha: None,
936            public_url: Some("https://api.example.com".to_string()),
937            private_ip: None,
938            cloud_provider: None,
939            created_at: Utc::now(),
940        }];
941
942        let endpoints = get_available_endpoints(&deployments);
943        assert_eq!(endpoints.len(), 1);
944        assert_eq!(endpoints[0].service_name, "api-svc");
945    }
946
947    #[test]
948    fn test_filter_endpoints_for_provider() {
949        let endpoints = vec![
950            // Public endpoint on Azure — should always be kept
951            AvailableServiceEndpoint {
952                service_name: "azure-api".to_string(),
953                url: "https://azure-api.example.com".to_string(),
954                is_private: false,
955                cloud_provider: Some("azure".to_string()),
956                status: "healthy".to_string(),
957            },
958            // Private endpoint on Hetzner — should be kept when deploying to Hetzner
959            AvailableServiceEndpoint {
960                service_name: "hetzner-worker".to_string(),
961                url: "http://10.0.0.5".to_string(),
962                is_private: true,
963                cloud_provider: Some("hetzner".to_string()),
964                status: "healthy".to_string(),
965            },
966            // Private endpoint on Azure — should NOT be kept when deploying to Hetzner
967            AvailableServiceEndpoint {
968                service_name: "azure-internal".to_string(),
969                url: "http://10.1.0.5".to_string(),
970                is_private: true,
971                cloud_provider: Some("azure".to_string()),
972                status: "healthy".to_string(),
973            },
974        ];
975
976        // Deploying to Hetzner: keep public endpoints + Hetzner private only
977        let filtered = filter_endpoints_for_provider(endpoints.clone(), "hetzner");
978        assert_eq!(filtered.len(), 2);
979        assert_eq!(filtered[0].service_name, "azure-api"); // public, always kept
980        assert_eq!(filtered[1].service_name, "hetzner-worker"); // same provider
981
982        // Deploying to Azure: keep public endpoints + Azure private only
983        let filtered = filter_endpoints_for_provider(endpoints, "azure");
984        assert_eq!(filtered.len(), 2);
985        assert_eq!(filtered[0].service_name, "azure-api"); // public
986        assert_eq!(filtered[1].service_name, "azure-internal"); // same provider
987    }
988
989    // =========================================================================
990    // Network endpoint tests
991    // =========================================================================
992
993    fn make_network(
994        id: &str,
995        provider: &str,
996        region: &str,
997        status: &str,
998        env_id: Option<&str>,
999    ) -> CloudRunnerNetwork {
1000        CloudRunnerNetwork {
1001            id: id.to_string(),
1002            project_id: "proj-1".to_string(),
1003            organization_id: "org-1".to_string(),
1004            environment_id: env_id.map(String::from),
1005            cloud_provider: provider.to_string(),
1006            region: region.to_string(),
1007            vpc_id: None,
1008            vpc_name: None,
1009            subnet_id: None,
1010            vpc_connector_id: None,
1011            vpc_connector_name: None,
1012            resource_group_name: None,
1013            container_app_environment_id: None,
1014            container_app_environment_name: None,
1015            default_domain: None,
1016            status: status.to_string(),
1017            error_message: None,
1018        }
1019    }
1020
1021    #[test]
1022    fn test_extract_network_endpoints_filters_by_provider_and_status() {
1023        let networks = vec![
1024            {
1025                let mut n = make_network("n1", "hetzner", "nbg1", "ready", Some("env-1"));
1026                n.vpc_id = Some("vpc-123".to_string());
1027                n.subnet_id = Some("subnet-456".to_string());
1028                n
1029            },
1030            // Different provider — should be excluded
1031            {
1032                let mut n = make_network("n2", "gcp", "us-central1", "ready", Some("env-1"));
1033                n.vpc_connector_name = Some("my-connector".to_string());
1034                n
1035            },
1036            // Same provider but not ready — should be excluded
1037            {
1038                let mut n = make_network("n3", "hetzner", "fsn1", "provisioning", Some("env-1"));
1039                n.vpc_id = Some("vpc-789".to_string());
1040                n
1041            },
1042        ];
1043
1044        let endpoints = extract_network_endpoints(&networks, "hetzner", Some("env-1"));
1045        assert_eq!(endpoints.len(), 1);
1046        assert_eq!(endpoints[0].network_id, "n1");
1047        assert_eq!(endpoints[0].cloud_provider, "hetzner");
1048        assert_eq!(endpoints[0].connection_details.len(), 2);
1049        assert!(
1050            endpoints[0]
1051                .connection_details
1052                .iter()
1053                .any(|(k, v)| k == "NETWORK_VPC_ID" && v == "vpc-123")
1054        );
1055        assert!(
1056            endpoints[0]
1057                .connection_details
1058                .iter()
1059                .any(|(k, v)| k == "NETWORK_SUBNET_ID" && v == "subnet-456")
1060        );
1061    }
1062
1063    #[test]
1064    fn test_extract_network_endpoints_azure() {
1065        let networks = vec![{
1066            let mut n = make_network("n1", "azure", "eastus", "ready", Some("env-1"));
1067            n.container_app_environment_name = Some("my-cae".to_string());
1068            n.default_domain = Some("my-app.azurecontainerapps.io".to_string());
1069            n.resource_group_name = Some("rg-prod".to_string());
1070            n
1071        }];
1072
1073        let endpoints = extract_network_endpoints(&networks, "azure", Some("env-1"));
1074        assert_eq!(endpoints.len(), 1);
1075        assert!(
1076            endpoints[0]
1077                .connection_details
1078                .iter()
1079                .any(|(k, v)| k == "AZURE_CONTAINER_APP_ENV_NAME" && v == "my-cae")
1080        );
1081        assert!(
1082            endpoints[0]
1083                .connection_details
1084                .iter()
1085                .any(|(k, v)| k == "NETWORK_DEFAULT_DOMAIN" && v == "my-app.azurecontainerapps.io")
1086        );
1087        assert!(
1088            endpoints[0]
1089                .connection_details
1090                .iter()
1091                .any(|(k, v)| k == "AZURE_RESOURCE_GROUP" && v == "rg-prod")
1092        );
1093    }
1094
1095    #[test]
1096    fn test_extract_network_endpoints_hetzner() {
1097        let networks = vec![{
1098            let mut n = make_network("n1", "hetzner", "nbg1", "ready", None);
1099            n.vpc_id = Some("hetz-vpc-1".to_string());
1100            n.subnet_id = Some("hetz-sub-1".to_string());
1101            n
1102        }];
1103
1104        let endpoints = extract_network_endpoints(&networks, "hetzner", Some("env-1"));
1105        // Shared network (no environment_id) should be included
1106        assert_eq!(endpoints.len(), 1);
1107        assert!(
1108            endpoints[0]
1109                .connection_details
1110                .iter()
1111                .any(|(k, v)| k == "NETWORK_VPC_ID" && v == "hetz-vpc-1")
1112        );
1113        assert!(
1114            endpoints[0]
1115                .connection_details
1116                .iter()
1117                .any(|(k, v)| k == "NETWORK_SUBNET_ID" && v == "hetz-sub-1")
1118        );
1119    }
1120
1121    #[test]
1122    fn test_extract_network_endpoints_gcp() {
1123        let networks = vec![{
1124            let mut n = make_network("n1", "gcp", "us-central1", "ready", Some("env-1"));
1125            n.vpc_connector_name =
1126                Some("projects/my-proj/locations/us-central1/connectors/vpc-conn".to_string());
1127            n
1128        }];
1129
1130        let endpoints = extract_network_endpoints(&networks, "gcp", Some("env-1"));
1131        assert_eq!(endpoints.len(), 1);
1132        assert!(
1133            endpoints[0]
1134                .connection_details
1135                .iter()
1136                .any(|(k, v)| k == "GCP_VPC_CONNECTOR"
1137                    && v == "projects/my-proj/locations/us-central1/connectors/vpc-conn")
1138        );
1139    }
1140
1141    #[test]
1142    fn test_extract_network_endpoints_filters_non_ready() {
1143        let networks = vec![
1144            {
1145                let mut n = make_network("n1", "hetzner", "nbg1", "error", Some("env-1"));
1146                n.vpc_id = Some("vpc-err".to_string());
1147                n
1148            },
1149            {
1150                let mut n = make_network("n2", "hetzner", "nbg1", "provisioning", Some("env-1"));
1151                n.vpc_id = Some("vpc-prov".to_string());
1152                n
1153            },
1154        ];
1155
1156        let endpoints = extract_network_endpoints(&networks, "hetzner", Some("env-1"));
1157        assert!(endpoints.is_empty());
1158    }
1159}