Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
84 changes: 70 additions & 14 deletions bin/core/src/api/execute/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ impl super::BatchExecute for BatchDeployStack {
stack,
services: Vec::new(),
stop_time: None,
force_recreate: false,
})
}
}
Expand Down Expand Up @@ -220,6 +221,7 @@ impl Resolve<ExecuteArgs> for DeployStack {
git_token,
registry_token,
replacers: secret_replacers.into_iter().collect(),
force_recreate: self.force_recreate,
})
.await?
}
Expand Down Expand Up @@ -423,12 +425,15 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
.map(|s| s.service_name.clone())
.collect::<Vec<_>>();
resolve_deploy_if_changed_action(
&stack,
deployed_contents,
latest_contents,
&services,
)
}
(None, _) => DeployIfChangedAction::FullDeploy,
(None, _) => DeployIfChangedAction::FullDeploy {
force_recreate: false,
},
_ => DeployIfChangedAction::Services {
deploy: Vec::new(),
restart: Vec::new(),
Expand All @@ -439,7 +444,7 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {

match action {
// Existing path pre 1.19.1
DeployIfChangedAction::FullDeploy => {
DeployIfChangedAction::FullDeploy { force_recreate } => {
// Don't actually send it here, let the handler send it after it can set action state.
// This is usually done in crate::helpers::update::init_execution_update.
update.id = add_update_without_send(&update).await?;
Expand All @@ -448,6 +453,7 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
stack: stack.name,
services: Vec::new(),
stop_time: self.stop_time,
force_recreate: force_recreate,
}
.resolve(&ExecuteArgs {
user: user.clone(),
Expand Down Expand Up @@ -570,6 +576,18 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
services = format!("{services:?}")
)
)]
/// Only ever called from `DeployStackIfChanged` with a service list derived
/// from changed `config_files` entries — compose and env files are registered
/// with an empty `services` array (see `StackFileDependency::full_redeploy`),
/// so they can never reach here. That makes `force_recreate` unconditionally
/// correct: a config file's content change does not alter the compose service
/// definition, so `up -d` alone would leave the container running the old file.
///
/// Uses `--force-recreate` rather than a `compose down` first. `down` is
/// service-scoped in name only — it also removes every service that depends on
/// the target, and the following `up -d <target>` restores only the target and
/// its dependencies, leaving those dependents removed. Measured 2026-08-04:
/// `down -- db` took `api` and `web` with it and neither came back.
async fn deploy_services(
stack: String,
services: Vec<String>,
Expand All @@ -582,6 +600,7 @@ async fn deploy_services(
stack,
services,
stop_time: None,
force_recreate: true,
});
let update = init_execution_update(&req, user).await?;
let ExecuteRequest::DeployStack(req) = req else {
Expand Down Expand Up @@ -688,7 +707,21 @@ async fn update_deployed_contents_with_latest(
enum DeployIfChangedAction {
/// Changes to any compose or env files
/// always lead to this.
FullDeploy,
FullDeploy {
/// Whether to pass `--force-recreate`.
///
/// True only when a `config_files` entry with an empty `services` array
/// is what triggered the deploy. Such a change is invisible to docker's
/// own diff — the compose service definitions are unchanged, only the
/// content behind a bind mount moved — so a plain `up -d` would report
/// every container up-to-date and recreate nothing.
///
/// Deliberately NOT set when a compose or env file triggered the deploy:
/// those change the service definitions, so docker already recreates
/// exactly the affected services, and forcing would needlessly bounce
/// every other container in the project.
force_recreate: bool,
},
/// If the above is not met, then changes to
/// any changed additional file with `requires = "Restart"`
/// and empty services array will lead to this.
Expand All @@ -705,31 +738,48 @@ enum DeployIfChangedAction {
}

fn resolve_deploy_if_changed_action(
stack: &Stack,
deployed_contents: &[FileContents],
latest_contents: &[StackRemoteFileContents],
all_services: &[String],
) -> DeployIfChangedAction {
let mut full_deploy = false;
let mut full_deploy_force_recreate = false;
let mut full_restart = false;
let mut deploy = HashSet::<String>::new();
let mut restart = HashSet::<String>::new();

for latest in latest_contents {
let Some(deployed) =
deployed_contents.iter().find(|c| c.path == latest.path)
else {
// If file doesn't exist in deployed contents, do full
// deploy to align this.
return DeployIfChangedAction::FullDeploy;
};
// Ignore unchanged files
if latest.contents == deployed.contents {
// A file absent from deployed contents was only just declared, so there
// is nothing to compare it against — treat it as changed.
//
// Deliberately NOT an early return to FullDeploy. Falling through to the
// match below means a newly declared file is scoped by its own `services`
// and `requires`, exactly like any other changed file, so adding one
// `config_files` entry no longer bounces every service in the stack.
//
// The cases that genuinely need a full pass still get one without any
// special handling: compose and env files are registered with an empty
// `services` array, so they land in the `(Redeploy, true)` arm below.
let changed =
match deployed_contents.iter().find(|c| c.path == latest.path) {
Some(deployed) => latest.contents != deployed.contents,
None => true,
};
if !changed {
continue;
}
match (latest.requires, latest.services.is_empty()) {
(StackFileRequires::Redeploy, true) => {
// File has requires = "Redeploy" at global level.
// Can do early return here.
return DeployIfChangedAction::FullDeploy;
// No early return: a config file later in the list may still
// require the destroy step, and returning here would miss it.
full_deploy = true;
// Compose / env files reach this arm too, but only a config file's
// content change is invisible to docker's own diff.
if stack.is_config_file(&latest.path) {
full_deploy_force_recreate = true;
}
}
(StackFileRequires::Redeploy, false) => {
// Requires redeploy on specific services
Expand All @@ -749,6 +799,12 @@ fn resolve_deploy_if_changed_action(
}
}

if full_deploy {
return DeployIfChangedAction::FullDeploy {
force_recreate: full_deploy_force_recreate,
};
}

match (full_restart, deploy.is_empty()) {
// Full restart required with NO deploys needed -> Full Restart
(true, true) => DeployIfChangedAction::FullRestart,
Expand Down
1 change: 1 addition & 0 deletions bin/core/src/api/listener/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ impl StackExecution for DeployStack {
stack: stack.id,
services: Vec::new(),
stop_time: None,
force_recreate: false,
});
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::DeployStack(req) = req else {
Expand Down
3 changes: 3 additions & 0 deletions bin/core/src/api/write/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,9 @@ pub async fn check_stack_for_update_inner(
stack: stack.id.clone(),
services: deploy_services,
stop_time: None,
// Image digest changes alter the service definition, so docker
// already recreates the affected services on its own.
force_recreate: false,
}),
auto_redeploy_user().to_owned(),
)
Expand Down
1 change: 1 addition & 0 deletions bin/core/src/helpers/procedure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ impl ExtendBatch for BatchDeployStack {
stack,
services: Vec::new(),
stop_time: None,
force_recreate: false,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions bin/core/src/sync/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ pub async fn deploy_from_cache(
stack: name.to_string(),
services: Vec::new(),
stop_time: None,
force_recreate: false,
});

let update = init_execution_update(&req, user).await?;
Expand Down
20 changes: 19 additions & 1 deletion bin/periphery/src/api/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ impl Resolve<crate::api::Args> for ComposeUp {
git_token,
registry_token,
mut replacers,
force_recreate,
} = self;

let mut res = DeployStackResponse::default();
Expand Down Expand Up @@ -723,8 +724,25 @@ impl Resolve<crate::api::Args> for ComposeUp {

// Run compose up
let extra_args = format_extra_args(&stack.config.extra_args);
// Requested by the caller for this deploy only. [DeployStackIfChanged] sets
// it when it acts on a `config_files` diff: only the content behind a bind
// mount moved, so the service definition is unchanged and a plain `up -d`
// reports the container up-to-date and recreates nothing.
//
// Skipped when the user has already pinned recreate behaviour themselves —
// `--force-recreate` and `--no-recreate` are mutually exclusive and compose
// hard-errors on the pair, which would fail the deploy outright.
let force_recreate = if force_recreate
&& !stack.config.extra_args.iter().any(|arg| {
let arg = arg.trim();
arg == "--force-recreate" || arg == "--no-recreate"
}) {
" --force-recreate"
} else {
""
};
let command = format!(
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} up -d{extra_args}{service_args}",
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} up -d{extra_args}{force_recreate}{service_args}",
);
let (command, _) = match maybe_wrap_command(
command,
Expand Down
15 changes: 15 additions & 0 deletions client/core/rs/src/api/execute/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ pub struct DeployStack {
/// Override the default termination max time.
/// Only used if the stack needs to be taken down first.
pub stop_time: Option<i32>,
/// Pass `--force-recreate`, recreating the target services even if their
/// compose definition is unchanged.
///
/// Needed when the only thing that changed is the *content* of a file
/// bind mounted into a service: the compose service definition is
/// unchanged, so `docker compose up -d` considers the container
/// up-to-date and will not recreate it. [DeployStackIfChanged] sets this
/// when it acts on a `config_files` diff.
///
/// Ignored if `extra_args` already pins recreate behaviour, since
/// `--force-recreate` and `--no-recreate` are mutually exclusive.
///
/// Note. For Swarm mode Stacks, this field is not supported and will be ignored.
#[serde(default)]
pub force_recreate: bool,
}

//
Expand Down
13 changes: 13 additions & 0 deletions client/periphery/rs/src/api/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ pub struct ComposeUp {
/// Propogate any secret replacers from core interpolation.
#[serde(default)]
pub replacers: Vec<(String, String)>,
/// Pass `--force-recreate` to `docker compose up`.
///
/// Set by `DeployStackIfChanged` when it acts on a `config_files` diff:
/// the compose service definition is unchanged in that case, so
/// `docker compose up -d` would consider the container up-to-date and
/// leave it running the old file contents.
///
/// Deliberately NOT a `compose down` first. Taking a service down is
/// service-scoped in name only: `compose down <svc>` also removes every
/// service that depends on it, and the following `up -d <svc>` brings back
/// only the target and its dependencies — leaving the dependents removed.
#[serde(default)]
pub force_recreate: bool,
}

//
Expand Down