@@ -8,26 +8,35 @@ use std::fs;
88use std:: path:: Path ;
99use thiserror:: Error ;
1010
11+ use crate :: shared:: Username ;
12+
1113/// Errors that can occur when creating a `CloudInitContext`
1214#[ derive( Error , Debug , Clone ) ]
1315pub enum CloudInitContextError {
1416 #[ error( "SSH public key is required but not provided" ) ]
1517 MissingSshPublicKey ,
18+ #[ error( "Username is required but not provided" ) ]
19+ MissingUsername ,
20+ #[ error( "Invalid username: {0}" ) ]
21+ InvalidUsername ( String ) ,
1622 #[ error( "Failed to read SSH public key from file: {0}" ) ]
1723 SshPublicKeyReadError ( String ) ,
1824}
1925
20- /// Template context for Cloud Init configuration with SSH public key
26+ /// Template context for Cloud Init configuration with SSH public key and username
2127#[ derive( Debug , Clone , Serialize ) ]
2228pub struct CloudInitContext {
2329 /// SSH public key content to be injected into cloud-init configuration
2430 pub ssh_public_key : String ,
31+ /// Username to be created in the cloud-init configuration
32+ pub username : Username ,
2533}
2634
2735/// Builder for `CloudInitContext` with fluent interface
2836#[ derive( Debug , Default ) ]
2937pub struct CloudInitContextBuilder {
3038 ssh_public_key : Option < String > ,
39+ username : Option < Username > ,
3140}
3241
3342impl CloudInitContextBuilder {
@@ -38,6 +47,20 @@ impl CloudInitContextBuilder {
3847 self
3948 }
4049
50+ /// Set the username for the cloud-init configuration
51+ ///
52+ /// # Errors
53+ /// Returns an error if the username is invalid according to Linux naming requirements
54+ pub fn with_username < S : Into < String > > (
55+ mut self ,
56+ username : S ,
57+ ) -> Result < Self , CloudInitContextError > {
58+ let username = Username :: new ( username)
59+ . map_err ( |e| CloudInitContextError :: InvalidUsername ( e. to_string ( ) ) ) ?;
60+ self . username = Some ( username) ;
61+ Ok ( self )
62+ }
63+
4164 /// Set the SSH public key by reading from a file path
4265 ///
4366 /// # Errors
@@ -68,15 +91,32 @@ impl CloudInitContextBuilder {
6891 . ssh_public_key
6992 . ok_or ( CloudInitContextError :: MissingSshPublicKey ) ?;
7093
71- Ok ( CloudInitContext { ssh_public_key } )
94+ let username = self
95+ . username
96+ . ok_or ( CloudInitContextError :: MissingUsername ) ?;
97+
98+ Ok ( CloudInitContext {
99+ ssh_public_key,
100+ username,
101+ } )
72102 }
73103}
74104
75105impl CloudInitContext {
76- /// Creates a new `CloudInitContext` with SSH public key content
77- #[ must_use]
78- pub fn new ( ssh_public_key : String ) -> Self {
79- Self { ssh_public_key }
106+ /// Creates a new `CloudInitContext` with SSH public key content and username
107+ ///
108+ /// # Errors
109+ /// Returns an error if the username is invalid according to Linux naming requirements
110+ pub fn new < S : Into < String > > (
111+ ssh_public_key : String ,
112+ username : S ,
113+ ) -> Result < Self , CloudInitContextError > {
114+ let username = Username :: new ( username)
115+ . map_err ( |e| CloudInitContextError :: InvalidUsername ( e. to_string ( ) ) ) ?;
116+ Ok ( Self {
117+ ssh_public_key,
118+ username,
119+ } )
80120 }
81121
82122 /// Creates a new builder for `CloudInitContext` with fluent interface
@@ -90,6 +130,12 @@ impl CloudInitContext {
90130 pub fn ssh_public_key ( & self ) -> & str {
91131 & self . ssh_public_key
92132 }
133+
134+ /// Get the username
135+ #[ must_use]
136+ pub fn username ( & self ) -> & str {
137+ self . username . as_str ( )
138+ }
93139}
94140
95141#[ cfg( test) ]
@@ -101,38 +147,48 @@ mod tests {
101147 #[ test]
102148 fn it_should_create_cloud_init_context_with_ssh_key ( ) {
103149 let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] " ; 104- let context = CloudInitContext :: new ( ssh_key. to_string ( ) ) ;
150+ let username = "testuser" ;
151+ let context = CloudInitContext :: new ( ssh_key. to_string ( ) , username) . unwrap ( ) ;
105152
106153 assert_eq ! ( context. ssh_public_key( ) , ssh_key) ;
154+ assert_eq ! ( context. username( ) , username) ;
107155 }
108156
109157 #[ test]
110158 fn it_should_build_context_with_builder_pattern ( ) {
111159 let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] " ; 160+ let username = "testuser" ;
112161 let context = CloudInitContext :: builder ( )
113162 . with_ssh_public_key ( ssh_key. to_string ( ) )
163+ . with_username ( username)
164+ . unwrap ( )
114165 . build ( )
115166 . unwrap ( ) ;
116167
117168 assert_eq ! ( context. ssh_public_key( ) , ssh_key) ;
169+ assert_eq ! ( context. username( ) , username) ;
118170 }
119171
120172 #[ test]
121173 fn it_should_read_ssh_key_from_file ( ) {
122174 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
123175 let key_file = temp_dir. path ( ) . join ( "test_key.pub" ) ;
124176 let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] \n " ; 177+ let username = "testuser" ;
125178
126179 fs:: write ( & key_file, ssh_key) . unwrap ( ) ;
127180
128181 let context = CloudInitContext :: builder ( )
129182 . with_ssh_public_key_from_file ( & key_file)
130183 . unwrap ( )
184+ . with_username ( username)
185+ . unwrap ( )
131186 . build ( )
132187 . unwrap ( ) ;
133188
134189 // Should trim the trailing newline
135190 assert_eq ! ( context. ssh_public_key( ) , ssh_key. trim( ) ) ;
191+ assert_eq ! ( context. username( ) , username) ;
136192 }
137193
138194 #[ test]
@@ -146,6 +202,20 @@ mod tests {
146202 ) ) ;
147203 }
148204
205+ #[ test]
206+ fn it_should_fail_when_username_is_missing ( ) {
207+ let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] " ; 208+ let result = CloudInitContext :: builder ( )
209+ . with_ssh_public_key ( ssh_key. to_string ( ) )
210+ . build ( ) ;
211+
212+ assert ! ( result. is_err( ) ) ;
213+ assert ! ( matches!(
214+ result. unwrap_err( ) ,
215+ CloudInitContextError :: MissingUsername
216+ ) ) ;
217+ }
218+
149219 #[ test]
150220 fn it_should_fail_when_ssh_key_file_does_not_exist ( ) {
151221 let result =
@@ -161,9 +231,40 @@ mod tests {
161231 #[ test]
162232 fn it_should_serialize_to_json ( ) {
163233 let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] " ; 164- let context = CloudInitContext :: new ( ssh_key. to_string ( ) ) ;
234+ let username = "testuser" ;
235+ let context = CloudInitContext :: new ( ssh_key. to_string ( ) , username) . unwrap ( ) ;
165236
166237 let json = serde_json:: to_value ( & context) . unwrap ( ) ;
167238 assert_eq ! ( json[ "ssh_public_key" ] , ssh_key) ;
239+ assert_eq ! ( json[ "username" ] , username) ;
240+ }
241+
242+ #[ test]
243+ fn it_should_fail_with_invalid_username ( ) {
244+ let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] " ; 245+ let invalid_username = "123invalid" ; // starts with digit
246+
247+ let result = CloudInitContext :: new ( ssh_key. to_string ( ) , invalid_username) ;
248+ assert ! ( result. is_err( ) ) ;
249+ assert ! ( matches!(
250+ result. unwrap_err( ) ,
251+ CloudInitContextError :: InvalidUsername ( _)
252+ ) ) ;
253+ }
254+
255+ #[ test]
256+ fn it_should_fail_with_builder_when_username_is_invalid ( ) {
257+ let ssh_key =
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... [email protected] " ; 258+ let invalid_username = "@invalid" ; // contains @ symbol
259+
260+ let result = CloudInitContext :: builder ( )
261+ . with_ssh_public_key ( ssh_key. to_string ( ) )
262+ . with_username ( invalid_username) ;
263+
264+ assert ! ( result. is_err( ) ) ;
265+ assert ! ( matches!(
266+ result. unwrap_err( ) ,
267+ CloudInitContextError :: InvalidUsername ( _)
268+ ) ) ;
168269 }
169270}
0 commit comments