use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{info, warn};
use crate::config::{CredentialStore, ResolvedSettings};
use crate::docker::Docker;
use crate::docker::recreate::{Cleanup, DockerOps, recreate_with_health};
use crate::errors::AppError;
use crate::health::{Clock, HealthConfig, HealthProbe, TokioClock};
use crate::labels::{self, Mode, PolicyDefaults};
use crate::updater::RecreateOutcome;
pub async fn run(
name: String,
credentials: Arc<CredentialStore>,
settings: ResolvedSettings,
) -> Result<(), AppError> {
let docker = Docker::connect(credentials)?;
run_with(
&docker,
&name,
&HealthConfig::default(),
&TokioClock,
settings.policy_defaults(),
settings.prune_dangling,
current_unix_timestamp,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_with(
docker: &(impl DockerOps + HealthProbe),
name: &str,
health: &HealthConfig,
clock: &impl Clock,
defaults: PolicyDefaults,
prune_dangling: bool,
ts_provider: impl Fn() -> i64,
) -> Result<(), AppError> {
let spec = docker.inspect(name).await?;
let empty: HashMap<String, String> = HashMap::new();
let policy = labels::parse_policy(spec.config.labels.as_ref().unwrap_or(&empty), defaults)?;
if !policy.enabled || policy.mode == Mode::Off {
warn!(
container = %name,
mode = %policy.mode,
enabled = policy.enabled,
"refusing to recreate: container is not opted into freshdock \
(set freshdock.enable=true and a non-off mode to allow even \
manual recreate)"
);
return Ok(());
}
let cleanup = Cleanup {
remove_replaced: policy.cleanup,
prune_dangling,
};
let outcome = recreate_with_health(docker, name, health, clock, cleanup, ts_provider).await?;
match outcome {
RecreateOutcome::Recreated { old_name, new_id } => {
info!(
container = %name,
archived_as = %old_name,
new_id = %new_id,
"recreate complete — new container healthy, old container removed"
);
println!(
"recreated {name}: healthy — removed old container {old_name}, new id {new_id}"
);
}
RecreateOutcome::RolledBack(event) => {
warn!(
container = %name,
reason = ?event.reason,
restored_from = %event.restored_from,
"recreate rolled back — previous container restored"
);
println!(
"recreate failed for {name}: new image was unhealthy ({:?}); rolled back to the previous container (restored from {})",
event.reason, event.restored_from
);
}
}
Ok(())
}
fn current_unix_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docker::DockerError;
use crate::docker::spec::ContainerSpec;
use crate::health::{ContainerRuntimeState, TokioClock};
use crate::registry::ImageRef;
use async_trait::async_trait;
use bollard::models::ContainerConfig;
struct GateOps {
labels: Option<HashMap<String, String>>,
}
impl GateOps {
fn with_labels(pairs: &[(&str, &str)]) -> Self {
Self {
labels: Some(
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
),
}
}
fn without_labels() -> Self {
Self { labels: None }
}
}
#[async_trait]
impl DockerOps for GateOps {
async fn inspect(&self, name: &str) -> Result<ContainerSpec, DockerError> {
Ok(ContainerSpec {
name: name.to_owned(),
image_ref: "nginx:alpine".to_owned(),
image_id: None,
config: ContainerConfig {
labels: self.labels.clone(),
..Default::default()
},
host_config: None,
network_endpoints: None,
})
}
async fn pull(&self, _image_ref: &ImageRef) -> Result<(), DockerError> {
panic!("policy gate must refuse before pull");
}
async fn stop(
&self,
_name: &str,
_signal: Option<&str>,
_timeout_s: Option<i64>,
) -> Result<(), DockerError> {
panic!("policy gate must refuse before stop");
}
async fn rename(&self, _name: &str, _ts_unix: i64) -> Result<String, DockerError> {
panic!("policy gate must refuse before rename");
}
async fn create_from_spec(
&self,
_name: &str,
_spec: &ContainerSpec,
_image: &str,
) -> Result<String, DockerError> {
panic!("policy gate must refuse before create");
}
async fn start(&self, _name_or_id: &str) -> Result<(), DockerError> {
panic!("policy gate must refuse before start");
}
async fn remove(&self, _name_or_id: &str, _force: bool) -> Result<(), DockerError> {
panic!("policy gate must refuse before remove");
}
async fn rename_to(&self, _from: &str, _to: &str) -> Result<(), DockerError> {
panic!("policy gate must refuse before rename_to");
}
async fn remove_image(&self, _id: &str, _force: bool) -> Result<(), DockerError> {
panic!("policy gate must refuse before remove_image");
}
async fn prune_dangling_images(&self) -> Result<(), DockerError> {
panic!("policy gate must refuse before prune_dangling_images");
}
}
#[async_trait]
impl HealthProbe for GateOps {
async fn probe_state(&self, _id: &str) -> Result<ContainerRuntimeState, DockerError> {
panic!("policy gate must refuse before any health probe");
}
}
async fn assert_refused(ops: GateOps) {
run_with(
&ops,
"c",
&HealthConfig::default(),
&TokioClock,
PolicyDefaults::default(),
false,
|| 0,
)
.await
.expect("a refused recreate is a graceful no-op, not an error");
}
#[tokio::test]
async fn no_freshdock_labels_is_refused() {
assert_refused(GateOps::without_labels()).await;
}
#[tokio::test]
async fn enable_false_is_refused() {
assert_refused(GateOps::with_labels(&[("freshdock.enable", "false")])).await;
}
#[tokio::test]
async fn mode_off_is_refused() {
assert_refused(GateOps::with_labels(&[
("freshdock.enable", "true"),
("freshdock.mode", "off"),
]))
.await;
}
}