periphery swarm stack deploy

This commit is contained in:
mbecker20
2025-12-04 12:52:52 -08:00
parent 98e5e84bc4
commit df016dfd8c
7 changed files with 342 additions and 108 deletions

View File

@@ -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(())
}

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(())
}

View File

@@ -15,6 +15,7 @@ mod config;
mod connection;
mod docker;
mod helpers;
mod stack;
mod state;
mod stats;
mod terminal;

View File

@@ -1,29 +1,26 @@
use std::{
fmt::Write,
path::{Path, PathBuf},
};
//! Module to handle common parts of deploying Compose and Swarm Stacks.
use anyhow::{Context, anyhow};
use command::run_komodo_standard_command;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, anyhow};
use formatting::format_serror;
use komodo_client::entities::{
FileContents, RepoExecutionArgs,
repo::Repo,
stack::{AdditionalEnvFile, Stack, StackRemoteFileContents},
stack::{Stack, StackRemoteFileContents},
to_path_compatible_name,
update::Log,
};
use periphery_client::api::{
DeployStackResponse, git::PullOrCloneRepo,
};
use resolver_api::Resolve;
use tokio::fs;
use resolver_api::Resolve as _;
use crate::{
api::Args, config::periphery_config, docker::docker_login,
};
use super::docker_compose;
pub mod write;
#[instrument(
"MaybeLoginRegistry",
@@ -34,10 +31,11 @@ pub async fn maybe_login_registry(
stack: &Stack,
registry_token: Option<String>,
logs: &mut Vec<Log>,
) {
) -> bool {
if !stack.config.registry_provider.is_empty()
&& !stack.config.registry_account.is_empty()
&& let Err(e) = docker_login(
{
if let Err(e) = docker_login(
&stack.config.registry_provider,
&stack.config.registry_account,
registry_token.as_deref(),
@@ -50,68 +48,16 @@ pub async fn maybe_login_registry(
)
})
.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()
{
logs.push(Log::error(
"Login to Registry",
format_serror(&e.into()),
));
}
true
} 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."
));
false
}
Ok(())
}
/// Only for git repo based Stacks.
@@ -231,9 +177,9 @@ pub async fn validate_files(
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:?}")
}) {
match tokio::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());

View File

@@ -61,7 +61,7 @@ impl WriteStackRes for &mut ComposeRunResponse {
repo = repo.as_ref().map(|repo| &repo.name),
)
)]
pub async fn stack<'a>(
pub async fn write_stack<'a>(
stack: &'a Stack,
repo: Option<&Repo>,
git_token: Option<String>,

View File

@@ -168,6 +168,9 @@ pub struct DeploymentConfig {
/// Extra args which are interpolated into the
/// `docker run` / `docker service create` command,
/// and affect the container configuration.
///
/// - Container ref: https://docs.docker.com/reference/cli/docker/container/run/#options
/// - Swarm Service ref: https://docs.docker.com/reference/cli/docker/service/create/#options
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,

View File

@@ -521,7 +521,7 @@ pub struct StackConfig {
/// The extra arguments to pass to the deploy command.
///
/// - For Compose stack, uses `docker compose up -d [EXTRA_ARGS]`.
/// - For Swarm mode. `docker stack deploy [OPTIONS] STACK`
/// - For Swarm mode. `docker stack deploy [EXTRA_ARGS] STACK_NAME`
///
/// If empty, no extra arguments will be passed.
#[serde(default, deserialize_with = "string_list_deserializer")]