@@ -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+
12581320pub 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}
0 commit comments