Skip to content
Merged

Dev #120

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8ab234c
build(deps): bump rustls-webpki from 0.103.10 to 0.103.13
dependabot[bot] May 15, 2026
f84162a
Update ws to version 8.21.0
depfu[bot] Jun 1, 2026
b668dcc
build(deps): bump openssl from 0.10.76 to 0.10.80 in /stacker/stacker
dependabot[bot] Jun 1, 2026
56ecfe7
build(deps): bump russh from 0.58.0 to 0.61.1 in /stacker/stacker
dependabot[bot] Jun 1, 2026
e4254a7
build(deps): bump tar from 0.4.45 to 0.4.46 in /stacker/stacker
dependabot[bot] Jun 1, 2026
0e4f4cc
label_matches
robotizeit Jul 16, 2026
a1b6e30
cargo fmt all
robotizeit Jul 16, 2026
8094110
probe timeout
robotizeit Jul 16, 2026
3b7ab67
docker_cli_output fix
robotizeit Jul 17, 2026
80079e7
Merge remote-tracking branch 'remotes/origin/depfu/update/stackerstac…
robotizeit Jul 17, 2026
ee8afb8
Merge remote-tracking branch 'remotes/origin/dependabot/cargo/rustls-…
robotizeit Jul 17, 2026
6cbf17d
Merge remote-tracking branch 'remotes/origin/dependabot/cargo/stacker…
robotizeit Jul 17, 2026
c98efa5
Merge remote-tracking branch 'remotes/origin/dependabot/cargo/stacker…
robotizeit Jul 17, 2026
e6ef3d4
Merge remote-tracking branch 'remotes/origin/dependabot/cargo/stacker…
robotizeit Jul 17, 2026
f9282b0
source_url: Option<String> added, optional external source URL (agent…
robotizeit Jul 20, 2026
d6c5287
fmt
robotizeit Jul 20, 2026
f1f3cb8
feat(pipe): add source_url for external HTTP source fetch
robotizeit Jul 20, 2026
c1c4a9b
fix: add source_url to remaining test constructors
robotizeit Jul 20, 2026
935d87c
fix: update test assertion to match new source_url error message
robotizeit Jul 20, 2026
25a4d6e
target_headers added
robotizeit Jul 20, 2026
8b5dba7
feat: add target_headers support for authenticated pipe delivery
robotizeit Jul 20, 2026
3a41413
probe with curl
robotizeit Jul 20, 2026
2c93f5b
clippy error fix
robotizeit Aug 1, 2026
345ec12
fix: gate docker-only container target behind cfg(feature = "docker")
robotizeit Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

170 changes: 140 additions & 30 deletions src/agent/docker.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#![cfg(feature = "docker")]
use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};
use bollard::container::LogOutput;
use bollard::exec::CreateExecOptions;
use bollard::models::{ContainerStatsResponse, ContainerSummaryStateEnum};
Expand All @@ -10,8 +10,35 @@ use bollard::query_parameters::{
use bollard::Docker;
use serde::Serialize;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, error};

/// Upper bound for a single Docker control-plane operation (list/inspect/exec
/// setup). Wraps bollard calls so an unresponsive daemon can never hang a
/// resolver — it covers both connection and execution, since the timeout spans
/// the whole request including connect.
const DOCKER_OP_TIMEOUT: Duration = Duration::from_secs(10);

/// Run a Docker control-plane future with [`DOCKER_OP_TIMEOUT`], mapping an
/// elapsed timeout into an error rather than hanging. Accepts any error type
/// bollard returns (it maps into `anyhow`).
async fn with_docker_timeout<T, E>(
what: &str,
fut: impl std::future::Future<Output = std::result::Result<T, E>>,
) -> Result<T>
where
E: std::error::Error + Send + Sync + 'static,
{
match tokio::time::timeout(DOCKER_OP_TIMEOUT, fut).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(err)) => Err(anyhow::Error::new(err).context(format!("docker {what}"))),
Err(_) => Err(anyhow::anyhow!(
"docker {what} timed out after {}s",
DOCKER_OP_TIMEOUT.as_secs()
)),
}
Comment on lines +16 to +39
}

