mirror of
https://github.com/moghtech/komodo.git
synced 2026-04-28 03:38:55 -05:00
Service aware config rotation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -155,6 +155,7 @@ pub enum PeripheryRequest {
|
||||
UpdateSwarmService(UpdateSwarmService),
|
||||
RemoveSwarmServices(RemoveSwarmServices),
|
||||
RemoveSwarmConfigs(RemoveSwarmConfigs),
|
||||
RotateSwarmConfig(RotateSwarmConfig),
|
||||
RemoveSwarmSecrets(RemoveSwarmSecrets),
|
||||
|
||||
// Terminal
|
||||
|
||||
77
bin/periphery/src/api/swarm/config.rs
Normal file
77
bin/periphery/src/api/swarm/config.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ========
|
||||
|
||||
@@ -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}")?;
|
||||
|
||||
@@ -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?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
// ========
|
||||
|
||||
Reference in New Issue
Block a user