11use std:: collections:: BTreeMap ;
22use std:: path:: { Path , PathBuf } ;
33
4- use crate :: cli:: config_parser:: { ServiceDefinition , StackerConfig } ;
4+ use crate :: cli:: config_parser:: { ProxyConfig , ProxyType , ServiceDefinition , StackerConfig } ;
55use crate :: cli:: error:: CliError ;
66
77#[ derive( Debug , Clone , Default , PartialEq , Eq ) ]
@@ -11,6 +11,107 @@ pub struct ComposeServiceSyncResult {
1111 pub updated_services : Vec < String > ,
1212}
1313
14+ /// Extract the service name from an upstream string like `svc:3000` or `http://svc:3000`.
15+ pub fn upstream_service_name ( upstream : & str ) -> Option < String > {
16+ let s = upstream
17+ . trim_start_matches ( "https://" )
18+ . trim_start_matches ( "http://" ) ;
19+ let host = s. split ( '/' ) . next ( ) ?;
20+ let name = host. split ( ':' ) . next ( ) ?;
21+ if name. is_empty ( ) {
22+ None
23+ } else {
24+ Some ( name. to_string ( ) )
25+ }
26+ }
27+
28+ /// Inject `default_network` into `service_name` inside `compose_doc` when the service is
29+ /// listed as an NginxProxyManager upstream. Declares the network as `external: true` at
30+ /// the top level. Returns `true` if the document was modified.
31+ pub fn inject_npm_proxy_network (
32+ compose_doc : & mut serde_yaml:: Value ,
33+ service_name : & str ,
34+ proxy : & ProxyConfig ,
35+ ) -> bool {
36+ if proxy. proxy_type != ProxyType :: NginxProxyManager {
37+ return false ;
38+ }
39+ let is_proxied = proxy. domains . iter ( ) . any ( |d| {
40+ upstream_service_name ( & d. upstream )
41+ . map ( |n| n == service_name)
42+ . unwrap_or ( false )
43+ } ) ;
44+ if !is_proxied {
45+ return false ;
46+ }
47+ inject_external_network ( compose_doc, service_name, "default_network" )
48+ }
49+
50+ fn inject_external_network (
51+ compose_doc : & mut serde_yaml:: Value ,
52+ service_name : & str ,
53+ network : & str ,
54+ ) -> bool {
55+ let mut changed = false ;
56+ let network_val = serde_yaml:: Value :: String ( network. to_string ( ) ) ;
57+
58+ if let Some ( svc) = compose_doc
59+ . get_mut ( "services" )
60+ . and_then ( |s| s. get_mut ( service_name) )
61+ . and_then ( serde_yaml:: Value :: as_mapping_mut)
62+ {
63+ let networks_key = serde_yaml:: Value :: String ( "networks" . to_string ( ) ) ;
64+ match svc. get_mut ( & networks_key) {
65+ Some ( serde_yaml:: Value :: Sequence ( seq) ) => {
66+ if !seq. contains ( & network_val) {
67+ seq. push ( network_val) ;
68+ changed = true ;
69+ }
70+ }
71+ None => {
72+ svc. insert (
73+ networks_key,
74+ serde_yaml:: Value :: Sequence ( vec ! [ network_val] ) ,
75+ ) ;
76+ changed = true ;
77+ }
78+ _ => { }
79+ }
80+ }
81+
82+ if changed {
83+ upsert_external_network ( compose_doc, network) ;
84+ }
85+ changed
86+ }
87+
88+ fn upsert_external_network ( compose_doc : & mut serde_yaml:: Value , network : & str ) {
89+ let Some ( root) = compose_doc. as_mapping_mut ( ) else {
90+ return ;
91+ } ;
92+ let networks_key = serde_yaml:: Value :: String ( "networks" . to_string ( ) ) ;
93+ if !root. contains_key ( & networks_key) {
94+ root. insert (
95+ networks_key. clone ( ) ,
96+ serde_yaml:: Value :: Mapping ( Default :: default ( ) ) ,
97+ ) ;
98+ }
99+ if let Some ( top_networks) = root
100+ . get_mut ( & networks_key)
101+ . and_then ( serde_yaml:: Value :: as_mapping_mut)
102+ {
103+ let net_key = serde_yaml:: Value :: String ( network. to_string ( ) ) ;
104+ if !top_networks. contains_key ( & net_key) {
105+ let mut net_config = serde_yaml:: Mapping :: new ( ) ;
106+ net_config. insert (
107+ serde_yaml:: Value :: String ( "external" . to_string ( ) ) ,
108+ serde_yaml:: Value :: Bool ( true ) ,
109+ ) ;
110+ top_networks. insert ( net_key, serde_yaml:: Value :: Mapping ( net_config) ) ;
111+ }
112+ }
113+ }
114+
14115pub fn sync_configured_compose_services (
15116 project_dir : & Path ,
16117 config : & StackerConfig ,
@@ -50,7 +151,21 @@ pub fn sync_configured_compose_services(
50151 service_name
51152 ) )
52153 } ) ?;
53- upsert_compose_service ( & mut compose_doc, service, & project_networks) ?;
154+
155+ let mut svc_networks = project_networks. clone ( ) ;
156+ if config. proxy . proxy_type == ProxyType :: NginxProxyManager
157+ && !svc_networks. contains ( & "default_network" . to_string ( ) )
158+ && config. proxy . domains . iter ( ) . any ( |d| {
159+ upstream_service_name ( & d. upstream )
160+ . map ( |n| n == * service_name)
161+ . unwrap_or ( false )
162+ } )
163+ {
164+ svc_networks. push ( "default_network" . to_string ( ) ) ;
165+ upsert_external_network ( & mut compose_doc, "default_network" ) ;
166+ }
167+
168+ upsert_compose_service ( & mut compose_doc, service, & svc_networks) ?;
54169 updated_services. push ( service. name . clone ( ) ) ;
55170 }
56171
@@ -272,10 +387,212 @@ fn push_unique_network(networks: &mut Vec<String>, name: &str) {
272387#[ cfg( test) ]
273388mod tests {
274389 use super :: * ;
275- use crate :: cli:: config_parser:: { AppSource , DeployConfig , ProjectConfig } ;
390+ use crate :: cli:: config_parser:: { AppSource , DeployConfig , DomainConfig , ProjectConfig , SslMode } ;
276391 use std:: collections:: HashMap ;
277392 use tempfile:: TempDir ;
278393
394+ // ── inject_npm_proxy_network unit tests ──────────────────────────────────
395+
396+ fn npm_proxy_config ( upstream : & str ) -> ProxyConfig {
397+ ProxyConfig {
398+ proxy_type : ProxyType :: NginxProxyManager ,
399+ auto_detect : false ,
400+ domains : vec ! [ DomainConfig {
401+ domain: "app.example.com" . into( ) ,
402+ ssl: SslMode :: Auto ,
403+ upstream: upstream. to_string( ) ,
404+ } ] ,
405+ config : None ,
406+ }
407+ }
408+
409+ fn compose_doc_with_service ( service : & str ) -> serde_yaml:: Value {
410+ serde_yaml:: from_str ( & format ! (
411+ "services:\n {service}:\n image: myapp:latest\n "
412+ ) )
413+ . unwrap ( )
414+ }
415+
416+ #[ test]
417+ fn inject_npm_proxy_network_adds_to_proxied_service ( ) {
418+ let mut doc = compose_doc_with_service ( "web" ) ;
419+ let changed = inject_npm_proxy_network ( & mut doc, "web" , & npm_proxy_config ( "web:3000" ) ) ;
420+ assert ! ( changed) ;
421+ let networks = doc[ "services" ] [ "web" ] [ "networks" ]
422+ . as_sequence ( )
423+ . unwrap ( )
424+ . iter ( )
425+ . map ( |v| v. as_str ( ) . unwrap ( ) )
426+ . collect :: < Vec < _ > > ( ) ;
427+ assert ! ( networks. contains( & "default_network" ) ) ;
428+ // top-level declares it external
429+ assert_eq ! (
430+ doc[ "networks" ] [ "default_network" ] [ "external" ] . as_bool( ) ,
431+ Some ( true )
432+ ) ;
433+ }
434+
435+ #[ test]
436+ fn inject_npm_proxy_network_returns_false_for_non_proxied_service ( ) {
437+ let mut doc = compose_doc_with_service ( "smtp" ) ;
438+ let changed = inject_npm_proxy_network ( & mut doc, "smtp" , & npm_proxy_config ( "web:3000" ) ) ;
439+ assert ! ( !changed) ;
440+ assert ! ( doc[ "services" ] [ "smtp" ] . get( "networks" ) . is_none( ) ) ;
441+ }
442+
443+ #[ test]
444+ fn inject_npm_proxy_network_returns_false_for_non_npm_proxy ( ) {
445+ let mut doc = compose_doc_with_service ( "web" ) ;
446+ let proxy = ProxyConfig {
447+ proxy_type : ProxyType :: Traefik ,
448+ auto_detect : false ,
449+ domains : vec ! [ DomainConfig {
450+ domain: "app.example.com" . into( ) ,
451+ ssl: SslMode :: Auto ,
452+ upstream: "web:3000" . into( ) ,
453+ } ] ,
454+ config : None ,
455+ } ;
456+ let changed = inject_npm_proxy_network ( & mut doc, "web" , & proxy) ;
457+ assert ! ( !changed) ;
458+ }
459+
460+ #[ test]
461+ fn inject_npm_proxy_network_is_idempotent ( ) {
462+ let mut doc: serde_yaml:: Value = serde_yaml:: from_str (
463+ "services:\n web:\n image: myapp:latest\n networks:\n - default_network\n " ,
464+ )
465+ . unwrap ( ) ;
466+ let changed = inject_npm_proxy_network ( & mut doc, "web" , & npm_proxy_config ( "web:3000" ) ) ;
467+ assert ! ( !changed, "already has default_network — should be a no-op" ) ;
468+ let seq = doc[ "services" ] [ "web" ] [ "networks" ] . as_sequence ( ) . unwrap ( ) ;
469+ let count = seq
470+ . iter ( )
471+ . filter ( |v| v. as_str ( ) == Some ( "default_network" ) )
472+ . count ( ) ;
473+ assert_eq ! ( count, 1 , "no duplicate entries" ) ;
474+ }
475+
476+ #[ test]
477+ fn inject_npm_proxy_network_parses_http_prefix_upstream ( ) {
478+ let proxy = ProxyConfig {
479+ proxy_type : ProxyType :: NginxProxyManager ,
480+ auto_detect : false ,
481+ domains : vec ! [ DomainConfig {
482+ domain: "app.example.com" . into( ) ,
483+ ssl: SslMode :: Off ,
484+ upstream: "http://api:8080" . into( ) ,
485+ } ] ,
486+ config : None ,
487+ } ;
488+ let mut doc = compose_doc_with_service ( "api" ) ;
489+ let changed = inject_npm_proxy_network ( & mut doc, "api" , & proxy) ;
490+ assert ! ( changed) ;
491+ let networks = doc[ "services" ] [ "api" ] [ "networks" ]
492+ . as_sequence ( )
493+ . unwrap ( )
494+ . iter ( )
495+ . map ( |v| v. as_str ( ) . unwrap ( ) )
496+ . collect :: < Vec < _ > > ( ) ;
497+ assert ! ( networks. contains( & "default_network" ) ) ;
498+ }
499+
500+ // ── sync_configured_compose_services proxy-inject tests ──────────────────
501+
502+ fn npm_stacker_config ( dir : & std:: path:: Path , service_name : & str ) -> StackerConfig {
503+ StackerConfig {
504+ project : ProjectConfig :: default ( ) ,
505+ app : AppSource :: default ( ) ,
506+ deploy : DeployConfig {
507+ compose_file : Some ( PathBuf :: from ( "docker-compose.yml" ) ) ,
508+ ..Default :: default ( )
509+ } ,
510+ proxy : ProxyConfig {
511+ proxy_type : ProxyType :: NginxProxyManager ,
512+ auto_detect : false ,
513+ domains : vec ! [ DomainConfig {
514+ domain: "app.example.com" . into( ) ,
515+ ssl: SslMode :: Auto ,
516+ upstream: format!( "{service_name}:3000" ) ,
517+ } ] ,
518+ config : None ,
519+ } ,
520+ services : vec ! [ ServiceDefinition {
521+ name: service_name. to_string( ) ,
522+ image: "myapp:latest" . to_string( ) ,
523+ ports: vec![ "3000:3000" . to_string( ) ] ,
524+ environment: HashMap :: new( ) ,
525+ volumes: vec![ ] ,
526+ depends_on: vec![ ] ,
527+ } ] ,
528+ ..Default :: default ( )
529+ }
530+ }
531+
532+ #[ test]
533+ fn sync_injects_default_network_for_npm_proxied_service ( ) {
534+ let dir = TempDir :: new ( ) . unwrap ( ) ;
535+ std:: fs:: write (
536+ dir. path ( ) . join ( "docker-compose.yml" ) ,
537+ "services:\n existing:\n image: nginx:latest\n " ,
538+ )
539+ . unwrap ( ) ;
540+
541+ let config = npm_stacker_config ( dir. path ( ) , "api" ) ;
542+ let result =
543+ sync_configured_compose_services ( dir. path ( ) , & config, & [ "api" . to_string ( ) ] ) . unwrap ( ) ;
544+
545+ assert_eq ! ( result. updated_services, vec![ "api" ] ) ;
546+ let updated = std:: fs:: read_to_string ( dir. path ( ) . join ( "docker-compose.yml" ) ) . unwrap ( ) ;
547+ assert ! (
548+ updated. contains( "default_network" ) ,
549+ "proxied service should have default_network injected:\n {updated}"
550+ ) ;
551+ assert ! (
552+ updated. contains( "external: true" ) || updated. contains( "external: 'true'" ) ,
553+ "default_network should be declared external:\n {updated}"
554+ ) ;
555+ }
556+
557+ #[ test]
558+ fn sync_does_not_inject_default_network_for_non_proxied_service ( ) {
559+ let dir = TempDir :: new ( ) . unwrap ( ) ;
560+ std:: fs:: write (
561+ dir. path ( ) . join ( "docker-compose.yml" ) ,
562+ "services:\n existing:\n image: nginx:latest\n " ,
563+ )
564+ . unwrap ( ) ;
565+
566+ // proxy points to "api" but we are syncing "smtp"
567+ let mut config = npm_stacker_config ( dir. path ( ) , "api" ) ;
568+ config. services = vec ! [ ServiceDefinition {
569+ name: "smtp" . to_string( ) ,
570+ image: "trydirect/smtp" . to_string( ) ,
571+ ports: vec![ ] ,
572+ environment: HashMap :: new( ) ,
573+ volumes: vec![ ] ,
574+ depends_on: vec![ ] ,
575+ } ] ;
576+
577+ let result =
578+ sync_configured_compose_services ( dir. path ( ) , & config, & [ "smtp" . to_string ( ) ] ) . unwrap ( ) ;
579+
580+ assert_eq ! ( result. updated_services, vec![ "smtp" ] ) ;
581+ let updated = std:: fs:: read_to_string ( dir. path ( ) . join ( "docker-compose.yml" ) ) . unwrap ( ) ;
582+ let smtp_section_start = updated. find ( "smtp:" ) . unwrap ( ) ;
583+ let smtp_section = & updated[ smtp_section_start..] ;
584+ // "smtp" block should not list default_network
585+ let next_service = smtp_section[ 5 ..] . find ( '\n' ) . map ( |i| & smtp_section[ ..i + 5 ] ) ;
586+ let _ = next_service; // just ensure smtp block doesn't have it
587+ assert ! (
588+ !smtp_section
589+ . lines( )
590+ . take( 10 )
591+ . any( |l| l. contains( "default_network" ) ) ,
592+ "non-proxied service should not get default_network:\n {updated}"
593+ ) ;
594+ }
595+
279596 #[ test]
280597 fn sync_configured_compose_services_upserts_service_networks_and_volumes ( ) {
281598 let dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments