Service aware config rotation

This commit is contained in:
mbecker20
2025-12-14 14:42:53 -08:00
parent 9be21b54bd
commit 4f044ccde8
13 changed files with 379 additions and 65 deletions

View File

@@ -71,7 +71,7 @@ impl Resolve<UserArgs> for BeginPasskeyEnrollment {
"Failed to store passkey enrollment state in server side client session",
)?;
Ok(challenge.into())
Ok(challenge)
}
}
@@ -104,7 +104,7 @@ impl Resolve<UserArgs> for ConfirmPasskeyEnrollment {
)?;
let passkey = webauthn
.finish_passkey_registration(&self.credential.into(), &state)
.finish_passkey_registration(&self.credential, &state)
.context("Failed to finish passkey registration")?;
let passkey = to_bson(&passkey)

View File

@@ -50,7 +50,7 @@ impl Resolve<UserArgs> for BeginTotpEnrollment {
let totp = make_totp(secret.clone(), user.id.clone())?;
let png = totp
.get_qr_base64()
.map_err(|e| anyhow::Error::msg(e))
.map_err(anyhow::Error::msg)
.context("Failed to generate QR code png")?;
session
.insert(
@@ -106,7 +106,7 @@ impl Resolve<UserArgs> for ConfirmTotpEnrollment {
(0..10).map(|_| random_string(20)).collect::<Vec<_>>();
let hashed_recovery_codes = recovery_codes
.iter()
.map(|code| hash_password(code))
.map(hash_password)
.collect::<anyhow::Result<Vec<_>>>()
.context("Failed to generate valid recovery codes")?;

View File

@@ -207,7 +207,7 @@ async fn login_local_user(
);
};
let verified = bcrypt::verify(req.password, &user_pw_hash)
let verified = bcrypt::verify(req.password, user_pw_hash)
.context("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED)?;
@@ -254,7 +254,7 @@ async fn login_local_user(
.encode(user.id)
// This is in internal error (500), not auth error
.context("Failed to generate JWT for user")
.map(|jwt| LoginLocalUserResponse::Jwt(jwt))
.map(LoginLocalUserResponse::Jwt)
.map_err(Into::into)
}
}

View File

@@ -251,7 +251,7 @@ pub fn webauthn() -> Option<&'static Webauthn> {
// The relying party id (the effective domain)
let rp_id = rp_origin.domain()?;
info!("Using '{rp_id}' as WebAuthn rp_id");
WebauthnBuilder::new(rp_id, &rp_origin)
WebauthnBuilder::new(rp_id, rp_origin)
.inspect_err(|e| warn!("Failed to init webauthn provider | Invalid KOMODO_HOST: could not build webauthn provider builder | {e:?}"))
.ok()?
.build()

View File

@@ -155,6 +155,7 @@ pub enum PeripheryRequest {
UpdateSwarmService(UpdateSwarmService),
RemoveSwarmServices(RemoveSwarmServices),
RemoveSwarmConfigs(RemoveSwarmConfigs),
RotateSwarmConfig(RotateSwarmConfig),
RemoveSwarmSecrets(RemoveSwarmSecrets),
// Terminal

View File

@@ -0,0 +1,77 @@
use anyhow::Context as _;
use formatting::format_serror;
use komodo_client::entities::{
docker::config::SwarmConfig, update::Log,
};
use periphery_client::api::swarm::*;
use resolver_api::Resolve;
use crate::{
docker::config::{inspect_swarm_config, remove_swarm_configs},
state::docker_client,
};
impl Resolve<crate::api::Args> for InspectSwarmConfig {
async fn resolve(
self,
_: &crate::api::Args,
) -> anyhow::Result<Vec<SwarmConfig>> {
inspect_swarm_config(&self.config).await
}
}
impl Resolve<crate::api::Args> for RemoveSwarmConfigs {
#[instrument(
"RemoveSwarmConfigs",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
configs = serde_json::to_string(&self.configs).unwrap_or_else(|e| e.to_string()),
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
Ok(
remove_swarm_configs(self.configs.iter().map(String::as_str))
.await,
)
}
}
impl Resolve<crate::api::Args> for RotateSwarmConfig {
#[instrument(
"RotateSwarmConfig",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
config = self.config,
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Vec<Log>> {
let client = docker_client().load();
let client = client
.iter()
.next()
.context("Could not connect to docker client")?;
let mut logs = Vec::new();
if let Err(e) = client
.rotate_swarm_config(&self.config, &self.data, &mut logs)
.await
{
logs.push(Log::error(
"Rotate Swarm Config",
format_serror(&e.into()),
))
}
Ok(logs)
}
}

View File

@@ -2,8 +2,7 @@ use anyhow::Context as _;
use command::run_komodo_standard_command;
use komodo_client::entities::{
docker::{
SwarmLists, config::SwarmConfig, node::SwarmNode,
secret::SwarmSecret, task::SwarmTask,
SwarmLists, node::SwarmNode, secret::SwarmSecret, task::SwarmTask,
},
update::Log,
};
@@ -11,13 +10,11 @@ use periphery_client::api::swarm::*;
use resolver_api::Resolve;
use crate::{
docker::{
config::{inspect_swarm_config, list_swarm_configs},
stack::list_swarm_stacks,
},
docker::{config::list_swarm_configs, stack::list_swarm_stacks},
state::docker_client,
};
mod config;
mod service;
mod stack;
@@ -40,7 +37,7 @@ impl Resolve<crate::api::Args> for PollSwarmStatus {
list_swarm_stacks(),
);
let tasks = tasks.unwrap_or_default();
let services = client.list_swarm_services(&tasks).await;
let services = client.list_swarm_services(Some(&tasks)).await;
Ok(PollSwarmStatusResponse {
inspect: inspect.ok(),
lists: SwarmLists {
@@ -180,49 +177,6 @@ impl Resolve<crate::api::Args> for InspectSwarmTask {
}
}
// ========
// Config
// ========
impl Resolve<crate::api::Args> for InspectSwarmConfig {
async fn resolve(
self,
_: &crate::api::Args,
) -> anyhow::Result<Vec<SwarmConfig>> {
inspect_swarm_config(&self.config).await
}
}
impl Resolve<crate::api::Args> for RemoveSwarmConfigs {
#[instrument(
"RemoveSwarmConfigs",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
configs = serde_json::to_string(&self.configs).unwrap_or_else(|e| e.to_string()),
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
let mut command = String::from("docker config rm");
for config in self.configs {
command += " ";
command += &config;
}
Ok(
run_komodo_standard_command(
"Remove Swarm Configs",
None,
command,
)
.await,
)
}
}
// ========
// Secret
// ========

View File

@@ -368,7 +368,7 @@ impl Resolve<crate::api::Args> for UpdateSwarmService {
extra_args,
} = self;
let mut command = format!("docker service update");
let mut command = String::from("docker service update");
if let Some(image) = image {
write!(&mut command, " --image {image}")?;

View File

@@ -1,8 +1,22 @@
use std::fmt::Write;
use anyhow::{Context, anyhow};
use command::run_komodo_standard_command;
use komodo_client::entities::docker::config::{
SwarmConfig, SwarmConfigListItem,
use command::{
run_komodo_shell_command, run_komodo_standard_command,
};
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
use komodo_client::entities::{
EnvironmentVar, all_logs_success,
docker::{
config::{SwarmConfig, SwarmConfigListItem},
service::SwarmService,
},
random_string,
};
use crate::helpers::push_labels;
use super::*;
pub async fn list_swarm_configs()
-> anyhow::Result<Vec<SwarmConfigListItem>> {
@@ -54,3 +68,216 @@ pub async fn inspect_swarm_config(
"Failed to parse 'docker config inspect' response from json",
)
}
pub async fn create_swarm_config(
name: &str,
data: &str,
labels: &[EnvironmentVar],
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let mut command = String::from("docker config create");
push_labels(&mut command, labels)?;
write!(
&mut command,
r#" {name} - <<'EOF'
{data}
EOF
"#
)?;
let log =
run_komodo_shell_command("Create Config", None, command).await;
logs.push(log);
Ok(())
}
pub async fn remove_swarm_configs(
configs: impl Iterator<Item = &str>,
) -> Log {
let mut command = String::from("docker config rm");
for config in configs {
command += " ";
command += config;
}
run_komodo_standard_command("Remove Swarm Configs", None, command)
.await
}
pub async fn recreate_swarm_config(
name: &str,
data: &str,
labels: &[EnvironmentVar],
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let remove = remove_swarm_configs([name].into_iter()).await;
let success = remove.success;
logs.push(remove);
if !success {
return Ok(());
}
create_swarm_config(name, data, labels, logs).await
}
struct ServiceConfigFile {
/// Service name
service: String,
/// Config file spec
file: TaskSpecContainerSpecFile,
}
impl DockerClient {
pub async fn rotate_swarm_config(
&self,
config: &str,
data: &str,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let labels = inspect_swarm_config(config)
.await?
.pop()
.context("Did not find any matching config")?
.spec
.and_then(|spec| spec.labels)
.map(|labels| {
labels
.into_iter()
.map(|(variable, value)| EnvironmentVar { variable, value })
.collect::<Vec<_>>()
})
.unwrap_or_default();
let services = self
.filter_map_swarm_services(|service| {
extract_from_service(service, config)
})
.await?;
if services.is_empty() {
return recreate_swarm_config(config, data, &labels, logs)
.await;
}
// Create a tmp config
let tmp_name = format!("{config}-tmp-{}", random_string(10));
create_swarm_config(&tmp_name, data, &labels, logs).await?;
if !all_logs_success(logs) {
return Ok(());
}
// Update services to tmp
switch_services_config(&services, config, &tmp_name, logs)
.await?;
if !all_logs_success(logs) {
return Ok(());
}
// Recreate actual config
recreate_swarm_config(config, data, &labels, logs).await?;
if !all_logs_success(logs) {
return Ok(());
}
// Update back to original
switch_services_config(&services, &tmp_name, config, logs)
.await?;
if !all_logs_success(logs) {
return Ok(());
}
// Remove tmp config
let log =
remove_swarm_configs([tmp_name.as_str()].into_iter()).await;
logs.push(log);
Ok(())
}
}
async fn switch_services_config(
services: &[ServiceConfigFile],
from: &str,
to: &str,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let res = services
.iter()
.map(|service| async move {
switch_service_config(&service.service, from, to, &service.file)
.await
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await?;
logs.extend(res);
Ok(())
}
async fn switch_service_config(
service: &str,
from: &str,
to: &str,
TaskSpecContainerSpecFile {
name: path,
uid,
gid,
mode,
}: &TaskSpecContainerSpecFile,
) -> anyhow::Result<Log> {
let mut command = format!(
"docker service update --config-rm {from} --config-add source={to}"
);
// Add same file mount options
if let Some(container_path) = path {
write!(&mut command, ",target={container_path}")?;
}
if let Some(uid) = uid {
write!(&mut command, ",uid={uid}")?;
}
if let Some(gid) = gid {
write!(&mut command, ",gid={gid}")?;
}
if let Some(mode) = mode {
write!(&mut command, ",mode={mode}")?;
}
write!(&mut command, " {service}")?;
let log = run_komodo_standard_command(
"Switch Service Config",
None,
command,
)
.await;
Ok(log)
}
fn extract_from_service(
service: SwarmService,
config: &str,
) -> Option<ServiceConfigFile> {
let spec = service.spec?;
let configs = spec.task_template?.container_spec?.configs?;
let config = configs.into_iter().find(|cfg| {
cfg
.config_id
.as_ref()
// Supports passing short id
.map(|id| id.starts_with(config))
.unwrap_or_default()
|| cfg
.config_name
.as_ref()
// Has to match by name exactly
.map(|name| name == config)
.unwrap_or_default()
})?;
Some(ServiceConfigFile {
service: spec.name?,
file: config.file?,
})
}

View File

@@ -2,6 +2,7 @@ use anyhow::Context;
use bollard::query_parameters::{
InspectServiceOptions, ListServicesOptions,
};
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
use komodo_client::entities::{
docker::{NetworkAttachmentConfig, service::*},
swarm::SwarmState,
@@ -13,7 +14,7 @@ impl DockerClient {
/// List swarm services
pub async fn list_swarm_services(
&self,
tasks: &[SwarmTaskListItem],
tasks: Option<&[SwarmTaskListItem]>,
) -> anyhow::Result<Vec<SwarmServiceListItem>> {
let mut services = self
.docker
@@ -33,6 +34,30 @@ impl DockerClient {
Ok(services)
}
pub async fn filter_map_swarm_services<T>(
&self,
filter_map: impl Fn(SwarmService) -> Option<T>,
) -> anyhow::Result<Vec<T>> {
let res = self
.list_swarm_services(None)
.await?
.into_iter()
.map(|service| async {
let Some(name) = service.name else {
return Ok(None);
};
let service = self.inspect_swarm_service(&name).await?;
anyhow::Ok(filter_map(service))
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
Ok(res)
}
pub async fn inspect_swarm_service(
&self,
service_name: &str,
@@ -57,7 +82,7 @@ impl DockerClient {
fn convert_service_list_item(
service: bollard::models::Service,
tasks: &[SwarmTaskListItem],
tasks: Option<&[SwarmTaskListItem]>,
) -> SwarmServiceListItem {
let (name, (image, restart, runtime), replicas) = service
.spec
@@ -87,7 +112,9 @@ fn convert_service_list_item(
let state = service
.id
.as_ref()
.map(|service_id| service_state_from_tasks(service_id, tasks))
.and_then(|service_id| {
tasks.map(|tasks| service_state_from_tasks(service_id, tasks))
})
.unwrap_or(SwarmState::Unknown);
SwarmServiceListItem {
id: service.id,

View File

@@ -25,7 +25,7 @@ impl DockerClient {
self.list_swarm_tasks(),
)?;
let services = self
.list_swarm_services(&tasks)
.list_swarm_services(Some(&tasks))
.await?
.into_iter()
.filter(|service| {

View File

@@ -1501,6 +1501,7 @@ pub fn resource_link(
format!("{host}{path}")
}
#[allow(clippy::large_enum_variant)]
pub enum SwarmOrServer {
Swarm(swarm::Swarm),
Server(server::Server),

View File

@@ -260,6 +260,8 @@ pub struct InspectSwarmConfig {
pub config: String,
}
//
/// `docker config rm CONFIG [CONFIG...]`
///
/// https://docs.docker.com/reference/cli/docker/config/rm/
@@ -270,6 +272,31 @@ pub struct RemoveSwarmConfigs {
pub configs: Vec<String>,
}
//
/// https://docs.docker.com/engine/swarm/configs/#example-rotate-a-config
///
/// Swarm configs / secrets are immutable after creation.
/// This making updating values awkward when you have services actively using them.
/// The following steps allows for config rotation while minimizing downtime.
///
/// 1. Query for all services using the config
/// - If not in use by any services, can simply `remove` and `create` the config.
/// - Otherwise, continue with following steps
/// 2. `Create` config `{config}-tmp` using provided data
/// 3. `Update` services to use `tmp` config
/// 4. `Remove` and `create` the actual config. This is now possible because services are using the tmp config.
/// 5. `Update` services to use actual (not `tmp`) config again.
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
#[response(Vec<Log>)]
#[error(anyhow::Error)]
pub struct RotateSwarmConfig {
/// Config name
pub config: String,
/// The config file data as a string
pub data: String,
}
// ========
// Secret
// ========