From effe737ffedaa3cd4e2d94fbe5c557528e2e9166 Mon Sep 17 00:00:00 2001 From: mbecker20 Date: Thu, 4 Dec 2025 12:52:52 -0800 Subject: [PATCH] periphery swarm stack deploy --- .../src/api/{compose/mod.rs => compose.rs} | 98 +++++-- bin/periphery/src/api/swarm/stack.rs | 248 +++++++++++++++++- bin/periphery/src/main.rs | 1 + .../{api/compose/helpers.rs => stack/mod.rs} | 96 ++----- .../src/{api/compose => stack}/write.rs | 2 +- client/core/rs/src/entities/deployment.rs | 3 + client/core/rs/src/entities/stack.rs | 2 +- 7 files changed, 342 insertions(+), 108 deletions(-) rename bin/periphery/src/api/{compose/mod.rs => compose.rs} (90%) rename bin/periphery/src/{api/compose/helpers.rs => stack/mod.rs} (70%) rename bin/periphery/src/{api/compose => stack}/write.rs (99%) diff --git a/bin/periphery/src/api/compose/mod.rs b/bin/periphery/src/api/compose.rs similarity index 90% rename from bin/periphery/src/api/compose/mod.rs rename to bin/periphery/src/api/compose.rs index aae8a943a..04811f930 100644 --- a/bin/periphery/src/api/compose/mod.rs +++ b/bin/periphery/src/api/compose.rs @@ -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 for GetComposeLog { async fn resolve( self, @@ -287,7 +287,7 @@ impl Resolve 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 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 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 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 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 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 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 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 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 for ComposeRun { Ok(log) } } + +fn env_file_args( + env_file_path: Option<&str>, + additional_env_files: &[AdditionalEnvFile], +) -> anyhow::Result { + 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(()) +} diff --git a/bin/periphery/src/api/swarm/stack.rs b/bin/periphery/src/api/swarm/stack.rs index 235c746e6..13eb48eff 100644 --- a/bin/periphery/src/api/swarm/stack.rs +++ b/bin/periphery/src/api/swarm/stack.rs @@ -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 for InspectSwarmStack { async fn resolve( @@ -67,6 +86,221 @@ impl Resolve for DeploySwarmStack { self, args: &crate::api::Args, ) -> Result { - 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::(&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(()) +} diff --git a/bin/periphery/src/main.rs b/bin/periphery/src/main.rs index b31a8b6da..7b6f048f9 100644 --- a/bin/periphery/src/main.rs +++ b/bin/periphery/src/main.rs @@ -15,6 +15,7 @@ mod config; mod connection; mod docker; mod helpers; +mod stack; mod state; mod stats; mod terminal; diff --git a/bin/periphery/src/api/compose/helpers.rs b/bin/periphery/src/stack/mod.rs similarity index 70% rename from bin/periphery/src/api/compose/helpers.rs rename to bin/periphery/src/stack/mod.rs index 957079933..352bbf74f 100644 --- a/bin/periphery/src/api/compose/helpers.rs +++ b/bin/periphery/src/stack/mod.rs @@ -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, logs: &mut Vec, -) { +) -> 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 { - 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()); diff --git a/bin/periphery/src/api/compose/write.rs b/bin/periphery/src/stack/write.rs similarity index 99% rename from bin/periphery/src/api/compose/write.rs rename to bin/periphery/src/stack/write.rs index bfeb29388..ae7ced1ad 100644 --- a/bin/periphery/src/api/compose/write.rs +++ b/bin/periphery/src/stack/write.rs @@ -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, diff --git a/client/core/rs/src/entities/deployment.rs b/client/core/rs/src/entities/deployment.rs index c7bf1b2cf..f60b7889b 100644 --- a/client/core/rs/src/entities/deployment.rs +++ b/client/core/rs/src/entities/deployment.rs @@ -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, diff --git a/client/core/rs/src/entities/stack.rs b/client/core/rs/src/entities/stack.rs index e79159574..14db90339 100644 --- a/client/core/rs/src/entities/stack.rs +++ b/client/core/rs/src/entities/stack.rs @@ -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")]