#[derive(Serialize, Clone, Debug)]
pub struct ContainerInfo {
pub name: String,
Expand Down Expand Up @@ -119,14 +146,31 @@ fn name_matches(container_name: &str, app_code: &str) -> bool {
false
}

/// Stacker's stable service-identity label, set by the control plane on every
/// project service. Preferred over Docker Compose's `com.docker.compose.service`
/// because it carries the app code the control plane resolves by and survives
/// compose service renames (the generated main service is named `app`, but its
/// `my.stacker.service` label is the project code).
const STACKER_SERVICE_LABEL: &str = "my.stacker.service";
const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service";

/// If a container's labels identify it as `app_code`, return which label
/// matched (for logging). Prefers the stacker-owned label over Compose's.
fn label_matches_app(labels: &HashMap<String, String>, app_code: &str) -> Option<&'static str> {
if labels.get(STACKER_SERVICE_LABEL).map(String::as_str) == Some(app_code) {
return Some(STACKER_SERVICE_LABEL);
}
if labels.get(COMPOSE_SERVICE_LABEL).map(String::as_str) == Some(app_code) {
return Some(COMPOSE_SERVICE_LABEL);
}
None
}

pub async fn resolve_container_name(name: &str) -> Result<String> {
let docker = docker_client()?;
let opts: Option<ListContainersOptions> =
Some(ListContainersOptionsBuilder::default().all(true).build());
let list = docker
.list_containers(opts)
.await
.context("list containers")?;
let list = with_docker_timeout("list_containers", docker.list_containers(opts)).await?;

tracing::debug!(
app_code = name,
Expand All @@ -143,15 +187,14 @@ pub async fn resolve_container_name(name: &str) -> Result<String> {
available_containers.push(normalized.to_string());

if let Some(labels) = container.labels.as_ref() {
if let Some(service) = labels.get("com.docker.compose.service") {
if service == name {
tracing::info!(
app_code = name,
resolved_name = normalized,
"Container name resolved via compose service label"
);
return Ok(normalized.to_string());
}
if let Some(matched_label) = label_matches_app(labels, name) {
tracing::info!(
app_code = name,
resolved_name = normalized,
matched_label,
"Container name resolved via service label"
);
return Ok(normalized.to_string());
}
}

Expand All @@ -177,14 +220,39 @@ pub async fn resolve_container_name(name: &str) -> Result<String> {
Ok(name.to_string())
}

pub async fn list_containers() -> Result<Vec<ContainerInfo>> {
pub async fn get_container_port(name: &str) -> Result<u16> {
let docker = docker_client()?;
let opts: Option<ListContainersOptions> =
Some(ListContainersOptionsBuilder::default().all(true).build());
let list = docker
.list_containers(opts)
let list = with_docker_timeout("list_containers", docker.list_containers(opts)).await?;

let resolved = resolve_container_name(name)
.await
.context("list containers")?;
.unwrap_or_else(|_| name.to_string());

for container in &list {
if let Some(names) = &container.names {
for entry in names {
let normalized = entry.trim_start_matches('/');
if normalized == resolved {
if let Some(ports) = &container.ports {
if let Some(port) = ports.iter().next() {
return Ok(port.private_port);
}
}
}
}
}
}

bail!("no exposed port found for container '{}'", name)
}

pub async fn list_containers() -> Result<Vec<ContainerInfo>> {
let docker = docker_client()?;
let opts: Option<ListContainersOptions> =
Some(ListContainersOptionsBuilder::default().all(true).build());
let list = with_docker_timeout("list_containers", docker.list_containers(opts)).await?;
Ok(list
.into_iter()
.map(|c| {
Expand Down Expand Up @@ -215,10 +283,7 @@ pub async fn list_containers_with_logs(tail: &str) -> Result<Vec<ContainerInfo>>
let docker = docker_client()?;
let opts: Option<ListContainersOptions> =
Some(ListContainersOptionsBuilder::default().all(true).build());
let list = docker
.list_containers(opts)
.await
.context("list containers")?;
let list = with_docker_timeout("list_containers", docker.list_containers(opts)).await?;

let mut result = Vec::with_capacity(list.len());

Expand Down Expand Up @@ -372,10 +437,7 @@ pub async fn list_container_health() -> Result<Vec<ContainerHealth>> {
let docker = docker_client()?;
let opts: Option<ListContainersOptions> =
Some(ListContainersOptionsBuilder::default().all(true).build());
let list = docker
.list_containers(opts)
.await
.context("list containers")?;
let list = with_docker_timeout("list_containers", docker.list_containers(opts)).await?;

let mut health = Vec::with_capacity(list.len());

Expand Down Expand Up @@ -788,18 +850,30 @@ pub async fn exec_in_container_argv(name: &str, argv: Vec<String>) -> Result<()>
/// Execute a shell command inside a running container and return output.
/// Returns (exit_code, stdout, stderr) tuple.
pub async fn exec_in_container_with_output(name: &str, cmd: &str) -> Result<(i64, String, String)> {
let resolved_name = resolve_container_name(name)
.await
.unwrap_or_else(|_| name.to_string());
exec_in_container_with_output_resolved(&resolved_name, cmd).await
}

/// Like [`exec_in_container_with_output`] but assumes `name` is already a real,
/// resolved container name and skips the `resolve_container_name` lookup. Use
/// this in hot paths (e.g. endpoint probing) that resolve the container once up
/// front, to avoid a full `list_containers` API call on every invocation.
pub async fn exec_in_container_with_output_resolved(
resolved_name: &str,
cmd: &str,
) -> Result<(i64, String, String)> {
use bollard::exec::StartExecResults;
use futures_util::StreamExt;

let docker = docker_client()?;
let resolved_name = resolve_container_name(name)
.await
.unwrap_or_else(|_| name.to_string());
let name = resolved_name;

// Create exec instance
let exec = docker
.create_exec(
&resolved_name,
resolved_name,
CreateExecOptions {
Comment on lines 873 to 877
attach_stdout: Some(true),
attach_stderr: Some(true),
Expand Down Expand Up @@ -874,6 +948,42 @@ mod tests {
assert!(name_matches("/komodo", "komodo"));
}

#[test]
fn label_matches_prefers_stacker_service_over_compose() {
let mut labels = HashMap::new();
// Generated main service: compose service name is "app", but the
// stacker label carries the project code.
labels.insert(COMPOSE_SERVICE_LABEL.to_string(), "app".to_string());
labels.insert(
STACKER_SERVICE_LABEL.to_string(),
"wordpress-matomo".to_string(),
);

// Resolves by the stacker label even though the compose service is "app".
assert_eq!(
label_matches_app(&labels, "wordpress-matomo"),
Some(STACKER_SERVICE_LABEL)
);
// The Docker Compose service name still resolves.
assert_eq!(
label_matches_app(&labels, "app"),
Some(COMPOSE_SERVICE_LABEL)
);
// No spurious match.
assert_eq!(label_matches_app(&labels, "matomo"), None);
}

#[test]
fn label_matches_compose_only_still_works() {
let mut labels = HashMap::new();
labels.insert(COMPOSE_SERVICE_LABEL.to_string(), "matomo".to_string());
assert_eq!(
label_matches_app(&labels, "matomo"),
Some(COMPOSE_SERVICE_LABEL)
);
assert_eq!(label_matches_app(&labels, "app"), None);
}

#[test]
fn test_name_matches_replica_suffix() {
assert!(name_matches("komodo_1", "komodo"));
Expand Down
Loading
Loading