From 105f6f52c2458bba6d3d34b832d9c1238af229b4 Mon Sep 17 00:00:00 2001
From: vsilent <jabberroid@gmail.com>
Date: Fri, 6 Mar 2026 23:12:16 +0200
Subject: [PATCH 1/3] autogenerate server name

---
 src/cli/install_runner.rs          |  9 +++
 src/cli/stacker_client.rs          | 69 ++++++++++++++++++++++
 src/console/commands/cli/deploy.rs | 94 ++++++++++++++++++++++++++----
 3 files changed, 162 insertions(+), 10 deletions(-)

diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs
index bcbc99da..84b03d9e 100644
--- a/src/cli/install_runner.rs
+++ b/src/cli/install_runner.rs
@@ -636,6 +636,15 @@ impl DeployStrategy for CloudDeploy {
                                     "server_id".to_string(),
                                     serde_json::Value::Number(sid.into()),
                                 );
+                                // When reusing an existing server, preserve
+                                // the user-chosen / looked-up name rather
+                                // than the auto-generated one.
+                                if let Some(srv_name) = &server_name {
+                                    obj.insert(
+                                        "name".to_string(),
+                                        serde_json::Value::String(srv_name.clone()),
+                                    );
+                                }
                             }
                         }
                     }
diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs
index 55ba6eb0..44cb0c1a 100644
--- a/src/cli/stacker_client.rs
+++ b/src/cli/stacker_client.rs
@@ -1255,6 +1255,37 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value {
 
 /// Build the deploy form payload that matches the Stacker server's
 /// `forms::project::Deploy` structure.
+/// Generate a deterministic but unique server name from the project name.
+///
+/// Format: `{project}-{4hex}` where the hex suffix is derived from the current
+/// timestamp so each deploy gets a distinct name, e.g. `website-a3f1`.
+/// The name is sanitised to contain only lowercase alphanumeric chars and hyphens.
+fn generate_server_name(project_name: &str) -> String {
+    use std::time::{SystemTime, UNIX_EPOCH};
+
+    // Sanitise project name: lowercase, replace non-alnum with hyphen, collapse runs
+    let sanitised: String = project_name
+        .to_lowercase()
+        .chars()
+        .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
+        .collect::<String>()
+        .split('-')
+        .filter(|s| !s.is_empty())
+        .collect::<Vec<_>>()
+        .join("-");
+
+    let base = if sanitised.is_empty() { "server" } else { &sanitised };
+
+    // 4-char hex suffix from current timestamp (unique per ~65k deploys within any second)
+    let ts = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_millis();
+    let suffix = format!("{:04x}", (ts & 0xFFFF) as u16);
+
+    format!("{}-{}", base, suffix)
+}
+
 pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
     let cloud = config.deploy.cloud.as_ref();
     let provider = cloud
@@ -1267,6 +1298,11 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
         _ => "ubuntu-22.04",
     };
 
