Skip to content

Commit 083c04b

Browse files
committed
feat: add username template variable to cloud-init.yml.tera
Replace hardcoded 'torrust' username with dynamic {{ username }} variable. - Update CloudInitContext to use Username type with validation - Modify cloud-init template to use {{ username }} variable - Update cloud-init renderer to extract username from SSH credentials - Fix all documentation examples to use Username::new() - Add proper import statements for Username type Breaking change: CloudInitContext API now requires username parameter and uses typed Username instead of raw strings for better validation.
1 parent 933956c commit 083c04b

File tree

8 files changed

+126
-19
lines changed

8 files changed

+126
-19
lines changed

src/e2e/containers/provisioned.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
//! StoppedProvisionedContainer, ContainerError,
2424
//! actions::{SshWaitAction, SshKeySetupAction}
2525
//! };
26-
//! use torrust_tracker_deploy::shared::ssh::SshCredentials;
26+
//! use torrust_tracker_deploy::shared::{Username, ssh::SshCredentials};
2727
//! use std::path::PathBuf;
2828
//! use std::time::Duration;
2929
//! use std::net::SocketAddr;
@@ -33,7 +33,7 @@
3333
//! let stopped = StoppedProvisionedContainer::default();
3434
//!
3535
//! // Transition to running state
36-
//! let running = stopped.start().await?;
36+
//! let running = stopped.start(None).await?;
3737
//!
3838
//! // Get connection details
3939
//! let socket_addr = running.ssh_socket_addr();
@@ -46,7 +46,7 @@
4646
//! let ssh_credentials = SshCredentials::new(
4747
//! PathBuf::from("/path/to/private_key"),
4848
//! PathBuf::from("/path/to/public_key.pub"),
49-
//! "torrust".to_string(),
49+
//! Username::new("torrust").unwrap(),
5050
//! );
5151
//! let ssh_key_setup_action = SshKeySetupAction::new();
5252
//! ssh_key_setup_action.execute(&running, &ssh_credentials).await?;

src/e2e/tasks/container/cleanup_infrastructure.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use crate::e2e::containers::RunningProvisionedContainer;
4343
/// #[tokio::main]
4444
/// async fn main() -> anyhow::Result<()> {
4545
/// let stopped_container = StoppedProvisionedContainer::default();
46-
/// let running_container = stopped_container.start().await?;
46+
/// let running_container = stopped_container.start(None).await?;
4747
///
4848
/// // ... perform tests ...
4949
///

src/e2e/tasks/run_configuration_validation.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ use crate::shared::ssh::SshConnection;
5959
/// ```rust,no_run
6060
/// use torrust_tracker_deploy::e2e::tasks::run_configuration_validation::run_configuration_validation;
6161
/// use torrust_tracker_deploy::config::SshCredentials;
62+
/// use torrust_tracker_deploy::shared::username::Username;
6263
/// use std::net::{IpAddr, Ipv4Addr, SocketAddr};
6364
///
6465
/// #[tokio::main]
@@ -67,7 +68,7 @@ use crate::shared::ssh::SshConnection;
6768
/// let ssh_credentials = SshCredentials::new(
6869
/// "./id_rsa".into(),
6970
/// "./id_rsa.pub".into(),
70-
/// "testuser".to_string()
71+
/// Username::new("testuser").unwrap()
7172
/// );
7273
///
7374
/// run_configuration_validation(socket_addr, &ssh_credentials).await?;

src/infrastructure/template/wrappers/tofu/lxd/cloud_init/context.rs

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,35 @@ use std::fs;
88
use std::path::Path;
99
use thiserror::Error;
1010

11+
use crate::shared::Username;
12+
1113
/// Errors that can occur when creating a `CloudInitContext`
1214
#[derive(Error, Debug, Clone)]
1315
pub 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)]
2228
pub 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)]
2937
pub struct CloudInitContextBuilder {
3038
ssh_public_key: Option<String>,
39+
username: Option<Username>,
3140
}
3241

