mod common;
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use lightshuttle_manifest::Manifest;
use lightshuttle_runtime::{
ContainerStatus, DockerRuntime, LifecycleHandle, LifecycleManager, LifecyclePlan, ManagerHandle,
};
const HEALTH_DEADLINE: Duration = Duration::from_secs(60);
const STOP_GRACE: Duration = Duration::from_secs(3);
fn plan_from(yaml: &str) -> LifecyclePlan {
let manifest = Manifest::parse(yaml).expect("manifest parses");
LifecyclePlan::from_manifest(&manifest).expect("plan builds")
}
fn observer() -> DockerRuntime {
DockerRuntime::connect().expect("Docker daemon reachable")
}
fn is_live(status: &ContainerStatus) -> bool {
matches!(
status,
ContainerStatus::Running | ContainerStatus::Starting | ContainerStatus::Healthy
)
}
#[tokio::test]
#[ignore = "requires a running Docker daemon"]
async fn up_brings_the_stack_live_then_down_stops_it() {
if !common::docker_available() {
eprintln!("skipping: docker not available");
return;
}
let project = common::unique_project("up");
let _guard = common::ProjectCleanup::new(project.clone());
let plan = plan_from(&two_container_stack(&project));
let runtime = DockerRuntime::connect().expect("Docker daemon reachable");
let (manager, _events) = LifecycleManager::new(plan, runtime);
manager.start_all().await.expect("stack boots");
let live = observer()
.list_managed(&project)
.await
.expect("list after start");
assert_eq!(
live.len(),
2,
"both resources should be managed, got {live:?}"
);
assert!(
live.iter().all(|c| is_live(&c.status)),
"every resource should be live after start, got {live:?}"
);
manager.stop_all(STOP_GRACE).await.expect("stack stops");
let stopped = observer()
.list_managed(&project)
.await
.expect("list after stop");
assert!(
stopped.iter().all(|c| !is_live(&c.status)),
"no resource should remain live after stop, got {stopped:?}"
);
}
#[tokio::test]
#[ignore = "requires a running Docker daemon"]
async fn health_gating_emits_resource_healthy() {
if !common::docker_available() {
eprintln!("skipping: docker not available");
return;
}
let project = common::unique_project("hc");
let _guard = common::ProjectCleanup::new(project.clone());
let plan = plan_from(&healthchecked_stack(&project));
let runtime = DockerRuntime::connect().expect("Docker daemon reachable");
let (manager, mut events) = LifecycleManager::new(plan, runtime);
manager.start_all().await.expect("stack boots");
common::wait_for_healthy(&mut events, "web", HEALTH_DEADLINE)
.await
.expect("web should report healthy from its Docker healthcheck");
manager.stop_all(STOP_GRACE).await.expect("stack stops");
}
#[tokio::test]
#[ignore = "requires a running Docker daemon"]
async fn restart_one_recreates_target_and_leaves_dependent_running() {
if !common::docker_available() {
eprintln!("skipping: docker not available");
return;
}
let project = common::unique_project("rst");
let _guard = common::ProjectCleanup::new(project.clone());
let plan = plan_from(&dependent_stack(&project));
let runtime = DockerRuntime::connect().expect("Docker daemon reachable");
let (manager, mut events) = LifecycleManager::new(plan, runtime);
manager.start_all().await.expect("stack boots");
common::wait_for_healthy(&mut events, "cache", HEALTH_DEADLINE)
.await
.expect("cache healthy before restart");
let api_before = managed_id(&project, "api").await;
manager.restart_one("cache").await.expect("cache restarts");
common::wait_for_healthy(&mut events, "cache", HEALTH_DEADLINE)
.await
.expect("cache healthy again after restart");
let api_after = managed_id(&project, "api").await;
assert_eq!(
api_before, api_after,
"the dependent `api` container must survive a restart of `cache`"
);
manager.stop_all(STOP_GRACE).await.expect("stack stops");
}
async fn managed_id(project: &str, resource: &str) -> String {
let managed = observer()
.list_managed(project)
.await
.expect("list managed");
match managed.into_iter().find(|c| c.resource == resource) {
Some(container) => container.id.to_string(),
None => panic!("resource `{resource}` should be managed in `{project}`"),
}
}
#[tokio::test]
#[ignore = "requires a running Docker daemon"]
async fn logs_stream_real_container_output() {
if !common::docker_available() {
eprintln!("skipping: docker not available");
return;
}
let project = common::unique_project("log");
let _guard = common::ProjectCleanup::new(project.clone());
let plan = plan_from(&log_marker_stack(&project));
let runtime = DockerRuntime::connect().expect("Docker daemon reachable");
let (manager, _events) = LifecycleManager::new(plan, runtime);
manager.start_all().await.expect("printer boots");
let handle = ManagerHandle::new(Arc::new(manager));
let mut stream = handle
.logs("printer", false)
.await
.expect("logs stream opens");
let mut collected = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.expect("log chunk reads");
collected.extend_from_slice(&chunk.bytes);
}
let text = String::from_utf8_lossy(&collected);
assert!(
text.contains(LOG_MARKER),
"log stream should carry the printed marker, got `{text}`"
);
handle
.manager()
.stop_all(STOP_GRACE)
.await
.expect("stack stops");
}
const LOG_MARKER: &str = "LIGHTSHUTTLE_LOG_MARKER";
fn two_container_stack(project: &str) -> String {
format!(
r#"
project:
name: {project}
resources:
alpha:
container:
image: alpine:3.20
command: ["sh", "-c", "sleep 60"]
beta:
container:
image: alpine:3.20
command: ["sh", "-c", "sleep 60"]
"#
)
}
fn healthchecked_stack(project: &str) -> String {
format!(
r#"
project:
name: {project}
resources:
web:
container:
image: alpine:3.20
command: ["sh", "-c", "sleep 60"]
healthcheck:
test: ["CMD-SHELL", "true"]
interval: "1s"
timeout: "2s"
retries: 3
start_period: "1s"
"#
)
}
fn dependent_stack(project: &str) -> String {
format!(
r#"
project:
name: {project}
resources:
cache:
container:
image: alpine:3.20
command: ["sh", "-c", "sleep 60"]
healthcheck:
test: ["CMD-SHELL", "true"]
interval: "1s"
timeout: "2s"
retries: 3
start_period: "1s"
api:
container:
image: alpine:3.20
command: ["sh", "-c", "sleep 60"]
depends_on: [cache]
"#
)
}
fn log_marker_stack(project: &str) -> String {
format!(
r#"
project:
name: {project}
resources:
printer:
container:
image: alpine:3.20
command: ["sh", "-c", "echo {LOG_MARKER}; sleep 60"]
"#
)
}