From 14468a9d7e74a9c406d270618be488fb65b27fc6 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 1 Jun 2026 14:13:44 +0300 Subject: [PATCH 01/72] adjust separator width, keep static for now --- src/console/commands/cli/agent.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index f1981e6f..322e7111 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -1895,12 +1895,16 @@ fn agent_version_label(version: &str) -> String { format!(" · v{}", version.trim().trim_start_matches('v')) } +/// Pretty-print a snapshot summary for human consumption. +// Width that covers the widest table (containers: 24+1+12+1+22+1+30 = 91 cols). +const STATUS_SEP_WIDTH: usize = 92; + /// Pretty-print a snapshot summary for human consumption. fn print_snapshot_summary( snap: &serde_json::Value, live_containers: Option<&Vec>, ) { - println!("{}", fmt::separator(60)); + println!("{}", fmt::separator(STATUS_SEP_WIDTH)); // Agent info if let Some(agent) = snap.get("agent") { @@ -1928,7 +1932,7 @@ fn print_snapshot_summary( println!("Agent: not registered"); } - println!("{}", fmt::separator(60)); + println!("{}", fmt::separator(STATUS_SEP_WIDTH)); if let Some(apps) = snap.get("apps").and_then(|v| v.as_array()) { print_apps_summary(apps); @@ -1936,7 +1940,7 @@ fn print_snapshot_summary( println!("Apps: none"); } - println!("{}", fmt::separator(60)); + println!("{}", fmt::separator(STATUS_SEP_WIDTH)); // Containers if let Some(containers) = live_containers { @@ -1945,7 +1949,7 @@ fn print_snapshot_summary( print_containers_summary(containers); } - println!("{}", fmt::separator(60)); + println!("{}", fmt::separator(STATUS_SEP_WIDTH)); // Recent commands if let Some(commands) = snap.get("commands").and_then(|v| v.as_array()) { From c78352aa95507f550ca12617b429f86203b60236 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 1 Jun 2026 19:17:27 +0300 Subject: [PATCH 02/72] bump version to v0.2.9 Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 71795e0e..1d65b5b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stacker" -version = "0.2.8" +version = "0.2.9" edition = "2021" default-run= "server" From 5803237a51cae9260fa6959295043e884a4ab67f Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 4 Jun 2026 12:41:58 +0300 Subject: [PATCH 03/72] New preflight in src/cli/install_runner.rs: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. parse_compose_host_port() — parses both string (127.0.0.1:3000:80) and mapping ({published: 3000}) compose port entries 2. collect_compose_host_port_services() — reads the compose file and returns all (host_port, service_name) pairs 3. get_own_compose_running_ports() — runs docker compose ps --format {{.Ports}} to discover ports currently held by this project's own containers (to avoid false positives on redeploys) 4. check_local_host_port_conflicts() — TCP-binds each declared port; if a bind fails AND the port isn't owned by the project's own containers, it's flagged as a real conflict 5. LocalDeploy::deploy() — calls the check before docker compose up; if conflicts are found, it returns a clear error like: Host port conflict detected before deploy: • port 3000 (service 'status-panel-web') is already allocated on this host — find the owner with: lsof -nP -iTCP:3000 -sTCP:LISTEN Stop the conflicting process or change the port in stacker.yml, then retry. --- Cargo.lock | 2 +- docker/dev/docker-compose.yml | 44 ++-- src/cli/config_parser.rs | 8 +- src/cli/generator/compose.rs | 42 ++++ src/cli/install_runner.rs | 368 +++++++++++++++++++++++++++++++++- src/connectors/hetzner.rs | 88 ++++++++ src/routes/project/deploy.rs | 113 +++++++++++ 7 files changed, 635 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07768711..cb511f47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6576,7 +6576,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.2.8" +version = "0.2.9" dependencies = [ "actix", "actix-casbin-auth", diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index ea7ee0d7..1f9051fe 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -1,5 +1,3 @@ -version: "2.2" - volumes: stackerdb: driver: local @@ -8,30 +6,28 @@ volumes: driver: local networks: - backend: + trydirect_default: driver: bridge - name: backend external: true trydirect-network: external: true name: trydirect-network - services: - stacker: - image: trydirect/stacker:0.0.8 - build: . + image: trydirect/stacker:latest + #image: trydirect/stacker:test container_name: stacker restart: always volumes: - ./stacker/files:/app/files + - ./.env:/app/.env - ./configuration.yaml:/app/configuration.yaml - ./access_control.conf:/app/access_control.conf - ./migrations:/app/migrations - - ./.env:/app/.env + - ./stacker/ansible:/ansible/roles:ro ports: - - "8000:8000" + - "8001:8000" env_file: - ./.env environment: @@ -41,12 +37,12 @@ services: stackerdb: condition: service_healthy networks: - - backend + - trydirect_default - - stacker_queue: - image: trydirect/stacker:0.0.7 - container_name: stacker_queue + stackermq: + image: trydirect/stacker:latest + #image: trydirect/stacker:test # for testing mcp + container_name: stackermq restart: always volumes: - ./configuration.yaml:/app/configuration.yaml @@ -54,10 +50,6 @@ services: environment: - RUST_LOG=debug - RUST_BACKTRACE=1 - - AMQP_HOST=rabbitmq - - AMQP_PORT=5672 - - AMQP_USERNAME=guest - - AMQP_PASSWORD=guest env_file: - ./.env depends_on: @@ -65,9 +57,7 @@ services: condition: service_healthy entrypoint: /app/console mq listen networks: - - backend - - trydirect-network - + - trydirect_default stackerdb: container_name: stackerdb @@ -76,17 +66,17 @@ services: interval: 10s timeout: 5s retries: 5 - image: postgres:18.3 + image: postgres:16.0 restart: always ports: - - 5432 + - 5434:5432 env_file: - ./.env volumes: - stackerdb:/var/lib/postgresql/data - ./postgresql.conf:/etc/postgresql/postgresql.conf networks: - - backend + - trydirect_default stackerredis: container_name: stackerredis @@ -105,5 +95,5 @@ services: options: max-size: "10m" tag: "container_{{.Name}}" - - + networks: + - trydirect_default diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 0a3d3236..ae92a1ad 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -1216,6 +1216,7 @@ pub struct ConfigBuilder { app_path: Option, app_image: Option, app_dockerfile: Option, + app_volumes: Vec, build_args: HashMap, services: Vec, proxy: Option, @@ -1275,6 +1276,11 @@ impl ConfigBuilder { self } + pub fn app_volumes(mut self, volumes: Vec) -> Self { + self.app_volumes = volumes; + self + } + pub fn build_arg, V: Into>(mut self, key: K, value: V) -> Self { self.build_args.insert(key.into(), value.into()); self @@ -1364,7 +1370,7 @@ impl ConfigBuilder { image: self.app_image, build: build_config, ports: Vec::new(), - volumes: Vec::new(), + volumes: self.app_volumes, environment: HashMap::new(), }, services: self.services, diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index c9e92d02..c53a89a4 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -106,6 +106,13 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { // --- Main app service --- let app_service = build_app_service(config); + for vol in &app_service.volumes { + if let Some(named) = extract_named_volume(vol) { + if !named_volumes.contains(&named) { + named_volumes.push(named); + } + } + } compose.services.push(app_service); // --- Additional services (databases, caches, etc.) --- @@ -736,6 +743,41 @@ mod tests { ); } + #[test] + fn app_named_volumes_appear_in_top_level_volumes_block() { + let config = ConfigBuilder::new() + .name("rustfs") + .app_type(AppType::Custom) + .app_image("rustfs/rustfs:latest") + .app_volumes(vec![ + "rustfs_data:/data".into(), + "rustfs_logs:/app/logs".into(), + "./local-config:/etc/config:ro".into(), // bind mount — must NOT appear + ]) + .build() + .unwrap(); + + let compose = ComposeDefinition::try_from(&config).unwrap(); + + assert!( + compose.volumes.contains(&"rustfs_data".to_string()), + "rustfs_data should be in top-level volumes" + ); + assert!( + compose.volumes.contains(&"rustfs_logs".to_string()), + "rustfs_logs should be in top-level volumes" + ); + assert!( + !compose.volumes.contains(&"./local-config".to_string()), + "bind mount should not appear in top-level volumes" + ); + + let yaml = compose.render(); + assert!(yaml.contains("volumes:"), "top-level volumes: block must exist"); + assert!(yaml.contains(" rustfs_data:"), "rustfs_data entry must appear"); + assert!(yaml.contains(" rustfs_logs:"), "rustfs_logs entry must appear"); + } + #[test] fn test_extract_named_volume_returns_name() { assert_eq!( diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index bd3ba250..1fa368af 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -170,6 +170,177 @@ pub fn strategy_for(target: &DeployTarget) -> Box { // LocalDeploy — docker compose up/down // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// Parse the host-port side of a single compose port entry. +/// +/// Handles both string form (`"127.0.0.1:3000:3000"`, `"3000:3000"`) and +/// mapping form (`{ published: 3000, target: 3000 }`). +fn parse_compose_host_port(entry: &serde_yaml::Value) -> Option { + match entry { + serde_yaml::Value::String(spec) => { + let trimmed = spec.trim(); + if trimmed.is_empty() { + return None; + } + let without_proto = trimmed.split('/').next().unwrap_or(trimmed); + let parts: Vec<&str> = without_proto.split(':').collect(); + if parts.len() < 2 { + return None; + } + let port = parts[parts.len() - 2].trim(); + if port.is_empty() { + None + } else { + Some(port.to_string()) + } + } + serde_yaml::Value::Mapping(m) => { + let key = serde_yaml::Value::String("published".to_string()); + m.get(&key).and_then(|v| match v { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + } + _ => None, + } +} + +/// Return all `(host_port, service_name)` pairs declared in a compose file. +fn collect_compose_host_port_services(compose_path: &Path) -> Vec<(String, String)> { + let raw = match std::fs::read_to_string(compose_path) { + Ok(r) => r, + Err(_) => return vec![], + }; + let doc: serde_yaml::Value = match serde_yaml::from_str(&raw) { + Ok(d) => d, + Err(_) => return vec![], + }; + let services = match doc + .as_mapping() + .and_then(|m| m.get(&serde_yaml::Value::String("services".to_string()))) + .and_then(|v| v.as_mapping()) + { + Some(s) => s, + None => return vec![], + }; + + let mut result = Vec::new(); + for (svc_key, svc_val) in services { + let svc_name = svc_key.as_str().unwrap_or("").to_string(); + let svc_map = match svc_val.as_mapping() { + Some(m) => m, + None => continue, + }; + let ports_key = serde_yaml::Value::String("ports".to_string()); + let ports = match svc_map.get(&ports_key).and_then(|v| v.as_sequence()) { + Some(p) => p, + None => continue, + }; + for port in ports { + if let Some(host_port) = parse_compose_host_port(port) { + if !host_port.is_empty() { + result.push((host_port, svc_name.clone())); + } + } + } + } + result +} + +/// Parse `0.0.0.0:3000->3000/tcp` → `"3000"` from `docker ps` port strings. +fn extract_port_from_docker_ps_entry(spec: &str) -> Option { + let spec = spec.trim(); + if spec.is_empty() { + return None; + } + // e.g. "0.0.0.0:3000->3000/tcp" or ":::3000->3000/tcp" or "3000/tcp" + let host_part = if let Some(arrow) = spec.find("->") { + &spec[..arrow] + } else { + return None; // no host binding, container-only port + }; + // host_part is e.g. "0.0.0.0:3000" or ":::3000" + host_part + .rsplit(':') + .next() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) +} + +/// Ask Docker for the host ports currently bound by THIS compose project's containers. +/// +/// Uses `docker compose -f ps --format "{{.Ports}}"`. +/// Returns an empty set if Docker is unavailable or the project has no running containers. +fn get_own_compose_running_ports( + compose_path: &Path, + executor: &dyn CommandExecutor, +) -> std::collections::HashSet { + let compose_str = compose_path.to_string_lossy(); + let out = match executor.execute( + "docker", + &["compose", "-f", &compose_str, "ps", "--format", "{{.Ports}}"], + ) { + Ok(o) if o.success() => o, + _ => return Default::default(), + }; + let mut ports = std::collections::HashSet::new(); + for line in out.stdout.lines() { + for segment in line.split(',') { + if let Some(p) = extract_port_from_docker_ps_entry(segment.trim()) { + ports.insert(p); + } + } + } + ports +} + +/// Pre-flight check: detect host ports that are already occupied by something +/// OTHER than the current compose project's own running containers. +/// +/// Returns a list of human-readable conflict descriptions (empty = no conflicts). +/// Silently skips any port it cannot inspect so that environments without full +/// Docker access still work. +fn check_local_host_port_conflicts( + compose_path: &Path, + executor: &dyn CommandExecutor, +) -> Vec { + use std::net::TcpListener; + + let port_services = collect_compose_host_port_services(compose_path); + if port_services.is_empty() { + return vec![]; + } + + // Find which ports are already bound on the local machine. + let occupied: Vec<(String, String)> = port_services + .into_iter() + .filter(|(port, _)| { + let addr = format!("0.0.0.0:{}", port); + TcpListener::bind(&addr).is_err() + }) + .collect(); + + if occupied.is_empty() { + return vec![]; + } + + // Exclude ports that belong to OUR own currently-running project containers — + // docker compose up will stop-and-restart them without a conflict. + let own_ports = get_own_compose_running_ports(compose_path, executor); + + occupied + .into_iter() + .filter(|(port, _)| !own_ports.contains(port)) + .map(|(port, svc)| { + format!( + "port {} (service '{}') is already allocated on this host — \ + find the owner with: lsof -nP -iTCP:{} -sTCP:LISTEN", + port, svc, port + ) + }) + .collect() +} + /// Detect which compose invocation is available on this host. /// /// Returns `("docker", vec!["compose"])` when the Docker Compose plugin is @@ -215,6 +386,19 @@ impl DeployStrategy for LocalDeploy { let compose_path = context.compose_path.to_string_lossy().to_string(); + // Pre-flight: catch host port conflicts before docker compose up so the + // error is actionable rather than buried in Docker daemon output. + let port_conflicts = check_local_host_port_conflicts(&context.compose_path, executor); + if !port_conflicts.is_empty() { + return Err(CliError::DeployFailed { + target: DeployTarget::Local, + reason: format!( + "Host port conflict detected before deploy:\n • {}\nStop the conflicting process or change the port in stacker.yml, then retry.", + port_conflicts.join("\n • ") + ), + }); + } + let (cmd, base_args) = resolve_compose_cmd(executor); let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); @@ -1167,7 +1351,19 @@ async fn fetch_live_containers( CliError::ConfigValidation(format!("Invalid list_containers parameters: {}", error)) })?; - let completed = client.agent_poll_result(&request, 120, 2).await?; + let completed = client + .agent_poll_result(&request, 120, 2) + .await + .map_err(|err| match err { + CliError::AgentCommandTimeout { + ref last_status, + ref deployment_hash, + .. + } if last_status == "queued" => CliError::AgentNotFound { + deployment_hash: deployment_hash.clone(), + }, + other => other, + })?; if completed.status != "completed" { let detail = completed .error @@ -2816,4 +3012,174 @@ mod tests { let args = cmd.build_args(); assert!(!args.contains(&"--rm".to_string())); } + + // ── Port-conflict preflight helpers ───────────────── + + #[test] + fn test_parse_compose_host_port_string_host_container() { + let v = serde_yaml::Value::String("3000:3000".to_string()); + assert_eq!(parse_compose_host_port(&v), Some("3000".to_string())); + } + + #[test] + fn test_parse_compose_host_port_string_ip_host_container() { + let v = serde_yaml::Value::String("127.0.0.1:8080:80".to_string()); + assert_eq!(parse_compose_host_port(&v), Some("8080".to_string())); + } + + #[test] + fn test_parse_compose_host_port_mapping() { + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("published".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(3000u64)), + ); + m.insert( + serde_yaml::Value::String("target".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(3000u64)), + ); + let v = serde_yaml::Value::Mapping(m); + assert_eq!(parse_compose_host_port(&v), Some("3000".to_string())); + } + + #[test] + fn test_parse_compose_host_port_container_only() { + // Port without host binding: "3000" → no host port to parse + let v = serde_yaml::Value::String("3000".to_string()); + assert_eq!(parse_compose_host_port(&v), None); + } + + #[test] + fn test_collect_compose_host_port_services() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + r#" +services: + web: + image: nginx + ports: + - "8080:80" + api: + image: myapp + ports: + - "127.0.0.1:9000:9000" +"# + ) + .unwrap(); + let pairs = collect_compose_host_port_services(tmp.path()); + let ports: Vec<&str> = pairs.iter().map(|(p, _)| p.as_str()).collect(); + assert!(ports.contains(&"8080"), "expected 8080"); + assert!(ports.contains(&"9000"), "expected 9000"); + } + + #[test] + fn test_extract_port_from_docker_ps_entry_standard() { + assert_eq!( + extract_port_from_docker_ps_entry("0.0.0.0:3000->3000/tcp"), + Some("3000".to_string()) + ); + } + + #[test] + fn test_extract_port_from_docker_ps_entry_ipv6() { + assert_eq!( + extract_port_from_docker_ps_entry(":::8080->8080/tcp"), + Some("8080".to_string()) + ); + } + + #[test] + fn test_extract_port_from_docker_ps_entry_container_only() { + assert_eq!(extract_port_from_docker_ps_entry("3000/tcp"), None); + } + + #[test] + fn test_check_local_host_port_conflicts_free_port() { + use std::io::Write; + // Pick an ephemeral port that should be free + let listener = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); + let free_port = listener.local_addr().unwrap().port(); + drop(listener); // release it + + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + "services:\n web:\n image: nginx\n ports:\n - \"{}:80\"\n", + free_port + ) + .unwrap(); + + let executor = MockExecutor::success(); + let conflicts = check_local_host_port_conflicts(tmp.path(), &executor); + assert!( + conflicts.is_empty(), + "expected no conflicts for free port {}: {:?}", + free_port, + conflicts + ); + } + + #[test] + fn test_check_local_host_port_conflicts_own_container_excluded() { + use std::io::Write; + + // Occupy a port to simulate an existing container + let listener = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + // Keep listener alive — port IS occupied + + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + "services:\n web:\n image: nginx\n ports:\n - \"{}:80\"\n", + port + ) + .unwrap(); + + // Simulate `docker compose ps` reporting the same port as owned by us + let ps_output = format!("0.0.0.0:{}->80/tcp", port); + let executor = MockExecutor::success_with_stdout(&ps_output); + + let conflicts = check_local_host_port_conflicts(tmp.path(), &executor); + drop(listener); + assert!( + conflicts.is_empty(), + "port owned by our own compose project should not be flagged: {:?}", + conflicts + ); + } + + #[test] + fn test_check_local_host_port_conflicts_external_conflict() { + use std::io::Write; + + // Occupy a port to simulate an external process + let listener = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + // Keep listener alive — port IS occupied by external + + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!( + tmp, + "services:\n web:\n image: nginx\n ports:\n - \"{}:80\"\n", + port + ) + .unwrap(); + + // Simulate `docker compose ps` returning empty (no own containers on this port) + let executor = MockExecutor::success_with_stdout(""); + + let conflicts = check_local_host_port_conflicts(tmp.path(), &executor); + drop(listener); + assert!( + !conflicts.is_empty(), + "external port conflict should be reported" + ); + assert!( + conflicts[0].contains(&port.to_string()), + "conflict message should mention the port" + ); + } } diff --git a/src/connectors/hetzner.rs b/src/connectors/hetzner.rs index 863d387b..bfe97a23 100644 --- a/src/connectors/hetzner.rs +++ b/src/connectors/hetzner.rs @@ -32,6 +32,12 @@ pub trait HetznerCloudConnector: Send + Sync { target: HetznerSnapshotTarget, description: &str, ) -> Result; + + async fn list_server_types( + &self, + token: &str, + location: Option<&str>, + ) -> Result, ConnectorError>; } #[derive(Clone)] @@ -141,6 +147,37 @@ impl HetznerCloudConnector for HetznerCloudClient { image_id, }) } + + async fn list_server_types( + &self, + token: &str, + location: Option<&str>, + ) -> Result, ConnectorError> { + let url = match location { + Some(loc) => format!("{}/server_types?location={}", self.base_url, loc), + None => format!("{}/server_types", self.base_url), + }; + + let response = self + .http_client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(ConnectorError::from)?; + + let status = response.status(); + if !status.is_success() { + return Err(status_to_error(status, "Hetzner server types lookup failed")); + } + + let body: HetznerServerTypesResponse = response + .json() + .await + .map_err(|err| ConnectorError::InvalidResponse(err.to_string()))?; + + Ok(body.server_types.into_iter().map(|t| t.name).collect()) + } } fn status_to_error(status: reqwest::StatusCode, message: &str) -> ConnectorError { @@ -225,6 +262,17 @@ struct HetznerActionResource { resource_type: String, } +#[derive(Debug, Deserialize)] +struct HetznerServerTypesResponse { + #[serde(default)] + server_types: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerType { + name: String, +} + #[cfg(test)] mod tests { use super::*; @@ -308,4 +356,44 @@ mod tests { assert_eq!(snapshot.action_id, 778); assert_eq!(snapshot.image_id, None); } + + #[tokio::test] + async fn list_server_types_returns_names() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/server_types")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "server_types": [ + {"id": 1, "name": "cx22"}, + {"id": 2, "name": "cx32"}, + {"id": 3, "name": "cx42"} + ] + }))) + .mount(&api) + .await; + + let client = HetznerCloudClient::new(api.uri()).unwrap(); + let types = client + .list_server_types("test-token", None) + .await + .unwrap(); + + assert_eq!(types, vec!["cx22", "cx32", "cx42"]); + } + + #[tokio::test] + async fn list_server_types_returns_unauthorized_on_401() { + let api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/server_types")) + .respond_with(ResponseTemplate::new(401)) + .mount(&api) + .await; + + let client = HetznerCloudClient::new(api.uri()).unwrap(); + let result = client.list_server_types("bad-token", None).await; + + assert!(matches!(result, Err(ConnectorError::Unauthorized(_)))); + } } diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index adc09a51..ac549894 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -233,6 +233,17 @@ struct HetznerIpv4 { ip: String, } +#[derive(Debug, Deserialize)] +struct HetznerServerTypesResponse { + #[serde(default)] + server_types: Vec, +} + +#[derive(Debug, Deserialize)] +struct HetznerServerTypeEntry { + name: String, +} + fn hetzner_api_base_url() -> String { std::env::var("STACKER_HETZNER_API_URL") .unwrap_or_else(|_| "https://api.hetzner.cloud/v1".to_string()) @@ -397,6 +408,92 @@ async fn validate_reused_cloud_server( Ok(()) } +async fn validate_hetzner_server_type( + cloud: &models::Cloud, + server_type: &str, + region: Option<&str>, +) -> Result<(), String> { + let server_type = server_type.trim(); + if server_type.is_empty() || !is_hetzner_provider(&cloud.provider) { + return Ok(()); + } + + let cloud = reveal_cloud_credentials(cloud); + let token = match cloud + .cloud_token + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + { + Some(t) => t.to_string(), + None => return Ok(()), + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .map_err(|err| format!("Could not initialize Hetzner API client: {}", err))?; + + let url = match region { + Some(loc) => format!("{}/server_types?location={}", hetzner_api_base_url(), loc), + None => format!("{}/server_types", hetzner_api_base_url()), + }; + + let response = match client.get(&url).bearer_auth(&token).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!( + "Could not reach Hetzner API to validate server type '{}': {}; proceeding", + server_type, + err + ); + return Ok(()); + } + }; + + if !response.status().is_success() { + tracing::warn!( + "Hetzner server_types API returned HTTP {}; skipping server type validation", + response.status().as_u16() + ); + return Ok(()); + } + + let body = match response.json::().await { + Ok(b) => b, + Err(err) => { + tracing::warn!( + "Invalid Hetzner server types response: {}; skipping validation", + err + ); + return Ok(()); + } + }; + + let available: Vec<&str> = body + .server_types + .iter() + .map(|t| t.name.as_str()) + .collect(); + + if !available + .iter() + .any(|name| name.eq_ignore_ascii_case(server_type)) + { + return Err(format!( + "Server type '{}' is not available in Hetzner. Available types: {}", + server_type, + if available.is_empty() { + "none found".to_string() + } else { + available.join(", ") + } + )); + } + + Ok(()) +} + async fn validate_template_server_capacity_requirements( template: &models::StackTemplate, requirements: &models::InfrastructureRequirements, @@ -1455,6 +1552,14 @@ pub async fn item( .await .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + validate_hetzner_server_type( + &cloud_creds, + server.server.as_deref().unwrap_or(""), + server.region.as_deref(), + ) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + ensure_default_status_panel_npm_credentials( user.as_ref(), &form, @@ -1702,6 +1807,14 @@ pub async fn saved_item( .await .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + validate_hetzner_server_type( + &cloud, + server.server.as_deref().unwrap_or(""), + server.region.as_deref(), + ) + .await + .map_err(|msg| JsonResponse::::build().bad_request(msg))?; + ensure_default_status_panel_npm_credentials( user.as_ref(), &form, From 4217cf39ea7741a450c04f83f09af19e45664af6 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 5 Jun 2026 11:25:51 +0300 Subject: [PATCH 04/72] site update, compose volumes fix --- README.md | 5 +- src/bin/stacker.rs | 96 ++++++++++++++++++++++ src/cli/compose_service_sync.rs | 9 +-- src/cli/generator/compose.rs | 32 ++++++-- src/cli/install_runner.rs | 9 ++- src/connectors/hetzner.rs | 10 +-- src/console/commands/cli/agent.rs | 33 ++++++-- src/console/commands/cli/deploy.rs | 40 +++++---- src/console/commands/cli/logs.rs | 12 +-- src/routes/project/deploy.rs | 6 +- website/assets/stacker-logomark.png | Bin 0 -> 16159 bytes website/dist/assets/stacker-logomark.png | Bin 0 -> 16159 bytes website/dist/index.html | 98 ++++++++++++++--------- website/dist/main.js | 12 ++- website/dist/main.js.map | 2 +- website/dist/styles.css | 5 ++ website/src/index.html | 98 ++++++++++++++--------- website/src/main.ts | 10 ++- website/src/styles.css | 5 ++ website/tsconfig.json | 6 +- 20 files changed, 349 insertions(+), 139 deletions(-) create mode 100644 website/assets/stacker-logomark.png create mode 100644 website/dist/assets/stacker-logomark.png diff --git a/README.md b/README.md index 3ff2d87c..cf59d511 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,10 @@ The end-user tool. No server required for local deploys. | `stacker status` | Show running containers and health | | `stacker logs` | View container logs (`--follow`, `--service`, `--tail`) | | `stacker secrets` | Manage local `.env` secrets or remote Vault-backed `service` / `server` secrets | -| `stacker list deployments` | List deployments on the Stacker server | +| `stacker list deployments` / `stacker deployments` | List deployments on the Stacker server | +| `stacker list servers` / `stacker servers` | List saved servers | +| `stacker list clouds` / `stacker clouds` | List saved cloud credentials | +| `stacker list ssh-keys` / `stacker ssh-keys` | List per-server SSH key status | | `stacker destroy` | Tear down the deployed stack | | `stacker config validate` | Validate `stacker.yml` syntax | | `stacker config show` | Show resolved configuration | diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index f404d882..22c92e46 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -258,6 +258,43 @@ enum StackerCommands { #[command(subcommand)] command: ListCommands, }, + /// List all projects (alias for `stacker list projects`) + Projects { + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List deployments (alias for `stacker list deployments`) + Deployments { + /// Filter by project ID + #[arg(long)] + project: Option, + /// Limit number of results + #[arg(long)] + limit: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List all servers (alias for `stacker list servers`) + Servers { + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List SSH keys (alias for `stacker list ssh-keys`) + #[command(name = "ssh-keys")] + SshKeys { + /// Output in JSON format + #[arg(long)] + json: bool, + }, + /// List saved cloud credentials (alias for `stacker list clouds`) + Clouds { + /// Output in JSON format + #[arg(long)] + json: bool, + }, /// SSH key management (generate, show, upload, repair) #[command(long_about = "Manage Stacker server SSH keys.\n\n\ Cloud deploys automatically create a local backup SSH key under the Stacker config directory and authorize it on the deployed server when possible. The `generate` command manages the server-side Vault key; `inject` repairs a server by using an already-working local private key to add the Vault public key.")] @@ -1935,6 +1972,27 @@ fn get_command( Box::new(stacker::console::commands::cli::list::ListCloudsCommand::new(json)) } }, + StackerCommands::Projects { json } => { + Box::new(stacker::console::commands::cli::list::ListProjectsCommand::new(json)) + } + StackerCommands::Deployments { + json, + project, + limit, + } => Box::new( + stacker::console::commands::cli::list::ListDeploymentsCommand::new( + json, project, limit, + ), + ), + StackerCommands::Servers { json } => { + Box::new(stacker::console::commands::cli::list::ListServersCommand::new(json)) + } + StackerCommands::SshKeys { json } => { + Box::new(stacker::console::commands::cli::list::ListSshKeysCommand::new(json)) + } + StackerCommands::Clouds { json } => { + Box::new(stacker::console::commands::cli::list::ListCloudsCommand::new(json)) + } StackerCommands::SshKey { command: ssh_cmd } => match ssh_cmd { SshKeyCommands::Generate { server_id, save_to } => Box::new( stacker::console::commands::cli::ssh_key::SshKeyGenerateCommand::new( @@ -2929,6 +2987,44 @@ mod tests { assert!(sync.is_ok(), "secrets apps sync should parse successfully"); } + #[test] + fn test_top_level_servers_alias_parses() { + let parsed = Cli::try_parse_from(["stacker", "servers", "--json"]); + + assert!( + parsed.is_ok(), + "top-level servers alias should parse successfully" + ); + } + + #[test] + fn test_top_level_deployments_alias_parses() { + let parsed = Cli::try_parse_from([ + "stacker", + "deployments", + "--project", + "42", + "--limit", + "10", + "--json", + ]); + + assert!( + parsed.is_ok(), + "top-level deployments alias should parse successfully" + ); + } + + #[test] + fn test_top_level_ssh_keys_alias_parses() { + let parsed = Cli::try_parse_from(["stacker", "ssh-keys", "--json"]); + + assert!( + parsed.is_ok(), + "top-level ssh-keys alias should parse successfully" + ); + } + #[test] fn test_secrets_help_mentions_remote_modes() { let mut command = Cli::command(); diff --git a/src/cli/compose_service_sync.rs b/src/cli/compose_service_sync.rs index 0daaa2d4..ed5ac006 100644 --- a/src/cli/compose_service_sync.rs +++ b/src/cli/compose_service_sync.rs @@ -69,10 +69,7 @@ fn inject_external_network( } } None => { - svc.insert( - networks_key, - serde_yaml::Value::Sequence(vec![network_val]), - ); + svc.insert(networks_key, serde_yaml::Value::Sequence(vec![network_val])); changed = true; } _ => {} @@ -387,7 +384,9 @@ fn push_unique_network(networks: &mut Vec, name: &str) { #[cfg(test)] mod tests { use super::*; - use crate::cli::config_parser::{AppSource, DeployConfig, DomainConfig, ProjectConfig, SslMode}; + use crate::cli::config_parser::{ + AppSource, DeployConfig, DomainConfig, ProjectConfig, SslMode, + }; use std::collections::HashMap; use tempfile::TempDir; diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index c53a89a4..b5c96829 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -3,7 +3,9 @@ use std::convert::TryFrom; use std::fmt; use std::path::Path; -use crate::cli::config_parser::{AppType, DomainConfig, ProxyType, ServiceDefinition, StackerConfig}; +use crate::cli::config_parser::{ + AppType, DomainConfig, ProxyType, ServiceDefinition, StackerConfig, +}; use crate::cli::error::CliError; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -160,7 +162,9 @@ impl TryFrom<&StackerConfig> for ComposeDefinition { } } if injected { - compose.external_networks.push("default_network".to_string()); + compose + .external_networks + .push("default_network".to_string()); } } @@ -773,9 +777,18 @@ mod tests { ); let yaml = compose.render(); - assert!(yaml.contains("volumes:"), "top-level volumes: block must exist"); - assert!(yaml.contains(" rustfs_data:"), "rustfs_data entry must appear"); - assert!(yaml.contains(" rustfs_logs:"), "rustfs_logs entry must appear"); + assert!( + yaml.contains("volumes:"), + "top-level volumes: block must exist" + ); + assert!( + yaml.contains(" rustfs_data:"), + "rustfs_data entry must appear" + ); + assert!( + yaml.contains(" rustfs_logs:"), + "rustfs_logs entry must appear" + ); } #[test] @@ -910,11 +923,16 @@ mod tests { api_svc.networks ); assert!( - compose.external_networks.contains(&"default_network".to_string()), + compose + .external_networks + .contains(&"default_network".to_string()), "default_network should be declared as external" ); let yaml = compose.render(); - assert!(yaml.contains("external: true"), "rendered YAML should declare default_network external:\n{yaml}"); + assert!( + yaml.contains("external: true"), + "rendered YAML should declare default_network external:\n{yaml}" + ); } #[test] diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index 1fa368af..adc3b52b 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -278,7 +278,14 @@ fn get_own_compose_running_ports( let compose_str = compose_path.to_string_lossy(); let out = match executor.execute( "docker", - &["compose", "-f", &compose_str, "ps", "--format", "{{.Ports}}"], + &[ + "compose", + "-f", + &compose_str, + "ps", + "--format", + "{{.Ports}}", + ], ) { Ok(o) if o.success() => o, _ => return Default::default(), diff --git a/src/connectors/hetzner.rs b/src/connectors/hetzner.rs index bfe97a23..6941b6e3 100644 --- a/src/connectors/hetzner.rs +++ b/src/connectors/hetzner.rs @@ -168,7 +168,10 @@ impl HetznerCloudConnector for HetznerCloudClient { let status = response.status(); if !status.is_success() { - return Err(status_to_error(status, "Hetzner server types lookup failed")); + return Err(status_to_error( + status, + "Hetzner server types lookup failed", + )); } let body: HetznerServerTypesResponse = response @@ -374,10 +377,7 @@ mod tests { .await; let client = HetznerCloudClient::new(api.uri()).unwrap(); - let types = client - .list_server_types("test-token", None) - .await - .unwrap(); + let types = client.list_server_types("test-token", None).await.unwrap(); assert_eq!(types, vec!["cx22", "cx32", "cx42"]); } diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index 322e7111..78d69402 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -452,8 +452,14 @@ fn print_health_result(info: &AgentCommandInfo) { if let Some(containers) = result.get("containers").and_then(|v| v.as_array()) { println!("{:<28} {:<10} {}", "CONTAINER", "STATE", "STATUS"); for c in containers { - let name = c.get("container_name").and_then(|v| v.as_str()).unwrap_or("-"); - let state = c.get("container_state").and_then(|v| v.as_str()).unwrap_or("-"); + let name = c + .get("container_name") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + let state = c + .get("container_state") + .and_then(|v| v.as_str()) + .unwrap_or("-"); let status = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); println!( "{:<28} {} {:<8} {}", @@ -469,9 +475,15 @@ fn print_health_result(info: &AgentCommandInfo) { // Single-container health if result_type == "health" { - let state = result.get("container_state").and_then(|v| v.as_str()).unwrap_or("-"); + let state = result + .get("container_state") + .and_then(|v| v.as_str()) + .unwrap_or("-"); let status = result.get("status").and_then(|v| v.as_str()).unwrap_or("-"); - let app = result.get("app_code").and_then(|v| v.as_str()).unwrap_or("-"); + let app = result + .get("app_code") + .and_then(|v| v.as_str()) + .unwrap_or("-"); println!( "{}: {} {} ({})", app, @@ -508,7 +520,10 @@ fn print_all_container_health(containers: &[serde_json::Value]) { println!("Overall: {} {}", progress::status_icon(overall), overall); println!(); - println!("{:<28} {:<12} {:<8} {:<8} {}", "CONTAINER", "STATE", "CPU%", "MEM%", "IMAGE"); + println!( + "{:<28} {:<12} {:<8} {:<8} {}", + "CONTAINER", "STATE", "CPU%", "MEM%", "IMAGE" + ); for c in containers { let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("-"); let state = c.get("status").and_then(|v| v.as_str()).unwrap_or("-"); @@ -656,8 +671,7 @@ impl CallableTrait for AgentHealthCommand { // No specific app requested → list all containers with health metrics. // This avoids sending app_code="all" to older agents that don't handle it. if self.app_code.is_none() && !self.include_system { - let containers = fetch_live_containers(&ctx, &hash)? - .unwrap_or_default(); + let containers = fetch_live_containers(&ctx, &hash)?.unwrap_or_default(); if self.json { println!("{}", serde_json::to_string_pretty(&containers)?); } else { @@ -1764,7 +1778,10 @@ fn print_containers_summary(containers: &[serde_json::Value]) { return; } - println!("{:<24} {:<12} {:<22} {:<30}", "CONTAINER", "STATE", "PORTS", "IMAGE"); + println!( + "{:<24} {:<12} {:<22} {:<30}", + "CONTAINER", "STATE", "PORTS", "IMAGE" + ); for c in containers { let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("-"); let state = c diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index f2a69ecf..74091991 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -2276,7 +2276,12 @@ impl DeployCommand { // Find compose file: prefer deploy.compose_file from stacker.yml, else default. let compose_path = stacker_config .as_ref() - .and_then(|c| c.deploy.compose_file.as_deref().map(|f| project_dir.join(f))) + .and_then(|c| { + c.deploy + .compose_file + .as_deref() + .map(|f| project_dir.join(f)) + }) .unwrap_or_else(|| project_dir.join("docker-compose.yml")); if !compose_path.exists() { @@ -2314,20 +2319,19 @@ impl DeployCommand { .to_string(); // Auto-inject default_network when the service is an NginxProxyManager upstream. - let compose_content = - if let Some(ref cfg) = stacker_config { - if crate::cli::compose_service_sync::inject_npm_proxy_network( - &mut compose_doc, - service, - &cfg.proxy, - ) { - serde_yaml::to_string(&compose_doc)? - } else { - compose_content - } + let compose_content = if let Some(ref cfg) = stacker_config { + if crate::cli::compose_service_sync::inject_npm_proxy_network( + &mut compose_doc, + service, + &cfg.proxy, + ) { + serde_yaml::to_string(&compose_doc)? } else { compose_content - }; + } + } else { + compose_content + }; let ctx = crate::cli::runtime::CliRuntime::new("deploy")?; let hash = resolve_deployment_hash(&None, &ctx)?; @@ -2388,7 +2392,10 @@ impl DeployCommand { Ok::<_, CliError>(()) }); if let Err(e) = registered { - eprintln!("Warning: deployed successfully but app registration failed: {}", e); + eprintln!( + "Warning: deployed successfully but app registration failed: {}", + e + ); } } } @@ -5363,8 +5370,8 @@ monitoring: // These tests verify the compose-mutation step that deploy_single_service // performs before sending compose content to the agent. - use crate::cli::config_parser::{DomainConfig, ProxyConfig, ProxyType, SslMode}; use crate::cli::compose_service_sync::inject_npm_proxy_network; + use crate::cli::config_parser::{DomainConfig, ProxyConfig, ProxyType, SslMode}; fn npm_proxy_for(upstream: &str) -> ProxyConfig { ProxyConfig { @@ -5381,7 +5388,8 @@ monitoring: #[test] fn deploy_single_service_compose_injection_adds_default_network_for_proxied_service() { - let compose_yaml = "services:\n api:\n image: myapp:latest\n ports:\n - \"3000:3000\"\n"; + let compose_yaml = + "services:\n api:\n image: myapp:latest\n ports:\n - \"3000:3000\"\n"; let mut doc: serde_yaml::Value = serde_yaml::from_str(compose_yaml).unwrap(); let changed = inject_npm_proxy_network(&mut doc, "api", &npm_proxy_for("api:3000")); diff --git a/src/console/commands/cli/logs.rs b/src/console/commands/cli/logs.rs index b15c7acc..914a84f6 100644 --- a/src/console/commands/cli/logs.rs +++ b/src/console/commands/cli/logs.rs @@ -254,7 +254,12 @@ fn run_remote_logs( let request = AgentEnqueueRequest::new(&hash, "list_containers") .with_parameters(¶ms) .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))?; - let info = run_remote_agent_command(&ctx, &request, "Discovering containers", REMOTE_TIMEOUT_SECS)?; + let info = run_remote_agent_command( + &ctx, + &request, + "Discovering containers", + REMOTE_TIMEOUT_SECS, + )?; if info.status != "completed" { let (summary, tip) = no_containers_messages(&hash); eprintln!("{}", summary); @@ -406,10 +411,7 @@ fn print_logs_result(app_code: &str, info: &AgentCommandInfo, multi: bool) { println!("(no log output)"); } else { for line in lines { - let msg = line - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let msg = line.get("message").and_then(|v| v.as_str()).unwrap_or(""); println!("{}", msg); } } diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index ac549894..9fbd34d2 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -470,11 +470,7 @@ async fn validate_hetzner_server_type( } }; - let available: Vec<&str> = body - .server_types - .iter() - .map(|t| t.name.as_str()) - .collect(); + let available: Vec<&str> = body.server_types.iter().map(|t| t.name.as_str()).collect(); if !available .iter() diff --git a/website/assets/stacker-logomark.png b/website/assets/stacker-logomark.png new file mode 100644 index 0000000000000000000000000000000000000000..08eaea996d41ae5e00be585267a7d4a327fc3607 GIT binary patch literal 16159 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 literal 0 HcmV?d00001 diff --git a/website/dist/assets/stacker-logomark.png b/website/dist/assets/stacker-logomark.png new file mode 100644 index 0000000000000000000000000000000000000000..08eaea996d41ae5e00be585267a7d4a327fc3607 GIT binary patch literal 16159 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 literal 0 HcmV?d00001 diff --git a/website/dist/index.html b/website/dist/index.html index edd79692..e212392c 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -3,8 +3,8 @@ - Stacker v0.2.8 — AI Infrastructure CLI by TryDirect - + Stacker v0.2.9 — AI Infrastructure CLI by TryDirect + @@ -20,7 +20,7 @@