3342
impl 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

75105
impl 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
}

src/infrastructure/template/wrappers/tofu/lxd/cloud_init/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ mod tests {
7575
fn create_cloud_init_context(ssh_key: &str) -> CloudInitContext {
7676
CloudInitContext::builder()
7777
.with_ssh_public_key(ssh_key.to_string())
78+
.with_username("testuser")
79+
.unwrap()
7880
.build()
7981
.unwrap()
8082
}

src/infrastructure/tofu/template/renderer/cloud_init.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
//! # use std::path::Path;
2222
//! # use torrust_tracker_deploy::infrastructure::tofu::template::renderer::cloud_init::CloudInitTemplateRenderer;
2323
//! # use torrust_tracker_deploy::domain::template::TemplateManager;
24-
//! # use torrust_tracker_deploy::shared::ssh::credentials::SshCredentials;
24+
//! # use torrust_tracker_deploy::shared::{Username, ssh::credentials::SshCredentials};
2525
//! # use std::path::PathBuf;
2626
//! #
2727
//! # #[tokio::main]
@@ -174,10 +174,12 @@ impl CloudInitTemplateRenderer {
174174
let template_file = File::new(Self::CLOUD_INIT_TEMPLATE_FILE, template_content)
175175
.map_err(|_| CloudInitTemplateError::FileCreationFailed)?;
176176

177-
// Create cloud-init context with SSH public key
177+
// Create cloud-init context with SSH public key and username
178178
let cloud_init_context = CloudInitContext::builder()
179179
.with_ssh_public_key_from_file(&ssh_credentials.ssh_pub_key_path)
180180
.map_err(|_| CloudInitTemplateError::SshKeyReadError)?
181+
.with_username(ssh_credentials.ssh_username.as_str())
182+
.map_err(|_| CloudInitTemplateError::ContextCreationFailed)?
181183
.build()
182184
.map_err(|_| CloudInitTemplateError::ContextCreationFailed)?;
183185

src/shared/ssh/connection.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ impl SshConnection {
4343
/// ```rust
4444
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
4545
/// # use std::path::PathBuf;
46-
/// # use torrust_tracker_deploy::shared::ssh::{SshCredentials, SshConnection};
46+
/// # use torrust_tracker_deploy::shared::{Username, ssh::{SshCredentials, SshConnection}};
4747
/// let credentials = SshCredentials::new(
4848
/// PathBuf::from("/home/user/.ssh/deploy_key"),
4949
/// PathBuf::from("/home/user/.ssh/deploy_key.pub"),
@@ -70,7 +70,7 @@ impl SshConnection {
7070
/// ```rust
7171
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
7272
/// # use std::path::PathBuf;
73-
/// # use torrust_tracker_deploy::shared::ssh::{SshCredentials, SshConnection};
73+
/// # use torrust_tracker_deploy::shared::{Username, ssh::{SshCredentials, SshConnection}};
7474
/// let credentials = SshCredentials::new(
7575
/// PathBuf::from("/home/user/.ssh/deploy_key"),
7676
/// PathBuf::from("/home/user/.ssh/deploy_key.pub"),

templates/tofu/lxd/cloud-init.yml.tera

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#cloud-config
22
# cloud-init template for LXD containers with dynamic SSH key injection
33
# This template uses Tera templating to inject the SSH public key from SshConfig.ssh_pub_key_path
4+
45
# Commented out for faster VM creation during development
56
# package_update: true
67
# package_upgrade: true
@@ -13,7 +14,7 @@
1314
# - vim
1415

1516
users:
16-
- name: torrust
17+
- name: {{ username }}
1718
groups: sudo
1819
shell: /bin/bash
1920
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
@@ -24,4 +25,4 @@ users:
2425
runcmd:
2526
- echo "VM provisioned successfully" > /tmp/provision_complete
2627
- systemctl enable ssh
27-
- systemctl start ssh
28+
- systemctl start ssh

0 commit comments

Comments
 (0)