mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-11 13:50:14 +00:00
feat(vdi): host port range + container lifecycle hooks (#137)
Contributed by @vk2amv (Lindsay). Adds two related operator features to the Docker VDI driver: 1. Bounded host port range via [vdi] port_range_start / port_range_end. Currently Docker picks an arbitrary high port for the container's RDP listener; this lets operators constrain it which matters for firewalls, reverse proxies, and identity-aware gates that need to know in advance which ports rustguac will use. Port selection inside the range is deterministic-from-username (FNV-1a hash), so reconnects from the same user get the same port. Falls through to the next port on collision. 2. Container lifecycle hook script via [vdi] container_hook_script. Called as 'up <port> <container_id> <container_name>' after Docker assigns the port, again as 'down ...' before removal. Lets deployments wire external preparation/cleanup (firewall opens, service mesh registration, identity-aware gates) without baking the logic into rustguac itself. Bounded timeout (default 10s). Script is invoked via Command::new (no shell), so no injection risk from container metadata. 3 new tests covering port-candidate behaviour. Docs in docs/configuration.md and docs/vdi.md. Thanks Lindsay.
This commit is contained in:
@@ -240,6 +240,10 @@ Enables VDI (Virtual Desktop Infrastructure) sessions using Docker containers. E
|
||||
| `default_cpu_limit` | float | `0` | Default CPU limit for containers (fractional cores, e.g. 2.0). 0 = no limit. |
|
||||
| `default_memory_limit` | integer | `0` | Default memory limit in MB. 0 = no limit. |
|
||||
| `ready_timeout_secs` | integer | `30` | Seconds to wait for xrdp to become ready in a new container. |
|
||||
| `port_range_start` | integer | *(none)* | First localhost port Docker may bind VDI RDP to. Must be set with `port_range_end`. |
|
||||
| `port_range_end` | integer | *(none)* | Last localhost port Docker may bind VDI RDP to. Must be set with `port_range_start`. |
|
||||
| `container_hook_script` | string | *(none)* | Optional VDI container hook script. Called as `<script> up <port> <container_id> <container_name>` before readiness checks and `<script> down <port> <container_id> <container_name>` before removal. |
|
||||
| `container_hook_timeout_secs` | integer | `10` | Seconds to wait for the VDI container hook script. |
|
||||
| `idle_timeout_mins` | integer | `60` | Minutes a container persists after last session disconnect. 0 = remove immediately. |
|
||||
| `allowed_images` | list | `[]` | Allowed Docker images (exact match). Empty = allow all. |
|
||||
| `home_base` | string | *(none)* | Base directory for persistent user home dirs. Each user gets `{home_base}/{username}` mounted into the container. |
|
||||
@@ -248,6 +252,10 @@ Enables VDI (Virtual Desktop Infrastructure) sessions using Docker containers. E
|
||||
[vdi]
|
||||
enabled = true
|
||||
idle_timeout_mins = 60
|
||||
# port_range_start = 39000
|
||||
# port_range_end = 39999
|
||||
# container_hook_script = "/opt/rustguac/vdi-container-hook.sh"
|
||||
# container_hook_timeout_secs = 10
|
||||
home_base = "/vdi-homes"
|
||||
# allowed_images = ["myregistry/desktop:latest"]
|
||||
```
|
||||
|
||||
+24
@@ -38,6 +38,10 @@ enabled = true
|
||||
# default_cpu_limit = 2.0 # cores, 0 = no limit
|
||||
# default_memory_limit = 2048 # MB, 0 = no limit
|
||||
# ready_timeout_secs = 30 # wait for xrdp to start
|
||||
# port_range_start = 39000 # optional localhost RDP port range
|
||||
# port_range_end = 39999
|
||||
# container_hook_script = "/opt/rustguac/vdi-container-hook.sh"
|
||||
# container_hook_timeout_secs = 10
|
||||
# idle_timeout_mins = 60 # container lifetime after disconnect
|
||||
# home_base = "/vdi-homes" # persistent home directories
|
||||
# allowed_images = ["myregistry/desktop:latest"] # whitelist, empty = allow all
|
||||
@@ -141,6 +145,26 @@ The connections shows an **Active Sessions** section with thumbnail previews of
|
||||
|
||||
Dormant VDI containers (running but no active browser session) also appear with their last captured thumbnail.
|
||||
|
||||
## Container hook
|
||||
|
||||
Set `container_hook_script` when Rustguac needs an external command to prepare
|
||||
or tear down access to a container's mapped RDP port. This can be used for
|
||||
deployment-specific setup that must happen after Docker has assigned the port
|
||||
and before Rustguac starts probing xrdp.
|
||||
|
||||
Rustguac calls the script as:
|
||||
|
||||
```bash
|
||||
/opt/rustguac/vdi-container-hook.sh up <port> <container_id> <container_name>
|
||||
/opt/rustguac/vdi-container-hook.sh down <port> <container_id> <container_name>
|
||||
```
|
||||
|
||||
`up` runs after Docker inspect finds the mapped RDP port and before Rustguac
|
||||
checks whether xrdp is ready on `127.0.0.1:<port>`. The script should return
|
||||
only after the local listener is available. `down` runs before Rustguac stops
|
||||
and removes the container. Hook execution is limited by
|
||||
`container_hook_timeout_secs` (default: 10 seconds).
|
||||
|
||||
## Per-entry settings
|
||||
|
||||
Each VDI connections entry can override:
|
||||
|
||||
@@ -241,6 +241,21 @@ pub struct VdiConfig {
|
||||
/// Seconds to wait for xrdp to become ready in a new container. Default: 30.
|
||||
#[serde(default = "default_ready_timeout_secs")]
|
||||
pub ready_timeout_secs: u64,
|
||||
/// First localhost port Docker may bind VDI RDP to. Unset = Docker chooses any random port.
|
||||
#[serde(default)]
|
||||
pub port_range_start: Option<u16>,
|
||||
/// Last localhost port Docker may bind VDI RDP to. Unset = Docker chooses any random port.
|
||||
#[serde(default)]
|
||||
pub port_range_end: Option<u16>,
|
||||
/// Optional script called when a VDI container's mapped RDP port should be
|
||||
/// prepared or torn down. Called as:
|
||||
/// <script> up <port> <container_id> <container_name>
|
||||
/// <script> down <port> <container_id> <container_name>
|
||||
#[serde(default)]
|
||||
pub container_hook_script: Option<String>,
|
||||
/// Seconds to wait for the VDI container hook script. Default: 10.
|
||||
#[serde(default = "default_container_hook_timeout_secs")]
|
||||
pub container_hook_timeout_secs: u64,
|
||||
/// Minutes a container persists after last session disconnect. Default: 60.
|
||||
/// Containers are kept running for reconnection. Set to 0 for immediate removal.
|
||||
#[serde(default = "default_idle_timeout_mins")]
|
||||
@@ -263,6 +278,10 @@ fn default_ready_timeout_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_container_hook_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_idle_timeout_mins() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
+27
-1
@@ -479,10 +479,36 @@ impl SessionManager {
|
||||
}
|
||||
match crate::vdi::DockerDriver::new(&vdi_cfg.docker_socket) {
|
||||
Ok(driver) => {
|
||||
let driver = driver.with_ready_timeout(vdi_cfg.ready_timeout_secs);
|
||||
let mut driver = driver
|
||||
.with_ready_timeout(vdi_cfg.ready_timeout_secs)
|
||||
.with_container_hook(
|
||||
vdi_cfg.container_hook_script.clone(),
|
||||
vdi_cfg.container_hook_timeout_secs,
|
||||
);
|
||||
match (vdi_cfg.port_range_start, vdi_cfg.port_range_end) {
|
||||
(Some(start), Some(end)) => {
|
||||
driver = match driver.with_host_port_range(start, end) {
|
||||
Ok(driver) => driver,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to initialize VDI Docker driver: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => {
|
||||
tracing::error!(
|
||||
"Failed to initialize VDI Docker driver: port_range_start and port_range_end must be set together"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
socket = %vdi_cfg.docker_socket,
|
||||
idle_timeout_mins = vdi_cfg.idle_timeout_mins,
|
||||
port_range_start = ?vdi_cfg.port_range_start,
|
||||
port_range_end = ?vdi_cfg.port_range_end,
|
||||
container_hook_script = ?vdi_cfg.container_hook_script,
|
||||
"VDI Docker driver initialized"
|
||||
);
|
||||
Some(Arc::new(driver))
|
||||
|
||||
+383
-72
@@ -10,11 +10,15 @@ use bollard::query_parameters::{
|
||||
use bollard::Docker;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Docker-based VDI driver. Connects to the local Docker daemon via unix socket.
|
||||
pub struct DockerDriver {
|
||||
client: Docker,
|
||||
ready_timeout: Duration,
|
||||
host_port_range: Option<(u16, u16)>,
|
||||
container_hook_script: Option<String>,
|
||||
container_hook_timeout: Duration,
|
||||
}
|
||||
|
||||
impl DockerDriver {
|
||||
@@ -30,6 +34,9 @@ impl DockerDriver {
|
||||
Ok(Self {
|
||||
client,
|
||||
ready_timeout: Duration::from_secs(30),
|
||||
host_port_range: None,
|
||||
container_hook_script: None,
|
||||
container_hook_timeout: Duration::from_secs(10),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +46,25 @@ impl DockerDriver {
|
||||
self
|
||||
}
|
||||
|
||||
/// Restrict Docker's published host port for RDP to an inclusive range.
|
||||
pub fn with_host_port_range(mut self, start: u16, end: u16) -> Result<Self, VdiError> {
|
||||
if start == 0 || start > end {
|
||||
return Err(VdiError::Docker(format!(
|
||||
"invalid VDI port range: {}-{}",
|
||||
start, end
|
||||
)));
|
||||
}
|
||||
self.host_port_range = Some((start, end));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Configure an external VDI container hook script.
|
||||
pub fn with_container_hook(mut self, script: Option<String>, timeout_secs: u64) -> Self {
|
||||
self.container_hook_script = script.filter(|s| !s.trim().is_empty());
|
||||
self.container_hook_timeout = Duration::from_secs(timeout_secs.max(1));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sanitize a username into a valid Docker container name suffix.
|
||||
fn sanitize_username(username: &str) -> String {
|
||||
username
|
||||
@@ -55,6 +81,126 @@ impl DockerDriver {
|
||||
format!("rustguac-vdi-{}", Self::sanitize_username(username))
|
||||
}
|
||||
|
||||
/// Candidate host ports to ask Docker to bind. Without a configured range,
|
||||
/// `0` preserves Docker's default random-port allocation.
|
||||
fn host_port_candidates(host_port_range: Option<(u16, u16)>, username: &str) -> Vec<String> {
|
||||
let Some((start, end)) = host_port_range else {
|
||||
return vec!["0".into()];
|
||||
};
|
||||
|
||||
let len = end as u32 - start as u32 + 1;
|
||||
let hash = username.bytes().fold(0xcbf29ce484222325_u64, |hash, b| {
|
||||
hash.wrapping_mul(0x100000001b3) ^ b as u64
|
||||
});
|
||||
let offset = (hash % len as u64) as u32;
|
||||
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
let port = start as u32 + ((offset + i) % len);
|
||||
port.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn port_in_configured_range(&self, port: u16) -> bool {
|
||||
match self.host_port_range {
|
||||
Some((start, end)) => (start..=end).contains(&port),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_container_name(
|
||||
inspect: &bollard::models::ContainerInspectResponse,
|
||||
fallback: &str,
|
||||
) -> String {
|
||||
inspect
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or(fallback)
|
||||
.trim_start_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn run_container_hook(
|
||||
&self,
|
||||
action: &str,
|
||||
port: u16,
|
||||
container_id: &str,
|
||||
container_name: &str,
|
||||
) -> Result<(), VdiError> {
|
||||
let Some(script) = self.container_hook_script.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut child = Command::new(script);
|
||||
child
|
||||
.arg(action)
|
||||
.arg(port.to_string())
|
||||
.arg(container_id)
|
||||
.arg(container_name)
|
||||
.env("RUSTGUAC_VDI_HOOK_ACTION", action)
|
||||
.env("RUSTGUAC_VDI_PORT", port.to_string())
|
||||
.env("RUSTGUAC_VDI_CONTAINER_ID", container_id)
|
||||
.env("RUSTGUAC_VDI_CONTAINER_NAME", container_name);
|
||||
|
||||
let output = tokio::time::timeout(self.container_hook_timeout, child.output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
VdiError::Timeout(format!(
|
||||
"VDI container hook '{}' timed out after {}s",
|
||||
script,
|
||||
self.container_hook_timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| {
|
||||
VdiError::Docker(format!(
|
||||
"failed to run VDI container hook '{}': {}",
|
||||
script, e
|
||||
))
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
tracing::info!(
|
||||
hook = %script,
|
||||
action,
|
||||
port,
|
||||
container = %container_name,
|
||||
"VDI container hook completed"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Err(VdiError::Docker(format!(
|
||||
"VDI container hook '{}' failed for action '{}' with status {}{}{}",
|
||||
script,
|
||||
action,
|
||||
output.status,
|
||||
if stdout.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; stdout: {}", stdout.trim())
|
||||
},
|
||||
if stderr.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; stderr: {}", stderr.trim())
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
async fn prepare_rdp_endpoint(
|
||||
&self,
|
||||
port: u16,
|
||||
container_id: &str,
|
||||
container_name: &str,
|
||||
) -> Result<(), VdiError> {
|
||||
self.run_container_hook("up", port, container_id, container_name)
|
||||
.await?;
|
||||
self.wait_for_ready("127.0.0.1", port).await
|
||||
}
|
||||
|
||||
/// Extract the host-mapped port for container port 3389/tcp from inspect data.
|
||||
fn extract_mapped_port(
|
||||
inspect: &bollard::models::ContainerInspectResponse,
|
||||
@@ -229,6 +375,18 @@ impl DockerDriver {
|
||||
if running {
|
||||
// Container is running — reuse it, but update password
|
||||
let port = Self::extract_mapped_port(&inspect)?;
|
||||
let container_id = inspect.id.as_deref().unwrap_or(&name).to_string();
|
||||
let container_name = Self::extract_container_name(&inspect, &name);
|
||||
if !self.port_in_configured_range(port) {
|
||||
tracing::info!(
|
||||
container = %name,
|
||||
port,
|
||||
"VDI container port is outside configured range — replacing container"
|
||||
);
|
||||
let cid = inspect.id.as_deref().unwrap_or(&name);
|
||||
let _ = self.do_stop_container(cid).await;
|
||||
return Box::pin(self.do_start_or_reuse(spec)).await;
|
||||
}
|
||||
self.update_container_password(&name, &spec.username, &spec.password)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
@@ -236,8 +394,24 @@ impl DockerDriver {
|
||||
port,
|
||||
"Reusing existing VDI container"
|
||||
);
|
||||
if let Err(e) = self
|
||||
.prepare_rdp_endpoint(port, &container_id, &container_name)
|
||||
.await
|
||||
{
|
||||
if self.host_port_range.is_some() {
|
||||
tracing::warn!(
|
||||
container = %name,
|
||||
port,
|
||||
"Reused VDI container endpoint failed; replacing container: {}",
|
||||
e
|
||||
);
|
||||
let _ = self.do_stop_container(&container_id).await;
|
||||
return Box::pin(self.do_start_or_reuse(spec)).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
return Ok(ContainerInfo {
|
||||
container_id: inspect.id.unwrap_or_default(),
|
||||
container_id,
|
||||
rdp_host: "127.0.0.1".into(),
|
||||
rdp_port: port,
|
||||
reused: true,
|
||||
@@ -245,27 +419,64 @@ impl DockerDriver {
|
||||
}
|
||||
|
||||
// Container exists but stopped — start it
|
||||
let port = Self::extract_mapped_port(&inspect)?;
|
||||
let container_id = inspect.id.as_deref().unwrap_or(&name).to_string();
|
||||
let container_name = Self::extract_container_name(&inspect, &name);
|
||||
if !self.port_in_configured_range(port) {
|
||||
tracing::info!(
|
||||
container = %name,
|
||||
port,
|
||||
"Stopped VDI container port is outside configured range — replacing container"
|
||||
);
|
||||
let cid = inspect.id.as_deref().unwrap_or(&name);
|
||||
let _ = self.do_stop_container(cid).await;
|
||||
return Box::pin(self.do_start_or_reuse(spec)).await;
|
||||
}
|
||||
|
||||
// Container exists but stopped with an acceptable port — start it
|
||||
tracing::info!(container = %name, "Starting stopped VDI container");
|
||||
self.client
|
||||
if let Err(e) = self
|
||||
.client
|
||||
.start_container(&name, None::<StartContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| VdiError::Docker(format!("failed to start container: {}", e)))?;
|
||||
{
|
||||
if self.host_port_range.is_some() {
|
||||
tracing::warn!(
|
||||
container = %name,
|
||||
port,
|
||||
"Stopped VDI container failed to start; replacing container: {}",
|
||||
e
|
||||
);
|
||||
let _ = self.do_stop_container(&container_id).await;
|
||||
return Box::pin(self.do_start_or_reuse(spec)).await;
|
||||
}
|
||||
return Err(VdiError::Docker(format!(
|
||||
"failed to start container: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
// Re-inspect to get port mapping
|
||||
let inspect = self
|
||||
.client
|
||||
.inspect_container(&name, None)
|
||||
if let Err(e) = self
|
||||
.prepare_rdp_endpoint(port, &container_id, &container_name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
VdiError::Docker(format!("failed to inspect after start: {}", e))
|
||||
})?;
|
||||
let port = Self::extract_mapped_port(&inspect)?;
|
||||
self.wait_for_ready("127.0.0.1", port).await?;
|
||||
{
|
||||
if self.host_port_range.is_some() {
|
||||
tracing::warn!(
|
||||
container = %name,
|
||||
port,
|
||||
"Started VDI container endpoint failed; replacing container: {}",
|
||||
e
|
||||
);
|
||||
let _ = self.do_stop_container(&container_id).await;
|
||||
return Box::pin(self.do_start_or_reuse(spec)).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.update_container_password(&name, &spec.username, &spec.password)
|
||||
.await?;
|
||||
|
||||
Ok(ContainerInfo {
|
||||
container_id: inspect.id.unwrap_or_default(),
|
||||
container_id,
|
||||
rdp_host: "127.0.0.1".into(),
|
||||
rdp_port: port,
|
||||
reused: true,
|
||||
@@ -304,16 +515,6 @@ impl DockerDriver {
|
||||
env_vec.push(format!("{}={}", k, v));
|
||||
}
|
||||
|
||||
// Port binding: 3389/tcp → random host port
|
||||
let mut port_bindings = HashMap::new();
|
||||
port_bindings.insert(
|
||||
"3389/tcp".to_string(),
|
||||
Some(vec![PortBinding {
|
||||
host_ip: Some("127.0.0.1".into()),
|
||||
host_port: Some("0".into()), // random port
|
||||
}]),
|
||||
);
|
||||
|
||||
// Labels
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("rustguac.managed".to_string(), "true".to_string());
|
||||
@@ -364,62 +565,108 @@ impl DockerDriver {
|
||||
None
|
||||
};
|
||||
|
||||
let host_config = HostConfig {
|
||||
port_bindings: Some(port_bindings),
|
||||
nano_cpus,
|
||||
memory,
|
||||
binds,
|
||||
..Default::default()
|
||||
};
|
||||
let mut last_error = None;
|
||||
for host_port in Self::host_port_candidates(self.host_port_range, &spec.username) {
|
||||
// Port binding: 3389/tcp → selected localhost host port.
|
||||
// A configured range uses explicit ports; no range uses
|
||||
// Docker's random allocation via host port 0.
|
||||
let mut port_bindings = HashMap::new();
|
||||
port_bindings.insert(
|
||||
"3389/tcp".to_string(),
|
||||
Some(vec![PortBinding {
|
||||
host_ip: Some("127.0.0.1".into()),
|
||||
host_port: Some(host_port.clone()),
|
||||
}]),
|
||||
);
|
||||
|
||||
let config = ContainerCreateBody {
|
||||
image: Some(spec.image.clone()),
|
||||
env: Some(env_vec),
|
||||
labels: Some(labels),
|
||||
host_config: Some(host_config),
|
||||
..Default::default()
|
||||
};
|
||||
let host_config = HostConfig {
|
||||
port_bindings: Some(port_bindings),
|
||||
nano_cpus,
|
||||
memory,
|
||||
binds: binds.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let opts = CreateContainerOptions {
|
||||
name: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ContainerCreateBody {
|
||||
image: Some(spec.image.clone()),
|
||||
env: Some(env_vec.clone()),
|
||||
labels: Some(labels.clone()),
|
||||
host_config: Some(host_config),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let created = self
|
||||
.client
|
||||
.create_container(Some(opts), config)
|
||||
.await
|
||||
.map_err(|e| VdiError::Docker(format!("failed to create container: {}", e)))?;
|
||||
let opts = CreateContainerOptions {
|
||||
name: Some(name.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
self.client
|
||||
.start_container(&name, None::<StartContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| VdiError::Docker(format!("failed to start container: {}", e)))?;
|
||||
let created = match self.client.create_container(Some(opts), config).await {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
last_error = Some(format!("failed to create container: {}", e));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Inspect to get the mapped port
|
||||
let inspect = self
|
||||
.client
|
||||
.inspect_container(&name, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
VdiError::Docker(format!("failed to inspect after create: {}", e))
|
||||
})?;
|
||||
let port = Self::extract_mapped_port(&inspect)?;
|
||||
self.wait_for_ready("127.0.0.1", port).await?;
|
||||
if let Err(e) = self
|
||||
.client
|
||||
.start_container(&name, None::<StartContainerOptions>)
|
||||
.await
|
||||
{
|
||||
last_error = Some(format!(
|
||||
"failed to start container with host port {}: {}",
|
||||
host_port, e
|
||||
));
|
||||
let _ = self.do_stop_container(&created.id).await;
|
||||
if self.host_port_range.is_some() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
container = %name,
|
||||
container_id = %created.id,
|
||||
port,
|
||||
"VDI container ready"
|
||||
);
|
||||
// Inspect to get the mapped port
|
||||
let inspect =
|
||||
self.client
|
||||
.inspect_container(&name, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
VdiError::Docker(format!("failed to inspect after create: {}", e))
|
||||
})?;
|
||||
let port = Self::extract_mapped_port(&inspect)?;
|
||||
let container_name = Self::extract_container_name(&inspect, &name);
|
||||
if let Err(e) = self
|
||||
.prepare_rdp_endpoint(port, &created.id, &container_name)
|
||||
.await
|
||||
{
|
||||
last_error = Some(format!(
|
||||
"failed to prepare VDI endpoint with host port {}: {}",
|
||||
port, e
|
||||
));
|
||||
let _ = self.do_stop_container(&created.id).await;
|
||||
if self.host_port_range.is_some() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(ContainerInfo {
|
||||
container_id: created.id,
|
||||
rdp_host: "127.0.0.1".into(),
|
||||
rdp_port: port,
|
||||
reused: false,
|
||||
})
|
||||
tracing::info!(
|
||||
container = %name,
|
||||
container_id = %created.id,
|
||||
port,
|
||||
"VDI container ready"
|
||||
);
|
||||
|
||||
return Ok(ContainerInfo {
|
||||
container_id: created.id,
|
||||
rdp_host: "127.0.0.1".into(),
|
||||
rdp_port: port,
|
||||
reused: false,
|
||||
});
|
||||
}
|
||||
|
||||
Err(VdiError::Docker(last_error.unwrap_or_else(|| {
|
||||
"failed to allocate a VDI host port".into()
|
||||
})))
|
||||
}
|
||||
Err(e) => Err(VdiError::Docker(format!(
|
||||
"failed to inspect container: {}",
|
||||
@@ -429,6 +676,37 @@ impl DockerDriver {
|
||||
}
|
||||
|
||||
async fn do_stop_container(&self, container_id: &str) -> Result<(), VdiError> {
|
||||
let hook_info = match self.client.inspect_container(container_id, None).await {
|
||||
Ok(inspect) => {
|
||||
let port = Self::extract_mapped_port(&inspect).ok();
|
||||
let name = Self::extract_container_name(&inspect, container_id);
|
||||
port.map(|port| (port, name))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
container_id,
|
||||
"Skipping VDI container hook teardown; inspect failed: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((port, name)) = hook_info {
|
||||
if let Err(e) = self
|
||||
.run_container_hook("down", port, container_id, &name)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
container_id,
|
||||
container = %name,
|
||||
port,
|
||||
"VDI container hook teardown failed: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop with 5s grace period
|
||||
let _ = self
|
||||
.client
|
||||
@@ -654,4 +932,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_candidates_defaults_to_docker_random() {
|
||||
assert_eq!(
|
||||
DockerDriver::host_port_candidates(None, "alice"),
|
||||
vec!["0".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_candidates_stay_within_configured_range() {
|
||||
let ports = DockerDriver::host_port_candidates(Some((39000, 39003)), "alice");
|
||||
assert_eq!(ports.len(), 4);
|
||||
for port in ports {
|
||||
let port = port.parse::<u16>().unwrap();
|
||||
assert!((39000..=39003).contains(&port));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_candidates_try_each_port_once() {
|
||||
let mut ports = DockerDriver::host_port_candidates(Some((39000, 39003)), "alice");
|
||||
ports.sort();
|
||||
assert_eq!(
|
||||
ports,
|
||||
vec![
|
||||
"39000".to_string(),
|
||||
"39001".to_string(),
|
||||
"39002".to_string(),
|
||||
"39003".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user