Skip to content

Commit a7cb0eb

Browse files
authored
Merge pull request #127 from trydirect/feature-redeploy-lock
Feature redeploy lock
2 parents d1d28f2 + c26c7d5 commit a7cb0eb

File tree

5 files changed

+250
-25
lines changed

5 files changed

+250
-25
lines changed

src/cli/install_runner.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,15 @@ impl DeployStrategy for CloudDeploy {
636636
"server_id".to_string(),
637637
serde_json::Value::Number(sid.into()),
638638
);
639+
// When reusing an existing server, preserve
640+
// the user-chosen / looked-up name rather
641+
// than the auto-generated one.
642+
if let Some(srv_name) = &server_name {
643+
obj.insert(
644+
"name".to_string(),
645+
serde_json::Value::String(srv_name.clone()),
646+
);
647+
}
639648
}
640649
}
641650
}

src/cli/stacker_client.rs

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -997,12 +997,21 @@ fn parse_port_mapping(port_str: &str) -> (String, String) {
997997
}
998998

999999
/// Parse a volume mapping string like "./dist:/usr/share/nginx/html" or "data:/var/lib/db"
1000-
/// into (host_path, container_path) tuple.
1001-
fn parse_volume_mapping(vol_str: &str) -> (String, String) {
1002-
if let Some((host, container)) = vol_str.split_once(':') {
1003-
(host.to_string(), container.to_string())
1004-
} else {
1005-
(vol_str.to_string(), vol_str.to_string())
1000+
/// into (host_path, container_path, read_only) tuple.
1001+
/// Handles optional `:ro` / `:rw` suffix (e.g. "/var/run/docker.sock:/var/run/docker.sock:ro").
1002+
fn parse_volume_mapping(vol_str: &str) -> (String, String, bool) {
1003+
let parts: Vec<&str> = vol_str.split(':').collect();
1004+
match parts.len() {
1005+
// "source:target:mode" (e.g. "/host:/container:ro")
1006+
3 => (
1007+
parts[0].to_string(),
1008+
parts[1].to_string(),
1009+
parts[2] == "ro",
1010+
),
1011+
// "source:target"
1012+
2 => (parts[0].to_string(), parts[1].to_string(), false),
1013+
// bare path
1014+
_ => (vol_str.to_string(), vol_str.to_string(), false),
10061015
}
10071016
}
10081017

@@ -1028,10 +1037,11 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde
10281037
.volumes
10291038
.iter()
10301039
.map(|v| {
1031-
let (host, container) = parse_volume_mapping(v);
1040+
let (host, container, read_only) = parse_volume_mapping(v);
10321041
serde_json::json!({
10331042
"host_path": host,
10341043
"container_path": container,
1044+
"read_only": read_only,
10351045
})
10361046
})
10371047
.collect();
@@ -1116,10 +1126,11 @@ fn app_source_to_app_json(
11161126
.volumes
11171127
.iter()
11181128
.map(|v| {
1119-
let (host, container) = parse_volume_mapping(v);
1129+
let (host, container, read_only) = parse_volume_mapping(v);
11201130
serde_json::json!({
11211131
"host_path": host,
11221132
"container_path": container,
1133+
"read_only": read_only,
11231134
})
11241135
})
11251136
.collect();
@@ -1255,6 +1266,57 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value {
12551266

12561267
/// Build the deploy form payload that matches the Stacker server's
12571268
/// `forms::project::Deploy` structure.
1269+
/// Generate a deterministic but unique server name from the project name.
1270+
///
1271+
/// Format: `{project}-{4hex}` where the hex suffix is derived from the current
1272+
/// timestamp so each deploy gets a distinct name, e.g. `website-a3f1`.
1273+
///
1274+
/// The name is sanitised to satisfy the strictest provider rules (Hetzner):
1275+
/// - only lowercase `a-z`, `0-9`, `-`
1276+
/// - must start with a letter
1277+
/// - must not end with `-`
1278+
/// - max 63 characters total
1279+
fn generate_server_name(project_name: &str) -> String {
1280+
use std::time::{SystemTime, UNIX_EPOCH};
1281+
1282+
// Sanitise project name: lowercase, replace non-alnum with hyphen, collapse runs
1283+
let sanitised: String = project_name
1284+
.to_lowercase()
1285+
.chars()
1286+
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
1287+
.collect::<String>()
1288+
.split('-')
1289+
.filter(|s| !s.is_empty())
1290+
.collect::<Vec<_>>()
1291+
.join("-");
1292+
1293+
// Ensure it starts with a letter (Hetzner requirement)
1294+
let base = if sanitised.is_empty() {
1295+
"srv".to_string()
1296+
} else if !sanitised.starts_with(|c: char| c.is_ascii_lowercase()) {
1297+
format!("srv-{}", sanitised)
1298+
} else {
1299+
sanitised
1300+
};
1301+
1302+
// 4-char hex suffix from current timestamp (unique per ~65k deploys within any second)
1303+
let ts = SystemTime::now()
1304+
.duration_since(UNIX_EPOCH)
1305+
.unwrap_or_default()
1306+
.as_millis();
1307+
let suffix = format!("{:04x}", (ts & 0xFFFF) as u16);
1308+
1309+
// Truncate base so total stays within 63 chars: base + '-' + 4-char suffix = base ≤ 58
1310+
let max_base = 63 - 1 - suffix.len(); // 58
1311+
let truncated = if base.len() > max_base {
1312+
base[..max_base].trim_end_matches('-').to_string()
1313+
} else {
1314+
base
1315+
};
1316+
1317+
format!("{}-{}", truncated, suffix)
1318+
}
1319+
12581320
pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
12591321
let cloud = config.deploy.cloud.as_ref();
12601322
let provider = cloud
@@ -1267,6 +1329,11 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
12671329
_ => "ubuntu-22.04",
12681330
};
12691331

1332+
// Auto-generate a server name from the project name so every
1333+
// provisioned server gets a recognisable label in `stacker list servers`.
1334+
let project_name = config.project.identity.clone().unwrap_or_else(|| config.name.clone());
1335+
let server_name = generate_server_name(&project_name);
1336+
12701337
let mut form = serde_json::json!({
12711338
"cloud": {
12721339
"provider": provider,
@@ -1276,6 +1343,7 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value {
12761343
"region": region,
12771344
"server": server_size,
12781345
"os": os,
1346+
"name": server_name,
12791347
},
12801348
"stack": {
12811349
"stack_code": config.project.identity.clone().unwrap_or_else(|| config.name.clone()),
@@ -1374,6 +1442,10 @@ mod tests {
13741442
assert_eq!(form["server"]["region"], "fsn1");
13751443
assert_eq!(form["server"]["server"], "cpx11");
13761444
assert_eq!(form["stack"]["stack_code"], "myproject");
1445+
// Auto-generated server name should start with the project name
1446+
let name = form["server"]["name"].as_str().unwrap();
1447+
assert!(name.starts_with("myproject-"), "server name should start with project name, got: {}", name);
1448+
assert_eq!(name.len(), "myproject-".len() + 4, "suffix should be 4 hex chars");
13771449
}
13781450

13791451
#[test]
@@ -1513,4 +1585,49 @@ mod tests {
15131585
let features = body["custom"]["feature"].as_array().unwrap();
15141586
assert!(features.is_empty(), "feature array should be empty when no proxy configured");
15151587
}
1588+
1589+
#[test]
1590+
fn test_generate_server_name_basic() {
1591+
let name = generate_server_name("website");
1592+
assert!(name.starts_with("website-"), "got: {}", name);
1593+
// 4 hex chars suffix
1594+
let suffix = &name["website-".len()..];
1595+
assert_eq!(suffix.len(), 4);
1596+
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "suffix should be hex, got: {}", suffix);
1597+
}
1598+
1599+
#[test]
1600+
fn test_generate_server_name_sanitises() {
1601+
let name = generate_server_name("My Cool App!");
1602+
assert!(name.starts_with("my-cool-app-"), "got: {}", name);
1603+
}
1604+
1605+
#[test]
1606+
fn test_generate_server_name_empty() {
1607+
let name = generate_server_name("");
1608+
assert!(name.starts_with("srv-"), "empty input should fallback to 'srv', got: {}", name);
1609+
}
1610+
1611+
#[test]
1612+
fn test_generate_server_name_special_chars() {
1613+
let name = generate_server_name("app___v2..beta");
1614+
assert!(name.starts_with("app-v2-beta-"), "consecutive separators collapsed, got: {}", name);
1615+
}
1616+
1617+
#[test]
1618+
fn test_generate_server_name_numeric_start() {
1619+
// Hetzner requires name to start with a letter
1620+
let name = generate_server_name("123app");
1621+
assert!(name.starts_with("srv-123app-"), "numeric start should get 'srv-' prefix, got: {}", name);
1622+
}
1623+
1624+
#[test]
1625+
fn test_generate_server_name_max_length() {
1626+
let long = "a".repeat(100);
1627+
let name = generate_server_name(&long);
1628+
assert!(name.len() <= 63, "name must be ≤63 chars (Hetzner), got {} chars: {}", name.len(), name);
1629+
assert!(name.starts_with("aaa"), "got: {}", name);
1630+
// Must not end with hyphen
1631+
assert!(!name.ends_with('-'), "must not end with hyphen, got: {}", name);
1632+
}
15161633
}

src/console/commands/cli/deploy.rs

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,8 @@ pub fn run_deploy(
755755
// If the server is reachable, automatically switch to Server target.
756756
// If not, show diagnostics and abort so the user can fix or remove the section.
757757
// Skipped when --force-new is set (user explicitly wants a fresh cloud provision).
758+
// When a lockfile exists, auto-inject the server name so the API reuses the server.
759+
let mut lock_server_name: Option<String> = None;
758760
if deploy_target == DeployTarget::Cloud && !force_new {
759761
if let Some(ref server_cfg) = config.deploy.server {
760762
eprintln!(" Found deploy.server section (host={}). Checking SSH connectivity...", server_cfg.host);
@@ -811,12 +813,16 @@ pub fn run_deploy(
811813
}
812814
} else if DeploymentLock::exists(project_dir) {
813815
// No deploy.server in config, but a lockfile exists from a prior deploy.
814-
// Inform the user without auto-switching — they must opt in.
816+
// Auto-inject the server name so the cloud deploy API reuses the same server.
815817
if let Ok(Some(lock)) = DeploymentLock::load(project_dir) {
816-
if let Some(ref ip) = lock.server_ip {
818+
if let Some(ref name) = lock.server_name {
819+
eprintln!(" ℹ Found previous deployment (server='{}') — reusing server", name);
820+
eprintln!(" To provision a new server instead: stacker deploy --force-new");
821+
lock_server_name = Some(name.clone());
822+
} else if let Some(ref ip) = lock.server_ip {
817823
if ip != "127.0.0.1" {
818824
eprintln!(" ℹ Found previous deployment to {} (from .stacker/deployment.lock)", ip);
819-
eprintln!(" To redeploy to the same server, run: stacker config lock");
825+
eprintln!(" Server name unknown — cannot auto-reuse. Run: stacker config lock");
820826
eprintln!(" To provision a new server instead: stacker deploy --force-new");
821827
}
822828
}
@@ -924,7 +930,10 @@ pub fn run_deploy(
924930
project_name_override: remote_overrides.project_name.clone(),
925931
key_name_override: remote_overrides.key_name.clone(),
926932
key_id_override: remote_overrides.key_id,
927-
server_name_override: remote_overrides.server_name.clone(),
933+
server_name_override: remote_overrides
934+
.server_name
935+
.clone()
936+
.or(lock_server_name),
928937
};
929938

930939
let result = strategy.deploy(&config, &context, executor)?;
@@ -1046,6 +1055,23 @@ impl DeployCommand {
10461055
let mut l = DeploymentLock::from_result(result)
10471056
.with_project_name(self.project_name.clone());
10481057

1058+
// If no --project flag, try to get the project name from config
1059+
if l.project_name.is_none() {
1060+
let config_path = match &self.file {
1061+
Some(f) => project_dir.join(f),
1062+
None => project_dir.join(DEFAULT_CONFIG_FILE),
1063+
};
1064+
if let Ok(config) = StackerConfig::from_file(&config_path) {
1065+
// Prefer project.identity as the registered name, fall back to config name
1066+
let name = config
1067+
.project
1068+
.identity
1069+
.filter(|s| !s.is_empty())
1070+
.unwrap_or(config.name);
1071+
l = l.with_project_name(Some(name));
1072+
}
1073+
}
1074+
10491075
// Try to fetch provisioned server details from the Stacker API
10501076
if let Some(project_id) = result.project_id {
10511077
match fetch_server_for_project(project_id as i32) {
@@ -1136,9 +1162,15 @@ impl DeployCommand {
11361162

11371163
/// After a cloud deploy completes, look up the provisioned server's details
11381164
/// (IP, SSH user, port, name) from the Stacker server API.
1165+
///
1166+
/// Retries up to 3 times with a 10-second delay between attempts, because the
1167+
/// server IP may not yet be assigned right after the deployment status becomes
1168+
/// "completed".
11391169
fn fetch_server_for_project(
11401170
project_id: i32,
11411171
) -> Result<Option<stacker_client::ServerInfo>, Box<dyn std::error::Error>> {
1172+
use std::time::Duration;
1173+
11421174
let cred_manager = CredentialsManager::with_default_store();
11431175
let creds = cred_manager.require_valid_token("server lookup")?;
11441176

@@ -1152,14 +1184,56 @@ fn fetch_server_for_project(
11521184

11531185
rt.block_on(async {
11541186
let client = StackerClient::new(&base_url, &creds.access_token);
1155-
let servers = client.list_servers().await?;
11561187

1157-
// Find the server linked to this project
1158-
let server = servers
1159-
.into_iter()
1160-
.find(|s| s.project_id == project_id && s.srv_ip.is_some());
1188+
let max_retries = 3;
1189+
let retry_delay = Duration::from_secs(10);
1190+
1191+
for attempt in 0..max_retries {
1192+
let servers = client.list_servers().await?;
1193+
1194+
// Find the server linked to this project
1195+
let server = servers
1196+
.into_iter()
1197+
.find(|s| s.project_id == project_id);
1198+
1199+
match server {
1200+
Some(ref s) if s.srv_ip.is_some() => {
1201+
// Server found with IP — done
1202+
return Ok(server);
1203+
}
1204+
Some(_) if attempt < max_retries - 1 => {
1205+
// Server found but no IP yet — wait and retry
1206+
eprintln!(
1207+
" Server found but IP not yet assigned (attempt {}/{}), retrying in {}s...",
1208+
attempt + 1,
1209+
max_retries,
1210+
retry_delay.as_secs(),
1211+
);
1212+
tokio::time::sleep(retry_delay).await;
1213+
}
1214+
Some(s) => {
1215+
// Final attempt — return server even without IP so we capture
1216+
// name, cloud_id, etc.
1217+
return Ok(Some(s));
1218+
}
1219+
None if attempt < max_retries - 1 => {
1220+
// No server yet — wait and retry
1221+
eprintln!(
1222+
" No server found for project {} (attempt {}/{}), retrying in {}s...",
1223+
project_id,
1224+
attempt + 1,
1225+
max_retries,
1226+
retry_delay.as_secs(),
1227+
);
1228+
tokio::time::sleep(retry_delay).await;
1229+
}
1230+
None => {
1231+
return Ok(None);
1232+
}
1233+
}
1234+
}
11611235

1162-
Ok(server)
1236+
Ok(None)
11631237
})
11641238
}
11651239

src/forms/project/volume.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,23 @@ impl TryInto<dctypes::AdvancedVolumes> for &Volume {
2727
type Error = String;
2828
fn try_into(self) -> Result<dctypes::AdvancedVolumes, Self::Error> {
2929
let source = self.host_path.clone();
30-
let target = self.container_path.clone();
30+
let raw_target = self.container_path.clone().unwrap_or_default();
31+
32+
// Strip `:ro` / `:rw` suffix from container_path and extract read_only flag.
33+
// Data may arrive with the mode embedded (e.g. "/var/run/docker.sock:ro").
34+
let (target, read_only) = if raw_target.ends_with(":ro") {
35+
(raw_target.trim_end_matches(":ro").to_string(), true)
36+
} else if raw_target.ends_with(":rw") {
37+
(raw_target.trim_end_matches(":rw").to_string(), false)
38+
} else {
39+
(raw_target, false)
40+
};
41+
3142
tracing::debug!(
32-
"Volume conversion result: source: {:?} target: {:?}",
43+
"Volume conversion result: source: {:?} target: {:?} read_only: {}",
3344
source,
34-
target
45+
target,
46+
read_only
3547
);
3648

3749
let _type = if self.is_named_docker_volume() {
@@ -42,9 +54,9 @@ impl TryInto<dctypes::AdvancedVolumes> for &Volume {
4254

4355
Ok(dctypes::AdvancedVolumes {
4456
source: source,
45-
target: target.unwrap_or("".to_string()),
57+
target: target,
4658
_type: _type.to_string(),
47-
read_only: false,
59+
read_only,
4860
bind: None,
4961
volume: None,
5062
tmpfs: None,

0 commit comments

Comments
 (0)