@@ -1259,7 +1259,12 @@ pub fn build_project_body(config: &StackerConfig) -> serde_json::Value {
12591259///
12601260/// Format: `{project}-{4hex}` where the hex suffix is derived from the current
12611261/// timestamp so each deploy gets a distinct name, e.g. `website-a3f1`.
1262- /// The name is sanitised to contain only lowercase alphanumeric chars and hyphens.
1262+ ///
1263+ /// The name is sanitised to satisfy the strictest provider rules (Hetzner):
1264+ /// - only lowercase `a-z`, `0-9`, `-`
1265+ /// - must start with a letter
1266+ /// - must not end with `-`
1267+ /// - max 63 characters total
12631268fn generate_server_name ( project_name : & str ) -> String {
12641269 use std:: time:: { SystemTime , UNIX_EPOCH } ;
12651270
@@ -1274,7 +1279,14 @@ fn generate_server_name(project_name: &str) -> String {
12741279 . collect :: < Vec < _ > > ( )
12751280 . join ( "-" ) ;
12761281
1277- let base = if sanitised. is_empty ( ) { "server" } else { & sanitised } ;
1282+ // Ensure it starts with a letter (Hetzner requirement)
1283+ let base = if sanitised. is_empty ( ) {
1284+ "srv" . to_string ( )
1285+ } else if !sanitised. starts_with ( |c : char | c. is_ascii_lowercase ( ) ) {
1286+ format ! ( "srv-{}" , sanitised)
1287+ } else {
1288+ sanitised
1289+ } ;
12781290
12791291 // 4-char hex suffix from current timestamp (unique per ~65k deploys within any second)
12801292 let ts = SystemTime :: now ( )
@@ -1283,7 +1295,15 @@ fn generate_server_name(project_name: &str) -> String {
12831295 . as_millis ( ) ;
12841296 let suffix = format ! ( "{:04x}" , ( ts & 0xFFFF ) as u16 ) ;
12851297
1286- format ! ( "{}-{}" , base, suffix)
1298+ // Truncate base so total stays within 63 chars: base + '-' + 4-char suffix = base ≤ 58
1299+ let max_base = 63 - 1 - suffix. len ( ) ; // 58
1300+ let truncated = if base. len ( ) > max_base {
1301+ base[ ..max_base] . trim_end_matches ( '-' ) . to_string ( )
1302+ } else {
1303+ base
1304+ } ;
1305+
1306+ format ! ( "{}-{}" , truncated, suffix)
12871307}
12881308
12891309pub fn build_deploy_form ( config : & StackerConfig ) -> serde_json:: Value {
@@ -1574,12 +1594,29 @@ mod tests {
15741594 #[ test]
15751595 fn test_generate_server_name_empty ( ) {
15761596 let name = generate_server_name ( "" ) ;
1577- assert ! ( name. starts_with( "server -" ) , "empty input should fallback to 'server ', got: {}" , name) ;
1597+ assert ! ( name. starts_with( "srv -" ) , "empty input should fallback to 'srv ', got: {}" , name) ;
15781598 }
15791599
15801600 #[ test]
15811601 fn test_generate_server_name_special_chars ( ) {
15821602 let name = generate_server_name ( "app___v2..beta" ) ;
15831603 assert ! ( name. starts_with( "app-v2-beta-" ) , "consecutive separators collapsed, got: {}" , name) ;
15841604 }
1605+
1606+ #[ test]
1607+ fn test_generate_server_name_numeric_start ( ) {
1608+ // Hetzner requires name to start with a letter
1609+ let name = generate_server_name ( "123app" ) ;
1610+ assert ! ( name. starts_with( "srv-123app-" ) , "numeric start should get 'srv-' prefix, got: {}" , name) ;
1611+ }
1612+
1613+ #[ test]
1614+ fn test_generate_server_name_max_length ( ) {
1615+ let long = "a" . repeat ( 100 ) ;
1616+ let name = generate_server_name ( & long) ;
1617+ assert ! ( name. len( ) <= 63 , "name must be ≤63 chars (Hetzner), got {} chars: {}" , name. len( ) , name) ;
1618+ assert ! ( name. starts_with( "aaa" ) , "got: {}" , name) ;
1619+ // Must not end with hyphen
1620+ assert ! ( !name. ends_with( '-' ) , "must not end with hyphen, got: {}" , name) ;
1621+ }
15851622}
0 commit comments