feat: add auto update
This commit is contained in:
24
agent/Cargo.lock
generated
24
agent/Cargo.lock
generated
@ -150,6 +150,7 @@ dependencies = [
|
||||
"hyperlocal",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@ -1191,24 +1192,13 @@ version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
@ -1585,16 +1575,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
@ -1640,10 +1620,8 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"rustls-pemfile",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
|
||||
@ -10,9 +10,9 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tonic = { version = "0.12", features = ["tls"] }
|
||||
tonic = { version = "0.12" }
|
||||
prost = "0.13"
|
||||
bollard = "0.17"
|
||||
bollard = { version = "0.17", default-features = false, features = ["rustls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
@ -3,34 +3,30 @@ FROM rust:slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
protobuf-compiler \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
musl-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy proto first (referenced by build.rs as ../proto/...)
|
||||
COPY proto/ ./proto/
|
||||
|
||||
# Cache dependencies: copy manifests, build with dummy main, then discard.
|
||||
COPY agent/Cargo.toml agent/Cargo.lock ./agent/
|
||||
COPY agent/build.rs ./agent/
|
||||
WORKDIR /src/agent
|
||||
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||
cargo build --release && \
|
||||
cargo build --release --target x86_64-unknown-linux-musl && \
|
||||
rm -rf src
|
||||
|
||||
# Full build.
|
||||
COPY agent/src ./src
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
RUN touch src/main.rs && cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
# ── Runtime ───────────────────────────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache ca-certificates docker-cli
|
||||
|
||||
COPY --from=builder /src/agent/target/release/containarr-agent /usr/local/bin/containarr-agent
|
||||
COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/containarr-agent /usr/local/bin/containarr-agent
|
||||
|
||||
ENTRYPOINT ["containarr-agent"]
|
||||
|
||||
@ -43,7 +43,7 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DockerClient {
|
||||
inner: Docker,
|
||||
pub inner: Docker,
|
||||
}
|
||||
|
||||
impl DockerClient {
|
||||
|
||||
@ -5,15 +5,23 @@ pub mod proto {
|
||||
}
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bollard::container::LogOutput;
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, LogOutput, StartContainerOptions,
|
||||
StopContainerOptions, RemoveContainerOptions,
|
||||
};
|
||||
use bollard::network::ConnectNetworkOptions;
|
||||
use bollard::models::EndpointSettings;
|
||||
use bollard::image::CreateImageOptions;
|
||||
use docker::{ContainerBackend, DockerClient};
|
||||
use futures_util::StreamExt as _;
|
||||
use proto::{
|
||||
agent_gateway_client::AgentGatewayClient,
|
||||
agent_message, server_message,
|
||||
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
|
||||
ImageInfo, VolumeInfo, NetworkInfo,
|
||||
FileResult, ImageInfo, VolumeInfo, NetworkInfo,
|
||||
UpdateCheckResult,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::{collections::HashMap, env, time::Duration};
|
||||
use tokio::{sync::mpsc, task::JoinHandle, time};
|
||||
use tonic::Request;
|
||||
@ -170,6 +178,165 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
|
||||
});
|
||||
log_tasks.insert(cmd.container_id, handle);
|
||||
}
|
||||
Some(server_message::Payload::ListDir(cmd)) => {
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = list_dir_handler(&cmd.path).await;
|
||||
let fr = match result {
|
||||
Ok(content) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: true,
|
||||
error: String::new(),
|
||||
content,
|
||||
},
|
||||
Err(e) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: false,
|
||||
error: e.to_string(),
|
||||
content: vec![],
|
||||
},
|
||||
};
|
||||
let _ = tx_clone.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
Some(server_message::Payload::ReadFile(cmd)) => {
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::fs::read(&cmd.path).await;
|
||||
let fr = match result {
|
||||
Ok(content) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: true,
|
||||
error: String::new(),
|
||||
content,
|
||||
},
|
||||
Err(e) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: false,
|
||||
error: e.to_string(),
|
||||
content: vec![],
|
||||
},
|
||||
};
|
||||
let _ = tx_clone.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
Some(server_message::Payload::WriteFile(cmd)) => {
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = write_file_handler(&cmd.path, &cmd.content).await;
|
||||
let fr = match result {
|
||||
Ok(()) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: true,
|
||||
error: String::new(),
|
||||
content: vec![],
|
||||
},
|
||||
Err(e) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: false,
|
||||
error: e.to_string(),
|
||||
content: vec![],
|
||||
},
|
||||
};
|
||||
let _ = tx_clone.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
Some(server_message::Payload::ExecCompose(cmd)) => {
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = exec_compose_handler(&cmd.path, &cmd.action).await;
|
||||
let fr = match result {
|
||||
Ok(content) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: true,
|
||||
error: String::new(),
|
||||
content,
|
||||
},
|
||||
Err(e) => FileResult {
|
||||
command_id: cmd.command_id,
|
||||
success: false,
|
||||
error: e.to_string(),
|
||||
content: vec![],
|
||||
},
|
||||
};
|
||||
let _ = tx_clone.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
Some(server_message::Payload::CreateDir(cmd)) => {
|
||||
let tx = tx.clone();
|
||||
let path = cmd.path.clone();
|
||||
let cmd_id = cmd.command_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error) = match tokio::fs::create_dir_all(&path).await {
|
||||
Ok(_) => (true, String::new()),
|
||||
Err(e) => (false, e.to_string()),
|
||||
};
|
||||
let _ = tx.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(FileResult {
|
||||
command_id: cmd_id,
|
||||
success,
|
||||
error,
|
||||
content: vec![],
|
||||
})),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
Some(server_message::Payload::CheckUpdate(cmd)) => {
|
||||
let tx_clone = tx.clone();
|
||||
let docker_clone = docker.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = check_update_handler(&docker_clone.inner, &cmd.container_id).await;
|
||||
let msg = match result {
|
||||
Ok((update_available, current_digest, remote_digest)) => {
|
||||
UpdateCheckResult {
|
||||
command_id: cmd.command_id,
|
||||
container_id: cmd.container_id,
|
||||
update_available,
|
||||
current_digest,
|
||||
remote_digest,
|
||||
error: String::new(),
|
||||
}
|
||||
}
|
||||
Err(e) => UpdateCheckResult {
|
||||
command_id: cmd.command_id,
|
||||
container_id: cmd.container_id,
|
||||
update_available: false,
|
||||
current_digest: String::new(),
|
||||
remote_digest: String::new(),
|
||||
error: e.to_string(),
|
||||
},
|
||||
};
|
||||
let _ = tx_clone.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::UpdateCheckResult(msg)),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
Some(server_message::Payload::UpdateContainer(cmd)) => {
|
||||
let tx_clone = tx.clone();
|
||||
let docker_clone = docker.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = update_container_handler(&docker_clone.inner, &cmd.container_id).await;
|
||||
let (success, error) = match result {
|
||||
Ok(()) => (true, String::new()),
|
||||
Err(e) => (false, e.to_string()),
|
||||
};
|
||||
let _ = tx_clone.send(AgentMessage {
|
||||
payload: Some(agent_message::Payload::Result(proto::CommandResult {
|
||||
command_id: cmd.command_id,
|
||||
success,
|
||||
error,
|
||||
})),
|
||||
}).await;
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@ -205,6 +372,281 @@ pub(crate) fn unix_now() -> i64 {
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
// ── FS / compose helpers ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub has_compose: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn list_dir_handler(path: &str) -> Result<Vec<u8>> {
|
||||
let mut read_dir = tokio::fs::read_dir(path).await
|
||||
.with_context(|| format!("read_dir {path}"))?;
|
||||
let mut entries: Vec<DirEntry> = Vec::new();
|
||||
while let Some(entry) = read_dir.next_entry().await
|
||||
.with_context(|| format!("next_entry in {path}"))? {
|
||||
let file_type = entry.file_type().await
|
||||
.with_context(|| format!("file_type for {:?}", entry.path()))?;
|
||||
let is_dir = file_type.is_dir();
|
||||
let has_compose = if is_dir {
|
||||
let p = entry.path();
|
||||
tokio::fs::try_exists(p.join("docker-compose.yaml")).await.unwrap_or(false)
|
||||
|| tokio::fs::try_exists(p.join("docker-compose.yml")).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
entries.push(DirEntry {
|
||||
name: entry.file_name().to_string_lossy().to_string(),
|
||||
is_dir,
|
||||
has_compose,
|
||||
});
|
||||
}
|
||||
serde_json::to_vec(&entries).context("serialize dir entries")
|
||||
}
|
||||
|
||||
pub(crate) async fn write_file_handler(path: &str, content: &[u8]) -> Result<()> {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
.with_context(|| format!("create_dir_all {:?}", parent))?;
|
||||
}
|
||||
tokio::fs::write(path, content).await
|
||||
.with_context(|| format!("write file {path}"))
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_compose_handler(path: &str, action: &str) -> Result<Vec<u8>> {
|
||||
let mut cmd = tokio::process::Command::new("docker");
|
||||
cmd.args(match action {
|
||||
"up" => vec!["compose", "up", "-d"],
|
||||
"down" => vec!["compose", "down"],
|
||||
"pull" => vec!["compose", "pull"],
|
||||
_ => vec!["compose", "up", "-d"],
|
||||
});
|
||||
cmd.current_dir(path);
|
||||
let output = cmd.output().await
|
||||
.with_context(|| format!("exec docker compose {action} in {path}"))?;
|
||||
if output.status.success() {
|
||||
Ok(output.stdout)
|
||||
} else {
|
||||
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns `(update_available, current_digest, remote_digest)`.
|
||||
pub(crate) async fn check_update_handler(
|
||||
docker: &bollard::Docker,
|
||||
container_id: &str,
|
||||
) -> Result<(bool, String, String)> {
|
||||
// 1. Inspect the container to get the image name
|
||||
let inspect = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.with_context(|| format!("inspect container {container_id}"))?;
|
||||
|
||||
let image_name = inspect
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.image.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("container {container_id} has no image config"))?
|
||||
.to_string();
|
||||
|
||||
// 2. Get current digest before pull
|
||||
let current_digest = get_image_digest(docker, &image_name).await;
|
||||
|
||||
// 3. Pull the image and detect if it was updated
|
||||
let update_available = pull_image_and_detect_update(docker, &image_name).await?;
|
||||
|
||||
// 4. Get remote digest after pull
|
||||
let remote_digest = get_image_digest(docker, &image_name).await;
|
||||
|
||||
Ok((update_available, current_digest, remote_digest))
|
||||
}
|
||||
|
||||
/// Pull the image via bollard and detect whether a newer image was downloaded.
|
||||
/// Returns `true` if "Downloaded newer image" was detected in the pull stream.
|
||||
pub(crate) async fn pull_image_and_detect_update(
|
||||
docker: &bollard::Docker,
|
||||
image_name: &str,
|
||||
) -> Result<bool> {
|
||||
let mut stream = docker.create_image(
|
||||
Some(CreateImageOptions {
|
||||
from_image: image_name,
|
||||
..Default::default()
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut update_available = false;
|
||||
while let Some(item) = stream.next().await {
|
||||
let info = item.with_context(|| format!("pull stream error for {image_name}"))?;
|
||||
if let Some(status) = &info.status {
|
||||
if parse_pull_status(status) == Some(true) {
|
||||
update_available = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(update_available)
|
||||
}
|
||||
|
||||
/// Parse the pull status string to detect update availability.
|
||||
/// Extracted as a pure function to enable unit testing.
|
||||
pub(crate) fn parse_pull_status(status: &str) -> Option<bool> {
|
||||
if status.contains("Downloaded newer image") {
|
||||
Some(true)
|
||||
} else if status.contains("Image is up to date") {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the first repo digest for an image, falling back to the image ID.
|
||||
async fn get_image_digest(docker: &bollard::Docker, image_name: &str) -> String {
|
||||
match docker.inspect_image(image_name).await {
|
||||
Ok(info) => {
|
||||
info.repo_digests
|
||||
.and_then(|d| d.into_iter().next())
|
||||
.or_else(|| info.id)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("inspect_image {} failed: {:#}", image_name, e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a container update via bollard recreate (always).
|
||||
pub(crate) async fn update_container_handler(
|
||||
docker: &bollard::Docker,
|
||||
container_id: &str,
|
||||
) -> Result<()> {
|
||||
let inspect = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.with_context(|| format!("inspect container {container_id}"))?;
|
||||
|
||||
info!("updating container {container_id} via bollard recreate");
|
||||
standalone_recreate(docker, container_id, &inspect).await
|
||||
}
|
||||
|
||||
/// Recreate a standalone container with its original config.
|
||||
async fn standalone_recreate(
|
||||
docker: &bollard::Docker,
|
||||
container_id: &str,
|
||||
inspect: &bollard::models::ContainerInspectResponse,
|
||||
) -> Result<()> {
|
||||
let image_name = inspect
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.image.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("container has no image config"))?
|
||||
.to_string();
|
||||
|
||||
// Strip leading '/' from container name
|
||||
let container_name = inspect
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or(container_id)
|
||||
.trim_start_matches('/')
|
||||
.to_string();
|
||||
|
||||
// Collect extra networks before stopping (all networks except the primary one)
|
||||
let extra_networks: Vec<String> = {
|
||||
let primary = inspect
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|hc| hc.network_mode.as_deref())
|
||||
.unwrap_or("bridge")
|
||||
.to_string();
|
||||
inspect
|
||||
.network_settings
|
||||
.as_ref()
|
||||
.and_then(|ns| ns.networks.as_ref())
|
||||
.map(|nets| {
|
||||
nets.keys()
|
||||
.filter(|n| **n != primary)
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// 1. Pull the new image
|
||||
pull_image_and_detect_update(docker, &image_name).await?;
|
||||
|
||||
// 2. Stop the container (ignore error if already stopped)
|
||||
let _ = docker
|
||||
.stop_container(container_id, Some(StopContainerOptions { t: 10 }))
|
||||
.await;
|
||||
|
||||
// 3. Remove the container
|
||||
docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("remove container {container_id}"))?;
|
||||
|
||||
// 4. Recreate with the same config
|
||||
let config = inspect.config.clone().unwrap_or_default();
|
||||
let host_config = inspect.host_config.clone();
|
||||
|
||||
let create_config = Config {
|
||||
image: Some(image_name.clone()),
|
||||
cmd: config.cmd,
|
||||
env: config.env,
|
||||
labels: config.labels,
|
||||
exposed_ports: config.exposed_ports,
|
||||
volumes: config.volumes,
|
||||
working_dir: config.working_dir,
|
||||
entrypoint: config.entrypoint,
|
||||
host_config,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let created = docker
|
||||
.create_container(
|
||||
Some(CreateContainerOptions {
|
||||
name: container_name.as_str(),
|
||||
..Default::default()
|
||||
}),
|
||||
create_config,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("create container {container_name}"))?;
|
||||
|
||||
// 5. Start the new container
|
||||
docker
|
||||
.start_container(&created.id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.with_context(|| format!("start container {}", created.id))?;
|
||||
|
||||
// 6. Reconnect to extra networks
|
||||
for net_name in &extra_networks {
|
||||
let _ = docker
|
||||
.connect_network(
|
||||
net_name,
|
||||
ConnectNetworkOptions {
|
||||
container: created.id.clone(),
|
||||
endpoint_config: EndpointSettings::default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
info!("container {container_name} recreated with new image {image_name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@ -457,4 +899,258 @@ mod tests {
|
||||
assert_eq!(chunk.stream, "stdout");
|
||||
assert_eq!(chunk.data, b"hello");
|
||||
}
|
||||
|
||||
// ── DirEntry JSON serialization ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dir_entry_serializes_file() {
|
||||
let entry = DirEntry { name: "foo.txt".to_string(), is_dir: false, has_compose: false };
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains(r#""name":"foo.txt""#));
|
||||
assert!(json.contains(r#""is_dir":false"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_entry_serializes_directory() {
|
||||
let entry = DirEntry { name: "subdir".to_string(), is_dir: true, has_compose: false };
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains(r#""name":"subdir""#));
|
||||
assert!(json.contains(r#""is_dir":true"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_entries_serialize_as_array() {
|
||||
let entries = vec![
|
||||
DirEntry { name: "a".to_string(), is_dir: false, has_compose: false },
|
||||
DirEntry { name: "b".to_string(), is_dir: true, has_compose: false },
|
||||
];
|
||||
let bytes = serde_json::to_vec(&entries).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
assert_eq!(parsed.as_array().unwrap().len(), 2);
|
||||
assert_eq!(parsed[0]["name"], "a");
|
||||
assert_eq!(parsed[1]["is_dir"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_entry_empty_name_is_valid() {
|
||||
let entry = DirEntry { name: String::new(), is_dir: false, has_compose: false };
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains(r#""name":"""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_entry_roundtrips_via_json() {
|
||||
let entries = vec![
|
||||
DirEntry { name: "compose.yml".to_string(), is_dir: false, has_compose: false },
|
||||
DirEntry { name: "data".to_string(), is_dir: true, has_compose: false },
|
||||
];
|
||||
let bytes = serde_json::to_vec(&entries).unwrap();
|
||||
let parsed: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(parsed[0]["name"], "compose.yml");
|
||||
assert_eq!(parsed[0]["is_dir"], false);
|
||||
assert_eq!(parsed[1]["name"], "data");
|
||||
assert_eq!(parsed[1]["is_dir"], true);
|
||||
}
|
||||
|
||||
// ── FileResult proto construction ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn file_result_success_fields() {
|
||||
let fr = FileResult {
|
||||
command_id: "cmd-fs-1".to_string(),
|
||||
success: true,
|
||||
error: String::new(),
|
||||
content: b"hello world".to_vec(),
|
||||
};
|
||||
assert!(fr.success);
|
||||
assert!(fr.error.is_empty());
|
||||
assert_eq!(fr.content, b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_result_error_fields() {
|
||||
let fr = FileResult {
|
||||
command_id: "cmd-fs-2".to_string(),
|
||||
success: false,
|
||||
error: "permission denied".to_string(),
|
||||
content: vec![],
|
||||
};
|
||||
assert!(!fr.success);
|
||||
assert_eq!(fr.error, "permission denied");
|
||||
assert!(fr.content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_message_wraps_file_result() {
|
||||
let fr = FileResult {
|
||||
command_id: "x".to_string(),
|
||||
success: true,
|
||||
error: String::new(),
|
||||
content: vec![],
|
||||
};
|
||||
let msg = AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
};
|
||||
assert!(matches!(
|
||||
msg.payload,
|
||||
Some(agent_message::Payload::FileResult(_))
|
||||
));
|
||||
}
|
||||
|
||||
// ── CreateDir message construction ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn create_dir_success_builds_correct_file_result() {
|
||||
// Simulate the success branch of the CreateDir handler
|
||||
let cmd_id = "create-dir-cmd-1".to_string();
|
||||
let (success, error) = (true, String::new());
|
||||
let fr = FileResult {
|
||||
command_id: cmd_id.clone(),
|
||||
success,
|
||||
error,
|
||||
content: vec![],
|
||||
};
|
||||
let msg = AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
};
|
||||
// The message must wrap a FileResult with success=true and no content
|
||||
if let Some(agent_message::Payload::FileResult(fr)) = msg.payload {
|
||||
assert!(fr.success);
|
||||
assert!(fr.error.is_empty());
|
||||
assert!(fr.content.is_empty());
|
||||
assert_eq!(fr.command_id, cmd_id);
|
||||
} else {
|
||||
panic!("expected FileResult payload");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_error_builds_correct_file_result() {
|
||||
// Simulate the error branch of the CreateDir handler
|
||||
let cmd_id = "create-dir-cmd-2".to_string();
|
||||
let err_msg = "permission denied (os error 13)".to_string();
|
||||
let (success, error) = (false, err_msg.clone());
|
||||
let fr = FileResult {
|
||||
command_id: cmd_id.clone(),
|
||||
success,
|
||||
error,
|
||||
content: vec![],
|
||||
};
|
||||
let msg = AgentMessage {
|
||||
payload: Some(agent_message::Payload::FileResult(fr)),
|
||||
};
|
||||
if let Some(agent_message::Payload::FileResult(fr)) = msg.payload {
|
||||
assert!(!fr.success);
|
||||
assert_eq!(fr.error, err_msg);
|
||||
assert!(fr.content.is_empty());
|
||||
assert_eq!(fr.command_id, cmd_id);
|
||||
} else {
|
||||
panic!("expected FileResult payload");
|
||||
}
|
||||
}
|
||||
|
||||
// ── parse_pull_status — unit tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_pull_status_detects_newer_image() {
|
||||
let status = "Status: Downloaded newer image for nginx:latest";
|
||||
assert_eq!(parse_pull_status(status), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_status_detects_up_to_date() {
|
||||
let status = "Status: Image is up to date for nginx:latest";
|
||||
assert_eq!(parse_pull_status(status), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_status_returns_none_for_progress() {
|
||||
assert_eq!(parse_pull_status("Pulling from library/nginx"), None);
|
||||
assert_eq!(parse_pull_status("Pull complete"), None);
|
||||
assert_eq!(parse_pull_status("Digest: sha256:abc123"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_status_returns_none_for_empty_string() {
|
||||
assert_eq!(parse_pull_status(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_status_newer_image_takes_priority() {
|
||||
// Edge case: both substrings in same string (shouldn't happen in practice)
|
||||
let status = "Downloaded newer image is up to date";
|
||||
assert_eq!(parse_pull_status(status), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pull_status_case_sensitive() {
|
||||
// Docker sends title-case; lowercase should not match
|
||||
assert_eq!(parse_pull_status("downloaded newer image"), None);
|
||||
assert_eq!(parse_pull_status("image is up to date"), None);
|
||||
}
|
||||
|
||||
// ── UpdateCheckResult proto construction ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn update_check_result_no_update_fields() {
|
||||
let r = UpdateCheckResult {
|
||||
command_id: "chk-1".to_string(),
|
||||
container_id: "c1".to_string(),
|
||||
update_available: false,
|
||||
current_digest: "sha256:aaa".to_string(),
|
||||
remote_digest: "sha256:aaa".to_string(),
|
||||
error: String::new(),
|
||||
};
|
||||
assert!(!r.update_available);
|
||||
assert_eq!(r.current_digest, r.remote_digest);
|
||||
assert!(r.error.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_result_update_available_fields() {
|
||||
let r = UpdateCheckResult {
|
||||
command_id: "chk-2".to_string(),
|
||||
container_id: "c2".to_string(),
|
||||
update_available: true,
|
||||
current_digest: "sha256:old".to_string(),
|
||||
remote_digest: "sha256:new".to_string(),
|
||||
error: String::new(),
|
||||
};
|
||||
assert!(r.update_available);
|
||||
assert_ne!(r.current_digest, r.remote_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_result_error_fields() {
|
||||
let r = UpdateCheckResult {
|
||||
command_id: "chk-3".to_string(),
|
||||
container_id: "c3".to_string(),
|
||||
update_available: false,
|
||||
current_digest: String::new(),
|
||||
remote_digest: String::new(),
|
||||
error: "container not found".to_string(),
|
||||
};
|
||||
assert!(!r.update_available);
|
||||
assert!(!r.error.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_message_wraps_update_check_result() {
|
||||
let r = UpdateCheckResult {
|
||||
command_id: "chk-4".to_string(),
|
||||
container_id: "c4".to_string(),
|
||||
update_available: true,
|
||||
current_digest: "sha256:old".to_string(),
|
||||
remote_digest: "sha256:new".to_string(),
|
||||
error: String::new(),
|
||||
};
|
||||
let msg = AgentMessage {
|
||||
payload: Some(agent_message::Payload::UpdateCheckResult(r)),
|
||||
};
|
||||
assert!(matches!(
|
||||
msg.payload,
|
||||
Some(agent_message::Payload::UpdateCheckResult(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user