+    // Auto-generate a server name from the project name so every
+    // provisioned server gets a recognisable label in `stacker list servers`.
+    let project_name = config.project.identity.clone().unwrap_or_else(|| config.name.clone());
+    let server_name = generate_server_name(&project_name);
+
     let mut form = serde_json::json!({
         "cloud": {
             "provider": provider,
@@ -1276,6 +1312,7 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
             "region": region,
             "server": server_size,
             "os": os,
+            "name": server_name,
         },
         "stack": {
             "stack_code": config.project.identity.clone().unwrap_or_else(|| config.name.clone()),
@@ -1374,6 +1411,10 @@ mod tests {
         assert_eq!(form["server"]["region"], "fsn1");
         assert_eq!(form["server"]["server"], "cx11");
         assert_eq!(form["stack"]["stack_code"], "myproject");
+        // Auto-generated server name should start with the project name
+        let name = form["server"]["name"].as_str().unwrap();
+        assert!(name.starts_with("myproject-"), "server name should start with project name, got: {}", name);
+        assert_eq!(name.len(), "myproject-".len() + 4, "suffix should be 4 hex chars");
     }
 
     #[test]
@@ -1513,4 +1554,32 @@ mod tests {
         let features = body["custom"]["feature"].as_array().unwrap();
         assert!(features.is_empty(), "feature array should be empty when no proxy configured");
     }
+
+    #[test]
+    fn test_generate_server_name_basic() {
+        let name = generate_server_name("website");
+        assert!(name.starts_with("website-"), "got: {}", name);
+        // 4 hex chars suffix
+        let suffix = &name["website-".len()..];
+        assert_eq!(suffix.len(), 4);
+        assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "suffix should be hex, got: {}", suffix);
+    }
+
+    #[test]
+    fn test_generate_server_name_sanitises() {
+        let name = generate_server_name("My Cool App!");
+        assert!(name.starts_with("my-cool-app-"), "got: {}", name);
+    }
+
+    #[test]
+    fn test_generate_server_name_empty() {
+        let name = generate_server_name("");
+        assert!(name.starts_with("server-"), "empty input should fallback to 'server', got: {}", name);
+    }
+
+    #[test]
+    fn test_generate_server_name_special_chars() {
+        let name = generate_server_name("app___v2..beta");
+        assert!(name.starts_with("app-v2-beta-"), "consecutive separators collapsed, got: {}", name);
+    }
 }
diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs
index 1ddf0186..f2b31ff2 100644
--- a/src/console/commands/cli/deploy.rs
+++ b/src/console/commands/cli/deploy.rs
@@ -755,6 +755,8 @@ pub fn run_deploy(
     //     If the server is reachable, automatically switch to Server target.
     //     If not, show diagnostics and abort so the user can fix or remove the section.
     //     Skipped when --force-new is set (user explicitly wants a fresh cloud provision).
+    //     When a lockfile exists, auto-inject the server name so the API reuses the server.
+    let mut lock_server_name: Option<String> = None;
     if deploy_target == DeployTarget::Cloud && !force_new {
         if let Some(ref server_cfg) = config.deploy.server {
             eprintln!("  Found deploy.server section (host={}). Checking SSH connectivity...", server_cfg.host);
@@ -811,12 +813,16 @@ pub fn run_deploy(
             }
         } else if DeploymentLock::exists(project_dir) {
             // No deploy.server in config, but a lockfile exists from a prior deploy.
-            // Inform the user without auto-switching — they must opt in.
+            // Auto-inject the server name so the cloud deploy API reuses the same server.
             if let Ok(Some(lock)) = DeploymentLock::load(project_dir) {
-                if let Some(ref ip) = lock.server_ip {
+                if let Some(ref name) = lock.server_name {
+                    eprintln!("  ℹ Found previous deployment (server='{}') — reusing server", name);
+                    eprintln!("    To provision a new server instead: stacker deploy --force-new");
+                    lock_server_name = Some(name.clone());
+                } else if let Some(ref ip) = lock.server_ip {
                     if ip != "127.0.0.1" {
                         eprintln!("  ℹ Found previous deployment to {} (from .stacker/deployment.lock)", ip);
-                        eprintln!("    To redeploy to the same server, run: stacker config lock");
+                        eprintln!("    Server name unknown — cannot auto-reuse. Run: stacker config lock");
                         eprintln!("    To provision a new server instead:   stacker deploy --force-new");
                     }
                 }
@@ -924,7 +930,10 @@ pub fn run_deploy(
         project_name_override: remote_overrides.project_name.clone(),
         key_name_override: remote_overrides.key_name.clone(),
         key_id_override: remote_overrides.key_id,
-        server_name_override: remote_overrides.server_name.clone(),
+        server_name_override: remote_overrides
+            .server_name
+            .clone()
+            .or(lock_server_name),
     };
 
     let result = strategy.deploy(&config, &context, executor)?;
@@ -1046,6 +1055,23 @@ impl DeployCommand {
                 let mut l = DeploymentLock::from_result(result)
                     .with_project_name(self.project_name.clone());
 
+                // If no --project flag, try to get the project name from config
+                if l.project_name.is_none() {
+                    let config_path = match &self.file {
+                        Some(f) => project_dir.join(f),
+                        None => project_dir.join(DEFAULT_CONFIG_FILE),
+                    };
+                    if let Ok(config) = StackerConfig::from_file(&config_path) {
+                        // Prefer project.identity as the registered name, fall back to config name
+                        let name = config
+                            .project
+                            .identity
+                            .filter(|s| !s.is_empty())
+                            .unwrap_or(config.name);
+                        l = l.with_project_name(Some(name));
+                    }
+                }
+
                 // Try to fetch provisioned server details from the Stacker API
                 if let Some(project_id) = result.project_id {
                     match fetch_server_for_project(project_id as i32) {
@@ -1136,9 +1162,15 @@ impl DeployCommand {
 
 /// After a cloud deploy completes, look up the provisioned server's details
 /// (IP, SSH user, port, name) from the Stacker server API.
+///
+/// Retries up to 3 times with a 10-second delay between attempts, because the
+/// server IP may not yet be assigned right after the deployment status becomes
+/// "completed".
 fn fetch_server_for_project(
     project_id: i32,
 ) -> Result<Option<stacker_client::ServerInfo>, Box<dyn std::error::Error>> {
+    use std::time::Duration;
+
     let cred_manager = CredentialsManager::with_default_store();
     let creds = cred_manager.require_valid_token("server lookup")?;
 
@@ -1152,14 +1184,56 @@ fn fetch_server_for_project(
 
     rt.block_on(async {
         let client = StackerClient::new(&base_url, &creds.access_token);
-        let servers = client.list_servers().await?;
 
-        // Find the server linked to this project
-        let server = servers
-            .into_iter()
-            .find(|s| s.project_id == project_id && s.srv_ip.is_some());
+        let max_retries = 3;
+        let retry_delay = Duration::from_secs(10);
+
+        for attempt in 0..max_retries {
+            let servers = client.list_servers().await?;
+
+            // Find the server linked to this project
+            let server = servers
+                .into_iter()
+                .find(|s| s.project_id == project_id);
+
+            match server {
+                Some(ref s) if s.srv_ip.is_some() => {
+                    // Server found with IP — done
+                    return Ok(server);
+                }
+                Some(_) if attempt < max_retries - 1 => {
+                    // Server found but no IP yet — wait and retry
+                    eprintln!(
+                        "  Server found but IP not yet assigned (attempt {}/{}), retrying in {}s...",
+                        attempt + 1,
+                        max_retries,
+                        retry_delay.as_secs(),
+                    );
+                    tokio::time::sleep(retry_delay).await;
+                }
+                Some(s) => {
+                    // Final attempt — return server even without IP so we capture
+                    // name, cloud_id, etc.
+                    return Ok(Some(s));
+                }
+                None if attempt < max_retries - 1 => {
+                    // No server yet — wait and retry
+                    eprintln!(
+                        "  No server found for project {} (attempt {}/{}), retrying in {}s...",
+                        project_id,
+                        attempt + 1,
+                        max_retries,
+                        retry_delay.as_secs(),
+                    );
+                    tokio::time::sleep(retry_delay).await;
+                }
+                None => {
+                    return Ok(None);
+                }
+            }
+        }
 
-        Ok(server)
+        Ok(None)
     })
 }
 

From 40f55a2095f97050cbfd20c97cdb37eaf5a9c8e2 Mon Sep 17 00:00:00 2001
From: vsilent <jabberroid@gmail.com>
Date: Fri, 6 Mar 2026 23:16:39 +0200
Subject: [PATCH 2/3] naming rules

---
 src/cli/stacker_client.rs | 45 +++++++++++++++++++++++++++++++++++----
 1 file changed, 41 insertions(+), 4 deletions(-)

diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs
index 44cb0c1a..2693e732 100644
--- a/src/cli/stacker_client.rs
+++ b/src/cli/stacker_client.rs
@@ -1259,7 +1259,12 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value {
 ///
 /// Format: `{project}-{4hex}` where the hex suffix is derived from the current
 /// timestamp so each deploy gets a distinct name, e.g. `website-a3f1`.
-/// The name is sanitised to contain only lowercase alphanumeric chars and hyphens.
+///
+/// The name is sanitised to satisfy the strictest provider rules (Hetzner):
+///   - only lowercase `a-z`, `0-9`, `-`
+///   - must start with a letter
+///   - must not end with `-`
+///   - max 63 characters total
 fn generate_server_name(project_name: &str) -> String {
     use std::time::{SystemTime, UNIX_EPOCH};
 
@@ -1274,7 +1279,14 @@ fn generate_server_name(project_name: &str) -> String {
         .collect::<Vec<_>>()
         .join("-");
 
-    let base = if sanitised.is_empty() { "server" } else { &sanitised };
+    // Ensure it starts with a letter (Hetzner requirement)
+    let base = if sanitised.is_empty() {
+        "srv".to_string()
+    } else if !sanitised.starts_with(|c: char| c.is_ascii_lowercase()) {
+        format!("srv-{}", sanitised)
+    } else {
+        sanitised
+    };
 
     // 4-char hex suffix from current timestamp (unique per ~65k deploys within any second)
     let ts = SystemTime::now()
@@ -1283,7 +1295,15 @@ fn generate_server_name(project_name: &str) -> String {
         .as_millis();
     let suffix = format!("{:04x}", (ts & 0xFFFF) as u16);
 
-    format!("{}-{}", base, suffix)
+    // Truncate base so total stays within 63 chars: base + '-' + 4-char suffix = base ≤ 58
+    let max_base = 63 - 1 - suffix.len(); // 58
+    let truncated = if base.len() > max_base {
+        base[..max_base].trim_end_matches('-').to_string()
+    } else {
+        base
+    };
+
+    format!("{}-{}", truncated, suffix)
 }
 
 pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
@@ -1574,7 +1594,7 @@ mod tests {
     #[test]
     fn test_generate_server_name_empty() {
         let name = generate_server_name("");
-        assert!(name.starts_with("server-"), "empty input should fallback to 'server', got: {}", name);
+        assert!(name.starts_with("srv-"), "empty input should fallback to 'srv', got: {}", name);
     }
 
     #[test]
@@ -1582,4 +1602,21 @@ mod tests {
         let name = generate_server_name("app___v2..beta");
         assert!(name.starts_with("app-v2-beta-"), "consecutive separators collapsed, got: {}", name);
     }
+
+    #[test]
+    fn test_generate_server_name_numeric_start() {
+        // Hetzner requires name to start with a letter
+        let name = generate_server_name("123app");
+        assert!(name.starts_with("srv-123app-"), "numeric start should get 'srv-' prefix, got: {}", name);
+    }
+
+    #[test]
+    fn test_generate_server_name_max_length() {
+        let long = "a".repeat(100);
+        let name = generate_server_name(&long);
+        assert!(name.len() <= 63, "name must be ≤63 chars (Hetzner), got {} chars: {}", name.len(), name);
+        assert!(name.starts_with("aaa"), "got: {}", name);
+        // Must not end with hyphen
+        assert!(!name.ends_with('-'), "must not end with hyphen, got: {}", name);
+    }
 }

From c26c7d5767daad26339eb971ce9d0d96e6c6c7af Mon Sep 17 00:00:00 2001
From: vsilent <jabberroid@gmail.com>
Date: Fri, 6 Mar 2026 23:51:43 +0200
Subject: [PATCH 3/3] :ro/:rw volume mode suffixes were being embedded into the
 container path instead of being extracted as a separate read_only

---
 src/cli/stacker_client.rs       | 27 +++++++++++++++++++--------
 src/forms/project/volume.rs     | 22 +++++++++++++++++-----
 src/services/config_renderer.rs | 17 +++++++++++++++--
 3 files changed, 51 insertions(+), 15 deletions(-)

diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs
index 2693e732..ddb103eb 100644
--- a/src/cli/stacker_client.rs
+++ b/src/cli/stacker_client.rs
@@ -997,12 +997,21 @@ fn parse_port_mapping(port_str: &str) -> (String, String) {
 }
 
 /// Parse a volume mapping string like "./dist:/usr/share/nginx/html" or "data:/var/lib/db"
-/// into (host_path, container_path) tuple.
-fn parse_volume_mapping(vol_str: &str) -> (String, String) {
-    if let Some((host, container)) = vol_str.split_once(':') {
-        (host.to_string(), container.to_string())
-    } else {
-        (vol_str.to_string(), vol_str.to_string())
+/// into (host_path, container_path, read_only) tuple.
+/// Handles optional `:ro` / `:rw` suffix (e.g. "/var/run/docker.sock:/var/run/docker.sock:ro").
+fn parse_volume_mapping(vol_str: &str) -> (String, String, bool) {
+    let parts: Vec<&str> = vol_str.split(':').collect();
+    match parts.len() {
+        // "source:target:mode" (e.g. "/host:/container:ro")
+        3 => (
+            parts[0].to_string(),
+            parts[1].to_string(),
+            parts[2] == "ro",
+        ),
+        // "source:target"
+        2 => (parts[0].to_string(), parts[1].to_string(), false),
+        // bare path
+        _ => (vol_str.to_string(), vol_str.to_string(), false),
     }
 }
 
@@ -1028,10 +1037,11 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde
         .volumes
         .iter()
         .map(|v| {
-            let (host, container) = parse_volume_mapping(v);
+            let (host, container, read_only) = parse_volume_mapping(v);
             serde_json::json!({
                 "host_path": host,
                 "container_path": container,
+                "read_only": read_only,
             })
         })
         .collect();
@@ -1116,10 +1126,11 @@ fn app_source_to_app_json(
         .volumes
         .iter()
         .map(|v| {
-            let (host, container) = parse_volume_mapping(v);
+            let (host, container, read_only) = parse_volume_mapping(v);
             serde_json::json!({
                 "host_path": host,
                 "container_path": container,
+                "read_only": read_only,
             })
         })
         .collect();
diff --git a/src/forms/project/volume.rs b/src/forms/project/volume.rs
index a40cc221..cc684efe 100644
--- a/src/forms/project/volume.rs
+++ b/src/forms/project/volume.rs
@@ -27,11 +27,23 @@ impl TryInto<dctypes::AdvancedVolumes> for &Volume {
     type Error = String;
     fn try_into(self) -> Result<dctypes::AdvancedVolumes, Self::Error> {
         let source = self.host_path.clone();
-        let target = self.container_path.clone();
+        let raw_target = self.container_path.clone().unwrap_or_default();
+
+        // Strip `:ro` / `:rw` suffix from container_path and extract read_only flag.
+        // Data may arrive with the mode embedded (e.g. "/var/run/docker.sock:ro").
+        let (target, read_only) = if raw_target.ends_with(":ro") {
+            (raw_target.trim_end_matches(":ro").to_string(), true)
+        } else if raw_target.ends_with(":rw") {
+            (raw_target.trim_end_matches(":rw").to_string(), false)
+        } else {
+            (raw_target, false)
+        };
+
         tracing::debug!(
-            "Volume conversion result: source: {:?} target: {:?}",
+            "Volume conversion result: source: {:?} target: {:?} read_only: {}",
             source,
-            target
+            target,
+            read_only
         );
 
         let _type = if self.is_named_docker_volume() {
@@ -42,9 +54,9 @@ impl TryInto<dctypes::AdvancedVolumes> for &Volume {
 
         Ok(dctypes::AdvancedVolumes {
             source: source,
-            target: target.unwrap_or("".to_string()),
+            target: target,
             _type: _type.to_string(),
-            read_only: false,
+            read_only,
             bind: None,
             volume: None,
             tmpfs: None,
diff --git a/src/services/config_renderer.rs b/src/services/config_renderer.rs
index ace96616..0b024360 100644
--- a/src/services/config_renderer.rs
+++ b/src/services/config_renderer.rs
@@ -344,20 +344,33 @@ impl ConfigRenderer {
                 let mut result = Vec::new();
                 for item in arr {
                     if let Value::Object(map) = item {
+                        // Support both "source"/"target" and "host_path"/"container_path" keys
                         let source = map
                             .get("source")
+                            .or_else(|| map.get("host_path"))
                             .and_then(|v| v.as_str())
                             .unwrap_or("")
                             .to_string();
-                        let target = map
+                        let raw_target = map
                             .get("target")
+                            .or_else(|| map.get("container_path"))
                             .and_then(|v| v.as_str())
                             .unwrap_or("")
                             .to_string();
+
+                        // Strip `:ro` / `:rw` suffix that may be embedded in the target path
+                        let (target, suffix_ro) = if raw_target.ends_with(":ro") {
+                            (raw_target.trim_end_matches(":ro").to_string(), true)
+                        } else if raw_target.ends_with(":rw") {
+                            (raw_target.trim_end_matches(":rw").to_string(), false)
+                        } else {
+                            (raw_target, false)
+                        };
+
                         let read_only = map
                             .get("read_only")
                             .and_then(|v| v.as_bool())
-                            .unwrap_or(false);
+                            .unwrap_or(suffix_ro);
                         if !source.is_empty() && !target.is_empty() {
                             result.push(VolumeMount {
                                 source,
