periphery swarm stack deploy

This commit is contained in:
mbecker20
2025-12-10 21:39:56 -08:00
committed by Maxwell Becker
parent 436959cbcd
commit effe737ffe
7 changed files with 342 additions and 108 deletions
@@ -1,4 +1,4 @@
use std::{borrow::Cow, path::PathBuf};
use std::{borrow::Cow, fmt::Write, path::PathBuf};
use anyhow::{Context, anyhow};
use command::{
@@ -12,8 +12,9 @@ use komodo_client::{
entities::{
FileContents, RepoExecutionResponse, all_logs_success,
stack::{
ComposeFile, ComposeService, ComposeServiceDeploy,
StackRemoteFileContents, StackServiceNames,
AdditionalEnvFile, ComposeFile, ComposeService,
ComposeServiceDeploy, StackRemoteFileContents,
StackServiceNames,
},
to_path_compatible_name,
update::Log,
@@ -29,13 +30,12 @@ use crate::{
config::periphery_config,
docker::compose::docker_compose,
helpers::{format_extra_args, format_log_grep},
stack::{
maybe_login_registry, pull_or_clone_stack, validate_files,
write::write_stack,
},
};
mod helpers;
mod write;
use helpers::*;
impl Resolve<crate::api::Args> for GetComposeLog {
async fn resolve(
self,
@@ -287,7 +287,7 @@ impl Resolve<crate::api::Args> for ComposePull {
.push_logs(&mut res.logs);
replacers.extend(interpolator.secret_replacers);
let (run_directory, env_file_path) = match write::stack(
let (run_directory, env_file_path) = match write_stack(
&stack,
repo.as_ref(),
git_token,
@@ -396,12 +396,6 @@ impl Resolve<crate::api::Args> for ComposeUp {
mut replacers,
} = self;
if !stack.config.swarm_id.is_empty() {
return Err(anyhow!(
"This method should only be called for Compose Stacks. This is an internal error and should not happen."
));
}
let mut res = DeployStackResponse::default();
let mut interpolator =
@@ -413,7 +407,7 @@ impl Resolve<crate::api::Args> for ComposeUp {
.push_logs(&mut res.logs);
replacers.extend(interpolator.secret_replacers);
let (run_directory, env_file_path) = match write::stack(
let (run_directory, env_file_path) = match write_stack(
&stack,
repo.as_ref(),
git_token,
@@ -451,7 +445,7 @@ impl Resolve<crate::api::Args> for ComposeUp {
if !stack.config.pre_deploy.is_none() {
let pre_deploy_path =
run_directory.join(&stack.config.pre_deploy.path);
let span = info_span!("RunPreDeploy");
let span = info_span!("ExecutePreDeploy");
if let Some(log) = run_komodo_command_with_sanitization(
"Pre Deploy",
pre_deploy_path.as_path(),
@@ -562,7 +556,7 @@ impl Resolve<crate::api::Args> for ComposeUp {
let command = format!(
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} build{build_extra_args}{service_args}",
);
let span = info_span!("RunComposeBuild");
let span = info_span!("ExecuteComposeBuild");
let Some(log) = run_komodo_command_with_sanitization(
"Compose Build",
run_directory.as_path(),
@@ -606,11 +600,11 @@ impl Resolve<crate::api::Args> for ComposeUp {
// Also check if project name changed, which also requires taking down.
|| last_project_name != project_name
{
// Take down the existing containers.
// Take down the existing compose stack.
// This one tries to use the previously deployed service name, to ensure the right stack is taken down.
helpers::compose_down(&last_project_name, &services, &mut res)
compose_down(&last_project_name, &services, &mut res)
.await
.context("failed to destroy existing containers")?;
.context("Failed to take down existing compose stack")?;
}
// Run compose up
@@ -634,7 +628,7 @@ impl Resolve<crate::api::Args> for ComposeUp {
compose_cmd_wrapper.replace("[[COMPOSE_COMMAND]]", &command);
}
let span = info_span!("RunComposeUp");
let span = info_span!("ExecuteComposeUp");
let Some(log) = run_komodo_command_with_sanitization(
"Compose Up",
run_directory.as_path(),
@@ -654,7 +648,7 @@ impl Resolve<crate::api::Args> for ComposeUp {
if res.deployed && !stack.config.post_deploy.is_none() {
let post_deploy_path =
run_directory.join(&stack.config.post_deploy.path);
let span = info_span!("RunPostDeploy");
let span = info_span!("ExecutePostDeploy");
if let Some(log) = run_komodo_command_with_sanitization(
"Post Deploy",
post_deploy_path.as_path(),
@@ -747,7 +741,7 @@ impl Resolve<crate::api::Args> for ComposeRun {
replacers.extend(interpolator.secret_replacers);
let mut res = ComposeRunResponse::default();
let (run_directory, env_file_path) = match write::stack(
let (run_directory, env_file_path) = match write_stack(
&stack,
repo.as_ref(),
git_token,
@@ -864,3 +858,59 @@ impl Resolve<crate::api::Args> for ComposeRun {
Ok(log)
}
}
fn env_file_args(
env_file_path: Option<&str>,
additional_env_files: &[AdditionalEnvFile],
) -> anyhow::Result<String> {
let mut res = String::new();
// Add additional env files (except komodo's own, which comes last)
for file in additional_env_files
.iter()
.filter(|f| env_file_path != Some(f.path.as_str()))
{
let path = &file.path;
write!(res, " --env-file {path}").with_context(|| {
format!("Failed to write --env-file arg for {path}")
})?;
}
// Add komodo's env file last for highest priority
if let Some(file) = env_file_path {
write!(res, " --env-file {file}").with_context(|| {
format!("Failed to write --env-file arg for {file}")
})?;
}
Ok(res)
}
#[instrument("ComposeDown", skip(res))]
async fn compose_down(
project: &str,
services: &[String],
res: &mut DeployStackResponse,
) -> anyhow::Result<()> {
let docker_compose = docker_compose();
let service_args = if services.is_empty() {
String::new()
} else {
format!(" {}", services.join(" "))
};
let log = run_komodo_standard_command(
"Compose Down",
None,
format!("{docker_compose} -p {project} down{service_args}"),
)
.await;
let success = log.success;
res.logs.push(log);
if !success {
return Err(anyhow!(
"Failed to bring down existing container(s) with docker compose down. Stopping run."
));
}
Ok(())
}
-258
View File
@@ -1,258 +0,0 @@
use std::{
fmt::Write,
path::{Path, PathBuf},
};
use anyhow::{Context, anyhow};
use command::run_komodo_standard_command;
use formatting::format_serror;
use komodo_client::entities::{
FileContents, RepoExecutionArgs,
repo::Repo,
stack::{AdditionalEnvFile, Stack, StackRemoteFileContents},
to_path_compatible_name,
update::Log,
};
use periphery_client::api::{
DeployStackResponse, git::PullOrCloneRepo,
};
use resolver_api::Resolve;
use tokio::fs;
use crate::{
api::Args, config::periphery_config, docker::docker_login,
};
use super::docker_compose;
#[instrument(
"MaybeLoginRegistry",
skip_all,
fields(stack = stack.name)
)]
pub async fn maybe_login_registry(
stack: &Stack,
registry_token: Option<String>,
logs: &mut Vec<Log>,
) {
if !stack.config.registry_provider.is_empty()
&& !stack.config.registry_account.is_empty()
&& let Err(e) = docker_login(
&stack.config.registry_provider,
&stack.config.registry_account,
registry_token.as_deref(),
)
.await
.with_context(|| {
format!(
"Domain: '{}' | Account: '{}'",
stack.config.registry_provider, stack.config.registry_account
)
})
.context("Failed to login to image registry")
{
logs.push(Log::error(
"Login to Registry",
format_serror(&e.into()),
));
}
}
pub fn env_file_args(
env_file_path: Option<&str>,
additional_env_files: &[AdditionalEnvFile],
) -> anyhow::Result<String> {
let mut res = String::new();
// Add additional env files (except komodo's own, which comes last)
for file in additional_env_files
.iter()
.filter(|f| env_file_path != Some(f.path.as_str()))
{
let path = &file.path;
write!(res, " --env-file {path}").with_context(|| {
format!("Failed to write --env-file arg for {path}")
})?;
}
// Add komodo's env file last for highest priority
if let Some(file) = env_file_path {
write!(res, " --env-file {file}").with_context(|| {
format!("Failed to write --env-file arg for {file}")
})?;
}
Ok(res)
}
#[instrument("ComposeDown", skip(res))]
pub async fn compose_down(
project: &str,
services: &[String],
res: &mut DeployStackResponse,
) -> anyhow::Result<()> {
let docker_compose = docker_compose();
let service_args = if services.is_empty() {
String::new()
} else {
format!(" {}", services.join(" "))
};
let log = run_komodo_standard_command(
"Compose Down",
None,
format!("{docker_compose} -p {project} down{service_args}"),
)
.await;
let success = log.success;
res.logs.push(log);
if !success {
return Err(anyhow!(
"Failed to bring down existing container(s) with docker compose down. Stopping run."
));
}
Ok(())
}
/// Only for git repo based Stacks.
/// Returns path to root directory of the stack repo.
///
/// Both Stack and Repo environment, on clone, on pull are ignored.
#[instrument(
"PullOrCloneStack",
skip_all,
fields(
stack = stack.name,
repo = repo.as_ref().map(|repo| &repo.name),
)
)]
pub async fn pull_or_clone_stack(
stack: &Stack,
repo: Option<&Repo>,
git_token: Option<String>,
req_args: &Args,
) -> anyhow::Result<PathBuf> {
if stack.config.files_on_host {
return Err(anyhow!(
"Wrong method called for files on host stack"
));
}
if repo.is_none() && stack.config.repo.is_empty() {
return Err(anyhow!("Repo is not configured"));
}
let (root, mut args) = if let Some(repo) = repo {
let root = periphery_config()
.repo_dir()
.join(to_path_compatible_name(&repo.name))
.join(&repo.config.path)
.components()
.collect::<PathBuf>();
let args: RepoExecutionArgs = repo.into();
(root, args)
} else {
let root = periphery_config()
.stack_dir()
.join(to_path_compatible_name(&stack.name))
.join(&stack.config.clone_path)
.components()
.collect::<PathBuf>();
let args: RepoExecutionArgs = stack.into();
(root, args)
};
args.destination = Some(root.display().to_string());
let git_token = crate::helpers::git_token(git_token, &args)?;
PullOrCloneRepo {
args,
git_token,
// All the extra pull functions
// (env, on clone, on pull)
// are disabled with this method.
environment: Default::default(),
env_file_path: Default::default(),
on_clone: Default::default(),
on_pull: Default::default(),
skip_secret_interp: Default::default(),
replacers: Default::default(),
}
.resolve(req_args)
.await?;
Ok(root)
}
#[instrument(
"ValidateStackFiles",
skip(stack, res),
fields(stack = stack.name)
)]
pub async fn validate_files(
stack: &Stack,
run_directory: &Path,
res: &mut DeployStackResponse,
) {
let file_paths = stack
.all_file_dependencies()
.into_iter()
.map(|file| {
(
// This will remove any intermediate uneeded '/./' in the path
run_directory
.join(&file.path)
.components()
.collect::<PathBuf>(),
file,
)
})
.collect::<Vec<_>>();
// First validate no missing files
for (full_path, file) in &file_paths {
if !full_path.exists() {
res.missing_files.push(file.path.clone());
}
}
if !res.missing_files.is_empty() {
res.logs.push(Log::error(
"Validate Files",
format_serror(
&anyhow!(
"Missing files: {}", res.missing_files.join(", ")
)
.context("Ensure the run_directory and all file paths are correct.")
.context("A file doesn't exist after writing stack.")
.into(),
),
));
return;
}
for (full_path, file) in file_paths {
let file_contents =
match fs::read_to_string(&full_path).await.with_context(|| {
format!("Failed to read file contents at {full_path:?}")
}) {
Ok(res) => res,
Err(e) => {
let error = format_serror(&e.into());
res
.logs
.push(Log::error("Read Compose File", error.clone()));
// This should only happen for repo stacks, ie remote error
res.remote_errors.push(FileContents {
path: file.path,
contents: error,
});
return;
}
};
res.file_contents.push(StackRemoteFileContents {
path: file.path,
contents: file_contents,
services: file.services,
requires: file.requires,
});
}
}
-394
View File
@@ -1,394 +0,0 @@
use std::path::PathBuf;
use anyhow::{Context, anyhow};
use formatting::format_serror;
use komodo_client::entities::{
FileContents, RepoExecutionArgs, all_logs_success, repo::Repo,
stack::Stack, to_path_compatible_name, update::Log,
};
use periphery_client::api::{
DeployStackResponse,
compose::{ComposePullResponse, ComposeRunResponse},
git::{CloneRepo, PullOrCloneRepo},
};
use resolver_api::Resolve;
use tokio::fs;
use crate::{api::Args, config::periphery_config, helpers};
pub trait WriteStackRes {
fn logs(&mut self) -> &mut Vec<Log>;
fn add_remote_error(&mut self, _contents: FileContents) {}
fn set_commit_hash(&mut self, _hash: Option<String>) {}
fn set_commit_message(&mut self, _message: Option<String>) {}
}
impl WriteStackRes for &mut DeployStackResponse {
fn logs(&mut self) -> &mut Vec<Log> {
&mut self.logs
}
fn add_remote_error(&mut self, contents: FileContents) {
self.remote_errors.push(contents);
}
fn set_commit_hash(&mut self, hash: Option<String>) {
self.commit_hash = hash;
}
fn set_commit_message(&mut self, message: Option<String>) {
self.commit_message = message;
}
}
impl WriteStackRes for &mut ComposePullResponse {
fn logs(&mut self) -> &mut Vec<Log> {
&mut self.logs
}
}
impl WriteStackRes for &mut ComposeRunResponse {
fn logs(&mut self) -> &mut Vec<Log> {
&mut self.logs
}
}
/// Either writes the stack file_contents to a file, or clones the repo.
/// Asssumes all interpolation is already complete.
/// Returns (run_directory, env_file_path, periphery_replacers)
#[instrument(
"WriteStack",
skip_all,
fields(
stack = stack.name,
repo = repo.as_ref().map(|repo| &repo.name),
)
)]
pub async fn stack<'a>(
stack: &'a Stack,
repo: Option<&Repo>,
git_token: Option<String>,
replacers: Vec<(String, String)>,
res: impl WriteStackRes,
req_args: &Args,
) -> anyhow::Result<(
// run_directory
PathBuf,
// env_file_path
Option<&'a str>,
)> {
if stack.config.files_on_host {
write_stack_files_on_host(stack, res).await
} else if let Some(repo) = repo {
write_stack_linked_repo(
stack, repo, git_token, replacers, res, req_args,
)
.await
} else if !stack.config.repo.is_empty() {
write_stack_inline_repo(stack, git_token, res, req_args).await
} else {
write_stack_ui_defined(stack, res).await
}
}
#[instrument("WriteStackFilesOnHost", skip_all)]
async fn write_stack_files_on_host(
stack: &Stack,
mut res: impl WriteStackRes,
) -> anyhow::Result<(
// run_directory
PathBuf,
// env_file_path
Option<&str>,
)> {
let run_directory = periphery_config()
.stack_dir()
.join(to_path_compatible_name(&stack.name))
.join(&stack.config.run_directory)
.components()
.collect::<PathBuf>();
let env_file_path = environment::write_env_file(
&stack.config.env_vars()?,
run_directory.as_path(),
&stack.config.env_file_path,
res.logs(),
)
.await;
if all_logs_success(res.logs()) {
Ok((
run_directory,
// Env file paths are expected to be already relative to run directory,
// so need to pass original env_file_path here.
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
} else {
Err(anyhow!("Failed to write env file, stopping run."))
}
}
#[instrument("WriteStackLinkedRepo", skip_all)]
async fn write_stack_linked_repo<'a>(
stack: &'a Stack,
repo: &Repo,
git_token: Option<String>,
replacers: Vec<(String, String)>,
mut res: impl WriteStackRes,
req_args: &Args,
) -> anyhow::Result<(
// run_directory
PathBuf,
// env_file_path
Option<&'a str>,
)> {
let root = periphery_config()
.repo_dir()
.join(to_path_compatible_name(&repo.name))
.join(&repo.config.path)
.components()
.collect::<PathBuf>();
let mut args: RepoExecutionArgs = repo.into();
// Set the clone destination to the one created for this run
args.destination = Some(root.display().to_string());
let git_token = stack_git_token(git_token, &args, &mut res)?;
let env_file_path = root
.join(&repo.config.env_file_path)
.components()
.collect::<PathBuf>()
.display()
.to_string();
let on_clone = (!repo.config.on_clone.is_none())
.then_some(repo.config.on_clone.clone());
let on_pull = (!repo.config.on_pull.is_none())
.then_some(repo.config.on_pull.clone());
let clone_res = if stack.config.reclone {
CloneRepo {
args,
git_token,
environment: repo.config.env_vars()?,
env_file_path,
on_clone,
on_pull,
skip_secret_interp: repo.config.skip_secret_interp,
replacers,
}
.resolve(req_args)
.await?
} else {
PullOrCloneRepo {
args,
git_token,
environment: repo.config.env_vars()?,
env_file_path,
on_clone,
on_pull,
skip_secret_interp: repo.config.skip_secret_interp,
replacers,
}
.resolve(req_args)
.await?
};
res.logs().extend(clone_res.res.logs);
res.set_commit_hash(clone_res.res.commit_hash);
res.set_commit_message(clone_res.res.commit_message);
if !all_logs_success(res.logs()) {
return Ok((root, None));
}
let run_directory = root
.join(&stack.config.run_directory)
.components()
.collect::<PathBuf>();
let env_file_path = environment::write_env_file(
&stack.config.env_vars()?,
run_directory.as_path(),
&stack.config.env_file_path,
res.logs(),
)
.await;
if !all_logs_success(res.logs()) {
return Err(anyhow!("Failed to write env file, stopping run"));
}
Ok((
run_directory,
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
}
#[instrument("WriteStackInlineRepo", skip_all)]
async fn write_stack_inline_repo<'a>(
stack: &'a Stack,
git_token: Option<String>,
mut res: impl WriteStackRes,
req_args: &Args,
) -> anyhow::Result<(
// run_directory
PathBuf,
// env_file_path
Option<&'a str>,
)> {
let root = periphery_config()
.stack_dir()
.join(to_path_compatible_name(&stack.name))
.join(&stack.config.clone_path)
.components()
.collect::<PathBuf>();
let mut args: RepoExecutionArgs = stack.into();
// Set the clone destination to the one created for this run
args.destination = Some(root.display().to_string());
let git_token = stack_git_token(git_token, &args, &mut res)?;
let clone_res = if stack.config.reclone {
CloneRepo {
args,
git_token,
environment: Default::default(),
env_file_path: Default::default(),
on_clone: Default::default(),
on_pull: Default::default(),
skip_secret_interp: Default::default(),
replacers: Default::default(),
}
.resolve(req_args)
.await?
} else {
PullOrCloneRepo {
args,
git_token,
environment: Default::default(),
env_file_path: Default::default(),
on_clone: Default::default(),
on_pull: Default::default(),
skip_secret_interp: Default::default(),
replacers: Default::default(),
}
.resolve(req_args)
.await?
};
res.logs().extend(clone_res.res.logs);
res.set_commit_hash(clone_res.res.commit_hash);
res.set_commit_message(clone_res.res.commit_message);
if !all_logs_success(res.logs()) {
return Ok((root, None));
}
let run_directory = root
.join(&stack.config.run_directory)
.components()
.collect::<PathBuf>();
let env_file_path = environment::write_env_file(
&stack.config.env_vars()?,
run_directory.as_path(),
&stack.config.env_file_path,
res.logs(),
)
.await;
if !all_logs_success(res.logs()) {
return Err(anyhow!("Failed to write env file, stopping run"));
}
Ok((
run_directory,
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
}
#[instrument("WriteStackUiDefined", skip_all)]
async fn write_stack_ui_defined(
stack: &Stack,
mut res: impl WriteStackRes,
) -> anyhow::Result<(
// run_directory
PathBuf,
// env_file_path
Option<&str>,
)> {
if stack.config.file_contents.trim().is_empty() {
return Err(anyhow!(
"Must either input compose file contents directly, or use files on host / git repo options."
));
}
let run_directory = periphery_config()
.stack_dir()
.join(to_path_compatible_name(&stack.name))
.components()
.collect::<PathBuf>();
// Ensure run directory exists
fs::create_dir_all(&run_directory).await.with_context(|| {
format!(
"failed to create stack run directory at {run_directory:?}"
)
})?;
let env_file_path = environment::write_env_file(
&stack.config.env_vars()?,
run_directory.as_path(),
&stack.config.env_file_path,
res.logs(),
)
.await;
if !all_logs_success(res.logs()) {
return Err(anyhow!("Failed to write env file, stopping run"));
}
let file_path = run_directory
.join(
stack
.config
.file_paths
// only need the first one, or default
.first()
.map(String::as_str)
.unwrap_or("compose.yaml"),
)
.components()
.collect::<PathBuf>();
secret_file::write_async(&file_path, &stack.config.file_contents)
.await
.with_context(|| {
format!("Failed to write compose file to {file_path:?}")
})?;
Ok((
run_directory,
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
}
fn stack_git_token<R: WriteStackRes>(
core_token: Option<String>,
args: &RepoExecutionArgs,
res: &mut R,
) -> anyhow::Result<Option<String>> {
helpers::git_token(core_token, args).map_err(|e| {
let error = format_serror(&e.into());
res
.logs()
.push(Log::error("Missing git token", error.clone()));
res.add_remote_error(FileContents {
path: Default::default(),
contents: error,
});
anyhow!("failed to find required git token, stopping run")
})
}
+241 -7
View File
@@ -1,13 +1,32 @@
use command::run_komodo_standard_command;
use komodo_client::entities::{
docker::stack::SwarmStack, update::Log,
use anyhow::{Context as _, anyhow};
use command::{
KomodoCommandMode, run_komodo_command_with_sanitization,
run_komodo_standard_command,
};
use periphery_client::api::swarm::{
DeploySwarmStack, InspectSwarmStack, RemoveSwarmStacks,
use formatting::format_serror;
use interpolate::Interpolator;
use komodo_client::{
entities::{
all_logs_success,
docker::stack::SwarmStack,
stack::{ComposeFile, ComposeService, StackServiceNames},
update::Log,
},
parsers::parse_multiline_command,
};
use periphery_client::api::{
DeployStackResponse,
swarm::{DeploySwarmStack, InspectSwarmStack, RemoveSwarmStacks},
};
use resolver_api::Resolve;
use tracing::Instrument as _;
use crate::docker::stack::inspect_swarm_stack;
use crate::{
config::periphery_config,
docker::stack::inspect_swarm_stack,
helpers::push_extra_args,
stack::{maybe_login_registry, validate_files, write::write_stack},
};
impl Resolve<crate::api::Args> for InspectSwarmStack {
async fn resolve(
@@ -67,6 +86,221 @@ impl Resolve<crate::api::Args> for DeploySwarmStack {
self,
args: &crate::api::Args,
) -> Result<Self::Response, Self::Error> {
todo!()
let DeploySwarmStack {
mut stack,
repo,
git_token,
registry_token,
mut replacers,
} = self;
let mut res = DeployStackResponse::default();
let mut interpolator =
Interpolator::new(None, &periphery_config().secrets);
// Only interpolate Stack. Repo interpolation will be handled
// by the CloneRepo / PullOrCloneRepo call.
interpolator
.interpolate_stack(&mut stack)?
.push_logs(&mut res.logs);
replacers.extend(interpolator.secret_replacers);
// Env files are not supported by docker stack deploy so are ignored.
let (run_directory, _) = match write_stack(
&stack,
repo.as_ref(),
git_token,
replacers.clone(),
&mut res,
args,
)
.await
{
Ok(res) => res,
Err(e) => {
res
.logs
.push(Log::error("Write Stack", format_serror(&e.into())));
return Ok(res);
}
};
// Canonicalize the path to ensure it exists, and is the cleanest path to the run directory.
let run_directory = run_directory.canonicalize().context(
"Failed to validate run directory on host after stack write (canonicalize error)",
)?;
validate_files(&stack, &run_directory, &mut res).await;
if !all_logs_success(&res.logs) {
return Ok(res);
}
let use_with_registry_auth =
maybe_login_registry(&stack, registry_token, &mut res.logs)
.await;
if !all_logs_success(&res.logs) {
return Ok(res);
}
// Pre deploy
if !stack.config.pre_deploy.is_none() {
let pre_deploy_path =
run_directory.join(&stack.config.pre_deploy.path);
let span = info_span!("ExecutePreDeploy");
if let Some(log) = run_komodo_command_with_sanitization(
"Pre Deploy",
pre_deploy_path.as_path(),
&stack.config.pre_deploy.command,
KomodoCommandMode::Multiline,
&replacers,
)
.instrument(span)
.await
{
res.logs.push(log);
if !all_logs_success(&res.logs) {
return Ok(res);
}
};
}
let file_args = stack.compose_file_paths().join(" -c ");
// This will be the last project name, which is the one that needs to be destroyed.
// Might be different from the current project name, if user renames stack / changes to custom project name.
let last_project_name = stack.project_name(false);
let project_name = stack.project_name(true);
// Uses 'docker stack config' command to extract services (including image)
// after performing interpolation
{
let command = format!("docker stack config -c {file_args}",);
let span = info_span!("GetStackConfig", command);
let Some(config_log) = run_komodo_command_with_sanitization(
"Stack Config",
run_directory.as_path(),
command,
KomodoCommandMode::Standard,
&replacers,
)
.instrument(span)
.await
else {
// Only reachable if command is empty,
// not the case since it is provided above.
unreachable!()
};
if !config_log.success {
res.logs.push(config_log);
return Ok(res);
}
let compose =
serde_yaml_ng::from_str::<ComposeFile>(&config_log.stdout)
.context("Failed to parse compose contents")?;
// Store sanitized stack config output
res.merged_config = Some(config_log.stdout);
for (service_name, ComposeService { image, .. }) in
compose.services
{
let image = image.unwrap_or_default();
res.services.push(StackServiceNames {
container_name: format!("{project_name}-{service_name}"),
service_name,
image,
});
}
}
if stack.config.destroy_before_deploy
// Also check if project name changed, which also requires taking down.
|| last_project_name != project_name
{
// Take down the existing stack.
// This one tries to use the previously deployed project name, to ensure the right stack is taken down.
remove_stack(&last_project_name, &mut res)
.await
.context("Failed to destroy existing stack")?;
}
// Run stack deploy
let mut command =
format!("docker stack deploy --detach=false -c {file_args}");
if use_with_registry_auth {
command += " --with-registry-auth";
}
push_extra_args(&mut command, &stack.config.extra_args)?;
// Apply compose cmd wrapper if configured
let compose_cmd_wrapper =
parse_multiline_command(&stack.config.compose_cmd_wrapper);
if !compose_cmd_wrapper.is_empty() {
if !compose_cmd_wrapper.contains("[[COMPOSE_COMMAND]]") {
res.logs.push(Log::error(
"Compose Command Wrapper",
"compose_cmd_wrapper is configured but does not contain [[COMPOSE_COMMAND]] placeholder. The placeholder is required to inject the compose command.".to_string(),
));
return Ok(res);
}
command =
compose_cmd_wrapper.replace("[[COMPOSE_COMMAND]]", &command);
}
let span = info_span!("ExecuteStackDeploy");
let Some(log) = run_komodo_command_with_sanitization(
"Stack Deploy",
run_directory.as_path(),
command,
KomodoCommandMode::Shell,
&replacers,
)
.instrument(span)
.await
else {
unreachable!()
};
res.deployed = log.success;
res.logs.push(log);
if res.deployed && !stack.config.post_deploy.is_none() {
let post_deploy_path =
run_directory.join(&stack.config.post_deploy.path);
let span = info_span!("ExecutePostDeploy");
if let Some(log) = run_komodo_command_with_sanitization(
"Post Deploy",
post_deploy_path.as_path(),
&stack.config.post_deploy.command,
KomodoCommandMode::Multiline,
&replacers,
)
.instrument(span)
.await
{
res.logs.push(log);
};
}
Ok(res)
}
}
#[instrument("RemoveStack", skip(res))]
async fn remove_stack(
stack: &str,
res: &mut DeployStackResponse,
) -> anyhow::Result<()> {
let log = run_komodo_standard_command(
"Remove Stack",
None,
format!("docker stack rm --detach=false {stack}"),
)
.await;
let success = log.success;
res.logs.push(log);
if !success {
return Err(anyhow!(
"Failed to remove existing stack with docker stack rm. Stopping run."
));
}
Ok(())
}