1use 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#[derive(Debug, Clone)]
14pub struct AvailableServiceEndpoint {
15 pub service_name: String,
16 pub url: String,
18 pub is_private: bool,
20 pub cloud_provider: Option<String>,
22 pub status: String,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum MatchConfidence {
28 Low,
29 Medium,
30 High,
31}
32
33#[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
42const URL_SUFFIXES: &[&str] = &[
47 "_URL",
48 "_SERVICE_URL",
49 "_ENDPOINT",
50 "_HOST",
51 "_BASE",
52 "_BASE_URL",
53 "_API_URL",
54 "_URI",
55];
56
57pub 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 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 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 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
152pub 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 return true;
169 }
170 ep.cloud_provider
172 .as_ref()
173 .map(|p| p.to_lowercase() == target)
174 .unwrap_or(false)
175 })
176 .collect()
177}
178
179pub 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
185pub fn extract_service_hint(env_var_name: &str) -> Option<String> {
191 let upper = env_var_name.to_uppercase();
192
193 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
209fn normalize(s: &str) -> String {
211 s.to_lowercase().replace(['-', '_'], "")
212}
213
214fn 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
223pub 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 if nh == ns || ns.starts_with(&nh) {
236 return Some(MatchConfidence::High);
237 }
238
239 if ns.contains(&nh) || nh.contains(&ns) {
241 return Some(MatchConfidence::Medium);
242 }
243
244 if nh.starts_with(&ns) || ns.starts_with(&nh) {
247 return Some(MatchConfidence::Medium);
248 }
249
250 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
274pub 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 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
319pub fn suggest_env_var_name(service_name: &str) -> String {
323 let base = service_name.to_uppercase().replace('-', "_");
324 format!("{}_URL", base)
325}
326
327pub 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 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 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 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#[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 pub connection_details: Vec<(String, String)>,
475}
476
477pub 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()) })
496 .map(|n| {
497 let mut details = Vec::new();
498
499 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 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 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
536pub 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 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#[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(), "DATABASE_URL".to_string(), ];
733
734 let suggestions = match_env_vars_to_services(&env_vars, &endpoints);
735
736 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 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 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 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 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 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 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 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 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 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 let filtered = filter_endpoints_for_provider(endpoints.clone(), "hetzner");
978 assert_eq!(filtered.len(), 2);
979 assert_eq!(filtered[0].service_name, "azure-api"); assert_eq!(filtered[1].service_name, "hetzner-worker"); let filtered = filter_endpoints_for_provider(endpoints, "azure");
984 assert_eq!(filtered.len(), 2);
985 assert_eq!(filtered[0].service_name, "azure-api"); assert_eq!(filtered[1].service_name, "azure-internal"); }
988
989 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 {
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 {
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 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}