Skip to content

Commit 9c102e5

Browse files
committed
inject default external network when npm is used
1 parent fbc4236 commit 9c102e5

5 files changed

Lines changed: 722 additions & 14 deletions

File tree

src/bin/stacker.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ enum StackerCommands {
108108
},
109109
/// Build & deploy the stack
110110
Deploy {
111+
/// Service name for surgical single-service deploy (e.g. `stacker deploy stacker-website`).
112+
/// Reads local docker-compose.yml, injects the service into the remote compose, and
113+
/// starts only that container — other running services are not touched.
114+
#[arg(value_name = "SERVICE")]
115+
service: Option<String>,
111116
/// Deployment target: local, cloud, server
112117
#[arg(long, value_name = "TARGET")]
113118
target: Option<String>,
@@ -1327,10 +1332,10 @@ enum AgentCommands {
13271332
/// Port to forward to
13281333
#[arg(long)]
13291334
port: u16,
1330-
/// Enable SSL/Let's Encrypt certificate issuance
1331-
#[arg(long, default_value_t = true)]
1335+
/// Enable SSL/Let's Encrypt certificate issuance (default: off; use --ssl to enable)
1336+
#[arg(long, default_value_t = false)]
13321337
ssl: bool,
1333-
/// Disable SSL/Let's Encrypt and create a plain HTTP proxy host
1338+
/// Disable SSL/Let's Encrypt (no-op; SSL is off by default)
13341339
#[arg(long = "no-ssl")]
13351340
no_ssl: bool,
13361341
/// Action: create, update, delete
@@ -1747,6 +1752,7 @@ fn get_command(
17471752
)
17481753
}
17491754
StackerCommands::Deploy {
1755+
service,
17501756
target,
17511757
environment,
17521758
file,
@@ -1770,6 +1776,7 @@ fn get_command(
17701776
dry_run,
17711777
force_rebuild,
17721778
)
1779+
.with_service(service)
17731780
.with_environment(environment)
17741781
.with_remote_overrides(project, key, server)
17751782
.with_key_id(key_id)

src/cli/compose_service_sync.rs

Lines changed: 320 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::BTreeMap;
22
use std::path::{Path, PathBuf};
33

4-
use crate::cli::config_parser::{ServiceDefinition, StackerConfig};
4+
use crate::cli::config_parser::{ProxyConfig, ProxyType, ServiceDefinition, StackerConfig};
55
use 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+
14115
pub 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)]
273388
mod 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

Comments
 (0)