mirror of
https://github.com/moghtech/komodo.git
synced 2026-11-03 09:03:37 -06:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c638e3876e | ||
|
|
fc4ea40b9c | ||
|
|
62b84328d8 | ||
|
|
a5ee0de394 | ||
|
|
bf0ab26d22 | ||
|
|
0dd2ade574 | ||
|
|
f0172c2721 | ||
|
|
363d330868 | ||
|
|
2843711796 | ||
|
|
4f044ccde8 | ||
|
|
9be21b54bd | ||
|
|
057ecbcfef | ||
|
|
e91d63f262 | ||
|
|
3b799edb30 | ||
|
|
41c7faf48d | ||
|
|
e1d745aee0 | ||
|
|
01df3add41 | ||
|
|
577973841b | ||
|
|
3e5311f413 | ||
|
|
46e99783f2 | ||
|
|
a6590804a7 | ||
|
|
bd752b7c72 | ||
|
|
de83b3a5a0 | ||
|
|
c91963fdbb |
Generated
+23
-43
@@ -976,7 +976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cache"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"tokio",
|
||||
@@ -1168,7 +1168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "command"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"komodo_client",
|
||||
"shlex",
|
||||
@@ -1195,7 +1195,7 @@ checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"colored",
|
||||
"indexmap 2.12.1",
|
||||
@@ -1512,20 +1512,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.9.0"
|
||||
@@ -1534,7 +1520,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "database"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
@@ -1847,7 +1833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "encoding"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -1889,7 +1875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "environment"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"formatting",
|
||||
@@ -1899,7 +1885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "environment_file"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
@@ -2025,7 +2011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "formatting"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"serror",
|
||||
]
|
||||
@@ -2191,7 +2177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cache",
|
||||
@@ -2273,12 +2259,6 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2848,7 +2828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "interpolate"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"komodo_client",
|
||||
@@ -2970,7 +2950,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komodo_cli"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -2998,7 +2978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komodo_client"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async_timing_util",
|
||||
@@ -3037,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komodo_core"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -3057,7 +3037,6 @@ dependencies = [
|
||||
"command",
|
||||
"config",
|
||||
"croner",
|
||||
"dashmap",
|
||||
"data-encoding",
|
||||
"database",
|
||||
"derive_variants",
|
||||
@@ -3116,7 +3095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "komodo_periphery"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -3130,6 +3109,7 @@ dependencies = [
|
||||
"colored",
|
||||
"command",
|
||||
"config",
|
||||
"data-encoding",
|
||||
"derive_variants",
|
||||
"dotenvy",
|
||||
"encoding",
|
||||
@@ -3238,7 +3218,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "logger"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"komodo_client",
|
||||
@@ -3540,7 +3520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noise"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -4017,7 +3997,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "periphery_client"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_variants",
|
||||
@@ -4407,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rate_limit"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -4554,7 +4534,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "response"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -4803,7 +4783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "secret_file"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
@@ -5916,7 +5896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "transport"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6139,7 +6119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "validations"
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bson",
|
||||
|
||||
+1
-2
@@ -8,7 +8,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0-dev-98"
|
||||
version = "2.0.0-dev-102"
|
||||
edition = "2024"
|
||||
authors = ["mbecker20 <becker.maxh@gmail.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -149,7 +149,6 @@ async-compression = { version = "0.4.35", features = ["tokio", "gzip"] }
|
||||
derive_builder = "0.20.2"
|
||||
comfy-table = "7.2.1"
|
||||
typeshare = "1.0.4"
|
||||
dashmap = "6.1.0"
|
||||
wildcard = "0.3.0"
|
||||
colored = "3.0.0"
|
||||
bytes = "1.11.0"
|
||||
|
||||
@@ -230,9 +230,21 @@ pub async fn handle(
|
||||
Execution::RemoveSwarmServices(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::CreateSwarmConfig(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::RotateSwarmConfig(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::RemoveSwarmConfigs(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::CreateSwarmSecret(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::RotateSwarmSecret(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::RemoveSwarmSecrets(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
@@ -515,10 +527,26 @@ pub async fn handle(
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::CreateSwarmConfig(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::RotateSwarmConfig(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::RemoveSwarmConfigs(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::CreateSwarmSecret(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::RotateSwarmSecret(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::RemoveSwarmSecrets(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
|
||||
@@ -67,7 +67,6 @@ wildcard.workspace = true
|
||||
arc-swap.workspace = true
|
||||
serde_qs.workspace = true
|
||||
colored.workspace = true
|
||||
dashmap.workspace = true
|
||||
tracing.workspace = true
|
||||
reqwest.workspace = true
|
||||
dotenvy.workspace = true
|
||||
|
||||
+12
-21
@@ -28,14 +28,10 @@ use serror::{AddStatusCode, AddStatusCodeError, Json};
|
||||
use tower_sessions::Session;
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::PasskeyAuthentication;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN,
|
||||
SESSION_KEY_USER_ID, memory_session_layer,
|
||||
},
|
||||
auth::{
|
||||
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
|
||||
get_user_id_from_headers,
|
||||
github::{self, client::github_oauth_client},
|
||||
google::{self, client::google_oauth_client},
|
||||
@@ -102,7 +98,7 @@ pub fn router() -> Router {
|
||||
router = router.nest("/oidc", oidc::router())
|
||||
}
|
||||
|
||||
router.layer(memory_session_layer(60))
|
||||
router
|
||||
}
|
||||
|
||||
async fn variant_handler(
|
||||
@@ -185,11 +181,11 @@ impl Resolve<AuthArgs> for ExchangeForJwt {
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let user_id = session
|
||||
.remove::<String>(SESSION_KEY_USER_ID)
|
||||
.await
|
||||
.context("Internal session type error")?
|
||||
.context("Authentication steps must be completed before JWT can be retrieved")?;
|
||||
let SessionUserId(user_id) = session
|
||||
.remove(SessionUserId::KEY)
|
||||
.await
|
||||
.context("Internal session type error")?
|
||||
.context("Authentication steps must be completed before JWT can be retrieved")?;
|
||||
|
||||
jwt_client().encode(user_id).map_err(Into::into)
|
||||
}
|
||||
@@ -216,8 +212,8 @@ impl Resolve<AuthArgs> for CompleteTotpLogin {
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let user_id = session
|
||||
.get::<String>(SESSION_KEY_TOTP_LOGIN)
|
||||
let SessionTotpLogin { user_id } = session
|
||||
.get(SessionTotpLogin::KEY)
|
||||
.await
|
||||
.context("Internal session type error")?
|
||||
.context(
|
||||
@@ -281,10 +277,8 @@ impl Resolve<AuthArgs> for CompletePasskeyLogin {
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
|
||||
let (user_id, server_state) = session
|
||||
.get::<(String, PasskeyAuthentication)>(
|
||||
SESSION_KEY_PASSKEY_LOGIN,
|
||||
)
|
||||
let SessionPasskeyLogin { user_id, state } = session
|
||||
.get(SessionPasskeyLogin::KEY)
|
||||
.await
|
||||
.context("Internal session type error")?
|
||||
.context(
|
||||
@@ -294,10 +288,7 @@ impl Resolve<AuthArgs> for CompletePasskeyLogin {
|
||||
// The result of this call must be used to
|
||||
// update the stored passkey info on database.
|
||||
let update = webauthn
|
||||
.finish_passkey_authentication(
|
||||
&self.credential,
|
||||
&server_state,
|
||||
)
|
||||
.finish_passkey_authentication(&self.credential, &state)
|
||||
.context("Failed to validate passkey")?;
|
||||
|
||||
let mut passkey = get_user(&user_id)
|
||||
|
||||
@@ -154,7 +154,11 @@ pub enum ExecuteRequest {
|
||||
RemoveSwarmNodes(RemoveSwarmNodes),
|
||||
RemoveSwarmStacks(RemoveSwarmStacks),
|
||||
RemoveSwarmServices(RemoveSwarmServices),
|
||||
CreateSwarmConfig(CreateSwarmConfig),
|
||||
RotateSwarmConfig(RotateSwarmConfig),
|
||||
RemoveSwarmConfigs(RemoveSwarmConfigs),
|
||||
CreateSwarmSecret(CreateSwarmSecret),
|
||||
RotateSwarmSecret(RotateSwarmSecret),
|
||||
RemoveSwarmSecrets(RemoveSwarmSecrets),
|
||||
|
||||
// ==== MAINTENANCE ====
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use formatting::format_serror;
|
||||
use komodo_client::{
|
||||
api::execute::{
|
||||
RemoveSwarmConfigs, RemoveSwarmNodes, RemoveSwarmSecrets,
|
||||
RemoveSwarmServices, RemoveSwarmStacks,
|
||||
CreateSwarmConfig, CreateSwarmSecret, RemoveSwarmConfigs,
|
||||
RemoveSwarmNodes, RemoveSwarmSecrets, RemoveSwarmServices,
|
||||
RemoveSwarmStacks, RotateSwarmConfig, RotateSwarmSecret,
|
||||
},
|
||||
entities::{permission::PermissionLevel, swarm::Swarm},
|
||||
};
|
||||
@@ -11,6 +12,7 @@ use resolver_api::Resolve;
|
||||
use crate::{
|
||||
api::execute::ExecuteArgs,
|
||||
helpers::{swarm::swarm_request, update::update_update},
|
||||
monitor::update_cache_for_swarm,
|
||||
permission::get_check_permissions,
|
||||
};
|
||||
|
||||
@@ -51,7 +53,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmNodes {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => update.logs.push(log),
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Remove Swarm Nodes",
|
||||
format_serror(
|
||||
@@ -104,7 +109,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmStacks {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => update.logs.push(log),
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Remove Swarm Stacks",
|
||||
format_serror(
|
||||
@@ -155,7 +163,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmServices {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => update.logs.push(log),
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Remove Swarm Services",
|
||||
format_serror(
|
||||
@@ -171,6 +182,118 @@ impl Resolve<ExecuteArgs> for RemoveSwarmServices {
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for CreateSwarmConfig {
|
||||
#[instrument(
|
||||
"CreateSwarmConfig",
|
||||
skip_all,
|
||||
fields(
|
||||
id = id.to_string(),
|
||||
operator = user.id,
|
||||
update_id = update.id,
|
||||
swarm = self.swarm,
|
||||
config = self.name,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
ExecuteArgs { user, update, id }: &ExecuteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let swarm = get_check_permissions::<Swarm>(
|
||||
&self.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
let mut update = update.clone();
|
||||
|
||||
match swarm_request(
|
||||
&swarm.config.server_ids,
|
||||
periphery_client::api::swarm::CreateSwarmConfig {
|
||||
name: self.name,
|
||||
data: self.data,
|
||||
labels: self.labels,
|
||||
template_driver: self.template_driver,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Create Swarm Config",
|
||||
format_serror(
|
||||
&e.context("Failed to create swarm config").into(),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
update.finalize();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for RotateSwarmConfig {
|
||||
#[instrument(
|
||||
"RotateSwarmConfig",
|
||||
skip_all,
|
||||
fields(
|
||||
id = id.to_string(),
|
||||
operator = user.id,
|
||||
update_id = update.id,
|
||||
swarm = self.swarm,
|
||||
config = self.config,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
ExecuteArgs { user, update, id }: &ExecuteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let swarm = get_check_permissions::<Swarm>(
|
||||
&self.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
let mut update = update.clone();
|
||||
|
||||
match swarm_request(
|
||||
&swarm.config.server_ids,
|
||||
periphery_client::api::swarm::RotateSwarmConfig {
|
||||
config: self.config,
|
||||
data: self.data,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(logs) => {
|
||||
update.logs.extend(logs);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Rotate Swarm Config",
|
||||
format_serror(
|
||||
&e.context("Failed to rotate swarm config").into(),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
update.finalize();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for RemoveSwarmConfigs {
|
||||
#[instrument(
|
||||
"RemoveSwarmConfigs",
|
||||
@@ -206,7 +329,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmConfigs {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => update.logs.push(log),
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Remove Swarm Configs",
|
||||
format_serror(
|
||||
@@ -222,6 +348,119 @@ impl Resolve<ExecuteArgs> for RemoveSwarmConfigs {
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for CreateSwarmSecret {
|
||||
#[instrument(
|
||||
"CreateSwarmSecret",
|
||||
skip_all,
|
||||
fields(
|
||||
id = id.to_string(),
|
||||
operator = user.id,
|
||||
update_id = update.id,
|
||||
swarm = self.swarm,
|
||||
secret = self.name,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
ExecuteArgs { user, update, id }: &ExecuteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let swarm = get_check_permissions::<Swarm>(
|
||||
&self.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
let mut update = update.clone();
|
||||
|
||||
match swarm_request(
|
||||
&swarm.config.server_ids,
|
||||
periphery_client::api::swarm::CreateSwarmSecret {
|
||||
name: self.name,
|
||||
data: self.data,
|
||||
driver: self.driver,
|
||||
labels: self.labels,
|
||||
template_driver: self.template_driver,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Create Swarm Secret",
|
||||
format_serror(
|
||||
&e.context("Failed to create swarm secret").into(),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
update.finalize();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for RotateSwarmSecret {
|
||||
#[instrument(
|
||||
"RotateSwarmSecret",
|
||||
skip_all,
|
||||
fields(
|
||||
id = id.to_string(),
|
||||
operator = user.id,
|
||||
update_id = update.id,
|
||||
swarm = self.swarm,
|
||||
secret = self.secret,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
ExecuteArgs { user, update, id }: &ExecuteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let swarm = get_check_permissions::<Swarm>(
|
||||
&self.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
let mut update = update.clone();
|
||||
|
||||
match swarm_request(
|
||||
&swarm.config.server_ids,
|
||||
periphery_client::api::swarm::RotateSwarmSecret {
|
||||
secret: self.secret,
|
||||
data: self.data,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(logs) => {
|
||||
update.logs.extend(logs);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Rotate Swarm Secret",
|
||||
format_serror(
|
||||
&e.context("Failed to rotate swarm secret").into(),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
update.finalize();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for RemoveSwarmSecrets {
|
||||
#[instrument(
|
||||
"RemoveSwarmSecrets",
|
||||
@@ -257,7 +496,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmSecrets {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(log) => update.logs.push(log),
|
||||
Ok(log) => {
|
||||
update.logs.push(log);
|
||||
update_cache_for_swarm(&swarm, true).await;
|
||||
}
|
||||
Err(e) => update.push_error_log(
|
||||
"Remove Swarm Secrets",
|
||||
format_serror(
|
||||
|
||||
+13
-12
@@ -8,7 +8,8 @@ use tower_http::{
|
||||
set_header::SetResponseHeaderLayer,
|
||||
};
|
||||
use tower_sessions::{
|
||||
Expiry, MemoryStore, SessionManagerLayer, cookie::time::Duration,
|
||||
Expiry, MemoryStore, SessionManagerLayer,
|
||||
cookie::{SameSite, time::Duration},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -52,6 +53,7 @@ pub fn app() -> Router {
|
||||
.nest("/listener", listener::router())
|
||||
.nest("/ws", ws::router())
|
||||
.nest("/client", ts_client::router())
|
||||
.layer(memory_session_layer())
|
||||
.fallback_service(serve_frontend)
|
||||
.layer(cors_layer())
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
@@ -72,21 +74,20 @@ pub fn app() -> Router {
|
||||
))
|
||||
}
|
||||
|
||||
fn memory_session_layer(
|
||||
expiry: i64,
|
||||
) -> SessionManagerLayer<MemoryStore> {
|
||||
const MEMORY_SESSION_EXPIRY_SECONDS: i64 = 60;
|
||||
|
||||
fn memory_session_layer() -> SessionManagerLayer<MemoryStore> {
|
||||
let config = core_config();
|
||||
let mut layer = SessionManagerLayer::new(MemoryStore::default())
|
||||
.with_expiry(Expiry::OnInactivity(Duration::seconds(expiry)))
|
||||
.with_secure(config.host.starts_with("https://"));
|
||||
.with_expiry(Expiry::OnInactivity(Duration::seconds(
|
||||
MEMORY_SESSION_EXPIRY_SECONDS,
|
||||
)))
|
||||
.with_secure(config.host.starts_with("https://"))
|
||||
// Needs Lax in order for sessions to work
|
||||
// accross oauth redirects.
|
||||
.with_same_site(SameSite::Lax);
|
||||
if let Some(domain) = core_host().and_then(|url| url.domain()) {
|
||||
layer = layer.with_domain(domain);
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
pub const SESSION_KEY_USER_ID: &str = "user-id";
|
||||
pub const SESSION_KEY_TOTP_LOGIN: &str = "totp-user-id";
|
||||
pub const SESSION_KEY_TOTP_ENROLLMENT: &str = "totp-enrollment";
|
||||
pub const SESSION_KEY_PASSKEY_LOGIN: &str = "passkey-user-id";
|
||||
pub const SESSION_KEY_PASSKEY_ENROLLMENT: &str = "passkey-enrollment";
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
use std::{collections::VecDeque, time::Instant};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use axum::{
|
||||
Extension, Json, Router, extract::Path, middleware, routing::post,
|
||||
};
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use database::hash_password;
|
||||
use database::mongo_indexed::doc;
|
||||
use database::mungos::{
|
||||
by_id::update_one_by_id, mongodb::bson::to_bson,
|
||||
};
|
||||
use derive_variants::EnumVariants;
|
||||
use komodo_client::entities::{random_bytes, random_string};
|
||||
use komodo_client::{
|
||||
api::user::*,
|
||||
entities::{api_key::ApiKey, komodo_timestamp, user::User},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use response::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serror::{AddStatusCode, AddStatusCodeError};
|
||||
use tower_sessions::Session;
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::PasskeyRegistration;
|
||||
|
||||
use crate::api::{
|
||||
SESSION_KEY_PASSKEY_ENROLLMENT, SESSION_KEY_TOTP_ENROLLMENT,
|
||||
memory_session_layer,
|
||||
};
|
||||
use crate::auth::totp::make_totp;
|
||||
use crate::config::core_config;
|
||||
use crate::helpers::validations::validate_api_key_name;
|
||||
use crate::state::webauthn;
|
||||
use crate::{
|
||||
auth::auth_request, helpers::query::get_user, state::db_client,
|
||||
};
|
||||
|
||||
use super::Variant;
|
||||
|
||||
pub struct UserArgs {
|
||||
pub user: User,
|
||||
/// Per-client session state
|
||||
pub session: Option<Session>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
|
||||
)]
|
||||
#[args(UserArgs)]
|
||||
#[response(Response)]
|
||||
#[error(serror::Error)]
|
||||
#[serde(tag = "type", content = "params")]
|
||||
enum UserRequest {
|
||||
PushRecentlyViewed(PushRecentlyViewed),
|
||||
SetLastSeenUpdate(SetLastSeenUpdate),
|
||||
CreateApiKey(CreateApiKey),
|
||||
DeleteApiKey(DeleteApiKey),
|
||||
BeginTotpEnrollment(BeginTotpEnrollment),
|
||||
ConfirmTotpEnrollment(ConfirmTotpEnrollment),
|
||||
UnenrollTotp(UnenrollTotp),
|
||||
BeginPasskeyEnrollment(BeginPasskeyEnrollment),
|
||||
ConfirmPasskeyEnrollment(ConfirmPasskeyEnrollment),
|
||||
UnenrollPasskey(UnenrollPasskey),
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", post(handler))
|
||||
.route("/{variant}", post(variant_handler))
|
||||
.layer(memory_session_layer(60))
|
||||
.layer(middleware::from_fn(auth_request))
|
||||
}
|
||||
|
||||
async fn variant_handler(
|
||||
session: Session,
|
||||
user: Extension<User>,
|
||||
Path(Variant { variant }): Path<Variant>,
|
||||
Json(params): Json<serde_json::Value>,
|
||||
) -> serror::Result<axum::response::Response> {
|
||||
let req: UserRequest = serde_json::from_value(json!({
|
||||
"type": variant,
|
||||
"params": params,
|
||||
}))?;
|
||||
handler(session, user, Json(req)).await
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
session: Session,
|
||||
Extension(user): Extension<User>,
|
||||
Json(request): Json<UserRequest>,
|
||||
) -> serror::Result<axum::response::Response> {
|
||||
let timer = Instant::now();
|
||||
let req_id = Uuid::new_v4();
|
||||
debug!(
|
||||
"/user request {req_id} | user: {} ({})",
|
||||
user.username, user.id
|
||||
);
|
||||
let res = request
|
||||
.resolve(&UserArgs {
|
||||
user,
|
||||
session: Some(session),
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = &res {
|
||||
warn!("/user request {req_id} error: {:#}", e.error);
|
||||
}
|
||||
let elapsed = timer.elapsed();
|
||||
debug!("/user request {req_id} | resolve time: {elapsed:?}");
|
||||
res.map(|res| res.0)
|
||||
}
|
||||
|
||||
const RECENTLY_VIEWED_MAX: usize = 10;
|
||||
|
||||
impl Resolve<UserArgs> for PushRecentlyViewed {
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<PushRecentlyViewedResponse> {
|
||||
let user = get_user(&user.id).await?;
|
||||
|
||||
let (resource_type, id) = self.resource.extract_variant_id();
|
||||
|
||||
let field = format!("recents.{resource_type}");
|
||||
|
||||
let update = match user.recents.get(&resource_type) {
|
||||
Some(recents) => {
|
||||
let mut recents = recents
|
||||
.iter()
|
||||
.filter(|_id| !id.eq(*_id))
|
||||
.take(RECENTLY_VIEWED_MAX - 1)
|
||||
.collect::<VecDeque<_>>();
|
||||
|
||||
recents.push_front(id);
|
||||
|
||||
doc! { &field: to_bson(&recents)? }
|
||||
}
|
||||
None => {
|
||||
doc! { &field: [id] }
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
database::mungos::update::Update::Set(update),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to update user '{field}'"))?;
|
||||
|
||||
Ok(PushRecentlyViewedResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for SetLastSeenUpdate {
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<SetLastSeenUpdateResponse> {
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
database::mungos::update::Update::Set(doc! {
|
||||
"last_update_view": komodo_timestamp()
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to update user 'last_update_view'")?;
|
||||
|
||||
Ok(SetLastSeenUpdateResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
const SECRET_LENGTH: usize = 40;
|
||||
const BCRYPT_COST: u32 = 10;
|
||||
|
||||
impl Resolve<UserArgs> for CreateApiKey {
|
||||
#[instrument(
|
||||
"CreateApiKey",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<CreateApiKeyResponse> {
|
||||
let user = get_user(&user.id).await?;
|
||||
|
||||
validate_api_key_name(&self.name)
|
||||
.status_code(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let key = format!("K-{}", random_string(SECRET_LENGTH));
|
||||
let secret = format!("S-{}", random_string(SECRET_LENGTH));
|
||||
let secret_hash = bcrypt::hash(&secret, BCRYPT_COST)
|
||||
.context("Failed at hashing secret string")?;
|
||||
|
||||
let api_key = ApiKey {
|
||||
name: self.name,
|
||||
key: key.clone(),
|
||||
secret: secret_hash,
|
||||
user_id: user.id.clone(),
|
||||
created_at: komodo_timestamp(),
|
||||
expires: self.expires,
|
||||
};
|
||||
|
||||
db_client()
|
||||
.api_keys
|
||||
.insert_one(api_key)
|
||||
.await
|
||||
.context("Failed to create api key on database")?;
|
||||
|
||||
Ok(CreateApiKeyResponse { key, secret })
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for DeleteApiKey {
|
||||
#[instrument(
|
||||
"DeleteApiKey",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<DeleteApiKeyResponse> {
|
||||
let client = db_client();
|
||||
|
||||
let key = client
|
||||
.api_keys
|
||||
.find_one(doc! { "key": &self.key })
|
||||
.await
|
||||
.context("Failed at database query")?
|
||||
.context("No api key with key found")?;
|
||||
|
||||
if user.id != key.user_id {
|
||||
return Err(
|
||||
anyhow!("Api key does not belong to user")
|
||||
.status_code(StatusCode::FORBIDDEN),
|
||||
);
|
||||
}
|
||||
|
||||
client
|
||||
.api_keys
|
||||
.delete_one(doc! { "key": key.key })
|
||||
.await
|
||||
.context("Failed to delete api key from database")?;
|
||||
|
||||
Ok(DeleteApiKeyResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
const TOTP_ENROLLMENT_SECRET_LENGTH: usize = 20;
|
||||
|
||||
impl Resolve<UserArgs> for BeginTotpEnrollment {
|
||||
#[instrument(
|
||||
"BeginTotpEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<BeginTotpEnrollmentResponse> {
|
||||
for locked_username in &core_config().lock_login_credentials_for {
|
||||
if *locked_username == user.username {
|
||||
return Err(
|
||||
anyhow!("User not allowed to enroll in TOTP 2FA.").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let secret_bytes = random_bytes(TOTP_ENROLLMENT_SECRET_LENGTH);
|
||||
let totp = make_totp(secret_bytes.clone(), user.id.clone())?;
|
||||
let png = totp
|
||||
.get_qr_base64()
|
||||
.map_err(|e| anyhow::Error::msg(e))
|
||||
.context("Failed to generate QR code png")?;
|
||||
session
|
||||
.insert(SESSION_KEY_TOTP_ENROLLMENT, secret_bytes)
|
||||
.await?;
|
||||
|
||||
Ok(BeginTotpEnrollmentResponse {
|
||||
uri: totp.get_url(),
|
||||
png,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for ConfirmTotpEnrollment {
|
||||
#[instrument(
|
||||
"ConfirmTotpEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<ConfirmTotpEnrollmentResponse> {
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let secret_bytes = session
|
||||
.remove::<Vec<u8>>(SESSION_KEY_TOTP_ENROLLMENT)
|
||||
.await
|
||||
.context("Totp enrollment was not initiated correctly")?
|
||||
.context(
|
||||
"Totp enrollment was not initiated correctly or timed out",
|
||||
)?;
|
||||
|
||||
let encoded_secret = BASE32_NOPAD.encode(&secret_bytes);
|
||||
|
||||
let totp = make_totp(secret_bytes, None)?;
|
||||
|
||||
let valid = totp
|
||||
.check_current(&self.code)
|
||||
.context("Failed to check code validity")?;
|
||||
|
||||
if !valid {
|
||||
return Err(anyhow!(
|
||||
"The provided code was not valid. Please try BeginTotpEnrollment flow again."
|
||||
).status_code(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
let recovery_codes =
|
||||
(0..10).map(|_| random_string(20)).collect::<Vec<_>>();
|
||||
let hashed_recovery_codes = recovery_codes
|
||||
.iter()
|
||||
.map(|code| hash_password(code))
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.context("Failed to generate valid recovery codes")?;
|
||||
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
doc! {
|
||||
"$set": {
|
||||
"totp.secret": encoded_secret,
|
||||
"totp.confirmed_at": komodo_timestamp(),
|
||||
"totp.recovery_codes": hashed_recovery_codes,
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to update user totp fields on database")?;
|
||||
|
||||
Ok(ConfirmTotpEnrollmentResponse { recovery_codes })
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for UnenrollTotp {
|
||||
#[instrument(
|
||||
"UnenrollTotp",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UnenrollTotpResponse> {
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
doc! {
|
||||
"$set": {
|
||||
"totp.secret": "",
|
||||
"totp.confirmed_at": 0,
|
||||
"totp.recovery_codes": [],
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to clear user totp fields on database")?;
|
||||
Ok(UnenrollTotpResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for BeginPasskeyEnrollment {
|
||||
#[instrument(
|
||||
"BeginPasskeyEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<BeginPasskeyEnrollmentResponse> {
|
||||
for locked_username in &core_config().lock_login_credentials_for {
|
||||
if *locked_username == user.username {
|
||||
return Err(
|
||||
anyhow!(
|
||||
"User not allowed to enroll in Passkey authentication."
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
|
||||
// Get two parts from this, the first is returned to the client.
|
||||
// The second must stay server side and is used in confirmation flow.
|
||||
let (challenge, server_state) = webauthn
|
||||
.start_passkey_registration(
|
||||
Uuid::new_v4(),
|
||||
&user.username,
|
||||
&user.username,
|
||||
None,
|
||||
)?;
|
||||
|
||||
session
|
||||
.insert(
|
||||
SESSION_KEY_PASSKEY_ENROLLMENT,
|
||||
(&user.id, server_state),
|
||||
)
|
||||
.await
|
||||
.context(
|
||||
"Failed to store passkey enrollment state in server side client session",
|
||||
)?;
|
||||
|
||||
Ok(challenge.into())
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for ConfirmPasskeyEnrollment {
|
||||
#[instrument(
|
||||
"ConfirmPasskeyEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<ConfirmPasskeyEnrollmentResponse> {
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
|
||||
let (user_id, server_state) = session
|
||||
.remove::<(String, PasskeyRegistration)>(
|
||||
SESSION_KEY_PASSKEY_ENROLLMENT,
|
||||
)
|
||||
.await
|
||||
.context("Passkey enrollment was not initiated correctly")?
|
||||
.context(
|
||||
"Passkey enrollment was not initiated correctly or timed out",
|
||||
)?;
|
||||
|
||||
let passkey = webauthn
|
||||
.finish_passkey_registration(
|
||||
&self.credential.into(),
|
||||
&server_state,
|
||||
)
|
||||
.context("Failed to finish passkey registration")?;
|
||||
|
||||
let passkey = to_bson(&passkey)
|
||||
.context("Failed to serialize passkey to BSON")?;
|
||||
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"passkey.passkey": passkey,
|
||||
"passkey.created_at": komodo_timestamp()
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user_id, update, None)
|
||||
.await
|
||||
.context("Failed to update user passkey options on database")?;
|
||||
|
||||
Ok(ConfirmPasskeyEnrollmentResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for UnenrollPasskey {
|
||||
#[instrument(
|
||||
"UnenrollPasskey",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UnenrollPasskeyResponse> {
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"passkey.passkey": null,
|
||||
"passkey.created_at": 0
|
||||
}
|
||||
};
|
||||
update_one_by_id(&db_client().users, &user.id, update, None)
|
||||
.await
|
||||
.context("Failed to update user passkey options on database")?;
|
||||
Ok(UnenrollPasskeyResponse {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use database::bson::doc;
|
||||
use komodo_client::{
|
||||
api::user::{
|
||||
CreateApiKey, CreateApiKeyResponse, DeleteApiKey,
|
||||
DeleteApiKeyResponse,
|
||||
},
|
||||
entities::{api_key::ApiKey, komodo_timestamp, random_string},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use serror::{AddStatusCode as _, AddStatusCodeError as _};
|
||||
|
||||
use crate::{
|
||||
helpers::{query::get_user, validations::validate_api_key_name},
|
||||
state::db_client,
|
||||
};
|
||||
|
||||
use super::UserArgs;
|
||||
|
||||
const SECRET_LENGTH: usize = 40;
|
||||
const BCRYPT_COST: u32 = 10;
|
||||
|
||||
impl Resolve<UserArgs> for CreateApiKey {
|
||||
#[instrument(
|
||||
"CreateApiKey",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<CreateApiKeyResponse> {
|
||||
let user = get_user(&user.id).await?;
|
||||
|
||||
validate_api_key_name(&self.name)
|
||||
.status_code(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let key = format!("K_{}_K", random_string(SECRET_LENGTH));
|
||||
let secret = format!("S_{}_S", random_string(SECRET_LENGTH));
|
||||
let secret_hash = bcrypt::hash(&secret, BCRYPT_COST)
|
||||
.context("Failed at hashing secret string")?;
|
||||
|
||||
let api_key = ApiKey {
|
||||
name: self.name,
|
||||
key: key.clone(),
|
||||
secret: secret_hash,
|
||||
user_id: user.id.clone(),
|
||||
created_at: komodo_timestamp(),
|
||||
expires: self.expires,
|
||||
};
|
||||
|
||||
db_client()
|
||||
.api_keys
|
||||
.insert_one(api_key)
|
||||
.await
|
||||
.context("Failed to create api key on database")?;
|
||||
|
||||
Ok(CreateApiKeyResponse { key, secret })
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for DeleteApiKey {
|
||||
#[instrument(
|
||||
"DeleteApiKey",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<DeleteApiKeyResponse> {
|
||||
let client = db_client();
|
||||
|
||||
let key = client
|
||||
.api_keys
|
||||
.find_one(doc! { "key": &self.key })
|
||||
.await
|
||||
.context("Failed at database query")?
|
||||
.context("No api key with key found")?;
|
||||
|
||||
if user.id != key.user_id {
|
||||
return Err(
|
||||
anyhow!("Api key does not belong to user")
|
||||
.status_code(StatusCode::FORBIDDEN),
|
||||
);
|
||||
}
|
||||
|
||||
client
|
||||
.api_keys
|
||||
.delete_one(doc! { "key": key.key })
|
||||
.await
|
||||
.context("Failed to delete api key from database")?;
|
||||
|
||||
Ok(DeleteApiKeyResponse {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use std::{collections::VecDeque, time::Instant};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use axum::{
|
||||
Extension, Json, Router, extract::Path, middleware, routing::post,
|
||||
};
|
||||
use database::{
|
||||
bson::{doc, to_bson},
|
||||
mungos::by_id::update_one_by_id,
|
||||
};
|
||||
use derive_variants::EnumVariants;
|
||||
use komodo_client::{
|
||||
api::user::*,
|
||||
entities::{NoData, komodo_timestamp, user::User},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use response::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serror::{AddStatusCode as _, AddStatusCodeError};
|
||||
use tower_sessions::Session;
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::auth_request,
|
||||
config::core_config,
|
||||
helpers::{
|
||||
query::get_user,
|
||||
validations::{validate_password, validate_username},
|
||||
},
|
||||
state::db_client,
|
||||
};
|
||||
|
||||
use super::Variant;
|
||||
|
||||
mod api_key;
|
||||
mod passkey;
|
||||
mod totp;
|
||||
|
||||
pub struct UserArgs {
|
||||
pub user: User,
|
||||
/// Per-client session state
|
||||
pub session: Option<Session>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Debug, Clone, Serialize, Deserialize, Resolve, EnumVariants,
|
||||
)]
|
||||
#[args(UserArgs)]
|
||||
#[response(Response)]
|
||||
#[error(serror::Error)]
|
||||
#[serde(tag = "type", content = "params")]
|
||||
enum UserRequest {
|
||||
PushRecentlyViewed(PushRecentlyViewed),
|
||||
SetLastSeenUpdate(SetLastSeenUpdate),
|
||||
UpdateUsername(UpdateUsername),
|
||||
UpdatePassword(UpdatePassword),
|
||||
CreateApiKey(CreateApiKey),
|
||||
DeleteApiKey(DeleteApiKey),
|
||||
BeginTotpEnrollment(BeginTotpEnrollment),
|
||||
ConfirmTotpEnrollment(ConfirmTotpEnrollment),
|
||||
UnenrollTotp(UnenrollTotp),
|
||||
BeginPasskeyEnrollment(BeginPasskeyEnrollment),
|
||||
ConfirmPasskeyEnrollment(ConfirmPasskeyEnrollment),
|
||||
UnenrollPasskey(UnenrollPasskey),
|
||||
BeginThirdPartyLoginLink(BeginThirdPartyLoginLink),
|
||||
UnlinkLogin(UnlinkLogin),
|
||||
UpdateThirdPartySkip2fa(UpdateThirdPartySkip2fa),
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", post(handler))
|
||||
.route("/{variant}", post(variant_handler))
|
||||
.layer(middleware::from_fn(auth_request))
|
||||
}
|
||||
|
||||
async fn variant_handler(
|
||||
session: Session,
|
||||
user: Extension<User>,
|
||||
Path(Variant { variant }): Path<Variant>,
|
||||
Json(params): Json<serde_json::Value>,
|
||||
) -> serror::Result<axum::response::Response> {
|
||||
let req: UserRequest = serde_json::from_value(json!({
|
||||
"type": variant,
|
||||
"params": params,
|
||||
}))?;
|
||||
handler(session, user, Json(req)).await
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
session: Session,
|
||||
Extension(user): Extension<User>,
|
||||
Json(request): Json<UserRequest>,
|
||||
) -> serror::Result<axum::response::Response> {
|
||||
let timer = Instant::now();
|
||||
let req_id = Uuid::new_v4();
|
||||
debug!(
|
||||
"/user request {req_id} | user: {} ({})",
|
||||
user.username, user.id
|
||||
);
|
||||
let res = request
|
||||
.resolve(&UserArgs {
|
||||
user,
|
||||
session: Some(session),
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = &res {
|
||||
warn!("/user request {req_id} error: {:#}", e.error);
|
||||
}
|
||||
let elapsed = timer.elapsed();
|
||||
debug!("/user request {req_id} | resolve time: {elapsed:?}");
|
||||
res.map(|res| res.0)
|
||||
}
|
||||
|
||||
const RECENTLY_VIEWED_MAX: usize = 10;
|
||||
|
||||
impl Resolve<UserArgs> for PushRecentlyViewed {
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<PushRecentlyViewedResponse> {
|
||||
let user = get_user(&user.id).await?;
|
||||
|
||||
let (resource_type, id) = self.resource.extract_variant_id();
|
||||
|
||||
let field = format!("recents.{resource_type}");
|
||||
|
||||
let update = match user.recents.get(&resource_type) {
|
||||
Some(recents) => {
|
||||
let mut recents = recents
|
||||
.iter()
|
||||
.filter(|_id| !id.eq(*_id))
|
||||
.take(RECENTLY_VIEWED_MAX - 1)
|
||||
.collect::<VecDeque<_>>();
|
||||
|
||||
recents.push_front(id);
|
||||
|
||||
doc! { &field: to_bson(&recents)? }
|
||||
}
|
||||
None => {
|
||||
doc! { &field: [id] }
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
database::mungos::update::Update::Set(update),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to update user '{field}'"))?;
|
||||
|
||||
Ok(PushRecentlyViewedResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for SetLastSeenUpdate {
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<SetLastSeenUpdateResponse> {
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
database::mungos::update::Update::Set(doc! {
|
||||
"last_update_view": komodo_timestamp()
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to update user 'last_update_view'")?;
|
||||
|
||||
Ok(SetLastSeenUpdateResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for UpdateUsername {
|
||||
#[instrument(
|
||||
"UpdateUsername",
|
||||
skip_all,
|
||||
fields(
|
||||
operator = user.id,
|
||||
new_username = self.username,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UpdateUsernameResponse> {
|
||||
check_locked(&user.username)?;
|
||||
|
||||
validate_username(&self.username)?;
|
||||
|
||||
let db = db_client();
|
||||
|
||||
if db
|
||||
.users
|
||||
.find_one(doc! { "username": &self.username })
|
||||
.await
|
||||
.context("Failed to query for existing users")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(anyhow!("Username already taken.").into());
|
||||
}
|
||||
|
||||
let update = doc! { "$set": { "username": self.username } };
|
||||
|
||||
update_one_by_id(&db.users, &user.id, update, None)
|
||||
.await
|
||||
.context("Failed to update user username on database.")?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for UpdatePassword {
|
||||
#[instrument(
|
||||
"UpdatePassword",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UpdatePasswordResponse> {
|
||||
check_locked(&user.username)?;
|
||||
|
||||
validate_password(&self.password)
|
||||
.status_code(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
db_client().set_user_password(user, &self.password).await?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SessionThirdPartyLinkInfo {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
impl SessionThirdPartyLinkInfo {
|
||||
pub const KEY: &str = "third-party-link-info";
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for BeginThirdPartyLoginLink {
|
||||
#[instrument(
|
||||
"BeginThirdPartyLoginLink",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<BeginThirdPartyLoginLinkResponse> {
|
||||
check_locked(&user.username)?;
|
||||
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
session
|
||||
.insert(
|
||||
SessionThirdPartyLinkInfo::KEY,
|
||||
SessionThirdPartyLinkInfo {
|
||||
user_id: user.id.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context(
|
||||
"Failed to insert third party link info into client session",
|
||||
)?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for UnlinkLogin {
|
||||
#[instrument(
|
||||
"UnlinkLogin",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UnlinkLoginResponse> {
|
||||
check_locked(&user.username)?;
|
||||
|
||||
let field = match self.provider.to_lowercase().as_str() {
|
||||
"local" => "linked_logins.Local",
|
||||
"github" => "linked_logins.Github",
|
||||
"google" => "linked_logins.Google",
|
||||
"oidc" => "linked_logins.Oidc",
|
||||
_ => {
|
||||
return Err(
|
||||
anyhow!(
|
||||
"Unrecognized third party login provider: {}",
|
||||
self.provider
|
||||
)
|
||||
.status_code(StatusCode::BAD_REQUEST),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let update = doc! {
|
||||
"$unset": {
|
||||
field: ""
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user.id, update, None)
|
||||
.await
|
||||
.context("Failed to unlink third partly login on database")?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for UpdateThirdPartySkip2fa {
|
||||
#[instrument(
|
||||
"UpdateThirdPartySkip2fa",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UpdateThirdPartySkip2faResponse> {
|
||||
check_locked(&user.username)?;
|
||||
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"third_party_skip_2fa": self.skip
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user.id, update, None)
|
||||
.await
|
||||
.context("Failed to unlink third partly login on database")?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
fn check_locked(username: &str) -> serror::Result<()> {
|
||||
for locked_username in &core_config().lock_login_credentials_for {
|
||||
if locked_username == "__ALL__" || *locked_username == username {
|
||||
return Err(
|
||||
anyhow!("User login credentials are locked.")
|
||||
.status_code(StatusCode::UNAUTHORIZED),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use anyhow::Context as _;
|
||||
use database::{
|
||||
bson::{doc, to_bson},
|
||||
mungos::by_id::update_one_by_id,
|
||||
};
|
||||
use komodo_client::{
|
||||
api::user::{
|
||||
BeginPasskeyEnrollment, BeginPasskeyEnrollmentResponse,
|
||||
ConfirmPasskeyEnrollment, ConfirmPasskeyEnrollmentResponse,
|
||||
UnenrollPasskey, UnenrollPasskeyResponse,
|
||||
},
|
||||
entities::komodo_timestamp,
|
||||
};
|
||||
use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::PasskeyRegistration;
|
||||
|
||||
use crate::state::{db_client, webauthn};
|
||||
|
||||
use super::UserArgs;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionPasskeyEnrollment {
|
||||
user_id: String,
|
||||
state: PasskeyRegistration,
|
||||
}
|
||||
impl SessionPasskeyEnrollment {
|
||||
const KEY: &str = "passkey-enrollment";
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for BeginPasskeyEnrollment {
|
||||
#[instrument(
|
||||
"BeginPasskeyEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<BeginPasskeyEnrollmentResponse> {
|
||||
super::check_locked(&user.username)?;
|
||||
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
|
||||
// Get two parts from this, the first is returned to the client.
|
||||
// The second must stay server side and is used in confirmation flow.
|
||||
let (challenge, state) = webauthn.start_passkey_registration(
|
||||
Uuid::new_v4(),
|
||||
&user.username,
|
||||
&user.username,
|
||||
None,
|
||||
)?;
|
||||
|
||||
session
|
||||
.insert(
|
||||
SessionPasskeyEnrollment::KEY,
|
||||
SessionPasskeyEnrollment {
|
||||
user_id: user.id.clone(),
|
||||
state
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context(
|
||||
"Failed to store passkey enrollment state in server side client session",
|
||||
)?;
|
||||
|
||||
Ok(challenge)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for ConfirmPasskeyEnrollment {
|
||||
#[instrument(
|
||||
"ConfirmPasskeyEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<ConfirmPasskeyEnrollmentResponse> {
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
|
||||
let SessionPasskeyEnrollment { user_id, state } = session
|
||||
.remove(SessionPasskeyEnrollment::KEY)
|
||||
.await
|
||||
.context("Passkey enrollment was not initiated correctly")?
|
||||
.context(
|
||||
"Passkey enrollment was not initiated correctly or timed out",
|
||||
)?;
|
||||
|
||||
let passkey = webauthn
|
||||
.finish_passkey_registration(&self.credential, &state)
|
||||
.context("Failed to finish passkey registration")?;
|
||||
|
||||
let passkey = to_bson(&passkey)
|
||||
.context("Failed to serialize passkey to BSON")?;
|
||||
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"passkey.passkey": passkey,
|
||||
"passkey.created_at": komodo_timestamp()
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user_id, update, None)
|
||||
.await
|
||||
.context("Failed to update user passkey options on database")?;
|
||||
|
||||
Ok(ConfirmPasskeyEnrollmentResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<UserArgs> for UnenrollPasskey {
|
||||
#[instrument(
|
||||
"UnenrollPasskey",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UnenrollPasskeyResponse> {
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"passkey.passkey": null,
|
||||
"passkey.created_at": 0
|
||||
}
|
||||
};
|
||||
update_one_by_id(&db_client().users, &user.id, update, None)
|
||||
.await
|
||||
.context("Failed to update user passkey options on database")?;
|
||||
Ok(UnenrollPasskeyResponse {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use database::{
|
||||
bson::doc, hash_password, mungos::by_id::update_one_by_id,
|
||||
};
|
||||
use komodo_client::{
|
||||
api::user::{
|
||||
BeginTotpEnrollment, BeginTotpEnrollmentResponse,
|
||||
ConfirmTotpEnrollment, ConfirmTotpEnrollmentResponse,
|
||||
UnenrollTotp, UnenrollTotpResponse,
|
||||
},
|
||||
entities::{komodo_timestamp, random_bytes, random_string},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serror::AddStatusCodeError as _;
|
||||
|
||||
use crate::{auth::totp::make_totp, state::db_client};
|
||||
|
||||
use super::UserArgs;
|
||||
|
||||
const TOTP_ENROLLMENT_SECRET_LENGTH: usize = 20;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionTotpEnrollment {
|
||||
secret: Vec<u8>,
|
||||
}
|
||||
impl SessionTotpEnrollment {
|
||||
const KEY: &str = "totp-enrollment";
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for BeginTotpEnrollment {
|
||||
#[instrument(
|
||||
"BeginTotpEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<BeginTotpEnrollmentResponse> {
|
||||
super::check_locked(&user.username)?;
|
||||
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let secret = random_bytes(TOTP_ENROLLMENT_SECRET_LENGTH);
|
||||
let totp = make_totp(secret.clone(), user.id.clone())?;
|
||||
let png = totp
|
||||
.get_qr_base64()
|
||||
.map_err(anyhow::Error::msg)
|
||||
.context("Failed to generate QR code png")?;
|
||||
session
|
||||
.insert(
|
||||
SessionTotpEnrollment::KEY,
|
||||
SessionTotpEnrollment { secret },
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(BeginTotpEnrollmentResponse {
|
||||
uri: totp.get_url(),
|
||||
png,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for ConfirmTotpEnrollment {
|
||||
#[instrument(
|
||||
"ConfirmTotpEnrollment",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, session }: &UserArgs,
|
||||
) -> serror::Result<ConfirmTotpEnrollmentResponse> {
|
||||
let session = session.as_ref().context(
|
||||
"Method called in invalid context. This should not happen",
|
||||
)?;
|
||||
|
||||
let SessionTotpEnrollment { secret } = session
|
||||
.remove(SessionTotpEnrollment::KEY)
|
||||
.await
|
||||
.context("Totp enrollment was not initiated correctly")?
|
||||
.context(
|
||||
"Totp enrollment was not initiated correctly or timed out",
|
||||
)?;
|
||||
|
||||
let encoded_secret = BASE32_NOPAD.encode(&secret);
|
||||
|
||||
let totp = make_totp(secret, None)?;
|
||||
|
||||
let valid = totp
|
||||
.check_current(&self.code)
|
||||
.context("Failed to check code validity")?;
|
||||
|
||||
if !valid {
|
||||
return Err(anyhow!(
|
||||
"The provided code was not valid. Please try BeginTotpEnrollment flow again."
|
||||
).status_code(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
let recovery_codes =
|
||||
(0..10).map(|_| random_string(20)).collect::<Vec<_>>();
|
||||
let hashed_recovery_codes = recovery_codes
|
||||
.iter()
|
||||
.map(hash_password)
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.context("Failed to generate valid recovery codes")?;
|
||||
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
doc! {
|
||||
"$set": {
|
||||
"totp.secret": encoded_secret,
|
||||
"totp.confirmed_at": komodo_timestamp(),
|
||||
"totp.recovery_codes": hashed_recovery_codes,
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to update user totp fields on database")?;
|
||||
|
||||
Ok(ConfirmTotpEnrollmentResponse { recovery_codes })
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<UserArgs> for UnenrollTotp {
|
||||
#[instrument(
|
||||
"UnenrollTotp",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
UserArgs { user, .. }: &UserArgs,
|
||||
) -> serror::Result<UnenrollTotpResponse> {
|
||||
update_one_by_id(
|
||||
&db_client().users,
|
||||
&user.id,
|
||||
doc! {
|
||||
"$set": {
|
||||
"totp.secret": "",
|
||||
"totp.confirmed_at": 0,
|
||||
"totp.recovery_codes": [],
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.context("Failed to clear user totp fields on database")?;
|
||||
Ok(UnenrollTotpResponse {})
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ mod alerter;
|
||||
mod build;
|
||||
mod builder;
|
||||
mod deployment;
|
||||
mod onboarding_key;
|
||||
mod onboarding;
|
||||
mod permissions;
|
||||
mod procedure;
|
||||
mod provider;
|
||||
@@ -159,8 +159,6 @@ pub enum WriteRequest {
|
||||
|
||||
// ==== USER ====
|
||||
CreateLocalUser(CreateLocalUser),
|
||||
UpdateUserUsername(UpdateUserUsername),
|
||||
UpdateUserPassword(UpdateUserPassword),
|
||||
DeleteUser(DeleteUser),
|
||||
|
||||
// ==== SERVICE USER ====
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Resolve<WriteArgs> for CreateOnboardingKey {
|
||||
let private_key = if let Some(private_key) = self.private_key {
|
||||
private_key
|
||||
} else {
|
||||
format!("O-{}", random_string(30))
|
||||
format!("O_{}_O", random_string(28))
|
||||
};
|
||||
let public_key = EncodedKeyPair::from_private_key(&private_key)?
|
||||
.public
|
||||
@@ -53,12 +53,14 @@ impl Resolve<WriteArgs> for CreateServiceUser {
|
||||
config,
|
||||
totp: Default::default(),
|
||||
passkey: Default::default(),
|
||||
third_party_skip_2fa: Default::default(),
|
||||
enabled: true,
|
||||
admin: false,
|
||||
super_admin: false,
|
||||
create_server_permissions: false,
|
||||
create_build_permissions: false,
|
||||
last_update_view: 0,
|
||||
linked_logins: Default::default(),
|
||||
recents: Default::default(),
|
||||
all: Default::default(),
|
||||
updated_at: komodo_timestamp(),
|
||||
|
||||
@@ -8,17 +8,13 @@ use database::{
|
||||
};
|
||||
use komodo_client::{
|
||||
api::write::*,
|
||||
entities::{
|
||||
NoData,
|
||||
user::{User, UserConfig},
|
||||
},
|
||||
entities::user::{User, UserConfig},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use serror::{AddStatusCode as _, AddStatusCodeError};
|
||||
|
||||
use crate::{
|
||||
config::core_config,
|
||||
helpers::validations::{validate_password, validate_username},
|
||||
state::db_client,
|
||||
};
|
||||
@@ -82,8 +78,10 @@ impl Resolve<WriteArgs> for CreateLocalUser {
|
||||
config: UserConfig::Local {
|
||||
password: hashed_password,
|
||||
},
|
||||
linked_logins: Default::default(),
|
||||
totp: Default::default(),
|
||||
passkey: Default::default(),
|
||||
third_party_skip_2fa: Default::default(),
|
||||
};
|
||||
|
||||
user.id = db_client()
|
||||
@@ -104,93 +102,6 @@ impl Resolve<WriteArgs> for CreateLocalUser {
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<WriteArgs> for UpdateUserUsername {
|
||||
#[instrument(
|
||||
"UpdateUserUsername",
|
||||
skip_all,
|
||||
fields(
|
||||
operator = user.id,
|
||||
new_username = self.username,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
WriteArgs { user }: &WriteArgs,
|
||||
) -> serror::Result<UpdateUserUsernameResponse> {
|
||||
for locked_username in &core_config().lock_login_credentials_for {
|
||||
if locked_username == "__ALL__"
|
||||
|| *locked_username == user.username
|
||||
{
|
||||
return Err(
|
||||
anyhow!("User not allowed to update their username.")
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validate_username(&self.username)?;
|
||||
|
||||
let db = db_client();
|
||||
|
||||
if db
|
||||
.users
|
||||
.find_one(doc! { "username": &self.username })
|
||||
.await
|
||||
.context("Failed to query for existing users")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(anyhow!("Username already taken.").into());
|
||||
}
|
||||
|
||||
let id = ObjectId::from_str(&user.id)
|
||||
.context("User id not valid ObjectId.")?;
|
||||
|
||||
db.users
|
||||
.update_one(
|
||||
doc! { "_id": id },
|
||||
doc! { "$set": { "username": self.username } },
|
||||
)
|
||||
.await
|
||||
.context("Failed to update user username on database.")?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<WriteArgs> for UpdateUserPassword {
|
||||
#[instrument(
|
||||
"UpdateUserPassword",
|
||||
skip_all,
|
||||
fields(operator = user.id)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
WriteArgs { user }: &WriteArgs,
|
||||
) -> serror::Result<UpdateUserPasswordResponse> {
|
||||
for locked_username in &core_config().lock_login_credentials_for {
|
||||
if locked_username == "__ALL__"
|
||||
|| *locked_username == user.username
|
||||
{
|
||||
return Err(
|
||||
anyhow!("User not allowed to update their password.")
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validate_password(&self.password)
|
||||
.status_code(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
db_client().set_user_password(user, &self.password).await?;
|
||||
|
||||
Ok(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<WriteArgs> for DeleteUser {
|
||||
#[instrument(
|
||||
"DeleteUser",
|
||||
|
||||
@@ -7,15 +7,15 @@ use komodo_client::entities::{
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{auth::STATE_PREFIX_LENGTH, config::core_config};
|
||||
|
||||
pub fn github_oauth_client() -> &'static Option<GithubOauthClient> {
|
||||
pub fn github_oauth_client() -> Option<&'static GithubOauthClient> {
|
||||
static GITHUB_OAUTH_CLIENT: OnceLock<Option<GithubOauthClient>> =
|
||||
OnceLock::new();
|
||||
GITHUB_OAUTH_CLIENT
|
||||
.get_or_init(|| GithubOauthClient::new(core_config()))
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
pub struct GithubOauthClient {
|
||||
@@ -24,7 +24,6 @@ pub struct GithubOauthClient {
|
||||
client_secret: String,
|
||||
redirect_uri: String,
|
||||
scopes: String,
|
||||
states: Mutex<Vec<String>>,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
@@ -69,15 +68,14 @@ impl GithubOauthClient {
|
||||
redirect_uri: format!("{host}/auth/github/callback"),
|
||||
user_agent: Default::default(),
|
||||
scopes: Default::default(),
|
||||
states: Default::default(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub async fn get_login_redirect_url(
|
||||
pub async fn get_state_and_login_redirect_url(
|
||||
&self,
|
||||
redirect: Option<String>,
|
||||
) -> String {
|
||||
) -> (String, String) {
|
||||
let state_prefix = random_string(STATE_PREFIX_LENGTH);
|
||||
let state = match redirect {
|
||||
Some(redirect) => format!("{state_prefix}{redirect}"),
|
||||
@@ -87,22 +85,7 @@ impl GithubOauthClient {
|
||||
"https://github.com/login/oauth/authorize?state={state}&client_id={}&redirect_uri={}&scope={}",
|
||||
self.client_id, self.redirect_uri, self.scopes
|
||||
);
|
||||
let mut states = self.states.lock().await;
|
||||
states.push(state);
|
||||
redirect_url
|
||||
}
|
||||
|
||||
pub async fn check_state(&self, state: &str) -> bool {
|
||||
let mut contained = false;
|
||||
self.states.lock().await.retain(|s| {
|
||||
if s.as_str() == state {
|
||||
contained = true;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
contained
|
||||
(state, redirect_url)
|
||||
}
|
||||
|
||||
pub async fn get_access_token(
|
||||
|
||||
+182
-39
@@ -8,8 +8,10 @@ use axum::{
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
};
|
||||
use database::mongo_indexed::Document;
|
||||
use database::mungos::mongodb::bson::doc;
|
||||
use database::{
|
||||
mongo_indexed::Document, mungos::by_id::update_one_by_id,
|
||||
};
|
||||
use futures_util::TryFutureExt;
|
||||
use komodo_client::{
|
||||
api::auth::UserIdOrTwoFactor,
|
||||
@@ -20,17 +22,18 @@ use komodo_client::{
|
||||
};
|
||||
use rate_limit::WithFailureRateLimit;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serror::{AddStatusCode, AddStatusCodeError as _};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN,
|
||||
SESSION_KEY_USER_ID,
|
||||
api::user::SessionThirdPartyLinkInfo,
|
||||
auth::{
|
||||
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
|
||||
format_redirect, github::client::GithubOauthClient,
|
||||
},
|
||||
auth::format_redirect,
|
||||
config::core_config,
|
||||
helpers::query::{find_github_user, get_user},
|
||||
state::{auth_rate_limiter, db_client, webauthn},
|
||||
};
|
||||
|
||||
@@ -42,18 +45,8 @@ pub mod client;
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/login",
|
||||
get(|Query(query): Query<RedirectQuery>| async {
|
||||
let uri = github_oauth_client()
|
||||
.as_ref()
|
||||
.context("Github Oauth not configured")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?
|
||||
.get_login_redirect_url(query.redirect)
|
||||
.await;
|
||||
serror::Result::Ok(Redirect::to(&uri))
|
||||
}),
|
||||
)
|
||||
.route("/login", get(login))
|
||||
.route("/link", get(link))
|
||||
.route(
|
||||
"/callback",
|
||||
get(
|
||||
@@ -74,6 +67,67 @@ pub fn router() -> Router {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionGithubVerificationInfo {
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl SessionGithubVerificationInfo {
|
||||
const KEY: &str = "github-verification-info";
|
||||
}
|
||||
|
||||
async fn login(
|
||||
session: Session,
|
||||
Query(query): Query<RedirectQuery>,
|
||||
) -> serror::Result<Redirect> {
|
||||
let (state, uri) = github_oauth_client()
|
||||
.as_ref()
|
||||
.context("Github Oauth not configured")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?
|
||||
.get_state_and_login_redirect_url(query.redirect)
|
||||
.await;
|
||||
session
|
||||
.insert(
|
||||
SessionGithubVerificationInfo::KEY,
|
||||
SessionGithubVerificationInfo { state },
|
||||
)
|
||||
.await
|
||||
.context("Failed to insert github oauth session state")?;
|
||||
Ok(Redirect::to(&uri))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionGithubLinkInfo {
|
||||
user_id: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl SessionGithubLinkInfo {
|
||||
const KEY: &str = "github-link-info";
|
||||
}
|
||||
|
||||
async fn link(session: Session) -> serror::Result<Redirect> {
|
||||
let SessionThirdPartyLinkInfo { user_id } = session
|
||||
.remove(SessionThirdPartyLinkInfo::KEY)
|
||||
.await
|
||||
.context("Invalid session third party link info.")?
|
||||
.context("Missing session third party link info")?;
|
||||
let (state, uri) = github_oauth_client()
|
||||
.as_ref()
|
||||
.context("Github Oauth not configured")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?
|
||||
.get_state_and_login_redirect_url(None)
|
||||
.await;
|
||||
session
|
||||
.insert(
|
||||
SessionGithubLinkInfo::KEY,
|
||||
SessionGithubLinkInfo { user_id, state },
|
||||
)
|
||||
.await
|
||||
.context("Failed to insert session link info")?;
|
||||
Ok(Redirect::to(&uri))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallbackQuery {
|
||||
state: String,
|
||||
@@ -84,25 +138,58 @@ async fn callback(
|
||||
Query(query): Query<CallbackQuery>,
|
||||
session: Session,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
let client = github_oauth_client().as_ref().unwrap();
|
||||
if !client.check_state(&query.state).await {
|
||||
return Err(anyhow!("state mismatch"));
|
||||
let client =
|
||||
github_oauth_client().context("Missing github oauth client")?;
|
||||
|
||||
// Check first if this is a link callback
|
||||
// and use the linking handler if so.
|
||||
if let Ok(Some(info)) =
|
||||
session.remove(SessionGithubLinkInfo::KEY).await
|
||||
{
|
||||
return link_github_callback(client, info, query).await;
|
||||
}
|
||||
|
||||
let SessionGithubVerificationInfo { state } = session
|
||||
.remove(SessionGithubVerificationInfo::KEY)
|
||||
.await
|
||||
.context("Invalid github oauth session state")?
|
||||
.context("Missing github oauth session state")?;
|
||||
|
||||
if query.state != state {
|
||||
return Err(anyhow!("State mismatch"));
|
||||
}
|
||||
|
||||
let token = client.get_access_token(&query.code).await?;
|
||||
|
||||
let github_user =
|
||||
client.get_github_user(&token.access_token).await?;
|
||||
|
||||
let github_id = github_user.id.to_string();
|
||||
|
||||
let db_client = db_client();
|
||||
let user = db_client
|
||||
.users
|
||||
.find_one(doc! { "config.data.github_id": &github_id })
|
||||
.await
|
||||
.context("failed at find user query from database")?;
|
||||
|
||||
let user = find_github_user(&github_id).await?;
|
||||
|
||||
let user_id_or_two_factor = match user {
|
||||
Some(user) => {
|
||||
match (user.passkey.passkey, user.totp.enrolled()) {
|
||||
match (
|
||||
user.third_party_skip_2fa,
|
||||
user.passkey.passkey,
|
||||
user.totp.enrolled(),
|
||||
) {
|
||||
// Skip / No 2FA
|
||||
(true, _, _) | (false, None, false) => {
|
||||
session
|
||||
.insert(
|
||||
SessionUserId::KEY,
|
||||
SessionUserId(user.id.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to store user id for client session")?;
|
||||
UserIdOrTwoFactor::UserId(user.id)
|
||||
}
|
||||
// WebAuthn Passkey 2FA
|
||||
(Some(passkey), _) => {
|
||||
(false, Some(passkey), _) => {
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
@@ -111,24 +198,28 @@ async fn callback(
|
||||
.context("Failed to start passkey authentication flow")?;
|
||||
session
|
||||
.insert(
|
||||
SESSION_KEY_PASSKEY_LOGIN,
|
||||
(user.id, server_state),
|
||||
SessionPasskeyLogin::KEY,
|
||||
SessionPasskeyLogin {
|
||||
user_id: user.id,
|
||||
state: server_state,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
UserIdOrTwoFactor::Passkey(response)
|
||||
}
|
||||
// TOTP 2FA
|
||||
(None, true) => {
|
||||
(false, None, true) => {
|
||||
session
|
||||
.insert(SESSION_KEY_TOTP_LOGIN, user.id)
|
||||
.insert(
|
||||
SessionTotpLogin::KEY,
|
||||
SessionTotpLogin { user_id: user.id },
|
||||
)
|
||||
.await
|
||||
.context(
|
||||
"Failed to store totp login state in for user session",
|
||||
)?;
|
||||
UserIdOrTwoFactor::Totp {}
|
||||
}
|
||||
// No 2FA
|
||||
(None, false) => UserIdOrTwoFactor::UserId(user.id),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -169,8 +260,10 @@ async fn callback(
|
||||
github_id,
|
||||
avatar: github_user.avatar_url,
|
||||
},
|
||||
linked_logins: Default::default(),
|
||||
totp: Default::default(),
|
||||
passkey: Default::default(),
|
||||
third_party_skip_2fa: Default::default(),
|
||||
};
|
||||
let user_id = db_client
|
||||
.users
|
||||
@@ -186,11 +279,7 @@ async fn callback(
|
||||
};
|
||||
let redirect = Some(&query.state[STATE_PREFIX_LENGTH..]);
|
||||
match user_id_or_two_factor {
|
||||
UserIdOrTwoFactor::UserId(user_id) => {
|
||||
session
|
||||
.insert(SESSION_KEY_USER_ID, user_id)
|
||||
.await
|
||||
.context("Failed to store user id for client session")?;
|
||||
UserIdOrTwoFactor::UserId(_) => {
|
||||
Ok(format_redirect(redirect, "redeem_ready=true"))
|
||||
}
|
||||
UserIdOrTwoFactor::Totp {} => {
|
||||
@@ -204,3 +293,57 @@ async fn callback(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This intercepts during the normal oauth callback if
|
||||
/// 'github-link-info' is found on session.
|
||||
async fn link_github_callback(
|
||||
client: &GithubOauthClient,
|
||||
SessionGithubLinkInfo { user_id, state }: SessionGithubLinkInfo,
|
||||
query: CallbackQuery,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
if query.state != state {
|
||||
return Err(anyhow!("State mismatch"));
|
||||
}
|
||||
|
||||
let token = client.get_access_token(&query.code).await?;
|
||||
|
||||
let github_user =
|
||||
client.get_github_user(&token.access_token).await?;
|
||||
let github_id = github_user.id.to_string();
|
||||
|
||||
if let Some(existing_user) = find_github_user(&github_id).await? {
|
||||
if existing_user.id == user_id {
|
||||
// Link is already complete, this is a no-op
|
||||
return Ok(Redirect::to(&format!(
|
||||
"{}/settings",
|
||||
core_config().host,
|
||||
)));
|
||||
} else {
|
||||
return Err(anyhow!("Account already linked to another user."));
|
||||
}
|
||||
}
|
||||
|
||||
let user = get_user(&user_id).await?;
|
||||
|
||||
if let UserConfig::Github { .. } = &user.config {
|
||||
return Err(anyhow!(
|
||||
"User is primary Github user, cannot link another Github login."
|
||||
));
|
||||
}
|
||||
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"linked_logins.Github.type": "Github",
|
||||
"linked_logins.Github.data.github_id": &github_id,
|
||||
"linked_logins.Github.data.avatar": &github_user.avatar_url,
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user_id, update, None)
|
||||
.await
|
||||
.context(
|
||||
"Failed to link Github login to existing user on database",
|
||||
)?;
|
||||
|
||||
Ok(Redirect::to(&format!("{}/settings", core_config().host,)))
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ use komodo_client::entities::{
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{auth::STATE_PREFIX_LENGTH, config::core_config};
|
||||
|
||||
pub fn google_oauth_client() -> &'static Option<GoogleOauthClient> {
|
||||
pub fn google_oauth_client() -> Option<&'static GoogleOauthClient> {
|
||||
static GOOGLE_OAUTH_CLIENT: OnceLock<Option<GoogleOauthClient>> =
|
||||
OnceLock::new();
|
||||
GOOGLE_OAUTH_CLIENT
|
||||
.get_or_init(|| GoogleOauthClient::new(core_config()))
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
pub struct GoogleOauthClient {
|
||||
@@ -25,7 +25,6 @@ pub struct GoogleOauthClient {
|
||||
client_secret: String,
|
||||
redirect_uri: String,
|
||||
scopes: String,
|
||||
states: Mutex<Vec<String>>,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
@@ -77,16 +76,15 @@ impl GoogleOauthClient {
|
||||
client_secret: secret.clone(),
|
||||
redirect_uri: format!("{host}/auth/google/callback"),
|
||||
user_agent: String::from("komodo"),
|
||||
states: Default::default(),
|
||||
scopes,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub async fn get_login_redirect_url(
|
||||
pub async fn get_state_and_login_redirect_url(
|
||||
&self,
|
||||
redirect: Option<String>,
|
||||
) -> String {
|
||||
) -> (String, String) {
|
||||
let state_prefix = random_string(STATE_PREFIX_LENGTH);
|
||||
let state = match redirect {
|
||||
Some(redirect) => format!("{state_prefix}{redirect}"),
|
||||
@@ -96,22 +94,7 @@ impl GoogleOauthClient {
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&state={state}&client_id={}&redirect_uri={}&scope={}",
|
||||
self.client_id, self.redirect_uri, self.scopes
|
||||
);
|
||||
let mut states = self.states.lock().await;
|
||||
states.push(state);
|
||||
redirect_url
|
||||
}
|
||||
|
||||
pub async fn check_state(&self, state: &str) -> bool {
|
||||
let mut contained = false;
|
||||
self.states.lock().await.retain(|s| {
|
||||
if s.as_str() == state {
|
||||
contained = true;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
contained
|
||||
(state, redirect_url)
|
||||
}
|
||||
|
||||
pub async fn get_access_token(
|
||||
|
||||
+194
-50
@@ -9,8 +9,10 @@ use axum::{
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
};
|
||||
use database::mongo_indexed::Document;
|
||||
use database::mungos::mongodb::bson::doc;
|
||||
use database::{
|
||||
mongo_indexed::Document, mungos::by_id::update_one_by_id,
|
||||
};
|
||||
use futures_util::TryFutureExt;
|
||||
use komodo_client::{
|
||||
api::auth::UserIdOrTwoFactor,
|
||||
@@ -21,14 +23,18 @@ use komodo_client::{
|
||||
};
|
||||
use rate_limit::WithFailureRateLimit;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serror::{AddStatusCode, AddStatusCodeError as _};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{
|
||||
api::{SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN, SESSION_KEY_USER_ID},
|
||||
auth::format_redirect,
|
||||
api::user::SessionThirdPartyLinkInfo,
|
||||
auth::{
|
||||
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
|
||||
format_redirect, google::client::GoogleOauthClient,
|
||||
},
|
||||
config::core_config,
|
||||
helpers::query::{find_google_user, get_user},
|
||||
state::{auth_rate_limiter, db_client, webauthn},
|
||||
};
|
||||
|
||||
@@ -40,26 +46,16 @@ pub mod client;
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/login",
|
||||
get(|Query(query): Query<RedirectQuery>| async move {
|
||||
let uri = google_oauth_client()
|
||||
.as_ref()
|
||||
.context("Google Oauth not configured")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?
|
||||
.get_login_redirect_url(query.redirect)
|
||||
.await;
|
||||
serror::Result::Ok(Redirect::to(&uri))
|
||||
}),
|
||||
)
|
||||
.route("/login", get(login))
|
||||
.route("/link", get(link))
|
||||
.route(
|
||||
"/callback",
|
||||
get(
|
||||
|query,
|
||||
session: Session,
|
||||
session,
|
||||
headers: HeaderMap,
|
||||
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
|
||||
callback(query, session)
|
||||
callback(session, query)
|
||||
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
|
||||
.with_failure_rate_limit_using_headers(
|
||||
auth_rate_limiter(),
|
||||
@@ -72,6 +68,67 @@ pub fn router() -> Router {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionGoogleVerificationInfo {
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl SessionGoogleVerificationInfo {
|
||||
const KEY: &str = "google-verification-info";
|
||||
}
|
||||
|
||||
async fn login(
|
||||
session: Session,
|
||||
Query(query): Query<RedirectQuery>,
|
||||
) -> serror::Result<Redirect> {
|
||||
let (state, uri) = google_oauth_client()
|
||||
.as_ref()
|
||||
.context("Google Oauth not configured")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?
|
||||
.get_state_and_login_redirect_url(query.redirect)
|
||||
.await;
|
||||
session
|
||||
.insert(
|
||||
SessionGoogleVerificationInfo::KEY,
|
||||
SessionGoogleVerificationInfo { state },
|
||||
)
|
||||
.await
|
||||
.context("Failed to insert google oauth session state")?;
|
||||
Ok(Redirect::to(&uri))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionGoogleLinkInfo {
|
||||
user_id: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl SessionGoogleLinkInfo {
|
||||
const KEY: &str = "google-link-info";
|
||||
}
|
||||
|
||||
async fn link(session: Session) -> serror::Result<Redirect> {
|
||||
let SessionThirdPartyLinkInfo { user_id } = session
|
||||
.remove(SessionThirdPartyLinkInfo::KEY)
|
||||
.await
|
||||
.context("Invalid session third party link info.")?
|
||||
.context("Missing session third party link info")?;
|
||||
let (state, uri) = google_oauth_client()
|
||||
.as_ref()
|
||||
.context("Google Oauth not configured")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?
|
||||
.get_state_and_login_redirect_url(None)
|
||||
.await;
|
||||
session
|
||||
.insert(
|
||||
SessionGoogleLinkInfo::KEY,
|
||||
SessionGoogleLinkInfo { user_id, state },
|
||||
)
|
||||
.await
|
||||
.context("Failed to insert session link info")?;
|
||||
Ok(Redirect::to(&uri))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallbackQuery {
|
||||
state: Option<String>,
|
||||
@@ -80,38 +137,68 @@ struct CallbackQuery {
|
||||
}
|
||||
|
||||
async fn callback(
|
||||
Query(query): Query<CallbackQuery>,
|
||||
session: Session,
|
||||
Query(query): Query<CallbackQuery>,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
// Safe: the method is only called after the client is_some
|
||||
let client = google_oauth_client().as_ref().unwrap();
|
||||
let client =
|
||||
google_oauth_client().context("Missing google oauth client")?;
|
||||
|
||||
if let Some(error) = query.error {
|
||||
return Err(anyhow!("auth error from google: {error}"));
|
||||
return Err(anyhow!("Auth error from google: {error}"));
|
||||
}
|
||||
let state = query
|
||||
|
||||
let client_state = query
|
||||
.state
|
||||
.context("callback query does not contain state")?;
|
||||
if !client.check_state(&state).await {
|
||||
return Err(anyhow!("state mismatch"));
|
||||
.context("Callback query does not contain state")?;
|
||||
let code =
|
||||
query.code.context("callback query does not contain code")?;
|
||||
|
||||
// Check first if this is a link callback
|
||||
// and use the linking handler if so.
|
||||
if let Ok(Some(info)) =
|
||||
session.remove(SessionGoogleLinkInfo::KEY).await
|
||||
{
|
||||
return link_google_callback(client, info, client_state, code)
|
||||
.await;
|
||||
}
|
||||
let token = client
|
||||
.get_access_token(
|
||||
&query.code.context("callback query does not contain code")?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let SessionGoogleVerificationInfo { state } = session
|
||||
.get(SessionGoogleVerificationInfo::KEY)
|
||||
.await
|
||||
.context("Invalid google oauth session state")?
|
||||
.context("Missing google oauth session state")?;
|
||||
|
||||
if client_state != state {
|
||||
return Err(anyhow!("State mismatch"));
|
||||
}
|
||||
|
||||
let token = client.get_access_token(&code).await?;
|
||||
|
||||
let google_user = client.get_google_user(&token.id_token)?;
|
||||
let google_id = google_user.id.to_string();
|
||||
let db_client = db_client();
|
||||
let user = db_client
|
||||
.users
|
||||
.find_one(doc! { "config.data.google_id": &google_id })
|
||||
.await
|
||||
.context("failed at find user query from mongo")?;
|
||||
|
||||
let user = find_google_user(&google_id).await?;
|
||||
|
||||
let user_id_or_two_factor = match user {
|
||||
Some(user) => {
|
||||
match (user.passkey.passkey, user.totp.enrolled()) {
|
||||
match (
|
||||
user.third_party_skip_2fa,
|
||||
user.passkey.passkey,
|
||||
user.totp.enrolled(),
|
||||
) {
|
||||
// Skip / No 2FA
|
||||
(true, _, _) | (false, None, false) => {
|
||||
session
|
||||
.insert(
|
||||
SessionUserId::KEY,
|
||||
SessionUserId(user.id.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to store user id for client session")?;
|
||||
UserIdOrTwoFactor::UserId(user.id)
|
||||
}
|
||||
// WebAuthn Passkey 2FA
|
||||
(Some(passkey), _) => {
|
||||
(false, Some(passkey), _) => {
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
@@ -120,27 +207,32 @@ async fn callback(
|
||||
.context("Failed to start passkey authentication flow")?;
|
||||
session
|
||||
.insert(
|
||||
SESSION_KEY_PASSKEY_LOGIN,
|
||||
(user.id, server_state),
|
||||
SessionPasskeyLogin::KEY,
|
||||
SessionPasskeyLogin {
|
||||
user_id: user.id,
|
||||
state: server_state,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
UserIdOrTwoFactor::Passkey(response)
|
||||
}
|
||||
// TOTP 2FA
|
||||
(None, true) => {
|
||||
(false, None, true) => {
|
||||
session
|
||||
.insert(SESSION_KEY_TOTP_LOGIN, user.id)
|
||||
.insert(
|
||||
SessionTotpLogin::KEY,
|
||||
SessionTotpLogin { user_id: user.id },
|
||||
)
|
||||
.await
|
||||
.context(
|
||||
"Failed to store totp login state in for user session",
|
||||
)?;
|
||||
UserIdOrTwoFactor::Totp {}
|
||||
}
|
||||
// No 2FA
|
||||
(None, false) => UserIdOrTwoFactor::UserId(user.id),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let db_client = db_client();
|
||||
let ts = unix_timestamp_ms() as i64;
|
||||
let no_users_exist =
|
||||
db_client.users.find_one(Document::new()).await?.is_none();
|
||||
@@ -183,8 +275,10 @@ async fn callback(
|
||||
google_id,
|
||||
avatar: google_user.picture,
|
||||
},
|
||||
linked_logins: Default::default(),
|
||||
totp: Default::default(),
|
||||
passkey: Default::default(),
|
||||
third_party_skip_2fa: Default::default(),
|
||||
};
|
||||
let user_id = db_client
|
||||
.users
|
||||
@@ -200,11 +294,7 @@ async fn callback(
|
||||
};
|
||||
let redirect = Some(&state[STATE_PREFIX_LENGTH..]);
|
||||
match user_id_or_two_factor {
|
||||
UserIdOrTwoFactor::UserId(user_id) => {
|
||||
session
|
||||
.insert(SESSION_KEY_USER_ID, user_id)
|
||||
.await
|
||||
.context("Failed to store user id for client session")?;
|
||||
UserIdOrTwoFactor::UserId(_) => {
|
||||
Ok(format_redirect(redirect, "redeem_ready=true"))
|
||||
}
|
||||
UserIdOrTwoFactor::Totp {} => {
|
||||
@@ -218,3 +308,57 @@ async fn callback(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This intercepts during the normal oauth callback if
|
||||
/// 'google-link-info' is found on session.
|
||||
async fn link_google_callback(
|
||||
client: &GoogleOauthClient,
|
||||
SessionGoogleLinkInfo { user_id, state }: SessionGoogleLinkInfo,
|
||||
client_state: String,
|
||||
code: String,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
if client_state != state {
|
||||
return Err(anyhow!("State mismatch"));
|
||||
}
|
||||
|
||||
let token = client.get_access_token(&code).await?;
|
||||
|
||||
let google_user = client.get_google_user(&token.id_token)?;
|
||||
let google_id = google_user.id.to_string();
|
||||
|
||||
if let Some(existing_user) = find_google_user(&google_id).await? {
|
||||
if existing_user.id == user_id {
|
||||
// Link is already complete, this is a no-op
|
||||
return Ok(Redirect::to(&format!(
|
||||
"{}/settings",
|
||||
core_config().host,
|
||||
)));
|
||||
} else {
|
||||
return Err(anyhow!("Account already linked to another user."));
|
||||
}
|
||||
}
|
||||
|
||||
let user = get_user(&user_id).await?;
|
||||
|
||||
if let UserConfig::Google { .. } = &user.config {
|
||||
return Err(anyhow!(
|
||||
"User is primary Google user, cannot link another Google login."
|
||||
));
|
||||
}
|
||||
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"linked_logins.Google.type": "Google",
|
||||
"linked_logins.Google.data.google_id": &google_id,
|
||||
"linked_logins.Google.data.avatar": &google_user.picture,
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user_id, update, None)
|
||||
.await
|
||||
.context(
|
||||
"Failed to link Google login to existing user on database",
|
||||
)?;
|
||||
|
||||
Ok(Redirect::to(&format!("{}/settings", core_config().host,)))
|
||||
}
|
||||
|
||||
+33
-17
@@ -11,7 +11,7 @@ use komodo_client::{
|
||||
LoginLocalUser, LoginLocalUserResponse, SignUpLocalUser,
|
||||
SignUpLocalUserResponse,
|
||||
},
|
||||
entities::user::{User, UserConfig},
|
||||
entities::user::{User, UserConfig, UserConfigVariant},
|
||||
};
|
||||
use rate_limit::{RateLimiter, WithFailureRateLimit};
|
||||
use reqwest::StatusCode;
|
||||
@@ -20,9 +20,8 @@ use serror::{AddStatusCode as _, AddStatusCodeError};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN, auth::AuthArgs,
|
||||
},
|
||||
api::auth::AuthArgs,
|
||||
auth::{SessionPasskeyLogin, SessionTotpLogin},
|
||||
config::core_config,
|
||||
helpers::validations::{validate_password, validate_username},
|
||||
state::{auth_rate_limiter, db_client, jwt_client, webauthn},
|
||||
@@ -109,8 +108,10 @@ async fn sign_up_local_user(
|
||||
config: UserConfig::Local {
|
||||
password: hashed_password,
|
||||
},
|
||||
linked_logins: Default::default(),
|
||||
totp: Default::default(),
|
||||
passkey: Default::default(),
|
||||
third_party_skip_2fa: Default::default(),
|
||||
};
|
||||
|
||||
let user_id = db_client()
|
||||
@@ -192,17 +193,21 @@ async fn login_local_user(
|
||||
.context("Invalid login credentials")
|
||||
.status_code(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let UserConfig::Local {
|
||||
password: user_pw_hash,
|
||||
} = user.config
|
||||
else {
|
||||
return Err(
|
||||
anyhow!("Invalid login credentials")
|
||||
.status_code(StatusCode::UNAUTHORIZED),
|
||||
);
|
||||
};
|
||||
let user_pw_hash =
|
||||
if let UserConfig::Local { password } = &user.config {
|
||||
password
|
||||
} else if let Some(UserConfig::Local { password }) =
|
||||
user.linked_logins.get(UserConfigVariant::Local)
|
||||
{
|
||||
password
|
||||
} else {
|
||||
return Err(
|
||||
anyhow!("Invalid login credentials")
|
||||
.status_code(StatusCode::UNAUTHORIZED),
|
||||
);
|
||||
};
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -223,13 +228,24 @@ async fn login_local_user(
|
||||
.start_passkey_authentication(&[passkey])
|
||||
.context("Failed to start passkey authentication flow")?;
|
||||
session
|
||||
.insert(SESSION_KEY_PASSKEY_LOGIN, (user.id, server_state))
|
||||
.insert(
|
||||
SessionPasskeyLogin::KEY,
|
||||
SessionPasskeyLogin {
|
||||
user_id: user.id,
|
||||
state: server_state,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(LoginLocalUserResponse::Passkey(response))
|
||||
}
|
||||
// TOTP 2FA
|
||||
(None, true) => {
|
||||
session.insert(SESSION_KEY_TOTP_LOGIN, user.id).await?;
|
||||
session
|
||||
.insert(
|
||||
SessionTotpLogin::KEY,
|
||||
SessionTotpLogin { user_id: user.id },
|
||||
)
|
||||
.await?;
|
||||
Ok(LoginLocalUserResponse::Totp {})
|
||||
}
|
||||
// No 2FA, can return JWT immediately
|
||||
@@ -238,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ use futures_util::TryFutureExt;
|
||||
use komodo_client::entities::{komodo_timestamp, user::User};
|
||||
use rate_limit::WithFailureRateLimit;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serror::AddStatusCodeError as _;
|
||||
use webauthn_rs::prelude::PasskeyAuthentication;
|
||||
|
||||
use crate::{
|
||||
config::core_config,
|
||||
@@ -44,6 +45,30 @@ struct RedirectQuery {
|
||||
redirect: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SessionUserId(pub String);
|
||||
impl SessionUserId {
|
||||
pub const KEY: &str = "user-id";
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SessionTotpLogin {
|
||||
pub user_id: String,
|
||||
}
|
||||
impl SessionTotpLogin {
|
||||
pub const KEY: &str = "totp-login";
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SessionPasskeyLogin {
|
||||
pub user_id: String,
|
||||
/// This must stay server side only
|
||||
pub state: PasskeyAuthentication,
|
||||
}
|
||||
impl SessionPasskeyLogin {
|
||||
pub const KEY: &str = "passkey-login";
|
||||
}
|
||||
|
||||
pub async fn auth_request(
|
||||
headers: HeaderMap,
|
||||
mut req: Request,
|
||||
@@ -62,7 +87,7 @@ pub async fn auth_request(
|
||||
)
|
||||
.await?;
|
||||
// Sanitize the user for safety before
|
||||
// attaching to the request handlers.
|
||||
// attaching to the request handlers.
|
||||
user.sanitize();
|
||||
req.extensions_mut().insert(user);
|
||||
Ok(next.run(req).await)
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::{Context, anyhow};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use openidconnect::{
|
||||
Client, ClientId, ClientSecret, EmptyAdditionalClaims,
|
||||
EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl,
|
||||
RedirectUrl, StandardErrorResponse, core::*,
|
||||
AccessToken, AccessTokenHash, AuthorizationCode, Client, ClientId,
|
||||
ClientSecret, CsrfToken, EmptyAdditionalClaims, EndpointMaybeSet,
|
||||
EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse,
|
||||
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||
StandardErrorResponse, SubjectIdentifier, TokenResponse as _,
|
||||
UserInfoClaims, core::*,
|
||||
};
|
||||
|
||||
use crate::config::core_config;
|
||||
|
||||
type OidcClient = Client<
|
||||
static APP_USER_AGENT: &str =
|
||||
concat!("Komodo/", env!("CARGO_PKG_VERSION"),);
|
||||
|
||||
fn oidc_reqwest_client() -> &'static reqwest::Client {
|
||||
static REQWEST: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
REQWEST.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(APP_USER_AGENT)
|
||||
.build()
|
||||
.expect("Invalid OIDC reqwest client")
|
||||
})
|
||||
}
|
||||
|
||||
pub type InnerOidcClient = Client<
|
||||
EmptyAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
@@ -30,6 +47,8 @@ type OidcClient = Client<
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
pub struct OidcClient(InnerOidcClient);
|
||||
|
||||
pub fn oidc_client() -> &'static ArcSwapOption<OidcClient> {
|
||||
static OIDC_CLIENT: OnceLock<ArcSwapOption<OidcClient>> =
|
||||
OnceLock::new();
|
||||
@@ -66,7 +85,7 @@ async fn reset_oidc_client() -> anyhow::Result<()> {
|
||||
// Use OpenID Connect Discovery to fetch the provider metadata.
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(config.oidc_provider.clone())?,
|
||||
super::reqwest_client(),
|
||||
oidc_reqwest_client(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to get OIDC /.well-known/openid-configuration")?;
|
||||
@@ -87,7 +106,112 @@ async fn reset_oidc_client() -> anyhow::Result<()> {
|
||||
core_config().host
|
||||
))?);
|
||||
|
||||
oidc_client().store(Some(client.into()));
|
||||
oidc_client().store(Some(OidcClient(client).into()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl OidcClient {
|
||||
pub fn authorize_url(
|
||||
&self,
|
||||
pkce_challenge: PkceCodeChallenge,
|
||||
) -> (url::Url, CsrfToken, Nonce) {
|
||||
self
|
||||
.0
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.url()
|
||||
}
|
||||
|
||||
/// Applied security validations and extracts the
|
||||
/// oidc user id.
|
||||
pub async fn validate_extract_subject_and_token(
|
||||
&self,
|
||||
(client, server): (CsrfToken, String),
|
||||
code: String,
|
||||
pkce_verifier: PkceCodeVerifier,
|
||||
nonce: Nonce,
|
||||
) -> anyhow::Result<(SubjectIdentifier, AccessToken)> {
|
||||
// Validate CSRF tokens match
|
||||
if client.secret() != &server {
|
||||
return Err(anyhow!("CSRF token invalid"));
|
||||
}
|
||||
|
||||
let reqwest_client = oidc_reqwest_client();
|
||||
let token_response = self
|
||||
.0
|
||||
.exchange_code(AuthorizationCode::new(code))
|
||||
.context("Failed to get Oauth token at exchange code")?
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(reqwest_client)
|
||||
.await
|
||||
.context("Failed to get Oauth token")?;
|
||||
|
||||
// Extract the ID token claims after verifying its authenticity and nonce.
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.context("OIDC Server did not return an ID token")?;
|
||||
|
||||
// Some providers attach additional audiences, they must be added here
|
||||
// so token verification succeeds.
|
||||
let verifier = self.0.id_token_verifier();
|
||||
let additional_audiences =
|
||||
&core_config().oidc_additional_audiences;
|
||||
let verifier = if additional_audiences.is_empty() {
|
||||
verifier
|
||||
} else {
|
||||
verifier.set_other_audience_verifier_fn(|aud| {
|
||||
additional_audiences.contains(aud)
|
||||
})
|
||||
};
|
||||
|
||||
let claims = id_token
|
||||
.claims(&verifier, &nonce)
|
||||
.context("Failed to verify token claims. This issue may be temporary (60 seconds max).")?;
|
||||
|
||||
// Verify the access token hash to ensure that the access token hasn't been substituted for
|
||||
// another user's.
|
||||
if let Some(expected_access_token_hash) =
|
||||
claims.access_token_hash()
|
||||
{
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
&token_response.access_token().clone(),
|
||||
id_token.signing_alg()?,
|
||||
id_token.signing_key(&verifier)?,
|
||||
)?;
|
||||
if actual_access_token_hash != *expected_access_token_hash {
|
||||
return Err(anyhow!("Invalid access token"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
claims.subject().clone(),
|
||||
token_response.access_token().clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fetch_user_info(
|
||||
&self,
|
||||
access_token: AccessToken,
|
||||
subject: SubjectIdentifier,
|
||||
) -> anyhow::Result<
|
||||
UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim>,
|
||||
> {
|
||||
self
|
||||
.0
|
||||
.user_info(access_token, Some(subject))
|
||||
.context("Invalid user info request")?
|
||||
.request_async::<EmptyAdditionalClaims, _, CoreGenderClaim>(
|
||||
oidc_reqwest_client(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to fetch OIDC user info")
|
||||
}
|
||||
}
|
||||
|
||||
+232
-157
@@ -1,4 +1,4 @@
|
||||
use std::{net::SocketAddr, sync::OnceLock};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use axum::{
|
||||
@@ -9,8 +9,10 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use client::oidc_client;
|
||||
use dashmap::DashMap;
|
||||
use database::mungos::mongodb::bson::{Document, doc};
|
||||
use database::mungos::{
|
||||
by_id::update_one_by_id,
|
||||
mongodb::bson::{Document, doc},
|
||||
};
|
||||
use futures_util::TryFutureExt;
|
||||
use komodo_client::{
|
||||
api::auth::UserIdOrTwoFactor,
|
||||
@@ -20,24 +22,22 @@ use komodo_client::{
|
||||
},
|
||||
};
|
||||
use openidconnect::{
|
||||
AccessTokenHash, AuthorizationCode, CsrfToken,
|
||||
EmptyAdditionalClaims, Nonce, OAuth2TokenResponse,
|
||||
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
|
||||
core::{CoreAuthenticationFlow, CoreGenderClaim},
|
||||
CsrfToken, Nonce, PkceCodeChallenge, PkceCodeVerifier,
|
||||
};
|
||||
use rate_limit::WithFailureRateLimit;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serror::{AddStatusCode as _, AddStatusCodeError};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN,
|
||||
SESSION_KEY_USER_ID,
|
||||
api::user::SessionThirdPartyLinkInfo,
|
||||
auth::{
|
||||
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
|
||||
format_redirect, oidc::client::OidcClient,
|
||||
},
|
||||
auth::format_redirect,
|
||||
config::core_config,
|
||||
helpers::query::{find_oidc_user, get_user},
|
||||
state::{auth_rate_limiter, db_client, webauthn},
|
||||
};
|
||||
|
||||
@@ -45,50 +45,30 @@ use super::RedirectQuery;
|
||||
|
||||
pub mod client;
|
||||
|
||||
static APP_USER_AGENT: &str =
|
||||
concat!("Komodo/", env!("CARGO_PKG_VERSION"),);
|
||||
|
||||
fn reqwest_client() -> &'static reqwest::Client {
|
||||
static REQWEST: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
REQWEST.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(APP_USER_AGENT)
|
||||
.build()
|
||||
.expect("Invalid OIDC reqwest client")
|
||||
})
|
||||
}
|
||||
|
||||
/// CSRF tokens can only be used once from the callback,
|
||||
/// and must be used within this timeframe
|
||||
const CSRF_VALID_FOR_MS: i64 = 120_000; // 2 minutes for user to log in.
|
||||
|
||||
type RedirectUrl = Option<String>;
|
||||
/// Maps the csrf secrets to other information added in the "login" method (before auth provider redirect).
|
||||
/// This information is retrieved in the "callback" method (after auth provider redirect).
|
||||
type VerifierMap =
|
||||
DashMap<String, (PkceCodeVerifier, Nonce, RedirectUrl, i64)>;
|
||||
fn verifier_tokens() -> &'static VerifierMap {
|
||||
static VERIFIERS: OnceLock<VerifierMap> = OnceLock::new();
|
||||
VERIFIERS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/login",
|
||||
get(|query| async {
|
||||
login(query).await.status_code(StatusCode::UNAUTHORIZED)
|
||||
get(|session, query| async {
|
||||
login(session, query)
|
||||
.await
|
||||
.status_code(StatusCode::UNAUTHORIZED)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/link",
|
||||
get(|session| async {
|
||||
link(session).await.status_code(StatusCode::UNAUTHORIZED)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/callback",
|
||||
get(
|
||||
|query,
|
||||
session: Session,
|
||||
session,
|
||||
headers: HeaderMap,
|
||||
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
|
||||
callback(query, session)
|
||||
callback(session, query)
|
||||
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
|
||||
.with_failure_rate_limit_using_headers(
|
||||
auth_rate_limiter(),
|
||||
@@ -101,7 +81,22 @@ pub fn router() -> Router {
|
||||
)
|
||||
}
|
||||
|
||||
type RedirectUrl = Option<String>;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionOidcVerificationInfo {
|
||||
csrf_token: String,
|
||||
pkce_verifier: PkceCodeVerifier,
|
||||
nonce: Nonce,
|
||||
redirect: RedirectUrl,
|
||||
}
|
||||
|
||||
impl SessionOidcVerificationInfo {
|
||||
const KEY: &str = "oidc-verification-info";
|
||||
}
|
||||
|
||||
async fn login(
|
||||
session: Session,
|
||||
Query(RedirectQuery { redirect }): Query<RedirectQuery>,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
let client = oidc_client().load();
|
||||
@@ -112,32 +107,76 @@ async fn login(
|
||||
PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
// Generate the authorization URL.
|
||||
let (auth_url, csrf_token, nonce) = client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.url();
|
||||
let (auth_url, csrf_token, nonce) =
|
||||
client.authorize_url(pkce_challenge);
|
||||
|
||||
// Data inserted here will be matched on callback side for csrf protection.
|
||||
verifier_tokens().insert(
|
||||
csrf_token.secret().clone(),
|
||||
(
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
redirect,
|
||||
komodo_timestamp() + CSRF_VALID_FOR_MS,
|
||||
),
|
||||
);
|
||||
session
|
||||
.insert(
|
||||
SessionOidcVerificationInfo::KEY,
|
||||
SessionOidcVerificationInfo {
|
||||
csrf_token: csrf_token.secret().clone(),
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
redirect,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("Failed to insert session verification info")?;
|
||||
|
||||
auth_redirect(auth_url.as_str())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SessionOidcLinkInfo {
|
||||
user_id: String,
|
||||
csrf_token: String,
|
||||
pkce_verifier: PkceCodeVerifier,
|
||||
nonce: Nonce,
|
||||
}
|
||||
|
||||
impl SessionOidcLinkInfo {
|
||||
const KEY: &str = "oidc-link-info";
|
||||
}
|
||||
|
||||
async fn link(session: Session) -> anyhow::Result<Redirect> {
|
||||
let SessionThirdPartyLinkInfo { user_id } = session
|
||||
.remove(SessionThirdPartyLinkInfo::KEY)
|
||||
.await
|
||||
.context("Invalid session third party link info.")?
|
||||
.context("Missing session third party link info")?;
|
||||
|
||||
let client = oidc_client().load();
|
||||
let client =
|
||||
client.as_ref().context("OIDC Client not configured")?;
|
||||
|
||||
let (pkce_challenge, pkce_verifier) =
|
||||
PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
// Generate the authorization URL.
|
||||
let (auth_url, csrf_token, nonce) =
|
||||
client.authorize_url(pkce_challenge);
|
||||
|
||||
session
|
||||
.insert(
|
||||
SessionOidcLinkInfo::KEY,
|
||||
SessionOidcLinkInfo {
|
||||
user_id,
|
||||
csrf_token: csrf_token.secret().clone(),
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("Failed to insert session link info")?;
|
||||
|
||||
auth_redirect(auth_url.as_str())
|
||||
}
|
||||
|
||||
/// Applies 'oidc_redirect_host'
|
||||
fn auth_redirect(auth_url: &str) -> anyhow::Result<Redirect> {
|
||||
let config = core_config();
|
||||
let redirect = if !config.oidc_redirect_host.is_empty() {
|
||||
let auth_url = auth_url.as_str();
|
||||
let (protocol, rest) = auth_url
|
||||
.split_once("://")
|
||||
.context("Invalid URL: Missing protocol (eg 'https://')")?;
|
||||
@@ -150,22 +189,21 @@ async fn login(
|
||||
&config.oidc_redirect_host,
|
||||
))
|
||||
} else {
|
||||
Redirect::to(auth_url.as_str())
|
||||
Redirect::to(auth_url)
|
||||
};
|
||||
|
||||
Ok(redirect)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallbackQuery {
|
||||
pub struct OidcCallbackQuery {
|
||||
state: Option<String>,
|
||||
code: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn callback(
|
||||
Query(query): Query<CallbackQuery>,
|
||||
session: Session,
|
||||
Query(query): Query<OidcCallbackQuery>,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
let client = oidc_client().load();
|
||||
let client =
|
||||
@@ -180,78 +218,58 @@ async fn callback(
|
||||
query.state.context("Provider did not return state")?,
|
||||
);
|
||||
|
||||
let (_, (pkce_verifier, nonce, redirect, valid_until)) =
|
||||
verifier_tokens()
|
||||
.remove(state.secret())
|
||||
.context("CSRF token invalid")?;
|
||||
|
||||
if komodo_timestamp() > valid_until {
|
||||
return Err(anyhow!(
|
||||
"CSRF token invalid (Timed out). The token must be used within 2 minutes."
|
||||
));
|
||||
}
|
||||
|
||||
let reqwest_client = reqwest_client();
|
||||
let token_response = client
|
||||
.exchange_code(AuthorizationCode::new(code))
|
||||
.context("Failed to get Oauth token at exchange code")?
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(reqwest_client)
|
||||
.await
|
||||
.context("Failed to get Oauth token")?;
|
||||
|
||||
// Extract the ID token claims after verifying its authenticity and nonce.
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.context("OIDC Server did not return an ID token")?;
|
||||
|
||||
// Some providers attach additional audiences, they must be added here
|
||||
// so token verification succeeds.
|
||||
let verifier = client.id_token_verifier();
|
||||
let additional_audiences = &core_config().oidc_additional_audiences;
|
||||
let verifier = if additional_audiences.is_empty() {
|
||||
verifier
|
||||
} else {
|
||||
verifier.set_other_audience_verifier_fn(|aud| {
|
||||
additional_audiences.contains(aud)
|
||||
})
|
||||
};
|
||||
|
||||
let claims = id_token
|
||||
.claims(&verifier, &nonce)
|
||||
.context("Failed to verify token claims. This issue may be temporary (60 seconds max).")?;
|
||||
|
||||
// Verify the access token hash to ensure that the access token hasn't been substituted for
|
||||
// another user's.
|
||||
if let Some(expected_access_token_hash) = claims.access_token_hash()
|
||||
// Check first if this is a link callback
|
||||
// and use the linking handler if so.
|
||||
if let Ok(Some(info)) =
|
||||
session.remove(SessionOidcLinkInfo::KEY).await
|
||||
{
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
id_token.signing_alg()?,
|
||||
id_token.signing_key(&verifier)?,
|
||||
)?;
|
||||
if actual_access_token_hash != *expected_access_token_hash {
|
||||
return Err(anyhow!("Invalid access token"));
|
||||
}
|
||||
return link_oidc_callback(client, info, state, code).await;
|
||||
}
|
||||
|
||||
let user_id = claims.subject().as_str();
|
||||
|
||||
let db_client = db_client();
|
||||
let user = db_client
|
||||
.users
|
||||
.find_one(doc! {
|
||||
"config.data.provider": &core_config().oidc_provider,
|
||||
"config.data.user_id": user_id
|
||||
})
|
||||
let SessionOidcVerificationInfo {
|
||||
csrf_token,
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
redirect,
|
||||
} = session
|
||||
.remove(SessionOidcVerificationInfo::KEY)
|
||||
.await
|
||||
.context("Failed at find user query from database")?;
|
||||
.context("Invalid session verification info.")?
|
||||
.context(
|
||||
"Missing session verification info for CSRF protection.",
|
||||
)?;
|
||||
|
||||
let (subject, token) = client
|
||||
.validate_extract_subject_and_token(
|
||||
(state, csrf_token),
|
||||
code,
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user = find_oidc_user(&subject).await?;
|
||||
|
||||
let user_id_or_two_factor = match user {
|
||||
Some(user) => {
|
||||
match (user.passkey.passkey, user.totp.enrolled()) {
|
||||
match (
|
||||
user.third_party_skip_2fa,
|
||||
user.passkey.passkey,
|
||||
user.totp.enrolled(),
|
||||
) {
|
||||
// Skip / No 2FA
|
||||
(true, _, _) | (false, None, false) => {
|
||||
session
|
||||
.insert(
|
||||
SessionUserId::KEY,
|
||||
SessionUserId(user.id.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to store user id for client session")?;
|
||||
UserIdOrTwoFactor::UserId(user.id)
|
||||
}
|
||||
// WebAuthn Passkey 2FA
|
||||
(Some(passkey), _) => {
|
||||
(false, Some(passkey), _) => {
|
||||
let webauthn = webauthn().context(
|
||||
"No webauthn provider available, invalid KOMODO_HOST config",
|
||||
)?;
|
||||
@@ -260,28 +278,33 @@ async fn callback(
|
||||
.context("Failed to start passkey authentication flow")?;
|
||||
session
|
||||
.insert(
|
||||
SESSION_KEY_PASSKEY_LOGIN,
|
||||
(user.id, server_state),
|
||||
SessionPasskeyLogin::KEY,
|
||||
SessionPasskeyLogin {
|
||||
user_id: user.id,
|
||||
state: server_state,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
UserIdOrTwoFactor::Passkey(response)
|
||||
}
|
||||
// TOTP 2FA
|
||||
(None, true) => {
|
||||
(false, None, true) => {
|
||||
session
|
||||
.insert(SESSION_KEY_TOTP_LOGIN, user.id)
|
||||
.insert(
|
||||
SessionTotpLogin::KEY,
|
||||
SessionTotpLogin { user_id: user.id },
|
||||
)
|
||||
.await
|
||||
.context(
|
||||
"Failed to store totp login state in for user session",
|
||||
)?;
|
||||
UserIdOrTwoFactor::Totp {}
|
||||
}
|
||||
// No 2FA
|
||||
(None, false) => UserIdOrTwoFactor::UserId(user.id),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let ts = komodo_timestamp();
|
||||
let db_client = db_client();
|
||||
let no_users_exist =
|
||||
db_client.users.find_one(Document::new()).await?.is_none();
|
||||
let core_config = core_config();
|
||||
@@ -290,17 +313,8 @@ async fn callback(
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
let user_info = client
|
||||
.user_info(
|
||||
token_response.access_token().clone(),
|
||||
claims.subject().clone().into(),
|
||||
)
|
||||
.context("Invalid user info request")?
|
||||
.request_async::<EmptyAdditionalClaims, _, CoreGenderClaim>(
|
||||
reqwest_client,
|
||||
)
|
||||
.await
|
||||
.context("Failed to fetch user info for new user")?;
|
||||
let user_info =
|
||||
client.fetch_user_info(token, subject.clone()).await?;
|
||||
|
||||
// Will use preferred_username, then email, then user_id if it isn't available.
|
||||
let mut username = user_info
|
||||
@@ -310,7 +324,7 @@ async fn callback(
|
||||
let email = user_info
|
||||
.email()
|
||||
.map(|email| email.as_str())
|
||||
.unwrap_or(user_id);
|
||||
.unwrap_or(subject.as_str());
|
||||
if core_config.oidc_use_full_email {
|
||||
email
|
||||
} else {
|
||||
@@ -348,10 +362,12 @@ async fn callback(
|
||||
all: Default::default(),
|
||||
config: UserConfig::Oidc {
|
||||
provider: core_config.oidc_provider.clone(),
|
||||
user_id: user_id.to_string(),
|
||||
user_id: subject.to_string(),
|
||||
},
|
||||
linked_logins: Default::default(),
|
||||
totp: Default::default(),
|
||||
passkey: Default::default(),
|
||||
third_party_skip_2fa: Default::default(),
|
||||
};
|
||||
|
||||
let user_id = db_client
|
||||
@@ -369,11 +385,7 @@ async fn callback(
|
||||
};
|
||||
|
||||
match user_id_or_two_factor {
|
||||
UserIdOrTwoFactor::UserId(user_id) => {
|
||||
session
|
||||
.insert(SESSION_KEY_USER_ID, user_id)
|
||||
.await
|
||||
.context("Failed to store user id for client session")?;
|
||||
UserIdOrTwoFactor::UserId(_) => {
|
||||
Ok(format_redirect(redirect.as_deref(), "redeem_ready=true"))
|
||||
}
|
||||
UserIdOrTwoFactor::Totp {} => {
|
||||
@@ -390,3 +402,66 @@ async fn callback(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This intercepts during the normal oauth callback if
|
||||
/// 'oidc-link-info' is found on session.
|
||||
async fn link_oidc_callback(
|
||||
client: &OidcClient,
|
||||
SessionOidcLinkInfo {
|
||||
user_id,
|
||||
csrf_token,
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
}: SessionOidcLinkInfo,
|
||||
state: CsrfToken,
|
||||
code: String,
|
||||
) -> anyhow::Result<Redirect> {
|
||||
let (subject, _) = client
|
||||
.validate_extract_subject_and_token(
|
||||
(state, csrf_token),
|
||||
code,
|
||||
pkce_verifier,
|
||||
nonce,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let oidc_provider = &core_config().oidc_provider;
|
||||
let oidc_user_id = subject.as_str();
|
||||
|
||||
// Ensure there are no other existing users with this login linked.
|
||||
if let Some(existing_user) = find_oidc_user(&subject).await? {
|
||||
if existing_user.id == user_id {
|
||||
// Link is already complete, this is a no-op
|
||||
return Ok(Redirect::to(&format!(
|
||||
"{}/settings",
|
||||
core_config().host,
|
||||
)));
|
||||
} else {
|
||||
return Err(anyhow!("Account already linked to another user."));
|
||||
}
|
||||
}
|
||||
|
||||
let user = get_user(&user_id).await?;
|
||||
|
||||
if let UserConfig::Oidc { .. } = &user.config {
|
||||
return Err(anyhow!(
|
||||
"User is primary Oidc user, cannot link another Oidc login."
|
||||
));
|
||||
}
|
||||
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"linked_logins.Oidc.type": "Oidc",
|
||||
"linked_logins.Oidc.data.provider": oidc_provider,
|
||||
"linked_logins.Oidc.data.user_id": oidc_user_id,
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&db_client().users, &user_id, update, None)
|
||||
.await
|
||||
.context(
|
||||
"Failed to link OIDC login to existing user on database",
|
||||
)?;
|
||||
|
||||
Ok(Redirect::to(&format!("{}/settings", core_config().host,)))
|
||||
}
|
||||
|
||||
@@ -1213,6 +1213,40 @@ async fn execute_execution(
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::CreateSwarmConfig(req) => {
|
||||
let req = ExecuteRequest::CreateSwarmConfig(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
let ExecuteRequest::CreateSwarmConfig(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
let update_id = update.id.clone();
|
||||
handle_resolve_result(
|
||||
req
|
||||
.resolve(&ExecuteArgs { user, update, id })
|
||||
.await
|
||||
.map_err(|e| e.error)
|
||||
.context("Failed at CreateSwarmConfig"),
|
||||
&update_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::RotateSwarmConfig(req) => {
|
||||
let req = ExecuteRequest::RotateSwarmConfig(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
let ExecuteRequest::RotateSwarmConfig(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
let update_id = update.id.clone();
|
||||
handle_resolve_result(
|
||||
req
|
||||
.resolve(&ExecuteArgs { user, update, id })
|
||||
.await
|
||||
.map_err(|e| e.error)
|
||||
.context("Failed at RotateSwarmConfig"),
|
||||
&update_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::RemoveSwarmConfigs(req) => {
|
||||
let req = ExecuteRequest::RemoveSwarmConfigs(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
@@ -1230,6 +1264,40 @@ async fn execute_execution(
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::CreateSwarmSecret(req) => {
|
||||
let req = ExecuteRequest::CreateSwarmSecret(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
let ExecuteRequest::CreateSwarmSecret(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
let update_id = update.id.clone();
|
||||
handle_resolve_result(
|
||||
req
|
||||
.resolve(&ExecuteArgs { user, update, id })
|
||||
.await
|
||||
.map_err(|e| e.error)
|
||||
.context("Failed at CreateSwarmSecret"),
|
||||
&update_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::RotateSwarmSecret(req) => {
|
||||
let req = ExecuteRequest::RotateSwarmSecret(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
let ExecuteRequest::RotateSwarmSecret(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
let update_id = update.id.clone();
|
||||
handle_resolve_result(
|
||||
req
|
||||
.resolve(&ExecuteArgs { user, update, id })
|
||||
.await
|
||||
.map_err(|e| e.error)
|
||||
.context("Failed at RotateSwarmSecret"),
|
||||
&update_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::RemoveSwarmSecrets(req) => {
|
||||
let req = ExecuteRequest::RemoveSwarmSecrets(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
|
||||
@@ -34,6 +34,7 @@ use komodo_client::{
|
||||
variable::Variable,
|
||||
},
|
||||
};
|
||||
use openidconnect::SubjectIdentifier;
|
||||
|
||||
use crate::{
|
||||
config::core_config,
|
||||
@@ -497,3 +498,65 @@ pub async fn get_swarm_or_server(
|
||||
|
||||
Ok(SwarmOrServer::Server(server))
|
||||
}
|
||||
|
||||
pub async fn find_github_user(
|
||||
github_id: &str,
|
||||
) -> anyhow::Result<Option<User>> {
|
||||
db_client()
|
||||
.users
|
||||
.find_one(doc! {
|
||||
// Find either primary or linked Github user
|
||||
"$or": [
|
||||
// User is primary Github user
|
||||
{ "config.data.github_id": &github_id },
|
||||
// User has linked this Github login
|
||||
{ "linked_logins.Github.data.github_id": &github_id }
|
||||
]
|
||||
})
|
||||
.await
|
||||
.context("Failed at find user query from database")
|
||||
}
|
||||
|
||||
pub async fn find_google_user(
|
||||
google_id: &str,
|
||||
) -> anyhow::Result<Option<User>> {
|
||||
db_client()
|
||||
.users
|
||||
.find_one(doc! {
|
||||
// Find either primary or linked Google user
|
||||
"$or": [
|
||||
// User is primary Google user
|
||||
{ "config.data.google_id": &google_id },
|
||||
// User has linked this Google login
|
||||
{ "linked_logins.Google.data.google_id": &google_id }
|
||||
]
|
||||
})
|
||||
.await
|
||||
.context("Failed at find user query from database")
|
||||
}
|
||||
|
||||
pub async fn find_oidc_user(
|
||||
subject: &SubjectIdentifier,
|
||||
) -> anyhow::Result<Option<User>> {
|
||||
let oidc_provider = &core_config().oidc_provider;
|
||||
let oidc_user_id = subject.as_str();
|
||||
db_client()
|
||||
.users
|
||||
.find_one(doc! {
|
||||
// Find either primary or linked Oidc user
|
||||
"$or": [
|
||||
// User is primary Oidc user
|
||||
{
|
||||
"config.data.provider": oidc_provider,
|
||||
"config.data.user_id": oidc_user_id
|
||||
},
|
||||
// User has linked this Oidc login
|
||||
{
|
||||
"linked_logins.Oidc.data.provider": oidc_provider,
|
||||
"linked_logins.Oidc.data.user_id": oidc_user_id
|
||||
}
|
||||
]
|
||||
})
|
||||
.await
|
||||
.context("Failed at find user query from database")
|
||||
}
|
||||
|
||||
@@ -141,12 +141,36 @@ pub async fn init_execution_update(
|
||||
resource::get::<Swarm>(&data.swarm).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::CreateSwarmConfig(data) => (
|
||||
Operation::CreateSwarmConfig,
|
||||
ResourceTarget::Swarm(
|
||||
resource::get::<Swarm>(&data.swarm).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::RotateSwarmConfig(data) => (
|
||||
Operation::RotateSwarmConfig,
|
||||
ResourceTarget::Swarm(
|
||||
resource::get::<Swarm>(&data.swarm).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::RemoveSwarmConfigs(data) => (
|
||||
Operation::RemoveSwarmConfigs,
|
||||
ResourceTarget::Swarm(
|
||||
resource::get::<Swarm>(&data.swarm).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::CreateSwarmSecret(data) => (
|
||||
Operation::CreateSwarmSecret,
|
||||
ResourceTarget::Swarm(
|
||||
resource::get::<Swarm>(&data.swarm).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::RotateSwarmSecret(data) => (
|
||||
Operation::RotateSwarmSecret,
|
||||
ResourceTarget::Swarm(
|
||||
resource::get::<Swarm>(&data.swarm).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::RemoveSwarmSecrets(data) => (
|
||||
Operation::RemoveSwarmSecrets,
|
||||
ResourceTarget::Swarm(
|
||||
|
||||
@@ -17,7 +17,6 @@ use komodo_client::{
|
||||
image::ImageListItem,
|
||||
service::SwarmServiceListItem,
|
||||
stack::SwarmStackListItem,
|
||||
task::SwarmTaskListItem,
|
||||
},
|
||||
komodo_timestamp,
|
||||
stack::{Stack, StackService, StackServiceNames, StackState},
|
||||
@@ -341,7 +340,6 @@ fn deployment_alert_sent_cache() -> &'static AlertCache<String> {
|
||||
pub async fn update_swarm_deployment_cache(
|
||||
deployments: Vec<Deployment>,
|
||||
swarm_services: &[SwarmServiceListItem],
|
||||
swarm_tasks: &[SwarmTaskListItem],
|
||||
) {
|
||||
let deployment_status_cache = deployment_status_cache();
|
||||
for deployment in deployments {
|
||||
@@ -361,30 +359,10 @@ pub async fn update_swarm_deployment_cache(
|
||||
.map(|s| s.curr.state);
|
||||
let current_state = service
|
||||
.as_ref()
|
||||
.map(|service| {
|
||||
let Some(service_id) = &service.id else {
|
||||
return DeploymentState::Unknown;
|
||||
};
|
||||
let tasks = swarm_tasks
|
||||
.iter()
|
||||
.filter(|task| {
|
||||
task
|
||||
.service_id
|
||||
.as_ref()
|
||||
.map(|id| id == service_id)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// If service exists but no tasks, it is unhealthy
|
||||
if tasks.is_empty() {
|
||||
return DeploymentState::Unhealthy;
|
||||
}
|
||||
for task in tasks {
|
||||
if task.desired_state != task.state {
|
||||
return DeploymentState::Unhealthy;
|
||||
}
|
||||
}
|
||||
DeploymentState::Running
|
||||
.map(|service| match service.state {
|
||||
SwarmState::Healthy => DeploymentState::Running,
|
||||
SwarmState::Unhealthy => DeploymentState::Unhealthy,
|
||||
SwarmState::Unknown => DeploymentState::Unknown,
|
||||
})
|
||||
.unwrap_or(DeploymentState::NotDeployed);
|
||||
deployment_status_cache
|
||||
|
||||
@@ -161,7 +161,6 @@ pub async fn update_cache_for_swarm(swarm: &Swarm, force: bool) {
|
||||
update_swarm_deployment_cache(
|
||||
resources.deployments,
|
||||
&lists.services,
|
||||
&lists.tasks,
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -781,6 +781,24 @@ async fn validate_config(
|
||||
.await?;
|
||||
params.swarm = swarm.id;
|
||||
}
|
||||
Execution::CreateSwarmConfig(params) => {
|
||||
let swarm = super::get_check_permissions::<Swarm>(
|
||||
¶ms.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
params.swarm = swarm.id;
|
||||
}
|
||||
Execution::RotateSwarmConfig(params) => {
|
||||
let swarm = super::get_check_permissions::<Swarm>(
|
||||
¶ms.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
params.swarm = swarm.id;
|
||||
}
|
||||
Execution::RemoveSwarmConfigs(params) => {
|
||||
let swarm = super::get_check_permissions::<Swarm>(
|
||||
¶ms.swarm,
|
||||
@@ -790,6 +808,24 @@ async fn validate_config(
|
||||
.await?;
|
||||
params.swarm = swarm.id;
|
||||
}
|
||||
Execution::CreateSwarmSecret(params) => {
|
||||
let swarm = super::get_check_permissions::<Swarm>(
|
||||
¶ms.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
params.swarm = swarm.id;
|
||||
}
|
||||
Execution::RotateSwarmSecret(params) => {
|
||||
let swarm = super::get_check_permissions::<Swarm>(
|
||||
¶ms.swarm,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
params.swarm = swarm.id;
|
||||
}
|
||||
Execution::RemoveSwarmSecrets(params) => {
|
||||
let swarm = super::get_check_permissions::<Swarm>(
|
||||
¶ms.swarm,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -63,14 +63,19 @@ impl ResourceSyncTrait for Deployment {
|
||||
update: Self::PartialConfig,
|
||||
) -> anyhow::Result<Self::ConfigDiff> {
|
||||
let resources = all_resources_cache().load();
|
||||
// need to replace the server id with name
|
||||
|
||||
original.swarm_id = resources
|
||||
.swarms
|
||||
.get(&original.swarm_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
original.server_id = resources
|
||||
.servers
|
||||
.get(&original.server_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// need to replace the build id with name
|
||||
if let DeploymentImage::Build { build_id, version } =
|
||||
&original.image
|
||||
{
|
||||
@@ -96,13 +101,19 @@ impl ResourceSyncTrait for Stack {
|
||||
update: Self::PartialConfig,
|
||||
) -> anyhow::Result<Self::ConfigDiff> {
|
||||
let resources = all_resources_cache().load();
|
||||
// Need to replace server id with name
|
||||
|
||||
original.swarm_id = resources
|
||||
.swarms
|
||||
.get(&original.swarm_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
original.server_id = resources
|
||||
.servers
|
||||
.get(&original.server_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
// Replace linked repo with name
|
||||
|
||||
original.linked_repo = resources
|
||||
.repos
|
||||
.get(&original.linked_repo)
|
||||
@@ -721,6 +732,20 @@ impl ResourceSyncTrait for Procedure {
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::CreateSwarmConfig(config) => {
|
||||
config.swarm = resources
|
||||
.swarms
|
||||
.get(&config.swarm)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::RotateSwarmConfig(config) => {
|
||||
config.swarm = resources
|
||||
.swarms
|
||||
.get(&config.swarm)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::RemoveSwarmConfigs(config) => {
|
||||
config.swarm = resources
|
||||
.swarms
|
||||
@@ -728,6 +753,20 @@ impl ResourceSyncTrait for Procedure {
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::CreateSwarmSecret(config) => {
|
||||
config.swarm = resources
|
||||
.swarms
|
||||
.get(&config.swarm)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::RotateSwarmSecret(config) => {
|
||||
config.swarm = resources
|
||||
.swarms
|
||||
.get(&config.swarm)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::RemoveSwarmSecrets(config) => {
|
||||
config.swarm = resources
|
||||
.swarms
|
||||
|
||||
@@ -214,6 +214,15 @@ impl ToToml for Swarm {
|
||||
impl ToToml for Stack {
|
||||
fn replace_ids(resource: &mut Resource<Self::Config, Self::Info>) {
|
||||
let all = all_resources_cache().load();
|
||||
|
||||
resource.config.swarm_id.clone_from(
|
||||
all
|
||||
.swarms
|
||||
.get(&resource.config.swarm_id)
|
||||
.map(|s| &s.name)
|
||||
.unwrap_or(&String::new()),
|
||||
);
|
||||
|
||||
resource.config.server_id.clone_from(
|
||||
all
|
||||
.servers
|
||||
@@ -221,6 +230,7 @@ impl ToToml for Stack {
|
||||
.map(|s| &s.name)
|
||||
.unwrap_or(&String::new()),
|
||||
);
|
||||
|
||||
resource.config.linked_repo.clone_from(
|
||||
all
|
||||
.repos
|
||||
@@ -239,6 +249,7 @@ impl ToToml for Stack {
|
||||
.map(|(key, value)| {
|
||||
#[allow(clippy::single_match)]
|
||||
match key.as_str() {
|
||||
"swarm_id" => return Ok((String::from("swarm"), value)),
|
||||
"server_id" => return Ok((String::from("server"), value)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -251,6 +262,15 @@ impl ToToml for Stack {
|
||||
impl ToToml for Deployment {
|
||||
fn replace_ids(resource: &mut Resource<Self::Config, Self::Info>) {
|
||||
let all = all_resources_cache().load();
|
||||
|
||||
resource.config.swarm_id.clone_from(
|
||||
all
|
||||
.swarms
|
||||
.get(&resource.config.swarm_id)
|
||||
.map(|s| &s.name)
|
||||
.unwrap_or(&String::new()),
|
||||
);
|
||||
|
||||
resource.config.server_id.clone_from(
|
||||
all
|
||||
.servers
|
||||
@@ -258,6 +278,7 @@ impl ToToml for Deployment {
|
||||
.map(|s| &s.name)
|
||||
.unwrap_or(&String::new()),
|
||||
);
|
||||
|
||||
if let DeploymentImage::Build { build_id, .. } =
|
||||
&mut resource.config.image
|
||||
{
|
||||
@@ -279,6 +300,7 @@ impl ToToml for Deployment {
|
||||
.into_iter()
|
||||
.map(|(key, mut value)| {
|
||||
match key.as_str() {
|
||||
"swarm_id" => return Ok((String::from("swarm"), value)),
|
||||
"server_id" => return Ok((String::from("server"), value)),
|
||||
"image" => {
|
||||
if let Some(DeploymentImage::Build { version, .. }) =
|
||||
@@ -868,6 +890,24 @@ impl ToToml for Procedure {
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
}
|
||||
Execution::CreateSwarmConfig(exec) => {
|
||||
exec.swarm.clone_from(
|
||||
all
|
||||
.swarms
|
||||
.get(&exec.swarm)
|
||||
.map(|a| &a.name)
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
}
|
||||
Execution::RotateSwarmConfig(exec) => {
|
||||
exec.swarm.clone_from(
|
||||
all
|
||||
.swarms
|
||||
.get(&exec.swarm)
|
||||
.map(|a| &a.name)
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
}
|
||||
Execution::RemoveSwarmConfigs(exec) => {
|
||||
exec.swarm.clone_from(
|
||||
all
|
||||
@@ -877,6 +917,24 @@ impl ToToml for Procedure {
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
}
|
||||
Execution::CreateSwarmSecret(exec) => {
|
||||
exec.swarm.clone_from(
|
||||
all
|
||||
.swarms
|
||||
.get(&exec.swarm)
|
||||
.map(|a| &a.name)
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
}
|
||||
Execution::RotateSwarmSecret(exec) => {
|
||||
exec.swarm.clone_from(
|
||||
all
|
||||
.swarms
|
||||
.get(&exec.swarm)
|
||||
.map(|a| &a.name)
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
}
|
||||
Execution::RemoveSwarmSecrets(exec) => {
|
||||
exec.swarm.clone_from(
|
||||
all
|
||||
|
||||
@@ -38,6 +38,7 @@ resolver_api.workspace = true
|
||||
# external
|
||||
hickory-resolver.workspace = true
|
||||
serde_yaml_ng.workspace = true
|
||||
data-encoding.workspace = true
|
||||
futures-util.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
portable-pty.workspace = true
|
||||
|
||||
@@ -154,7 +154,11 @@ pub enum PeripheryRequest {
|
||||
CreateSwarmService(CreateSwarmService),
|
||||
UpdateSwarmService(UpdateSwarmService),
|
||||
RemoveSwarmServices(RemoveSwarmServices),
|
||||
CreateSwarmConfig(CreateSwarmConfig),
|
||||
RotateSwarmConfig(RotateSwarmConfig),
|
||||
RemoveSwarmConfigs(RemoveSwarmConfigs),
|
||||
CreateSwarmSecret(CreateSwarmSecret),
|
||||
RotateSwarmSecret(RotateSwarmSecret),
|
||||
RemoveSwarmSecrets(RemoveSwarmSecrets),
|
||||
|
||||
// Terminal
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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::{
|
||||
create_swarm_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<SwarmConfig> {
|
||||
inspect_swarm_config(&self.config).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<crate::api::Args> for CreateSwarmConfig {
|
||||
#[instrument(
|
||||
"CreateSwarmConfig",
|
||||
skip_all,
|
||||
fields(
|
||||
id = args.id.to_string(),
|
||||
core = args.core,
|
||||
config = self.name,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
args: &crate::api::Args,
|
||||
) -> anyhow::Result<Log> {
|
||||
create_swarm_config(&self).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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
use anyhow::Context as _;
|
||||
use command::run_komodo_standard_command;
|
||||
use komodo_client::entities::{
|
||||
docker::{
|
||||
SwarmLists, config::SwarmConfig, node::SwarmNode,
|
||||
secret::SwarmSecret, task::SwarmTask,
|
||||
},
|
||||
docker::{SwarmLists, node::SwarmNode, task::SwarmTask},
|
||||
update::Log,
|
||||
};
|
||||
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 secret;
|
||||
mod service;
|
||||
mod stack;
|
||||
|
||||
@@ -31,23 +27,28 @@ impl Resolve<crate::api::Args> for PollSwarmStatus {
|
||||
.iter()
|
||||
.next()
|
||||
.context("Could not connect to docker client")?;
|
||||
let (inspect, nodes, tasks, secrets, configs, stacks) = tokio::join!(
|
||||
let (inspect, nodes, tasks, stacks) = tokio::join!(
|
||||
client.inspect_swarm(),
|
||||
client.list_swarm_nodes(),
|
||||
client.list_swarm_tasks(),
|
||||
client.list_swarm_secrets(),
|
||||
list_swarm_configs(),
|
||||
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
|
||||
.unwrap_or_default();
|
||||
let (configs, secrets) = tokio::join!(
|
||||
list_swarm_configs(&services),
|
||||
client.list_swarm_secrets(&services),
|
||||
);
|
||||
Ok(PollSwarmStatusResponse {
|
||||
inspect: inspect.ok(),
|
||||
lists: SwarmLists {
|
||||
nodes: nodes.unwrap_or_default(),
|
||||
services: services.unwrap_or_default(),
|
||||
secrets: secrets.unwrap_or_default(),
|
||||
services,
|
||||
configs: configs.unwrap_or_default(),
|
||||
secrets: secrets.unwrap_or_default(),
|
||||
stacks: stacks.unwrap_or_default(),
|
||||
tasks,
|
||||
},
|
||||
@@ -179,94 +180,3 @@ impl Resolve<crate::api::Args> for InspectSwarmTask {
|
||||
client.inspect_swarm_task(&self.task).await
|
||||
}
|
||||
}
|
||||
|
||||
// ========
|
||||
// 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
|
||||
// ========
|
||||
|
||||
impl Resolve<crate::api::Args> for InspectSwarmSecret {
|
||||
async fn resolve(
|
||||
self,
|
||||
_: &crate::api::Args,
|
||||
) -> anyhow::Result<SwarmSecret> {
|
||||
let client = docker_client().load();
|
||||
let client = client
|
||||
.iter()
|
||||
.next()
|
||||
.context("Could not connect to docker client")?;
|
||||
client.inspect_swarm_secret(&self.secret).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<crate::api::Args> for RemoveSwarmSecrets {
|
||||
#[instrument(
|
||||
"RemoveSwarmSecrets",
|
||||
skip_all,
|
||||
fields(
|
||||
id = args.id.to_string(),
|
||||
core = args.core,
|
||||
secrets = serde_json::to_string(&self.secrets).unwrap_or_else(|e| e.to_string()),
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
args: &crate::api::Args,
|
||||
) -> anyhow::Result<Log> {
|
||||
let mut command = String::from("docker secret rm");
|
||||
for secret in self.secrets {
|
||||
command += " ";
|
||||
command += &secret;
|
||||
}
|
||||
Ok(
|
||||
run_komodo_standard_command(
|
||||
"Remove Swarm Secrets",
|
||||
None,
|
||||
command,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use anyhow::Context as _;
|
||||
use formatting::format_serror;
|
||||
use komodo_client::entities::{
|
||||
docker::secret::SwarmSecret, update::Log,
|
||||
};
|
||||
use periphery_client::api::swarm::*;
|
||||
use resolver_api::Resolve;
|
||||
|
||||
use crate::{
|
||||
docker::secret::{create_swarm_secret, remove_swarm_secrets},
|
||||
state::docker_client,
|
||||
};
|
||||
|
||||
impl Resolve<crate::api::Args> for InspectSwarmSecret {
|
||||
async fn resolve(
|
||||
self,
|
||||
_: &crate::api::Args,
|
||||
) -> anyhow::Result<SwarmSecret> {
|
||||
let client = docker_client().load();
|
||||
let client = client
|
||||
.iter()
|
||||
.next()
|
||||
.context("Could not connect to docker client")?;
|
||||
client.inspect_swarm_secret(&self.secret).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<crate::api::Args> for CreateSwarmSecret {
|
||||
#[instrument(
|
||||
"CreateSwarmSecret",
|
||||
skip_all,
|
||||
fields(
|
||||
id = args.id.to_string(),
|
||||
core = args.core,
|
||||
config = self.name,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
args: &crate::api::Args,
|
||||
) -> anyhow::Result<Log> {
|
||||
create_swarm_secret(&self).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<crate::api::Args> for RotateSwarmSecret {
|
||||
#[instrument(
|
||||
"RotateSwarmSecret",
|
||||
skip_all,
|
||||
fields(
|
||||
id = args.id.to_string(),
|
||||
core = args.core,
|
||||
secret = self.secret,
|
||||
)
|
||||
)]
|
||||
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_secret(&self.secret, self.data, &mut logs)
|
||||
.await
|
||||
{
|
||||
logs.push(Log::error(
|
||||
"Rotate Swarm Secret",
|
||||
format_serror(&e.into()),
|
||||
))
|
||||
}
|
||||
|
||||
Ok(logs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<crate::api::Args> for RemoveSwarmSecrets {
|
||||
#[instrument(
|
||||
"RemoveSwarmSecrets",
|
||||
skip_all,
|
||||
fields(
|
||||
id = args.id.to_string(),
|
||||
core = args.core,
|
||||
secrets = serde_json::to_string(&self.secrets).unwrap_or_else(|e| e.to_string()),
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
args: &crate::api::Args,
|
||||
) -> anyhow::Result<Log> {
|
||||
Ok(
|
||||
remove_swarm_secrets(self.secrets.iter().map(String::as_str))
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use formatting::format_serror;
|
||||
use interpolate::Interpolator;
|
||||
use komodo_client::entities::{
|
||||
deployment::{
|
||||
Deployment, DeploymentConfig, DeploymentImage,
|
||||
Conversion, Deployment, DeploymentConfig, DeploymentImage,
|
||||
conversions_from_str, extract_registry_domain,
|
||||
},
|
||||
docker::service::SwarmService,
|
||||
@@ -311,10 +311,9 @@ fn docker_service_create_command(
|
||||
"-p",
|
||||
)?;
|
||||
|
||||
push_conversions(
|
||||
push_mounts(
|
||||
&mut res,
|
||||
&conversions_from_str(volumes).context("Invalid volumes")?,
|
||||
"--mount",
|
||||
)?;
|
||||
|
||||
push_environment(
|
||||
@@ -368,7 +367,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}")?;
|
||||
@@ -419,3 +418,36 @@ impl Resolve<crate::api::Args> for UpdateSwarmService {
|
||||
Ok(log)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_mounts(
|
||||
command: &mut String,
|
||||
mounts: &[Conversion],
|
||||
) -> anyhow::Result<()> {
|
||||
for Conversion { local, container } in mounts {
|
||||
let (typ, src) = if local == "tmpfs" {
|
||||
("tmpfs", None)
|
||||
} else if local.starts_with('/') || local.starts_with('.') {
|
||||
("bind", Some(local))
|
||||
} else {
|
||||
("volume", Some(local))
|
||||
};
|
||||
let (dst, readonly) =
|
||||
if let Some((container, mode)) = container.split_once(':') {
|
||||
(container, mode == "ro")
|
||||
} else {
|
||||
(container.as_str(), false)
|
||||
};
|
||||
write!(command, " --mount type={typ}")
|
||||
.context("Failed to format mounts 'type'")?;
|
||||
if let Some(src) = src {
|
||||
write!(command, ",src={src}")
|
||||
.context("Failed to format mounts 'src'")?;
|
||||
}
|
||||
write!(command, ",dst={dst}")
|
||||
.context("Failed to format mounts 'dst'")?;
|
||||
if readonly {
|
||||
command.push_str(",ro=true");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ use tracing::Instrument as _;
|
||||
|
||||
use crate::{
|
||||
config::periphery_config,
|
||||
docker::stack::inspect_swarm_stack,
|
||||
helpers::push_extra_args,
|
||||
stack::{maybe_login_registry, validate_files, write::write_stack},
|
||||
state::docker_client,
|
||||
};
|
||||
|
||||
impl Resolve<crate::api::Args> for InspectSwarmStack {
|
||||
@@ -35,7 +35,12 @@ impl Resolve<crate::api::Args> for InspectSwarmStack {
|
||||
self,
|
||||
_: &crate::api::Args,
|
||||
) -> anyhow::Result<SwarmStack> {
|
||||
inspect_swarm_stack(self.stack).await
|
||||
let client = docker_client().load();
|
||||
let client = client
|
||||
.iter()
|
||||
.next()
|
||||
.context("Could not connect to docker client")?;
|
||||
client.inspect_swarm_stack(self.stack).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use command::run_komodo_standard_command;
|
||||
use komodo_client::entities::docker::config::{
|
||||
SwarmConfig, SwarmConfigListItem,
|
||||
};
|
||||
use std::fmt::Write;
|
||||
|
||||
pub async fn list_swarm_configs()
|
||||
-> anyhow::Result<Vec<SwarmConfigListItem>> {
|
||||
use anyhow::{Context, anyhow};
|
||||
use command::{
|
||||
run_komodo_shell_command, run_komodo_standard_command,
|
||||
};
|
||||
use data_encoding::BASE64URL;
|
||||
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
|
||||
use komodo_client::entities::{
|
||||
all_logs_success,
|
||||
docker::{
|
||||
config::{SwarmConfig, SwarmConfigListItem},
|
||||
service::{SwarmService, SwarmServiceListItem},
|
||||
},
|
||||
random_string,
|
||||
};
|
||||
use periphery_client::api::swarm::CreateSwarmConfig;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub async fn list_swarm_configs(
|
||||
services: &[SwarmServiceListItem],
|
||||
) -> anyhow::Result<Vec<SwarmConfigListItem>> {
|
||||
let res = run_komodo_standard_command(
|
||||
"List Swarm Configs",
|
||||
None,
|
||||
@@ -23,7 +38,21 @@ pub async fn list_swarm_configs()
|
||||
let mut res = serde_json::from_str::<Vec<SwarmConfigListItem>>(
|
||||
&format!("[{}]", res.stdout.trim().replace('\n', ",")),
|
||||
)
|
||||
.context("Failed to parse 'docker config ls' response from json")?;
|
||||
.context("Failed to parse 'docker config ls' response from json")?
|
||||
.into_iter()
|
||||
.map(|mut res| {
|
||||
res.in_use = res
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|name| {
|
||||
services
|
||||
.iter()
|
||||
.any(|service| service.configs.contains(name))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
res
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
res.sort_by(|a, b| {
|
||||
a.name
|
||||
@@ -36,7 +65,7 @@ pub async fn list_swarm_configs()
|
||||
|
||||
pub async fn inspect_swarm_config(
|
||||
config: &str,
|
||||
) -> anyhow::Result<Vec<SwarmConfig>> {
|
||||
) -> anyhow::Result<SwarmConfig> {
|
||||
let res = run_komodo_standard_command(
|
||||
"Inspect Swarm Config",
|
||||
None,
|
||||
@@ -50,7 +79,272 @@ pub async fn inspect_swarm_config(
|
||||
)));
|
||||
}
|
||||
|
||||
serde_json::from_str(&res.stdout).context(
|
||||
"Failed to parse 'docker config inspect' response from json",
|
||||
let mut res = serde_json::from_str::<Vec<SwarmConfig>>(&res.stdout)
|
||||
.context(
|
||||
"Failed to parse 'docker config inspect' response from json",
|
||||
)?
|
||||
.pop()
|
||||
.with_context(|| {
|
||||
format!("Did not find any config matching {config}")
|
||||
})?;
|
||||
|
||||
// Convert data back to readable / editable format
|
||||
res.spec.iter_mut().next().map(|spec| {
|
||||
spec.data.iter_mut().next().map(|data| {
|
||||
if let Ok(res) = BASE64URL
|
||||
.decode(data.as_bytes())
|
||||
.map_err(anyhow::Error::new)
|
||||
.and_then(|data| {
|
||||
String::from_utf8(data).map_err(anyhow::Error::new)
|
||||
})
|
||||
{
|
||||
*data = res;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn create_swarm_config(
|
||||
CreateSwarmConfig {
|
||||
name,
|
||||
data,
|
||||
labels,
|
||||
template_driver,
|
||||
}: &CreateSwarmConfig,
|
||||
) -> anyhow::Result<Log> {
|
||||
let mut command = String::from("docker config create");
|
||||
|
||||
for label in labels {
|
||||
write!(&mut command, " --label {label}")?;
|
||||
}
|
||||
|
||||
if let Some(driver) = template_driver {
|
||||
write!(&mut command, " --template-driver {driver}")?;
|
||||
}
|
||||
|
||||
write!(
|
||||
&mut command,
|
||||
r#" {name} - <<'EOF'
|
||||
{}
|
||||
EOF"#,
|
||||
data.trim()
|
||||
)?;
|
||||
|
||||
let log =
|
||||
run_komodo_shell_command("Create Config", None, command).await;
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
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(
|
||||
config: &CreateSwarmConfig,
|
||||
logs: &mut Vec<Log>,
|
||||
) -> anyhow::Result<()> {
|
||||
let remove =
|
||||
remove_swarm_configs([config.name.as_str()].into_iter()).await;
|
||||
let success = remove.success;
|
||||
logs.push(remove);
|
||||
if !success {
|
||||
return Ok(());
|
||||
}
|
||||
let log = create_swarm_config(config).await?;
|
||||
logs.push(log);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ServiceConfigFile {
|
||||
/// Service name
|
||||
service: String,
|
||||
/// Config file spec
|
||||
file: TaskSpecContainerSpecFile,
|
||||
}
|
||||
|
||||
impl DockerClient {
|
||||
pub async fn rotate_swarm_config(
|
||||
&self,
|
||||
config: &str,
|
||||
data: String,
|
||||
logs: &mut Vec<Log>,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = inspect_swarm_config(config).await?;
|
||||
let config_id = config.id.context("Failed to get config id")?;
|
||||
let spec = config.spec.context("Failed to get config spec")?;
|
||||
let name = spec.name.context("Failed to get config name")?;
|
||||
let labels = spec
|
||||
.labels
|
||||
.map(|labels| {
|
||||
labels
|
||||
.into_iter()
|
||||
.map(|(variable, value)| format!("{variable}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let template_driver =
|
||||
spec.templating.map(|templating| templating.name);
|
||||
|
||||
let create_config = CreateSwarmConfig {
|
||||
name,
|
||||
data,
|
||||
labels,
|
||||
template_driver,
|
||||
};
|
||||
|
||||
let services = self
|
||||
.filter_map_swarm_services(|service| {
|
||||
extract_from_service(service, &config_id)
|
||||
})
|
||||
.await?;
|
||||
if services.is_empty() {
|
||||
return recreate_swarm_config(&create_config, logs).await;
|
||||
}
|
||||
|
||||
// Create a tmp config for rotation
|
||||
let tmp_create_config = CreateSwarmConfig {
|
||||
name: format!(
|
||||
"{}-tmp-{}",
|
||||
create_config.name,
|
||||
random_string(10)
|
||||
),
|
||||
data: create_config.data.clone(),
|
||||
labels: create_config.labels.clone(),
|
||||
template_driver: create_config.template_driver.clone(),
|
||||
};
|
||||
let log = create_swarm_config(&tmp_create_config).await?;
|
||||
logs.push(log);
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update services to tmp
|
||||
switch_services_config(
|
||||
&services,
|
||||
&create_config.name,
|
||||
&tmp_create_config.name,
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Recreate actual config
|
||||
recreate_swarm_config(&create_config, logs).await?;
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update back to original
|
||||
switch_services_config(
|
||||
&services,
|
||||
&tmp_create_config.name,
|
||||
&create_config.name,
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove tmp config
|
||||
let log = remove_swarm_configs(
|
||||
[tmp_create_config.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_id: &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()
|
||||
.map(|id| id == config_id)
|
||||
.unwrap_or_default()
|
||||
})?;
|
||||
Some(ServiceConfigFile {
|
||||
service: spec.name?,
|
||||
file: config.file?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use komodo_client::entities::{
|
||||
|
||||
pub mod compose;
|
||||
pub mod config;
|
||||
pub mod secret;
|
||||
pub mod stack;
|
||||
pub mod stats;
|
||||
|
||||
@@ -16,7 +17,6 @@ mod container;
|
||||
mod image;
|
||||
mod network;
|
||||
mod node;
|
||||
mod secret;
|
||||
mod service;
|
||||
mod swarm;
|
||||
mod task;
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use anyhow::Context;
|
||||
use bollard::query_parameters::ListSecretsOptions;
|
||||
use komodo_client::entities::docker::secret::{
|
||||
SecretSpec, SwarmSecret, SwarmSecretListItem,
|
||||
use command::{
|
||||
run_komodo_shell_command, run_komodo_standard_command,
|
||||
};
|
||||
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
|
||||
use komodo_client::entities::{
|
||||
all_logs_success,
|
||||
docker::{
|
||||
secret::{SecretSpec, SwarmSecret, SwarmSecretListItem},
|
||||
service::{SwarmService, SwarmServiceListItem},
|
||||
task::TaskSpecContainerSpecFile,
|
||||
},
|
||||
random_string,
|
||||
update::Log,
|
||||
};
|
||||
use periphery_client::api::swarm::CreateSwarmSecret;
|
||||
|
||||
use super::DockerClient;
|
||||
|
||||
impl DockerClient {
|
||||
pub async fn list_swarm_secrets(
|
||||
&self,
|
||||
services: &[SwarmServiceListItem],
|
||||
) -> anyhow::Result<Vec<SwarmSecretListItem>> {
|
||||
let mut secrets = self
|
||||
.docker
|
||||
@@ -16,7 +31,7 @@ impl DockerClient {
|
||||
.await
|
||||
.context("Failed to query for swarm secret list")?
|
||||
.into_iter()
|
||||
.map(convert_secret_list_item)
|
||||
.map(|secret| convert_secret_list_item(secret, services))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
secrets.sort_by(|a, b| {
|
||||
@@ -47,6 +62,7 @@ impl DockerClient {
|
||||
|
||||
fn convert_secret_list_item(
|
||||
secret: bollard::models::Secret,
|
||||
services: &[SwarmServiceListItem],
|
||||
) -> SwarmSecretListItem {
|
||||
let (name, driver, templating) = secret
|
||||
.spec
|
||||
@@ -58,11 +74,20 @@ fn convert_secret_list_item(
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let in_use = name
|
||||
.as_ref()
|
||||
.map(|name| {
|
||||
services
|
||||
.iter()
|
||||
.any(|service| service.secrets.contains(name))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
SwarmSecretListItem {
|
||||
id: secret.id,
|
||||
name,
|
||||
driver,
|
||||
templating,
|
||||
in_use,
|
||||
created_at: secret.created_at,
|
||||
updated_at: secret.updated_at,
|
||||
}
|
||||
@@ -83,3 +108,265 @@ fn convert_secret(secret: bollard::models::Secret) -> SwarmSecret {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_swarm_secret(
|
||||
CreateSwarmSecret {
|
||||
name,
|
||||
data,
|
||||
driver,
|
||||
labels,
|
||||
template_driver,
|
||||
}: &CreateSwarmSecret,
|
||||
) -> anyhow::Result<Log> {
|
||||
let mut command = String::from("docker secret create");
|
||||
|
||||
if let Some(driver) = driver {
|
||||
write!(&mut command, " --driver {driver}")?;
|
||||
}
|
||||
|
||||
for label in labels {
|
||||
write!(&mut command, " --label {label}")?;
|
||||
}
|
||||
|
||||
if let Some(driver) = template_driver {
|
||||
write!(&mut command, " --template-driver {driver}")?;
|
||||
}
|
||||
|
||||
let mut sanitized_command = command.clone();
|
||||
|
||||
write!(
|
||||
&mut command,
|
||||
r#" {name} - <<'EOF'
|
||||
{}
|
||||
EOF"#,
|
||||
data.trim()
|
||||
)?;
|
||||
|
||||
write!(
|
||||
&mut sanitized_command,
|
||||
r#" {name} - <<'EOF'
|
||||
<secret-data>
|
||||
EOF"#
|
||||
)?;
|
||||
|
||||
let mut log =
|
||||
run_komodo_shell_command("Create Secret", None, command).await;
|
||||
|
||||
log.command = sanitized_command;
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
pub async fn remove_swarm_secrets(
|
||||
secrets: impl Iterator<Item = &str>,
|
||||
) -> Log {
|
||||
let mut command = String::from("docker secret rm");
|
||||
for secret in secrets {
|
||||
command += " ";
|
||||
command += secret;
|
||||
}
|
||||
run_komodo_standard_command("Remove Swarm Secrets", None, command)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn recreate_swarm_secret(
|
||||
secret: &CreateSwarmSecret,
|
||||
logs: &mut Vec<Log>,
|
||||
) -> anyhow::Result<()> {
|
||||
let remove =
|
||||
remove_swarm_secrets([secret.name.as_str()].into_iter()).await;
|
||||
let success = remove.success;
|
||||
logs.push(remove);
|
||||
if !success {
|
||||
return Ok(());
|
||||
}
|
||||
let log = create_swarm_secret(secret).await?;
|
||||
logs.push(log);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ServiceSecretFile {
|
||||
/// Service name
|
||||
service: String,
|
||||
/// Secret file spec
|
||||
file: TaskSpecContainerSpecFile,
|
||||
}
|
||||
|
||||
impl DockerClient {
|
||||
pub async fn rotate_swarm_secret(
|
||||
&self,
|
||||
secret: &str,
|
||||
data: String,
|
||||
logs: &mut Vec<Log>,
|
||||
) -> anyhow::Result<()> {
|
||||
let secret = self.inspect_swarm_secret(secret).await?;
|
||||
let secret_id = secret.id.context("Failed to get secret id")?;
|
||||
let spec = secret.spec.context("Failed to get secret spec")?;
|
||||
let name = spec.name.context("Failed to get secret name")?;
|
||||
let driver = spec.driver.map(|driver| driver.name);
|
||||
let labels = spec
|
||||
.labels
|
||||
.map(|labels| {
|
||||
labels
|
||||
.into_iter()
|
||||
.map(|(variable, value)| format!("{variable}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let template_driver =
|
||||
spec.templating.map(|templating| templating.name);
|
||||
|
||||
let create_secret = CreateSwarmSecret {
|
||||
name,
|
||||
data,
|
||||
driver,
|
||||
labels,
|
||||
template_driver,
|
||||
};
|
||||
|
||||
let services = self
|
||||
.filter_map_swarm_services(|service| {
|
||||
extract_from_service(service, &secret_id)
|
||||
})
|
||||
.await?;
|
||||
if services.is_empty() {
|
||||
return recreate_swarm_secret(&create_secret, logs).await;
|
||||
}
|
||||
|
||||
// Create a tmp secret for rotation
|
||||
let tmp_create_config = CreateSwarmSecret {
|
||||
name: format!(
|
||||
"{}-tmp-{}",
|
||||
create_secret.name,
|
||||
random_string(10)
|
||||
),
|
||||
data: create_secret.data.clone(),
|
||||
driver: create_secret.driver.clone(),
|
||||
labels: create_secret.labels.clone(),
|
||||
template_driver: create_secret.template_driver.clone(),
|
||||
};
|
||||
let log = create_swarm_secret(&tmp_create_config).await?;
|
||||
logs.push(log);
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update services to tmp
|
||||
switch_services_secret(
|
||||
&services,
|
||||
&create_secret.name,
|
||||
&tmp_create_config.name,
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Recreate actual secret
|
||||
recreate_swarm_secret(&create_secret, logs).await?;
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update back to original
|
||||
switch_services_secret(
|
||||
&services,
|
||||
&tmp_create_config.name,
|
||||
&create_secret.name,
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
if !all_logs_success(logs) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove tmp secret
|
||||
let log = remove_swarm_secrets(
|
||||
[tmp_create_config.name.as_str()].into_iter(),
|
||||
)
|
||||
.await;
|
||||
logs.push(log);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn switch_services_secret(
|
||||
services: &[ServiceSecretFile],
|
||||
from: &str,
|
||||
to: &str,
|
||||
logs: &mut Vec<Log>,
|
||||
) -> anyhow::Result<()> {
|
||||
let res = services
|
||||
.iter()
|
||||
.map(|service| async move {
|
||||
switch_service_secret(&service.service, from, to, &service.file)
|
||||
.await
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
logs.extend(res);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn switch_service_secret(
|
||||
service: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
TaskSpecContainerSpecFile {
|
||||
name: path,
|
||||
uid,
|
||||
gid,
|
||||
mode,
|
||||
}: &TaskSpecContainerSpecFile,
|
||||
) -> anyhow::Result<Log> {
|
||||
let mut command = format!(
|
||||
"docker service update --secret-rm {from} --secret-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 Secret",
|
||||
None,
|
||||
command,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
fn extract_from_service(
|
||||
service: SwarmService,
|
||||
secret_id: &str,
|
||||
) -> Option<ServiceSecretFile> {
|
||||
let spec = service.spec?;
|
||||
let secrets = spec.task_template?.container_spec?.secrets?;
|
||||
let secret = secrets.into_iter().find(|cfg| {
|
||||
cfg
|
||||
.secret_id
|
||||
.as_ref()
|
||||
.map(|id| id == secret_id)
|
||||
.unwrap_or_default()
|
||||
})?;
|
||||
Some(ServiceSecretFile {
|
||||
service: spec.name?,
|
||||
file: secret.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,37 +82,65 @@ 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
|
||||
.map(|spec| {
|
||||
(
|
||||
spec.name,
|
||||
spec
|
||||
.task_template
|
||||
.map(|template| {
|
||||
(
|
||||
template.container_spec.and_then(|spec| spec.image),
|
||||
template.restart_policy.and_then(|policy| {
|
||||
policy
|
||||
.condition
|
||||
.map(convert_task_spec_restart_policy_condition)
|
||||
}),
|
||||
template.runtime,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
spec.mode.and_then(|mode| {
|
||||
mode.replicated.and_then(|replicated| replicated.replicas)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (name, ((image, configs, secrets), restart, runtime), replicas) =
|
||||
service
|
||||
.spec
|
||||
.map(|spec| {
|
||||
(
|
||||
spec.name,
|
||||
spec
|
||||
.task_template
|
||||
.map(|template| {
|
||||
(
|
||||
template
|
||||
.container_spec
|
||||
.map(|spec| {
|
||||
(
|
||||
spec.image,
|
||||
spec
|
||||
.configs
|
||||
.map(|configs| {
|
||||
configs
|
||||
.into_iter()
|
||||
.filter_map(|config| config.config_name)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
spec
|
||||
.secrets
|
||||
.map(|secrets| {
|
||||
secrets
|
||||
.into_iter()
|
||||
.filter_map(|secret| secret.secret_name)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
template.restart_policy.and_then(|policy| {
|
||||
policy
|
||||
.condition
|
||||
.map(convert_task_spec_restart_policy_condition)
|
||||
}),
|
||||
template.runtime,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
spec.mode.and_then(|mode| {
|
||||
mode.replicated.and_then(|replicated| replicated.replicas)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
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,
|
||||
@@ -97,6 +150,8 @@ fn convert_service_list_item(
|
||||
image,
|
||||
restart,
|
||||
runtime,
|
||||
configs,
|
||||
secrets,
|
||||
created_at: service.created_at,
|
||||
updated_at: service.updated_at,
|
||||
}
|
||||
@@ -114,8 +169,28 @@ fn service_state_from_tasks(
|
||||
.unwrap_or_default()
|
||||
});
|
||||
for task in tasks {
|
||||
if task.state != task.desired_state {
|
||||
return SwarmState::Unhealthy;
|
||||
let (Some(state), Some(desired_state)) =
|
||||
(task.state, task.desired_state)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match (state, desired_state) {
|
||||
// Both running, healthy
|
||||
(TaskState::RUNNING, TaskState::RUNNING) => continue,
|
||||
// Not running when it should be, unhealthy
|
||||
(_, TaskState::RUNNING) => return SwarmState::Unhealthy,
|
||||
// Should be shutdown but its running, unhealthy
|
||||
(TaskState::RUNNING, TaskState::SHUTDOWN) => {
|
||||
return SwarmState::Unhealthy;
|
||||
}
|
||||
// Very likely healthy
|
||||
(_, TaskState::SHUTDOWN) => continue,
|
||||
// All others must match
|
||||
(state, desired) => {
|
||||
if state != desired {
|
||||
return SwarmState::Unhealthy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SwarmState::Healthy
|
||||
|
||||
@@ -2,27 +2,68 @@ use anyhow::{Context, anyhow};
|
||||
use command::run_komodo_standard_command;
|
||||
use futures_util::{StreamExt, stream::FuturesOrdered};
|
||||
use komodo_client::entities::{
|
||||
docker::stack::{
|
||||
SwarmStack, SwarmStackListItem, SwarmStackServiceListItem,
|
||||
SwarmStackTaskListItem,
|
||||
docker::{
|
||||
service::SwarmServiceListItem,
|
||||
stack::{
|
||||
SwarmStack, SwarmStackListItem, SwarmStackServiceListItem,
|
||||
SwarmStackTaskListItem,
|
||||
},
|
||||
},
|
||||
swarm::SwarmState,
|
||||
};
|
||||
|
||||
pub async fn inspect_swarm_stack(
|
||||
name: String,
|
||||
) -> anyhow::Result<SwarmStack> {
|
||||
let (tasks, services) = tokio::try_join!(
|
||||
list_swarm_stack_tasks(&name),
|
||||
list_swarm_stack_services(&name)
|
||||
)?;
|
||||
let state = state_from_tasks(&tasks);
|
||||
Ok(SwarmStack {
|
||||
name,
|
||||
state,
|
||||
services,
|
||||
tasks,
|
||||
})
|
||||
use super::*;
|
||||
|
||||
impl DockerClient {
|
||||
pub async fn inspect_swarm_stack(
|
||||
&self,
|
||||
name: String,
|
||||
) -> anyhow::Result<SwarmStack> {
|
||||
let (service_ids, swarm_tasks, tasks) = tokio::try_join!(
|
||||
list_swarm_stack_service_ids(&name),
|
||||
list_swarm_stack_tasks(&name),
|
||||
self.list_swarm_tasks(),
|
||||
)?;
|
||||
let services = self
|
||||
.list_swarm_services(Some(&tasks))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|service| {
|
||||
service
|
||||
.id
|
||||
.as_ref()
|
||||
.map(|long_id| {
|
||||
service_ids
|
||||
.iter()
|
||||
// These service ids are shortened coming from docker,
|
||||
// only need to check that long id starts with short id
|
||||
.any(|short_id| long_id.starts_with(short_id))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let task_ids = swarm_tasks
|
||||
.into_iter()
|
||||
.filter_map(|task| task.id)
|
||||
.collect::<Vec<_>>();
|
||||
let tasks = tasks
|
||||
.into_iter()
|
||||
.filter(|task| {
|
||||
task
|
||||
.id
|
||||
.as_ref()
|
||||
.map(|id| task_ids.contains(id))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let state = state_from_services(&services);
|
||||
Ok(SwarmStack {
|
||||
name,
|
||||
state,
|
||||
services,
|
||||
tasks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_swarm_stacks()
|
||||
@@ -72,9 +113,9 @@ pub async fn list_swarm_stacks()
|
||||
Ok(stacks)
|
||||
}
|
||||
|
||||
pub async fn list_swarm_stack_services(
|
||||
pub async fn list_swarm_stack_service_ids(
|
||||
stack: &str,
|
||||
) -> anyhow::Result<Vec<SwarmStackServiceListItem>> {
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let res = run_komodo_standard_command(
|
||||
"List Swarm Stack Services",
|
||||
None,
|
||||
@@ -89,18 +130,17 @@ pub async fn list_swarm_stack_services(
|
||||
}
|
||||
|
||||
// The output is in JSONL, need to convert to standard JSON vec.
|
||||
let mut services =
|
||||
serde_json::from_str::<Vec<SwarmStackServiceListItem>>(&format!(
|
||||
"[{}]",
|
||||
res.stdout.trim().replace('\n', ",")
|
||||
))
|
||||
.context(
|
||||
"Failed to parse 'docker stack services' response from json",
|
||||
)?;
|
||||
let ids = serde_json::from_str::<Vec<SwarmStackServiceListItem>>(
|
||||
&format!("[{}]", res.stdout.trim().replace('\n', ",")),
|
||||
)
|
||||
.context(
|
||||
"Failed to parse 'docker stack services' response from json",
|
||||
)?
|
||||
.into_iter()
|
||||
.filter_map(|service| service.id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
services.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
Ok(services)
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub async fn list_swarm_stack_tasks(
|
||||
@@ -120,24 +160,25 @@ pub async fn list_swarm_stack_tasks(
|
||||
}
|
||||
|
||||
// The output is in JSONL, need to convert to standard JSON vec.
|
||||
let mut tasks =
|
||||
serde_json::from_str::<Vec<SwarmStackTaskListItem>>(&format!(
|
||||
"[{}]",
|
||||
res.stdout.trim().replace('\n', ",")
|
||||
))
|
||||
.context(
|
||||
"Failed to parse 'docker stack ps' response from json",
|
||||
)?;
|
||||
|
||||
tasks.sort_by(|a, b| {
|
||||
a.desired_state
|
||||
.cmp(&b.desired_state)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
let tasks = serde_json::from_str::<Vec<SwarmStackTaskListItem>>(
|
||||
&format!("[{}]", res.stdout.trim().replace('\n', ",")),
|
||||
)
|
||||
.context("Failed to parse 'docker stack ps' response from json")?;
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub fn state_from_services<'a>(
|
||||
services: impl IntoIterator<Item = &'a SwarmServiceListItem>,
|
||||
) -> SwarmState {
|
||||
for service in services {
|
||||
if matches!(service.state, SwarmState::Unhealthy) {
|
||||
return SwarmState::Unhealthy;
|
||||
}
|
||||
}
|
||||
SwarmState::Healthy
|
||||
}
|
||||
|
||||
pub fn state_from_tasks<'a>(
|
||||
tasks: impl IntoIterator<Item = &'a SwarmStackTaskListItem>,
|
||||
) -> SwarmState {
|
||||
@@ -152,8 +193,21 @@ pub fn state_from_tasks<'a>(
|
||||
let Some(current) = current.split(" ").next() else {
|
||||
continue;
|
||||
};
|
||||
if current != desired {
|
||||
return SwarmState::Unhealthy;
|
||||
match (current, desired.as_str()) {
|
||||
// Both running, healthy
|
||||
("Running", "Running") => continue,
|
||||
// Not running when it should be, unhealthy
|
||||
(_, "Running") => return SwarmState::Unhealthy,
|
||||
// Should be shutdown but its running, unhealthy
|
||||
("Running", "Shutdown") => return SwarmState::Unhealthy,
|
||||
// Very likely healthy
|
||||
(_, "Shutdown") => continue,
|
||||
// All others must match
|
||||
(current, desired) => {
|
||||
if current != desired {
|
||||
return SwarmState::Unhealthy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SwarmState::Healthy
|
||||
|
||||
@@ -53,6 +53,33 @@ fn convert_task_list_item(
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (configs, secrets) = task
|
||||
.spec
|
||||
.and_then(|spec| {
|
||||
spec.container_spec.map(|spec| {
|
||||
(
|
||||
spec
|
||||
.configs
|
||||
.map(|config| {
|
||||
config
|
||||
.into_iter()
|
||||
.filter_map(|config| config.config_name)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
spec
|
||||
.secrets
|
||||
.map(|secret| {
|
||||
secret
|
||||
.into_iter()
|
||||
.filter_map(|secret| secret.secret_name)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
SwarmTaskListItem {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
@@ -61,6 +88,8 @@ fn convert_task_list_item(
|
||||
container_id,
|
||||
state,
|
||||
desired_state: task.desired_state.map(convert_task_state),
|
||||
configs,
|
||||
secrets,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
}
|
||||
|
||||
@@ -163,7 +163,11 @@ pub enum Execution {
|
||||
RemoveSwarmNodes(RemoveSwarmNodes),
|
||||
RemoveSwarmStacks(RemoveSwarmStacks),
|
||||
RemoveSwarmServices(RemoveSwarmServices),
|
||||
CreateSwarmConfig(CreateSwarmConfig),
|
||||
RotateSwarmConfig(RotateSwarmConfig),
|
||||
RemoveSwarmConfigs(RemoveSwarmConfigs),
|
||||
CreateSwarmSecret(CreateSwarmSecret),
|
||||
RotateSwarmSecret(RotateSwarmSecret),
|
||||
RemoveSwarmSecrets(RemoveSwarmSecrets),
|
||||
|
||||
// MAINTENANCE
|
||||
|
||||
@@ -108,6 +108,77 @@ pub struct RemoveSwarmServices {
|
||||
// = Config =
|
||||
// ==========
|
||||
|
||||
/// `docker config create [OPTIONS] CONFIG file|-`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/config/create/
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Resolve,
|
||||
EmptyTraits,
|
||||
Parser,
|
||||
)]
|
||||
#[empty_traits(KomodoExecuteRequest)]
|
||||
#[response(Update)]
|
||||
#[error(serror::Error)]
|
||||
pub struct CreateSwarmConfig {
|
||||
/// Name or id
|
||||
pub swarm: String,
|
||||
/// The name of the config to create
|
||||
pub name: String,
|
||||
/// The data to store in the config
|
||||
pub data: String,
|
||||
/// Docker labels to give the config
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
/// Optional custom template driver
|
||||
pub template_driver: Option<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.
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Resolve,
|
||||
EmptyTraits,
|
||||
Parser,
|
||||
)]
|
||||
#[empty_traits(KomodoExecuteRequest)]
|
||||
#[response(Update)]
|
||||
#[error(serror::Error)]
|
||||
pub struct RotateSwarmConfig {
|
||||
/// Name or id
|
||||
pub swarm: String,
|
||||
/// Config name
|
||||
pub config: String,
|
||||
/// The new config data as a string
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// `docker config rm CONFIG [CONFIG...]`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/config/rm/
|
||||
@@ -136,6 +207,77 @@ pub struct RemoveSwarmConfigs {
|
||||
// = Secret =
|
||||
// ==========
|
||||
|
||||
/// `docker config create [OPTIONS] CONFIG file|-`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/config/create/
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Resolve,
|
||||
EmptyTraits,
|
||||
Parser,
|
||||
)]
|
||||
#[empty_traits(KomodoExecuteRequest)]
|
||||
#[response(Update)]
|
||||
#[error(serror::Error)]
|
||||
pub struct CreateSwarmSecret {
|
||||
/// Name or id
|
||||
pub swarm: String,
|
||||
/// The name of the secret to create
|
||||
pub name: String,
|
||||
/// The data to store in the secret
|
||||
pub data: String,
|
||||
/// Optional custom secret driver
|
||||
pub driver: Option<String>,
|
||||
/// Docker labels to give the secret
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
/// Optional custom template driver
|
||||
pub template_driver: Option<String>,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
|
||||
///
|
||||
/// Swarm configs / secrets are immutable after creation.
|
||||
/// This making updating values awkward when you have services actively using them.
|
||||
/// The following steps allows for secret rotation while minimizing downtime.
|
||||
///
|
||||
/// 1. Query for all services using the secret
|
||||
/// - If not in use by any services, can simply `remove` and `create` the secret.
|
||||
/// - Otherwise, continue with following steps
|
||||
/// 2. `Create` secret `{secret}-tmp` using provided data
|
||||
/// 3. `Update` services to use `tmp` secret
|
||||
/// 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
|
||||
/// 5. `Update` services to use actual (not `tmp`) secret again.
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Resolve,
|
||||
EmptyTraits,
|
||||
Parser,
|
||||
)]
|
||||
#[empty_traits(KomodoExecuteRequest)]
|
||||
#[response(Update)]
|
||||
#[error(serror::Error)]
|
||||
pub struct RotateSwarmSecret {
|
||||
/// Name or id
|
||||
pub swarm: String,
|
||||
/// Secret name
|
||||
pub secret: String,
|
||||
/// The new secret data as a string
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// `docker secret rm SECRET [SECRET...]`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/secret/rm/
|
||||
|
||||
@@ -443,7 +443,7 @@ pub struct InspectSwarmConfig {
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type InspectSwarmConfigResponse = Vec<SwarmConfig>;
|
||||
pub type InspectSwarmConfigResponse = SwarmConfig;
|
||||
|
||||
//
|
||||
|
||||
|
||||
@@ -48,6 +48,91 @@ pub type SetLastSeenUpdateResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// Begin linking flow for a third party login. Response: [NoData].
|
||||
///
|
||||
/// First call this method when authenticated, then
|
||||
/// redirect user to /api/auth/{provider}/link.
|
||||
///
|
||||
/// 'provider' can be:
|
||||
/// - github
|
||||
/// - google
|
||||
/// - oidc
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoUserRequest)]
|
||||
#[response(BeginThirdPartyLoginLinkResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct BeginThirdPartyLoginLink {}
|
||||
|
||||
#[typeshare]
|
||||
pub type BeginThirdPartyLoginLinkResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// Unlink a login. Response: [NoData].
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoUserRequest)]
|
||||
#[response(UnlinkLoginResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct UnlinkLogin {
|
||||
/// 'provider' can be:
|
||||
/// - Local
|
||||
/// - Github
|
||||
/// - Google
|
||||
/// - Oidc
|
||||
pub provider: String,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type UnlinkLoginResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// Update the calling users username.
|
||||
/// Response: [NoData].
|
||||
///
|
||||
/// Will fail if the new username is invalid or already taken.
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoUserRequest)]
|
||||
#[response(UpdateUsernameResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct UpdateUsername {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type UpdateUsernameResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// Update the calling user's password. Response: [NoData].
|
||||
///
|
||||
/// If the User was created using third party login method,
|
||||
/// using [UpdatePassword] adds or updates the Local linked (additional) login method.
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoUserRequest)]
|
||||
#[response(UpdatePasswordResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct UpdatePassword {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type UpdatePasswordResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// Create an api key for the calling user.
|
||||
/// Response: [CreateApiKeyResponse].
|
||||
///
|
||||
@@ -225,3 +310,22 @@ pub struct UnenrollPasskey {}
|
||||
/// Response for [UnenrollPasskey].
|
||||
#[typeshare]
|
||||
pub type UnenrollPasskeyResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// Updates's whether user can skip 2fa when logging in using a third party (Oauth / OIDC).
|
||||
/// Response: [NoData]
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoUserRequest)]
|
||||
#[response(UpdateThirdPartySkip2faResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct UpdateThirdPartySkip2fa {
|
||||
pub skip: bool,
|
||||
}
|
||||
|
||||
/// Response for [UpdateThirdPartySkip2fa].
|
||||
#[typeshare]
|
||||
pub type UpdateThirdPartySkip2faResponse = NoData;
|
||||
|
||||
@@ -3,48 +3,10 @@ use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::entities::{NoData, user::User};
|
||||
use crate::entities::user::User;
|
||||
|
||||
use super::KomodoWriteRequest;
|
||||
|
||||
//
|
||||
|
||||
/// **Only for local users**. Update the calling users username.
|
||||
/// Response: [NoData].
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoWriteRequest)]
|
||||
#[response(UpdateUserUsernameResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct UpdateUserUsername {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type UpdateUserUsernameResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// **Only for local users**. Update the calling users password.
|
||||
/// Response: [NoData].
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
|
||||
)]
|
||||
#[empty_traits(KomodoWriteRequest)]
|
||||
#[response(UpdateUserPasswordResponse)]
|
||||
#[error(serror::Error)]
|
||||
pub struct UpdateUserPassword {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type UpdateUserPasswordResponse = NoData;
|
||||
|
||||
//
|
||||
|
||||
/// **Admin only**. Delete a user.
|
||||
/// Admins can delete any non-admin user.
|
||||
/// Only Super Admin can delete an admin.
|
||||
|
||||
@@ -19,6 +19,10 @@ pub struct SwarmConfigListItem {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: Option<String>,
|
||||
|
||||
/// Whether the config is in use by any service
|
||||
#[serde(default, rename = "InUse")]
|
||||
pub in_use: bool,
|
||||
|
||||
#[serde(rename = "CreatedAt")]
|
||||
pub created_at: Option<String>,
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct SwarmSecretListItem {
|
||||
#[serde(rename = "Templating")]
|
||||
pub templating: Option<String>,
|
||||
|
||||
/// Whether the secret is in use by any service
|
||||
#[serde(rename = "InUse")]
|
||||
pub in_use: bool,
|
||||
|
||||
#[serde(rename = "CreatedAt")]
|
||||
pub created_at: Option<String>,
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ pub struct SwarmServiceListItem {
|
||||
#[serde(rename = "Replicas")]
|
||||
pub replicas: Option<I64>,
|
||||
|
||||
/// Attached config names
|
||||
#[serde(rename = "Configs")]
|
||||
pub configs: Vec<String>,
|
||||
|
||||
/// Attached secret names
|
||||
#[serde(rename = "Secrets")]
|
||||
pub secrets: Vec<String>,
|
||||
|
||||
/// Swarm service state.
|
||||
/// - Healthy if all associated tasks match their desired state (or report no desired state)
|
||||
/// - Unhealthy otherwise
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::entities::swarm::SwarmState;
|
||||
use crate::entities::{
|
||||
docker::{service::SwarmServiceListItem, task::SwarmTaskListItem},
|
||||
swarm::SwarmState,
|
||||
};
|
||||
|
||||
/// Swarm stack list item.
|
||||
/// Returned by `docker stack ls --format json`
|
||||
@@ -62,11 +65,11 @@ pub struct SwarmStack {
|
||||
|
||||
/// Services part of the stack
|
||||
#[serde(rename = "Services")]
|
||||
pub services: Vec<SwarmStackServiceListItem>,
|
||||
pub services: Vec<SwarmServiceListItem>,
|
||||
|
||||
/// Tasks part of the stack
|
||||
#[serde(rename = "Tasks")]
|
||||
pub tasks: Vec<SwarmStackTaskListItem>,
|
||||
pub tasks: Vec<SwarmTaskListItem>,
|
||||
}
|
||||
|
||||
/// Swarm stack service list item.
|
||||
@@ -81,26 +84,25 @@ pub struct SwarmStackServiceListItem {
|
||||
/// The *short* swarm service ID
|
||||
#[serde(rename = "ID")]
|
||||
pub id: Option<String>,
|
||||
// /// The service name.
|
||||
// #[serde(rename = "Name")]
|
||||
// pub name: Option<String>,
|
||||
|
||||
/// The service name.
|
||||
#[serde(rename = "Name")]
|
||||
pub name: Option<String>,
|
||||
// /// The service mode.
|
||||
// #[serde(rename = "Mode")]
|
||||
// pub mode: Option<String>,
|
||||
|
||||
/// The service mode.
|
||||
#[serde(rename = "Mode")]
|
||||
pub mode: Option<String>,
|
||||
// /// The service replicas, formatted as string.
|
||||
// #[serde(rename = "Replicas")]
|
||||
// pub replicas: Option<String>,
|
||||
|
||||
/// The service replicas, formatted as string.
|
||||
#[serde(rename = "Replicas")]
|
||||
pub replicas: Option<String>,
|
||||
// /// The image associated with service
|
||||
// #[serde(rename = "Image")]
|
||||
// pub image: Option<String>,
|
||||
|
||||
/// The image associated with service
|
||||
#[serde(rename = "Image")]
|
||||
pub image: Option<String>,
|
||||
|
||||
/// Task exposed ports, formatted as a string.
|
||||
#[serde(rename = "Ports")]
|
||||
pub ports: Option<String>,
|
||||
// /// Task exposed ports, formatted as a string.
|
||||
// #[serde(rename = "Ports")]
|
||||
// pub ports: Option<String>,
|
||||
}
|
||||
|
||||
/// Swarm stack task list item.
|
||||
@@ -116,31 +118,30 @@ pub struct SwarmStackTaskListItem {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: Option<String>,
|
||||
|
||||
/// Swarm stack task name.
|
||||
#[serde(rename = "Name")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// The image associated with task
|
||||
#[serde(rename = "Image")]
|
||||
pub image: Option<String>,
|
||||
|
||||
/// The node the task is running on
|
||||
#[serde(rename = "Node")]
|
||||
pub node: Option<String>,
|
||||
|
||||
/// The task desired state. Matches 'CurrentState' when healthy.
|
||||
#[serde(rename = "DesiredState")]
|
||||
pub desired_state: Option<String>,
|
||||
|
||||
/// The task current state. Matches 'DesiredState' when healthy.
|
||||
#[serde(rename = "CurrentState")]
|
||||
pub current_state: Option<String>,
|
||||
|
||||
/// An error message, if one exists
|
||||
#[serde(rename = "Error")]
|
||||
pub error: Option<String>,
|
||||
/// The task desired state. Matches 'CurrentState' when healthy.
|
||||
#[serde(rename = "DesiredState")]
|
||||
pub desired_state: Option<String>,
|
||||
// /// Swarm stack task name.
|
||||
// #[serde(rename = "Name")]
|
||||
// pub name: Option<String>,
|
||||
|
||||
/// Task exposed ports, formatted as a string.
|
||||
#[serde(rename = "Ports")]
|
||||
pub ports: Option<String>,
|
||||
// /// The image associated with task
|
||||
// #[serde(rename = "Image")]
|
||||
// pub image: Option<String>,
|
||||
|
||||
// /// The node the task is running on
|
||||
// #[serde(rename = "Node")]
|
||||
// pub node: Option<String>,
|
||||
|
||||
// /// An error message, if one exists
|
||||
// #[serde(rename = "Error")]
|
||||
// pub error: Option<String>,
|
||||
|
||||
// /// Task exposed ports, formatted as a string.
|
||||
// #[serde(rename = "Ports")]
|
||||
// pub ports: Option<String>,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ pub struct SwarmTaskListItem {
|
||||
#[serde(rename = "DesiredState")]
|
||||
pub desired_state: Option<TaskState>,
|
||||
|
||||
/// Attached config names
|
||||
#[serde(rename = "Configs")]
|
||||
pub configs: Vec<String>,
|
||||
|
||||
/// Attached secret names
|
||||
#[serde(rename = "Secrets")]
|
||||
pub secrets: Vec<String>,
|
||||
|
||||
#[serde(rename = "CreatedAt")]
|
||||
pub created_at: Option<String>,
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ impl Version {
|
||||
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
|
||||
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Parser,
|
||||
)]
|
||||
pub struct EnvironmentVar {
|
||||
pub variable: String,
|
||||
@@ -1063,7 +1063,11 @@ pub enum Operation {
|
||||
RemoveSwarmNodes,
|
||||
RemoveSwarmStacks,
|
||||
RemoveSwarmServices,
|
||||
CreateSwarmConfig,
|
||||
RotateSwarmConfig,
|
||||
RemoveSwarmConfigs,
|
||||
CreateSwarmSecret,
|
||||
RotateSwarmSecret,
|
||||
RemoveSwarmSecrets,
|
||||
|
||||
// Server
|
||||
@@ -1501,6 +1505,7 @@ pub fn resource_link(
|
||||
format!("{host}{path}")
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum SwarmOrServer {
|
||||
Swarm(swarm::Swarm),
|
||||
Server(server::Server),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use base64urlsafedata::Base64UrlSafeData;
|
||||
use derive_variants::{EnumVariants, ExtractVariant};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{AsRefStr, Display, EnumString};
|
||||
use typeshare::typeshare;
|
||||
use webauthn_rs::prelude::Passkey;
|
||||
|
||||
@@ -65,9 +68,14 @@ pub struct User {
|
||||
#[serde(default)]
|
||||
pub create_build_permissions: bool,
|
||||
|
||||
/// The user-type specific config.
|
||||
/// The primary user login.
|
||||
pub config: UserConfig,
|
||||
|
||||
/// Additional linked login methods.
|
||||
/// May not contain 'Service' type config.
|
||||
#[serde(default)]
|
||||
pub linked_logins: LinkedLoginsMap,
|
||||
|
||||
/// TOTP 2fa credentials
|
||||
#[serde(default)]
|
||||
pub totp: UserTotpConfig,
|
||||
@@ -76,6 +84,10 @@ pub struct User {
|
||||
#[serde(default)]
|
||||
pub passkey: UserPasskeyConfig,
|
||||
|
||||
/// Allow third party logins to skip 2fa.
|
||||
#[serde(default = "default_third_party_skip_2fa")]
|
||||
pub third_party_skip_2fa: bool,
|
||||
|
||||
/// When the user last opened updates dropdown.
|
||||
#[serde(default)]
|
||||
pub last_update_view: I64,
|
||||
@@ -93,8 +105,27 @@ pub struct User {
|
||||
pub updated_at: I64,
|
||||
}
|
||||
|
||||
fn default_third_party_skip_2fa() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, EnumVariants)]
|
||||
#[variant_derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Display,
|
||||
EnumString,
|
||||
AsRefStr
|
||||
)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum UserConfig {
|
||||
/// User that logs in with username / password
|
||||
@@ -121,6 +152,42 @@ impl Default for UserConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl UserConfig {
|
||||
pub fn sanitize(&mut self) {
|
||||
if let UserConfig::Local { password } = self {
|
||||
password.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LinkedLoginsMap(HashMap<UserConfigVariant, UserConfig>);
|
||||
|
||||
impl LinkedLoginsMap {
|
||||
pub fn get(
|
||||
&self,
|
||||
variant: UserConfigVariant,
|
||||
) -> Option<&UserConfig> {
|
||||
self.0.get(&variant)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, login: UserConfig) -> anyhow::Result<()> {
|
||||
if let UserConfig::Service { .. } = &login {
|
||||
return Err(anyhow!(
|
||||
"Cannot insert Service type configuration as additional login method."
|
||||
));
|
||||
}
|
||||
let key = login.extract_variant();
|
||||
self.0.insert(key, login);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, variant: UserConfigVariant) {
|
||||
self.0.remove(&variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UserTotpConfig {
|
||||
@@ -160,9 +227,12 @@ impl UserPasskeyConfig {
|
||||
impl User {
|
||||
/// Prepares user object for transport by clearing any sensitive fields
|
||||
pub fn sanitize(&mut self) {
|
||||
if let UserConfig::Local { password } = &mut self.config {
|
||||
password.clear();
|
||||
}
|
||||
self.config.sanitize();
|
||||
self
|
||||
.linked_logins
|
||||
.0
|
||||
.values_mut()
|
||||
.for_each(UserConfig::sanitize);
|
||||
self.totp.sanitize();
|
||||
self.passkey.sanitize();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function fix_types() {
|
||||
.replaceAll("AlerterEndpointVariant", 'AlerterEndpoint["type"]')
|
||||
.replaceAll("AlertDataVariant", 'AlertData["type"]')
|
||||
.replaceAll("ServerTemplateConfigVariant", 'ServerTemplateConfig["type"]')
|
||||
.replaceAll("UserConfigVariant", 'UserConfig["type"]')
|
||||
// Add '| string' to env vars
|
||||
.replaceAll("EnvironmentVar[]", "EnvironmentVar[] | string")
|
||||
.replaceAll("IndexSet", "Array")
|
||||
|
||||
@@ -13,6 +13,8 @@ export type AuthResponses = {
|
||||
export type UserResponses = {
|
||||
PushRecentlyViewed: Types.PushRecentlyViewedResponse;
|
||||
SetLastSeenUpdate: Types.SetLastSeenUpdateResponse;
|
||||
UpdateUsername: Types.UpdateUsernameResponse;
|
||||
UpdatePassword: Types.UpdatePasswordResponse;
|
||||
CreateApiKey: Types.CreateApiKeyResponse;
|
||||
DeleteApiKey: Types.DeleteApiKeyResponse;
|
||||
BeginTotpEnrollment: Types.BeginTotpEnrollmentResponse;
|
||||
@@ -21,6 +23,9 @@ export type UserResponses = {
|
||||
BeginPasskeyEnrollment: Types.BeginPasskeyEnrollmentResponse;
|
||||
ConfirmPasskeyEnrollment: Types.ConfirmPasskeyEnrollmentResponse;
|
||||
UnenrollPasskey: Types.UnenrollPasskeyResponse;
|
||||
BeginThirdPartyLoginLink: Types.BeginThirdPartyLoginLinkResponse;
|
||||
UnlinkLogin: Types.UnlinkLoginResponse;
|
||||
UpdateThirdPartySkip2fa: Types.UpdateThirdPartySkip2faResponse;
|
||||
};
|
||||
|
||||
export type ReadResponses = {
|
||||
@@ -319,8 +324,6 @@ export type WriteResponses = {
|
||||
|
||||
// ==== USER ====
|
||||
CreateLocalUser: Types.CreateLocalUserResponse;
|
||||
UpdateUserUsername: Types.UpdateUserUsernameResponse;
|
||||
UpdateUserPassword: Types.UpdateUserPasswordResponse;
|
||||
DeleteUser: Types.DeleteUserResponse;
|
||||
|
||||
// ==== SERVICE USER ====
|
||||
@@ -454,7 +457,11 @@ export type ExecuteResponses = {
|
||||
RemoveSwarmNodes: Types.Update;
|
||||
RemoveSwarmStacks: Types.Update;
|
||||
RemoveSwarmServices: Types.Update;
|
||||
CreateSwarmConfig: Types.Update;
|
||||
RotateSwarmConfig: Types.Update;
|
||||
RemoveSwarmConfigs: Types.Update;
|
||||
CreateSwarmSecret: Types.Update;
|
||||
RotateSwarmSecret: Types.Update;
|
||||
RemoveSwarmSecrets: Types.Update;
|
||||
|
||||
// ==== MAINTENANCE ====
|
||||
|
||||
+264
-117
@@ -350,6 +350,8 @@ export type _CreationChallengeResponse = any;
|
||||
/** Response for [BeginPasskeyEnrollment]. */
|
||||
export type BeginPasskeyEnrollmentResponse = _CreationChallengeResponse;
|
||||
|
||||
export type BeginThirdPartyLoginLinkResponse = NoData;
|
||||
|
||||
export enum Operation {
|
||||
None = "None",
|
||||
CreateSwarm = "CreateSwarm",
|
||||
@@ -359,7 +361,11 @@ export enum Operation {
|
||||
RemoveSwarmNodes = "RemoveSwarmNodes",
|
||||
RemoveSwarmStacks = "RemoveSwarmStacks",
|
||||
RemoveSwarmServices = "RemoveSwarmServices",
|
||||
CreateSwarmConfig = "CreateSwarmConfig",
|
||||
RotateSwarmConfig = "RotateSwarmConfig",
|
||||
RemoveSwarmConfigs = "RemoveSwarmConfigs",
|
||||
CreateSwarmSecret = "CreateSwarmSecret",
|
||||
RotateSwarmSecret = "RotateSwarmSecret",
|
||||
RemoveSwarmSecrets = "RemoveSwarmSecrets",
|
||||
CreateServer = "CreateServer",
|
||||
UpdateServer = "UpdateServer",
|
||||
@@ -921,7 +927,11 @@ export type Execution =
|
||||
| { type: "RemoveSwarmNodes", params: RemoveSwarmNodes }
|
||||
| { type: "RemoveSwarmStacks", params: RemoveSwarmStacks }
|
||||
| { type: "RemoveSwarmServices", params: RemoveSwarmServices }
|
||||
| { type: "CreateSwarmConfig", params: CreateSwarmConfig }
|
||||
| { type: "RotateSwarmConfig", params: RotateSwarmConfig }
|
||||
| { type: "RemoveSwarmConfigs", params: RemoveSwarmConfigs }
|
||||
| { type: "CreateSwarmSecret", params: CreateSwarmSecret }
|
||||
| { type: "RotateSwarmSecret", params: RotateSwarmSecret }
|
||||
| { type: "RemoveSwarmSecrets", params: RemoveSwarmSecrets }
|
||||
| { type: "ClearRepoCache", params: ClearRepoCache }
|
||||
| { type: "BackupCoreDatabase", params: BackupCoreDatabase }
|
||||
@@ -1102,6 +1112,8 @@ export type UserConfig =
|
||||
description: string;
|
||||
}};
|
||||
|
||||
export type LinkedLoginsMap = Record<UserConfig["type"], UserConfig>;
|
||||
|
||||
export interface UserTotpConfig {
|
||||
/** TOTP shared secret, encrypted */
|
||||
secret: string;
|
||||
@@ -1138,12 +1150,19 @@ export interface User {
|
||||
create_server_permissions?: boolean;
|
||||
/** Whether the user has permission to create builds */
|
||||
create_build_permissions?: boolean;
|
||||
/** The user-type specific config. */
|
||||
/** The primary user login. */
|
||||
config: UserConfig;
|
||||
/**
|
||||
* Additional linked login methods.
|
||||
* May not contain 'Service' type config.
|
||||
*/
|
||||
linked_logins?: LinkedLoginsMap;
|
||||
/** TOTP 2fa credentials */
|
||||
totp?: UserTotpConfig;
|
||||
/** WebAuthn Passkey 2fa credentials */
|
||||
passkey?: UserPasskeyConfig;
|
||||
/** Allow third party logins to skip 2fa. */
|
||||
third_party_skip_2fa: boolean;
|
||||
/** When the user last opened updates dropdown. */
|
||||
last_update_view?: I64;
|
||||
/** Recently viewed ids */
|
||||
@@ -4176,50 +4195,73 @@ export enum SwarmState {
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm stack service list item.
|
||||
* Returned by `docker stack services --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/services/#format
|
||||
*/
|
||||
export interface SwarmStackServiceListItem {
|
||||
/** The *short* swarm service ID */
|
||||
/** Swarm service list item. */
|
||||
export interface SwarmServiceListItem {
|
||||
ID?: string;
|
||||
/** The service name. */
|
||||
/** Name of the service. */
|
||||
Name?: string;
|
||||
/** The service mode. */
|
||||
Mode?: string;
|
||||
/** The service replicas, formatted as string. */
|
||||
Replicas?: string;
|
||||
/** The image associated with service */
|
||||
Image?: string;
|
||||
/** Task exposed ports, formatted as a string. */
|
||||
Ports?: string;
|
||||
/** Runtime is the type of runtime specified for the task executor. */
|
||||
Runtime?: string;
|
||||
/** Condition for restart. */
|
||||
Restart?: TaskSpecRestartPolicyConditionEnum;
|
||||
/** Number of replicas */
|
||||
Replicas?: I64;
|
||||
/** Attached config names */
|
||||
Configs: string[];
|
||||
/** Attached secret names */
|
||||
Secrets: string[];
|
||||
/**
|
||||
* Swarm service state.
|
||||
* - Healthy if all associated tasks match their desired state (or report no desired state)
|
||||
* - Unhealthy otherwise
|
||||
*
|
||||
* Not included in docker cli return, computed by Komodo
|
||||
*/
|
||||
State: SwarmState;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm stack task list item.
|
||||
* Returned by `docker stack ps --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
|
||||
*/
|
||||
export interface SwarmStackTaskListItem {
|
||||
/** The task ID */
|
||||
export enum TaskState {
|
||||
NEW = "new",
|
||||
ALLOCATED = "allocated",
|
||||
PENDING = "pending",
|
||||
ASSIGNED = "assigned",
|
||||
ACCEPTED = "accepted",
|
||||
PREPARING = "preparing",
|
||||
READY = "ready",
|
||||
STARTING = "starting",
|
||||
RUNNING = "running",
|
||||
COMPLETE = "complete",
|
||||
SHUTDOWN = "shutdown",
|
||||
FAILED = "failed",
|
||||
REJECTED = "rejected",
|
||||
REMOVE = "remove",
|
||||
ORPHANED = "orphaned",
|
||||
}
|
||||
|
||||
/** Swarm task list item. */
|
||||
export interface SwarmTaskListItem {
|
||||
/** The ID of the task. */
|
||||
ID?: string;
|
||||
/** Swarm stack task name. */
|
||||
/** Name of the task. */
|
||||
Name?: string;
|
||||
/** The image associated with task */
|
||||
Image?: string;
|
||||
/** The node the task is running on */
|
||||
Node?: string;
|
||||
/** The task desired state. Matches 'CurrentState' when healthy. */
|
||||
DesiredState?: string;
|
||||
/** The task current state. Matches 'DesiredState' when healthy. */
|
||||
CurrentState?: string;
|
||||
/** An error message, if one exists */
|
||||
Error?: string;
|
||||
/** Task exposed ports, formatted as a string. */
|
||||
Ports?: string;
|
||||
/** The ID of the node that this task is on. */
|
||||
NodeID?: string;
|
||||
/** The ID of the service this task is part of. */
|
||||
ServiceID?: string;
|
||||
/** The ID of container associated with this task. */
|
||||
ContainerID?: string;
|
||||
State?: TaskState;
|
||||
DesiredState?: TaskState;
|
||||
/** Attached config names */
|
||||
Configs: string[];
|
||||
/** Attached secret names */
|
||||
Secrets: string[];
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4242,16 +4284,16 @@ export interface SwarmStack {
|
||||
*/
|
||||
State: SwarmState;
|
||||
/** Services part of the stack */
|
||||
Services: SwarmStackServiceListItem[];
|
||||
Services: SwarmServiceListItem[];
|
||||
/** Tasks part of the stack */
|
||||
Tasks: SwarmStackTaskListItem[];
|
||||
Tasks: SwarmTaskListItem[];
|
||||
}
|
||||
|
||||
export type InspectStackSwarmInfoResponse = SwarmStack;
|
||||
|
||||
export type InspectStackSwarmServiceResponse = SwarmService;
|
||||
|
||||
export type InspectSwarmConfigResponse = SwarmConfig[];
|
||||
export type InspectSwarmConfigResponse = SwarmConfig;
|
||||
|
||||
export enum NodeSpecRoleEnum {
|
||||
EMPTY = "",
|
||||
@@ -4526,24 +4568,6 @@ export type InspectSwarmServiceResponse = SwarmService;
|
||||
|
||||
export type InspectSwarmStackResponse = SwarmStack;
|
||||
|
||||
export enum TaskState {
|
||||
NEW = "new",
|
||||
ALLOCATED = "allocated",
|
||||
PENDING = "pending",
|
||||
ASSIGNED = "assigned",
|
||||
ACCEPTED = "accepted",
|
||||
PREPARING = "preparing",
|
||||
READY = "ready",
|
||||
STARTING = "starting",
|
||||
RUNNING = "running",
|
||||
COMPLETE = "complete",
|
||||
SHUTDOWN = "shutdown",
|
||||
FAILED = "failed",
|
||||
REJECTED = "rejected",
|
||||
REMOVE = "remove",
|
||||
ORPHANED = "orphaned",
|
||||
}
|
||||
|
||||
/** represents the status of a container. */
|
||||
export interface ContainerStatus {
|
||||
ContainerID?: string;
|
||||
@@ -5073,31 +5097,6 @@ export type ServerListItem = ResourceListItem<ServerListItemInfo>;
|
||||
|
||||
export type ListServersResponse = ServerListItem[];
|
||||
|
||||
/** Swarm service list item. */
|
||||
export interface SwarmServiceListItem {
|
||||
ID?: string;
|
||||
/** Name of the service. */
|
||||
Name?: string;
|
||||
/** The image associated with service */
|
||||
Image?: string;
|
||||
/** Runtime is the type of runtime specified for the task executor. */
|
||||
Runtime?: string;
|
||||
/** Condition for restart. */
|
||||
Restart?: TaskSpecRestartPolicyConditionEnum;
|
||||
/** Number of replicas */
|
||||
Replicas?: I64;
|
||||
/**
|
||||
* Swarm service state.
|
||||
* - Healthy if all associated tasks match their desired state (or report no desired state)
|
||||
* - Unhealthy otherwise
|
||||
*
|
||||
* Not included in docker cli return, computed by Komodo
|
||||
*/
|
||||
State: SwarmState;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
|
||||
export interface StackService {
|
||||
/** The service name */
|
||||
service: string;
|
||||
@@ -5204,6 +5203,8 @@ export interface SwarmConfigListItem {
|
||||
/** User-defined name of the config. */
|
||||
Name?: string;
|
||||
ID?: string;
|
||||
/** Whether the config is in use by any service */
|
||||
InUse?: boolean;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
/**
|
||||
@@ -5249,6 +5250,8 @@ export interface SwarmSecretListItem {
|
||||
* If no driver is set, no templating is used.
|
||||
*/
|
||||
Templating?: string;
|
||||
/** Whether the secret is in use by any service */
|
||||
InUse: boolean;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
@@ -5284,24 +5287,6 @@ export interface SwarmStackListItem {
|
||||
|
||||
export type ListSwarmStacksResponse = SwarmStackListItem[];
|
||||
|
||||
/** Swarm task list item. */
|
||||
export interface SwarmTaskListItem {
|
||||
/** The ID of the task. */
|
||||
ID?: string;
|
||||
/** Name of the task. */
|
||||
Name?: string;
|
||||
/** The ID of the node that this task is on. */
|
||||
NodeID?: string;
|
||||
/** The ID of the service this task is part of. */
|
||||
ServiceID?: string;
|
||||
/** The ID of container associated with this task. */
|
||||
ContainerID?: string;
|
||||
State?: TaskState;
|
||||
DesiredState?: TaskState;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
|
||||
export type ListSwarmTasksResponse = SwarmTaskListItem[];
|
||||
|
||||
export interface SwarmListItemInfo {
|
||||
@@ -5481,12 +5466,16 @@ export type UnenrollPasskeyResponse = NoData;
|
||||
/** Response for [UnenrollTotp]. */
|
||||
export type UnenrollTotpResponse = NoData;
|
||||
|
||||
export type UnlinkLoginResponse = NoData;
|
||||
|
||||
export type UpdateDockerRegistryAccountResponse = DockerRegistryAccount;
|
||||
|
||||
export type UpdateGitProviderAccountResponse = GitProviderAccount;
|
||||
|
||||
export type UpdateOnboardingKeyResponse = OnboardingKey;
|
||||
|
||||
export type UpdatePasswordResponse = NoData;
|
||||
|
||||
export type UpdatePermissionOnResourceTypeResponse = NoData;
|
||||
|
||||
export type UpdatePermissionOnTargetResponse = NoData;
|
||||
@@ -5497,13 +5486,14 @@ export type UpdateResourceMetaResponse = NoData;
|
||||
|
||||
export type UpdateServiceUserDescriptionResponse = User;
|
||||
|
||||
/** Response for [UpdateThirdPartySkip2fa]. */
|
||||
export type UpdateThirdPartySkip2faResponse = NoData;
|
||||
|
||||
export type UpdateUserAdminResponse = NoData;
|
||||
|
||||
export type UpdateUserBasePermissionsResponse = NoData;
|
||||
|
||||
export type UpdateUserPasswordResponse = NoData;
|
||||
|
||||
export type UpdateUserUsernameResponse = NoData;
|
||||
export type UpdateUsernameResponse = NoData;
|
||||
|
||||
export type UpdateVariableDescriptionResponse = Variable;
|
||||
|
||||
@@ -5862,6 +5852,20 @@ export interface BatchRunProcedure {
|
||||
export interface BeginPasskeyEnrollment {
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin linking flow for a third party login. Response: [NoData].
|
||||
*
|
||||
* First call this method when authenticated, then
|
||||
* redirect user to /api/auth/{provider}/link.
|
||||
*
|
||||
* 'provider' can be:
|
||||
* - github
|
||||
* - google
|
||||
* - oidc
|
||||
*/
|
||||
export interface BeginThirdPartyLoginLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts enrollment flow for TOTP 2FA auth support.
|
||||
* Response: [BeginTotpEnrollmentResponse]
|
||||
@@ -6651,6 +6655,44 @@ export interface CreateSwarm {
|
||||
config?: _PartialSwarmConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* `docker config create [OPTIONS] CONFIG file|-`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/config/create/
|
||||
*/
|
||||
export interface CreateSwarmConfig {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** The name of the config to create */
|
||||
name: string;
|
||||
/** The data to store in the config */
|
||||
data: string;
|
||||
/** Docker labels to give the config */
|
||||
labels?: string[];
|
||||
/** Optional custom template driver */
|
||||
template_driver?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `docker config create [OPTIONS] CONFIG file|-`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/config/create/
|
||||
*/
|
||||
export interface CreateSwarmSecret {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** The name of the secret to create */
|
||||
name: string;
|
||||
/** The data to store in the secret */
|
||||
data: string;
|
||||
/** Optional custom secret driver */
|
||||
driver?: string;
|
||||
/** Docker labels to give the secret */
|
||||
labels?: string[];
|
||||
/** Optional custom template driver */
|
||||
template_driver?: string;
|
||||
}
|
||||
|
||||
/** Create a tag. Response: [Tag]. */
|
||||
export interface CreateTag {
|
||||
/** The name of the tag. */
|
||||
@@ -9369,6 +9411,54 @@ export interface RotateServerKeys {
|
||||
server: 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.
|
||||
*/
|
||||
export interface RotateSwarmConfig {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** Config name */
|
||||
config: string;
|
||||
/** The new config data as a string */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
|
||||
*
|
||||
* Swarm configs / secrets are immutable after creation.
|
||||
* This making updating values awkward when you have services actively using them.
|
||||
* The following steps allows for secret rotation while minimizing downtime.
|
||||
*
|
||||
* 1. Query for all services using the secret
|
||||
* - If not in use by any services, can simply `remove` and `create` the secret.
|
||||
* - Otherwise, continue with following steps
|
||||
* 2. `Create` secret `{secret}-tmp` using provided data
|
||||
* 3. `Update` services to use `tmp` secret
|
||||
* 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
|
||||
* 5. `Update` services to use actual (not `tmp`) secret again.
|
||||
*/
|
||||
export interface RotateSwarmSecret {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** Secret name */
|
||||
secret: string;
|
||||
/** The new secret data as a string */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** Runs the target Action. Response: [Update] */
|
||||
export interface RunAction {
|
||||
/** Id or name */
|
||||
@@ -9774,6 +9864,32 @@ export interface SwarmConfig {
|
||||
Spec?: ConfigSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm stack service list item.
|
||||
* Returned by `docker stack services --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/services/#format
|
||||
*/
|
||||
export interface SwarmStackServiceListItem {
|
||||
/** The *short* swarm service ID */
|
||||
ID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm stack task list item.
|
||||
* Returned by `docker stack ps --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
|
||||
*/
|
||||
export interface SwarmStackTaskListItem {
|
||||
/** The task ID */
|
||||
ID?: string;
|
||||
/** The task current state. Matches 'DesiredState' when healthy. */
|
||||
CurrentState?: string;
|
||||
/** The task desired state. Matches 'CurrentState' when healthy. */
|
||||
DesiredState?: string;
|
||||
}
|
||||
|
||||
/** JSON structure to send new terminal window dimensions */
|
||||
export interface TerminalResizeMessage {
|
||||
rows: number;
|
||||
@@ -9813,6 +9929,18 @@ export interface UnenrollPasskey {
|
||||
export interface UnenrollTotp {
|
||||
}
|
||||
|
||||
/** Unlink a login. Response: [NoData]. */
|
||||
export interface UnlinkLogin {
|
||||
/**
|
||||
* 'provider' can be:
|
||||
* - Local
|
||||
* - Github
|
||||
* - Google
|
||||
* - Oidc
|
||||
*/
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/** Unpauses all containers on the target server. Response: [Update] */
|
||||
export interface UnpauseAllContainers {
|
||||
/** Name or id */
|
||||
@@ -9989,6 +10117,16 @@ export interface UpdateOnboardingKey {
|
||||
create_builder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the calling user's password. Response: [NoData].
|
||||
*
|
||||
* If the User was created using third party login method,
|
||||
* using [UpdatePassword] adds or updates the Local linked (additional) login method.
|
||||
*/
|
||||
export interface UpdatePassword {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Admin only.** Update a user or user groups base permission level on a resource type.
|
||||
* Response: [NoData].
|
||||
@@ -10183,6 +10321,14 @@ export interface UpdateTagColor {
|
||||
color: TagColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates's whether user can skip 2fa when logging in using a third party (Oauth / OIDC).
|
||||
* Response: [NoData]
|
||||
*/
|
||||
export interface UpdateThirdPartySkip2fa {
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Super Admin only.** Update's whether a user is admin.
|
||||
* Response: [NoData].
|
||||
@@ -10210,18 +10356,12 @@ export interface UpdateUserBasePermissions {
|
||||
}
|
||||
|
||||
/**
|
||||
* **Only for local users**. Update the calling users password.
|
||||
* Update the calling users username.
|
||||
* Response: [NoData].
|
||||
*
|
||||
* Will fail if the new username is invalid or already taken.
|
||||
*/
|
||||
export interface UpdateUserPassword {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Only for local users**. Update the calling users username.
|
||||
* Response: [NoData].
|
||||
*/
|
||||
export interface UpdateUserUsername {
|
||||
export interface UpdateUsername {
|
||||
username: string;
|
||||
}
|
||||
|
||||
@@ -10391,7 +10531,11 @@ export type ExecuteRequest =
|
||||
| { type: "RemoveSwarmNodes", params: RemoveSwarmNodes }
|
||||
| { type: "RemoveSwarmStacks", params: RemoveSwarmStacks }
|
||||
| { type: "RemoveSwarmServices", params: RemoveSwarmServices }
|
||||
| { type: "CreateSwarmConfig", params: CreateSwarmConfig }
|
||||
| { type: "RotateSwarmConfig", params: RotateSwarmConfig }
|
||||
| { type: "RemoveSwarmConfigs", params: RemoveSwarmConfigs }
|
||||
| { type: "CreateSwarmSecret", params: CreateSwarmSecret }
|
||||
| { type: "RotateSwarmSecret", params: RotateSwarmSecret }
|
||||
| { type: "RemoveSwarmSecrets", params: RemoveSwarmSecrets }
|
||||
| { type: "ClearRepoCache", params: ClearRepoCache }
|
||||
| { type: "BackupCoreDatabase", params: BackupCoreDatabase }
|
||||
@@ -10691,6 +10835,8 @@ export type UserIdOrTwoFactor =
|
||||
export type UserRequest =
|
||||
| { type: "PushRecentlyViewed", params: PushRecentlyViewed }
|
||||
| { type: "SetLastSeenUpdate", params: SetLastSeenUpdate }
|
||||
| { type: "UpdateUsername", params: UpdateUsername }
|
||||
| { type: "UpdatePassword", params: UpdatePassword }
|
||||
| { type: "CreateApiKey", params: CreateApiKey }
|
||||
| { type: "DeleteApiKey", params: DeleteApiKey }
|
||||
| { type: "BeginTotpEnrollment", params: BeginTotpEnrollment }
|
||||
@@ -10698,7 +10844,10 @@ export type UserRequest =
|
||||
| { type: "UnenrollTotp", params: UnenrollTotp }
|
||||
| { type: "BeginPasskeyEnrollment", params: BeginPasskeyEnrollment }
|
||||
| { type: "ConfirmPasskeyEnrollment", params: ConfirmPasskeyEnrollment }
|
||||
| { type: "UnenrollPasskey", params: UnenrollPasskey };
|
||||
| { type: "UnenrollPasskey", params: UnenrollPasskey }
|
||||
| { type: "BeginThirdPartyLoginLink", params: BeginThirdPartyLoginLink }
|
||||
| { type: "UnlinkLogin", params: UnlinkLogin }
|
||||
| { type: "UpdateThirdPartySkip2fa", params: UpdateThirdPartySkip2fa };
|
||||
|
||||
export type WriteRequest =
|
||||
| { type: "UpdateResourceMeta", params: UpdateResourceMeta }
|
||||
@@ -10777,8 +10926,6 @@ export type WriteRequest =
|
||||
| { type: "UpdateOnboardingKey", params: UpdateOnboardingKey }
|
||||
| { type: "DeleteOnboardingKey", params: DeleteOnboardingKey }
|
||||
| { type: "CreateLocalUser", params: CreateLocalUser }
|
||||
| { type: "UpdateUserUsername", params: UpdateUserUsername }
|
||||
| { type: "UpdateUserPassword", params: UpdateUserPassword }
|
||||
| { type: "DeleteUser", params: DeleteUser }
|
||||
| { type: "CreateServiceUser", params: CreateServiceUser }
|
||||
| { type: "UpdateServiceUserDescription", params: UpdateServiceUserDescription }
|
||||
|
||||
@@ -254,12 +254,58 @@ pub struct InspectSwarmTask {
|
||||
// ========
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Vec<SwarmConfig>)]
|
||||
#[response(SwarmConfig)]
|
||||
#[error(anyhow::Error)]
|
||||
pub struct InspectSwarmConfig {
|
||||
pub config: String,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// `docker config create [OPTIONS] CONFIG file|-`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/config/create/
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Log)]
|
||||
#[error(anyhow::Error)]
|
||||
pub struct CreateSwarmConfig {
|
||||
/// The name of the config to create
|
||||
pub name: String,
|
||||
/// The data to store in the config
|
||||
pub data: String,
|
||||
/// Docker labels to give the config
|
||||
pub labels: Vec<String>,
|
||||
/// Optional custom template driver
|
||||
pub template_driver: Option<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 or id
|
||||
pub config: String,
|
||||
/// The config file data as a string
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// `docker config rm CONFIG [CONFIG...]`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/config/rm/
|
||||
@@ -281,6 +327,50 @@ pub struct InspectSwarmSecret {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// `docker secret create [OPTIONS] CONFIG file|-`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/secret/create/
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Log)]
|
||||
#[error(anyhow::Error)]
|
||||
pub struct CreateSwarmSecret {
|
||||
/// The name of the secret to create
|
||||
pub name: String,
|
||||
/// The data to store in the secret
|
||||
pub data: String,
|
||||
/// Use a custom secret driver
|
||||
pub driver: Option<String>,
|
||||
/// Docker labels to give the secret
|
||||
pub labels: Vec<String>,
|
||||
/// Optional custom template driver
|
||||
pub template_driver: Option<String>,
|
||||
}
|
||||
|
||||
/// https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
|
||||
///
|
||||
/// 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 secret
|
||||
/// - If not in use by any services, can simply `remove` and `create` the secret.
|
||||
/// - Otherwise, continue with following steps
|
||||
/// 2. `Create` secret `{secret}-tmp` using provided data
|
||||
/// 3. `Update` services to use `tmp` secret instead of original secret
|
||||
/// 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
|
||||
/// 5. `Update` services to use actual (not `tmp`) secret again.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Vec<Log>)]
|
||||
#[error(anyhow::Error)]
|
||||
pub struct RotateSwarmSecret {
|
||||
/// Secret name or id
|
||||
pub secret: String,
|
||||
/// The new secret data as a string
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// `docker secret rm SECRET [SECRET...]`
|
||||
///
|
||||
/// https://docs.docker.com/reference/cli/docker/secret/rm/
|
||||
|
||||
@@ -21,9 +21,9 @@ default_profile = "Default"
|
||||
# Default: 14
|
||||
max_backups = 7
|
||||
|
||||
# Options: HorizontalOnly, VeriticalOnly, OutsideOnly, InsideOnly, AllBorders
|
||||
# Default: HorizontalOnly
|
||||
table_format = "HorizontalOnly"
|
||||
# Options: Horizontal, Veritical, Outside, Inside, All
|
||||
# Default: Horizontal
|
||||
table_borders = "Horizontal"
|
||||
|
||||
[[profile]]
|
||||
name = "Default"
|
||||
|
||||
+9
-2
@@ -11,6 +11,8 @@ export type AuthResponses = {
|
||||
export type UserResponses = {
|
||||
PushRecentlyViewed: Types.PushRecentlyViewedResponse;
|
||||
SetLastSeenUpdate: Types.SetLastSeenUpdateResponse;
|
||||
UpdateUsername: Types.UpdateUsernameResponse;
|
||||
UpdatePassword: Types.UpdatePasswordResponse;
|
||||
CreateApiKey: Types.CreateApiKeyResponse;
|
||||
DeleteApiKey: Types.DeleteApiKeyResponse;
|
||||
BeginTotpEnrollment: Types.BeginTotpEnrollmentResponse;
|
||||
@@ -19,6 +21,9 @@ export type UserResponses = {
|
||||
BeginPasskeyEnrollment: Types.BeginPasskeyEnrollmentResponse;
|
||||
ConfirmPasskeyEnrollment: Types.ConfirmPasskeyEnrollmentResponse;
|
||||
UnenrollPasskey: Types.UnenrollPasskeyResponse;
|
||||
BeginThirdPartyLoginLink: Types.BeginThirdPartyLoginLinkResponse;
|
||||
UnlinkLogin: Types.UnlinkLoginResponse;
|
||||
UpdateThirdPartySkip2fa: Types.UpdateThirdPartySkip2faResponse;
|
||||
};
|
||||
export type ReadResponses = {
|
||||
GetVersion: Types.GetVersionResponse;
|
||||
@@ -238,8 +243,6 @@ export type WriteResponses = {
|
||||
UpdateOnboardingKey: Types.UpdateOnboardingKeyResponse;
|
||||
DeleteOnboardingKey: Types.DeleteOnboardingKeyResponse;
|
||||
CreateLocalUser: Types.CreateLocalUserResponse;
|
||||
UpdateUserUsername: Types.UpdateUserUsernameResponse;
|
||||
UpdateUserPassword: Types.UpdateUserPasswordResponse;
|
||||
DeleteUser: Types.DeleteUserResponse;
|
||||
CreateServiceUser: Types.CreateServiceUserResponse;
|
||||
UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse;
|
||||
@@ -339,7 +342,11 @@ export type ExecuteResponses = {
|
||||
RemoveSwarmNodes: Types.Update;
|
||||
RemoveSwarmStacks: Types.Update;
|
||||
RemoveSwarmServices: Types.Update;
|
||||
CreateSwarmConfig: Types.Update;
|
||||
RotateSwarmConfig: Types.Update;
|
||||
RemoveSwarmConfigs: Types.Update;
|
||||
CreateSwarmSecret: Types.Update;
|
||||
RotateSwarmSecret: Types.Update;
|
||||
RemoveSwarmSecrets: Types.Update;
|
||||
ClearRepoCache: Types.Update;
|
||||
BackupCoreDatabase: Types.Update;
|
||||
|
||||
Vendored
+273
-115
@@ -355,6 +355,7 @@ export type BatchExecutionResponse = BatchExecutionResponseItem[];
|
||||
export type _CreationChallengeResponse = any;
|
||||
/** Response for [BeginPasskeyEnrollment]. */
|
||||
export type BeginPasskeyEnrollmentResponse = _CreationChallengeResponse;
|
||||
export type BeginThirdPartyLoginLinkResponse = NoData;
|
||||
export declare enum Operation {
|
||||
None = "None",
|
||||
CreateSwarm = "CreateSwarm",
|
||||
@@ -364,7 +365,11 @@ export declare enum Operation {
|
||||
RemoveSwarmNodes = "RemoveSwarmNodes",
|
||||
RemoveSwarmStacks = "RemoveSwarmStacks",
|
||||
RemoveSwarmServices = "RemoveSwarmServices",
|
||||
CreateSwarmConfig = "CreateSwarmConfig",
|
||||
RotateSwarmConfig = "RotateSwarmConfig",
|
||||
RemoveSwarmConfigs = "RemoveSwarmConfigs",
|
||||
CreateSwarmSecret = "CreateSwarmSecret",
|
||||
RotateSwarmSecret = "RotateSwarmSecret",
|
||||
RemoveSwarmSecrets = "RemoveSwarmSecrets",
|
||||
CreateServer = "CreateServer",
|
||||
UpdateServer = "UpdateServer",
|
||||
@@ -1050,9 +1055,21 @@ export type Execution =
|
||||
} | {
|
||||
type: "RemoveSwarmServices";
|
||||
params: RemoveSwarmServices;
|
||||
} | {
|
||||
type: "CreateSwarmConfig";
|
||||
params: CreateSwarmConfig;
|
||||
} | {
|
||||
type: "RotateSwarmConfig";
|
||||
params: RotateSwarmConfig;
|
||||
} | {
|
||||
type: "RemoveSwarmConfigs";
|
||||
params: RemoveSwarmConfigs;
|
||||
} | {
|
||||
type: "CreateSwarmSecret";
|
||||
params: CreateSwarmSecret;
|
||||
} | {
|
||||
type: "RotateSwarmSecret";
|
||||
params: RotateSwarmSecret;
|
||||
} | {
|
||||
type: "RemoveSwarmSecrets";
|
||||
params: RemoveSwarmSecrets;
|
||||
@@ -1250,6 +1267,7 @@ export type UserConfig =
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
export type LinkedLoginsMap = Record<UserConfig["type"], UserConfig>;
|
||||
export interface UserTotpConfig {
|
||||
/** TOTP shared secret, encrypted */
|
||||
secret: string;
|
||||
@@ -1283,12 +1301,19 @@ export interface User {
|
||||
create_server_permissions?: boolean;
|
||||
/** Whether the user has permission to create builds */
|
||||
create_build_permissions?: boolean;
|
||||
/** The user-type specific config. */
|
||||
/** The primary user login. */
|
||||
config: UserConfig;
|
||||
/**
|
||||
* Additional linked login methods.
|
||||
* May not contain 'Service' type config.
|
||||
*/
|
||||
linked_logins?: LinkedLoginsMap;
|
||||
/** TOTP 2fa credentials */
|
||||
totp?: UserTotpConfig;
|
||||
/** WebAuthn Passkey 2fa credentials */
|
||||
passkey?: UserPasskeyConfig;
|
||||
/** Allow third party logins to skip 2fa. */
|
||||
third_party_skip_2fa: boolean;
|
||||
/** When the user last opened updates dropdown. */
|
||||
last_update_view?: I64;
|
||||
/** Recently viewed ids */
|
||||
@@ -4162,49 +4187,71 @@ export declare enum SwarmState {
|
||||
/** Unknown case */
|
||||
Unknown = "Unknown"
|
||||
}
|
||||
/**
|
||||
* Swarm stack service list item.
|
||||
* Returned by `docker stack services --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/services/#format
|
||||
*/
|
||||
export interface SwarmStackServiceListItem {
|
||||
/** The *short* swarm service ID */
|
||||
/** Swarm service list item. */
|
||||
export interface SwarmServiceListItem {
|
||||
ID?: string;
|
||||
/** The service name. */
|
||||
/** Name of the service. */
|
||||
Name?: string;
|
||||
/** The service mode. */
|
||||
Mode?: string;
|
||||
/** The service replicas, formatted as string. */
|
||||
Replicas?: string;
|
||||
/** The image associated with service */
|
||||
Image?: string;
|
||||
/** Task exposed ports, formatted as a string. */
|
||||
Ports?: string;
|
||||
/** Runtime is the type of runtime specified for the task executor. */
|
||||
Runtime?: string;
|
||||
/** Condition for restart. */
|
||||
Restart?: TaskSpecRestartPolicyConditionEnum;
|
||||
/** Number of replicas */
|
||||
Replicas?: I64;
|
||||
/** Attached config names */
|
||||
Configs: string[];
|
||||
/** Attached secret names */
|
||||
Secrets: string[];
|
||||
/**
|
||||
* Swarm service state.
|
||||
* - Healthy if all associated tasks match their desired state (or report no desired state)
|
||||
* - Unhealthy otherwise
|
||||
*
|
||||
* Not included in docker cli return, computed by Komodo
|
||||
*/
|
||||
State: SwarmState;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
/**
|
||||
* Swarm stack task list item.
|
||||
* Returned by `docker stack ps --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
|
||||
*/
|
||||
export interface SwarmStackTaskListItem {
|
||||
/** The task ID */
|
||||
export declare enum TaskState {
|
||||
NEW = "new",
|
||||
ALLOCATED = "allocated",
|
||||
PENDING = "pending",
|
||||
ASSIGNED = "assigned",
|
||||
ACCEPTED = "accepted",
|
||||
PREPARING = "preparing",
|
||||
READY = "ready",
|
||||
STARTING = "starting",
|
||||
RUNNING = "running",
|
||||
COMPLETE = "complete",
|
||||
SHUTDOWN = "shutdown",
|
||||
FAILED = "failed",
|
||||
REJECTED = "rejected",
|
||||
REMOVE = "remove",
|
||||
ORPHANED = "orphaned"
|
||||
}
|
||||
/** Swarm task list item. */
|
||||
export interface SwarmTaskListItem {
|
||||
/** The ID of the task. */
|
||||
ID?: string;
|
||||
/** Swarm stack task name. */
|
||||
/** Name of the task. */
|
||||
Name?: string;
|
||||
/** The image associated with task */
|
||||
Image?: string;
|
||||
/** The node the task is running on */
|
||||
Node?: string;
|
||||
/** The task desired state. Matches 'CurrentState' when healthy. */
|
||||
DesiredState?: string;
|
||||
/** The task current state. Matches 'DesiredState' when healthy. */
|
||||
CurrentState?: string;
|
||||
/** An error message, if one exists */
|
||||
Error?: string;
|
||||
/** Task exposed ports, formatted as a string. */
|
||||
Ports?: string;
|
||||
/** The ID of the node that this task is on. */
|
||||
NodeID?: string;
|
||||
/** The ID of the service this task is part of. */
|
||||
ServiceID?: string;
|
||||
/** The ID of container associated with this task. */
|
||||
ContainerID?: string;
|
||||
State?: TaskState;
|
||||
DesiredState?: TaskState;
|
||||
/** Attached config names */
|
||||
Configs: string[];
|
||||
/** Attached secret names */
|
||||
Secrets: string[];
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
/**
|
||||
* All entities related to docker stack available over CLI.
|
||||
@@ -4226,13 +4273,13 @@ export interface SwarmStack {
|
||||
*/
|
||||
State: SwarmState;
|
||||
/** Services part of the stack */
|
||||
Services: SwarmStackServiceListItem[];
|
||||
Services: SwarmServiceListItem[];
|
||||
/** Tasks part of the stack */
|
||||
Tasks: SwarmStackTaskListItem[];
|
||||
Tasks: SwarmTaskListItem[];
|
||||
}
|
||||
export type InspectStackSwarmInfoResponse = SwarmStack;
|
||||
export type InspectStackSwarmServiceResponse = SwarmService;
|
||||
export type InspectSwarmConfigResponse = SwarmConfig[];
|
||||
export type InspectSwarmConfigResponse = SwarmConfig;
|
||||
export declare enum NodeSpecRoleEnum {
|
||||
EMPTY = "",
|
||||
WORKER = "worker",
|
||||
@@ -4474,23 +4521,6 @@ export interface SwarmSecret {
|
||||
export type InspectSwarmSecretResponse = SwarmSecret;
|
||||
export type InspectSwarmServiceResponse = SwarmService;
|
||||
export type InspectSwarmStackResponse = SwarmStack;
|
||||
export declare enum TaskState {
|
||||
NEW = "new",
|
||||
ALLOCATED = "allocated",
|
||||
PENDING = "pending",
|
||||
ASSIGNED = "assigned",
|
||||
ACCEPTED = "accepted",
|
||||
PREPARING = "preparing",
|
||||
READY = "ready",
|
||||
STARTING = "starting",
|
||||
RUNNING = "running",
|
||||
COMPLETE = "complete",
|
||||
SHUTDOWN = "shutdown",
|
||||
FAILED = "failed",
|
||||
REJECTED = "rejected",
|
||||
REMOVE = "remove",
|
||||
ORPHANED = "orphaned"
|
||||
}
|
||||
/** represents the status of a container. */
|
||||
export interface ContainerStatus {
|
||||
ContainerID?: string;
|
||||
@@ -4949,30 +4979,6 @@ export interface ServerListItemInfo {
|
||||
}
|
||||
export type ServerListItem = ResourceListItem<ServerListItemInfo>;
|
||||
export type ListServersResponse = ServerListItem[];
|
||||
/** Swarm service list item. */
|
||||
export interface SwarmServiceListItem {
|
||||
ID?: string;
|
||||
/** Name of the service. */
|
||||
Name?: string;
|
||||
/** The image associated with service */
|
||||
Image?: string;
|
||||
/** Runtime is the type of runtime specified for the task executor. */
|
||||
Runtime?: string;
|
||||
/** Condition for restart. */
|
||||
Restart?: TaskSpecRestartPolicyConditionEnum;
|
||||
/** Number of replicas */
|
||||
Replicas?: I64;
|
||||
/**
|
||||
* Swarm service state.
|
||||
* - Healthy if all associated tasks match their desired state (or report no desired state)
|
||||
* - Unhealthy otherwise
|
||||
*
|
||||
* Not included in docker cli return, computed by Komodo
|
||||
*/
|
||||
State: SwarmState;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
export interface StackService {
|
||||
/** The service name */
|
||||
service: string;
|
||||
@@ -5072,6 +5078,8 @@ export interface SwarmConfigListItem {
|
||||
/** User-defined name of the config. */
|
||||
Name?: string;
|
||||
ID?: string;
|
||||
/** Whether the config is in use by any service */
|
||||
InUse?: boolean;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
/**
|
||||
@@ -5112,6 +5120,8 @@ export interface SwarmSecretListItem {
|
||||
* If no driver is set, no templating is used.
|
||||
*/
|
||||
Templating?: string;
|
||||
/** Whether the secret is in use by any service */
|
||||
InUse: boolean;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
@@ -5142,23 +5152,6 @@ export interface SwarmStackListItem {
|
||||
Namespace?: string;
|
||||
}
|
||||
export type ListSwarmStacksResponse = SwarmStackListItem[];
|
||||
/** Swarm task list item. */
|
||||
export interface SwarmTaskListItem {
|
||||
/** The ID of the task. */
|
||||
ID?: string;
|
||||
/** Name of the task. */
|
||||
Name?: string;
|
||||
/** The ID of the node that this task is on. */
|
||||
NodeID?: string;
|
||||
/** The ID of the service this task is part of. */
|
||||
ServiceID?: string;
|
||||
/** The ID of container associated with this task. */
|
||||
ContainerID?: string;
|
||||
State?: TaskState;
|
||||
DesiredState?: TaskState;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
}
|
||||
export type ListSwarmTasksResponse = SwarmTaskListItem[];
|
||||
export interface SwarmListItemInfo {
|
||||
/** Servers part of the swarm */
|
||||
@@ -5313,18 +5306,21 @@ export type SwarmQuery = ResourceQuery<SwarmQuerySpecifics>;
|
||||
export type UnenrollPasskeyResponse = NoData;
|
||||
/** Response for [UnenrollTotp]. */
|
||||
export type UnenrollTotpResponse = NoData;
|
||||
export type UnlinkLoginResponse = NoData;
|
||||
export type UpdateDockerRegistryAccountResponse = DockerRegistryAccount;
|
||||
export type UpdateGitProviderAccountResponse = GitProviderAccount;
|
||||
export type UpdateOnboardingKeyResponse = OnboardingKey;
|
||||
export type UpdatePasswordResponse = NoData;
|
||||
export type UpdatePermissionOnResourceTypeResponse = NoData;
|
||||
export type UpdatePermissionOnTargetResponse = NoData;
|
||||
export type UpdateProcedureResponse = Procedure;
|
||||
export type UpdateResourceMetaResponse = NoData;
|
||||
export type UpdateServiceUserDescriptionResponse = User;
|
||||
/** Response for [UpdateThirdPartySkip2fa]. */
|
||||
export type UpdateThirdPartySkip2faResponse = NoData;
|
||||
export type UpdateUserAdminResponse = NoData;
|
||||
export type UpdateUserBasePermissionsResponse = NoData;
|
||||
export type UpdateUserPasswordResponse = NoData;
|
||||
export type UpdateUserUsernameResponse = NoData;
|
||||
export type UpdateUsernameResponse = NoData;
|
||||
export type UpdateVariableDescriptionResponse = Variable;
|
||||
export type UpdateVariableIsSecretResponse = Variable;
|
||||
export type UpdateVariableValueResponse = Variable;
|
||||
@@ -5638,6 +5634,19 @@ export interface BatchRunProcedure {
|
||||
*/
|
||||
export interface BeginPasskeyEnrollment {
|
||||
}
|
||||
/**
|
||||
* Begin linking flow for a third party login. Response: [NoData].
|
||||
*
|
||||
* First call this method when authenticated, then
|
||||
* redirect user to /api/auth/{provider}/link.
|
||||
*
|
||||
* 'provider' can be:
|
||||
* - github
|
||||
* - google
|
||||
* - oidc
|
||||
*/
|
||||
export interface BeginThirdPartyLoginLink {
|
||||
}
|
||||
/**
|
||||
* Starts enrollment flow for TOTP 2FA auth support.
|
||||
* Response: [BeginTotpEnrollmentResponse]
|
||||
@@ -6370,6 +6379,42 @@ export interface CreateSwarm {
|
||||
/** Optional partial config to initialize the swarm with. */
|
||||
config?: _PartialSwarmConfig;
|
||||
}
|
||||
/**
|
||||
* `docker config create [OPTIONS] CONFIG file|-`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/config/create/
|
||||
*/
|
||||
export interface CreateSwarmConfig {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** The name of the config to create */
|
||||
name: string;
|
||||
/** The data to store in the config */
|
||||
data: string;
|
||||
/** Docker labels to give the config */
|
||||
labels?: string[];
|
||||
/** Optional custom template driver */
|
||||
template_driver?: string;
|
||||
}
|
||||
/**
|
||||
* `docker config create [OPTIONS] CONFIG file|-`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/config/create/
|
||||
*/
|
||||
export interface CreateSwarmSecret {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** The name of the secret to create */
|
||||
name: string;
|
||||
/** The data to store in the secret */
|
||||
data: string;
|
||||
/** Optional custom secret driver */
|
||||
driver?: string;
|
||||
/** Docker labels to give the secret */
|
||||
labels?: string[];
|
||||
/** Optional custom template driver */
|
||||
template_driver?: string;
|
||||
}
|
||||
/** Create a tag. Response: [Tag]. */
|
||||
export interface CreateTag {
|
||||
/** The name of the tag. */
|
||||
@@ -8825,6 +8870,52 @@ export interface RotateServerKeys {
|
||||
/** Server Id or name */
|
||||
server: 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.
|
||||
*/
|
||||
export interface RotateSwarmConfig {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** Config name */
|
||||
config: string;
|
||||
/** The new config data as a string */
|
||||
data: string;
|
||||
}
|
||||
/**
|
||||
* https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
|
||||
*
|
||||
* Swarm configs / secrets are immutable after creation.
|
||||
* This making updating values awkward when you have services actively using them.
|
||||
* The following steps allows for secret rotation while minimizing downtime.
|
||||
*
|
||||
* 1. Query for all services using the secret
|
||||
* - If not in use by any services, can simply `remove` and `create` the secret.
|
||||
* - Otherwise, continue with following steps
|
||||
* 2. `Create` secret `{secret}-tmp` using provided data
|
||||
* 3. `Update` services to use `tmp` secret
|
||||
* 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
|
||||
* 5. `Update` services to use actual (not `tmp`) secret again.
|
||||
*/
|
||||
export interface RotateSwarmSecret {
|
||||
/** Name or id */
|
||||
swarm: string;
|
||||
/** Secret name */
|
||||
secret: string;
|
||||
/** The new secret data as a string */
|
||||
data: string;
|
||||
}
|
||||
/** Runs the target Action. Response: [Update] */
|
||||
export interface RunAction {
|
||||
/** Id or name */
|
||||
@@ -9200,6 +9291,30 @@ export interface SwarmConfig {
|
||||
UpdatedAt?: string;
|
||||
Spec?: ConfigSpec;
|
||||
}
|
||||
/**
|
||||
* Swarm stack service list item.
|
||||
* Returned by `docker stack services --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/services/#format
|
||||
*/
|
||||
export interface SwarmStackServiceListItem {
|
||||
/** The *short* swarm service ID */
|
||||
ID?: string;
|
||||
}
|
||||
/**
|
||||
* Swarm stack task list item.
|
||||
* Returned by `docker stack ps --format json <NAME>`
|
||||
*
|
||||
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
|
||||
*/
|
||||
export interface SwarmStackTaskListItem {
|
||||
/** The task ID */
|
||||
ID?: string;
|
||||
/** The task current state. Matches 'DesiredState' when healthy. */
|
||||
CurrentState?: string;
|
||||
/** The task desired state. Matches 'CurrentState' when healthy. */
|
||||
DesiredState?: string;
|
||||
}
|
||||
/** JSON structure to send new terminal window dimensions */
|
||||
export interface TerminalResizeMessage {
|
||||
rows: number;
|
||||
@@ -9233,6 +9348,17 @@ export interface UnenrollPasskey {
|
||||
*/
|
||||
export interface UnenrollTotp {
|
||||
}
|
||||
/** Unlink a login. Response: [NoData]. */
|
||||
export interface UnlinkLogin {
|
||||
/**
|
||||
* 'provider' can be:
|
||||
* - Local
|
||||
* - Github
|
||||
* - Google
|
||||
* - Oidc
|
||||
*/
|
||||
provider: string;
|
||||
}
|
||||
/** Unpauses all containers on the target server. Response: [Update] */
|
||||
export interface UnpauseAllContainers {
|
||||
/** Name or id */
|
||||
@@ -9397,6 +9523,15 @@ export interface UpdateOnboardingKey {
|
||||
/** Update whether to create Builder */
|
||||
create_builder?: boolean;
|
||||
}
|
||||
/**
|
||||
* Update the calling user's password. Response: [NoData].
|
||||
*
|
||||
* If the User was created using third party login method,
|
||||
* using [UpdatePassword] adds or updates the Local linked (additional) login method.
|
||||
*/
|
||||
export interface UpdatePassword {
|
||||
password: string;
|
||||
}
|
||||
/**
|
||||
* **Admin only.** Update a user or user groups base permission level on a resource type.
|
||||
* Response: [NoData].
|
||||
@@ -9579,6 +9714,13 @@ export interface UpdateTagColor {
|
||||
/** The new color for the tag. */
|
||||
color: TagColor;
|
||||
}
|
||||
/**
|
||||
* Updates's whether user can skip 2fa when logging in using a third party (Oauth / OIDC).
|
||||
* Response: [NoData]
|
||||
*/
|
||||
export interface UpdateThirdPartySkip2fa {
|
||||
skip: boolean;
|
||||
}
|
||||
/**
|
||||
* **Super Admin only.** Update's whether a user is admin.
|
||||
* Response: [NoData].
|
||||
@@ -9604,17 +9746,12 @@ export interface UpdateUserBasePermissions {
|
||||
create_builds?: boolean;
|
||||
}
|
||||
/**
|
||||
* **Only for local users**. Update the calling users password.
|
||||
* Update the calling users username.
|
||||
* Response: [NoData].
|
||||
*
|
||||
* Will fail if the new username is invalid or already taken.
|
||||
*/
|
||||
export interface UpdateUserPassword {
|
||||
password: string;
|
||||
}
|
||||
/**
|
||||
* **Only for local users**. Update the calling users username.
|
||||
* Response: [NoData].
|
||||
*/
|
||||
export interface UpdateUserUsername {
|
||||
export interface UpdateUsername {
|
||||
username: string;
|
||||
}
|
||||
/** **Admin only.** Update variable description. Response: [Variable]. */
|
||||
@@ -9917,9 +10054,21 @@ export type ExecuteRequest = {
|
||||
} | {
|
||||
type: "RemoveSwarmServices";
|
||||
params: RemoveSwarmServices;
|
||||
} | {
|
||||
type: "CreateSwarmConfig";
|
||||
params: CreateSwarmConfig;
|
||||
} | {
|
||||
type: "RotateSwarmConfig";
|
||||
params: RotateSwarmConfig;
|
||||
} | {
|
||||
type: "RemoveSwarmConfigs";
|
||||
params: RemoveSwarmConfigs;
|
||||
} | {
|
||||
type: "CreateSwarmSecret";
|
||||
params: CreateSwarmSecret;
|
||||
} | {
|
||||
type: "RotateSwarmSecret";
|
||||
params: RotateSwarmSecret;
|
||||
} | {
|
||||
type: "RemoveSwarmSecrets";
|
||||
params: RemoveSwarmSecrets;
|
||||
@@ -10510,6 +10659,12 @@ export type UserRequest = {
|
||||
} | {
|
||||
type: "SetLastSeenUpdate";
|
||||
params: SetLastSeenUpdate;
|
||||
} | {
|
||||
type: "UpdateUsername";
|
||||
params: UpdateUsername;
|
||||
} | {
|
||||
type: "UpdatePassword";
|
||||
params: UpdatePassword;
|
||||
} | {
|
||||
type: "CreateApiKey";
|
||||
params: CreateApiKey;
|
||||
@@ -10534,6 +10689,15 @@ export type UserRequest = {
|
||||
} | {
|
||||
type: "UnenrollPasskey";
|
||||
params: UnenrollPasskey;
|
||||
} | {
|
||||
type: "BeginThirdPartyLoginLink";
|
||||
params: BeginThirdPartyLoginLink;
|
||||
} | {
|
||||
type: "UnlinkLogin";
|
||||
params: UnlinkLogin;
|
||||
} | {
|
||||
type: "UpdateThirdPartySkip2fa";
|
||||
params: UpdateThirdPartySkip2fa;
|
||||
};
|
||||
export type WriteRequest = {
|
||||
type: "UpdateResourceMeta";
|
||||
@@ -10763,12 +10927,6 @@ export type WriteRequest = {
|
||||
} | {
|
||||
type: "CreateLocalUser";
|
||||
params: CreateLocalUser;
|
||||
} | {
|
||||
type: "UpdateUserUsername";
|
||||
params: UpdateUserUsername;
|
||||
} | {
|
||||
type: "UpdateUserPassword";
|
||||
params: UpdateUserPassword;
|
||||
} | {
|
||||
type: "DeleteUser";
|
||||
params: DeleteUser;
|
||||
|
||||
@@ -72,7 +72,11 @@ export var Operation;
|
||||
Operation["RemoveSwarmNodes"] = "RemoveSwarmNodes";
|
||||
Operation["RemoveSwarmStacks"] = "RemoveSwarmStacks";
|
||||
Operation["RemoveSwarmServices"] = "RemoveSwarmServices";
|
||||
Operation["CreateSwarmConfig"] = "CreateSwarmConfig";
|
||||
Operation["RotateSwarmConfig"] = "RotateSwarmConfig";
|
||||
Operation["RemoveSwarmConfigs"] = "RemoveSwarmConfigs";
|
||||
Operation["CreateSwarmSecret"] = "CreateSwarmSecret";
|
||||
Operation["RotateSwarmSecret"] = "RotateSwarmSecret";
|
||||
Operation["RemoveSwarmSecrets"] = "RemoveSwarmSecrets";
|
||||
Operation["CreateServer"] = "CreateServer";
|
||||
Operation["UpdateServer"] = "UpdateServer";
|
||||
@@ -589,6 +593,24 @@ export var SwarmState;
|
||||
/** Unknown case */
|
||||
SwarmState["Unknown"] = "Unknown";
|
||||
})(SwarmState || (SwarmState = {}));
|
||||
export var TaskState;
|
||||
(function (TaskState) {
|
||||
TaskState["NEW"] = "new";
|
||||
TaskState["ALLOCATED"] = "allocated";
|
||||
TaskState["PENDING"] = "pending";
|
||||
TaskState["ASSIGNED"] = "assigned";
|
||||
TaskState["ACCEPTED"] = "accepted";
|
||||
TaskState["PREPARING"] = "preparing";
|
||||
TaskState["READY"] = "ready";
|
||||
TaskState["STARTING"] = "starting";
|
||||
TaskState["RUNNING"] = "running";
|
||||
TaskState["COMPLETE"] = "complete";
|
||||
TaskState["SHUTDOWN"] = "shutdown";
|
||||
TaskState["FAILED"] = "failed";
|
||||
TaskState["REJECTED"] = "rejected";
|
||||
TaskState["REMOVE"] = "remove";
|
||||
TaskState["ORPHANED"] = "orphaned";
|
||||
})(TaskState || (TaskState = {}));
|
||||
export var NodeSpecRoleEnum;
|
||||
(function (NodeSpecRoleEnum) {
|
||||
NodeSpecRoleEnum["EMPTY"] = "";
|
||||
@@ -622,24 +644,6 @@ export var SwarmSpecCaConfigExternalCasProtocolEnum;
|
||||
SwarmSpecCaConfigExternalCasProtocolEnum["EMPTY"] = "";
|
||||
SwarmSpecCaConfigExternalCasProtocolEnum["CFSSL"] = "cfssl";
|
||||
})(SwarmSpecCaConfigExternalCasProtocolEnum || (SwarmSpecCaConfigExternalCasProtocolEnum = {}));
|
||||
export var TaskState;
|
||||
(function (TaskState) {
|
||||
TaskState["NEW"] = "new";
|
||||
TaskState["ALLOCATED"] = "allocated";
|
||||
TaskState["PENDING"] = "pending";
|
||||
TaskState["ASSIGNED"] = "assigned";
|
||||
TaskState["ACCEPTED"] = "accepted";
|
||||
TaskState["PREPARING"] = "preparing";
|
||||
TaskState["READY"] = "ready";
|
||||
TaskState["STARTING"] = "starting";
|
||||
TaskState["RUNNING"] = "running";
|
||||
TaskState["COMPLETE"] = "complete";
|
||||
TaskState["SHUTDOWN"] = "shutdown";
|
||||
TaskState["FAILED"] = "failed";
|
||||
TaskState["REJECTED"] = "rejected";
|
||||
TaskState["REMOVE"] = "remove";
|
||||
TaskState["ORPHANED"] = "orphaned";
|
||||
})(TaskState || (TaskState = {}));
|
||||
export var PortTypeEnum;
|
||||
(function (PortTypeEnum) {
|
||||
PortTypeEnum["EMPTY"] = "";
|
||||
|
||||
@@ -59,6 +59,7 @@ export const MonacoEditor = ({
|
||||
filename,
|
||||
minHeight,
|
||||
className,
|
||||
id,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onValueChange?: (value: string) => void;
|
||||
@@ -67,6 +68,7 @@ export const MonacoEditor = ({
|
||||
readOnly?: boolean;
|
||||
minHeight?: number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}) => {
|
||||
const enable_fancy_toml =
|
||||
useRead("GetCoreInfo", {}).data?.enable_fancy_toml ?? false;
|
||||
@@ -161,7 +163,7 @@ export const MonacoEditor = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mx-2 my-1 w-full", className)}>
|
||||
<div id={id} className={cn("mx-2 my-1 w-full", className)}>
|
||||
<Editor
|
||||
language={language}
|
||||
value={value}
|
||||
|
||||
@@ -84,8 +84,8 @@ export const ResourcePageHeader = ({
|
||||
resource: Types.ResourceListItem<unknown> | undefined;
|
||||
/** Only pass if not passing resource */
|
||||
name?: string;
|
||||
state: string | undefined;
|
||||
status: string | undefined;
|
||||
state: ReactNode | undefined;
|
||||
status: ReactNode | undefined;
|
||||
}) => {
|
||||
const color = text_color_class_by_intention(intent);
|
||||
const background = hex_color_by_intention(intent) + "15";
|
||||
|
||||
@@ -181,38 +181,67 @@ export const DeploymentComponents: RequiredResourceComponents = {
|
||||
{ type: "Image" }
|
||||
>
|
||||
)?.params.image
|
||||
: info?.image || "N/A"}
|
||||
: info?.image.split("@")[0] || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
DockerResource: ({ id }) => {
|
||||
const deployment = useDeployment(id);
|
||||
const service = useRead(
|
||||
"ListSwarmServices",
|
||||
{ swarm: deployment?.info.swarm_id! },
|
||||
{ enabled: !!deployment?.info.swarm_id }
|
||||
).data?.find((service) => service.Name === deployment?.name);
|
||||
if (
|
||||
!deployment ||
|
||||
[
|
||||
Types.DeploymentState.Unknown,
|
||||
Types.DeploymentState.NotDeployed,
|
||||
].includes(deployment.info.state)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (deployment.info.swarm_id) {
|
||||
return (
|
||||
<SwarmResourceLink
|
||||
type="Service"
|
||||
swarm_id={deployment.info.swarm_id}
|
||||
resource_id={deployment.name}
|
||||
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap">
|
||||
<SwarmResourceLink
|
||||
type="Service"
|
||||
swarm_id={deployment.info.swarm_id}
|
||||
resource_id={deployment.name}
|
||||
name={deployment.name}
|
||||
/>
|
||||
{service?.Configs.map((config) => (
|
||||
<div key={config} className="border-l pl-4 text-sm">
|
||||
<SwarmResourceLink
|
||||
type="Config"
|
||||
swarm_id={deployment.info.swarm_id}
|
||||
resource_id={config}
|
||||
name={config}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{service?.Secrets.map((secret) => (
|
||||
<div key={secret} className="border-l pl-4 text-sm">
|
||||
<SwarmResourceLink
|
||||
type="Secret"
|
||||
swarm_id={deployment.info.swarm_id}
|
||||
resource_id={secret}
|
||||
name={secret}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DockerResourceLink
|
||||
type="container"
|
||||
name={deployment.name}
|
||||
server_id={deployment.info.server_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DockerResourceLink
|
||||
type="container"
|
||||
name={deployment.name}
|
||||
server_id={deployment.info.server_id}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Ports: ({ id }) => {
|
||||
const deployment = useDeployment(id);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Types } from "komodo_client";
|
||||
import { useDeployment } from ".";
|
||||
import { useLocalStorage, usePermissions } from "@lib/hooks";
|
||||
import { useLocalStorage, usePermissions, useRead } from "@lib/hooks";
|
||||
import { useServer } from "../server";
|
||||
import { useMemo } from "react";
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
MobileFriendlyTabsSelector,
|
||||
TabNoContent,
|
||||
@@ -11,6 +11,8 @@ import { DeploymentConfig } from "./config";
|
||||
import { DeploymentLogs } from "./log";
|
||||
import { DeploymentInspect } from "./inspect";
|
||||
import { ContainerTerminals } from "@components/terminal/container";
|
||||
import { SwarmServiceTasksTable } from "../swarm/table";
|
||||
import { useWebsocketMessages } from "@lib/socket";
|
||||
|
||||
export const DeploymentTabs = ({ id }: { id: string }) => {
|
||||
const deployment = useDeployment(id);
|
||||
@@ -18,7 +20,7 @@ export const DeploymentTabs = ({ id }: { id: string }) => {
|
||||
return <DeploymentTabsInner deployment={deployment} />;
|
||||
};
|
||||
|
||||
type DeploymentTabsView = "Config" | "Log" | "Inspect" | "Terminals";
|
||||
type DeploymentTabsView = "Config" | "Tasks" | "Log" | "Inspect" | "Terminals";
|
||||
|
||||
const DeploymentTabsInner = ({
|
||||
deployment,
|
||||
@@ -37,18 +39,23 @@ const DeploymentTabsInner = ({
|
||||
useServer(deployment.info.server_id)?.info.container_terminals_disabled ??
|
||||
false;
|
||||
const state = deployment.info.state;
|
||||
|
||||
const downOrUnknown =
|
||||
state === undefined ||
|
||||
state === Types.DeploymentState.Unknown ||
|
||||
state === Types.DeploymentState.NotDeployed;
|
||||
|
||||
const logsDisabled = !specificLogs || downOrUnknown;
|
||||
const inspectDisabled = !specificInspect || downOrUnknown;
|
||||
|
||||
const terminalDisabled =
|
||||
!specificTerminal ||
|
||||
container_terminals_disabled ||
|
||||
state !== Types.DeploymentState.Running;
|
||||
|
||||
const view =
|
||||
(logsDisabled && _view === "Log") ||
|
||||
(downOrUnknown && _view === "Tasks") ||
|
||||
(inspectDisabled && _view === "Inspect") ||
|
||||
(terminalDisabled && _view === "Terminals")
|
||||
? "Config"
|
||||
@@ -59,6 +66,11 @@ const DeploymentTabsInner = ({
|
||||
{
|
||||
value: "Config",
|
||||
},
|
||||
{
|
||||
value: "Tasks",
|
||||
disabled: downOrUnknown,
|
||||
hidden: !deployment.info.swarm_id,
|
||||
},
|
||||
{
|
||||
value: "Log",
|
||||
disabled: logsDisabled,
|
||||
@@ -98,6 +110,10 @@ const DeploymentTabsInner = ({
|
||||
switch (view) {
|
||||
case "Config":
|
||||
return <DeploymentConfig id={deployment.id} titleOther={Selector} />;
|
||||
case "Tasks":
|
||||
return (
|
||||
<DeploymentTasksTable deployment={deployment} Selector={Selector} />
|
||||
);
|
||||
case "Log":
|
||||
return <DeploymentLogs id={deployment.id} titleOther={Selector} />;
|
||||
case "Inspect":
|
||||
@@ -112,3 +128,32 @@ const DeploymentTabsInner = ({
|
||||
return <ContainerTerminals target={target} titleOther={Selector} />;
|
||||
}
|
||||
};
|
||||
|
||||
const DeploymentTasksTable = ({
|
||||
deployment,
|
||||
Selector,
|
||||
}: {
|
||||
deployment: Types.DeploymentListItem;
|
||||
Selector: ReactNode;
|
||||
}) => {
|
||||
const { data, refetch } = useRead("ListSwarmServices", {
|
||||
swarm: deployment.info.swarm_id,
|
||||
});
|
||||
const service = data?.find((service) => service.Name === deployment.name);
|
||||
useWebsocketMessages(
|
||||
"deployment-swarm-tasks",
|
||||
(update) =>
|
||||
update.operation === Types.Operation.Deploy &&
|
||||
update.target.id === deployment.id &&
|
||||
refetch()
|
||||
);
|
||||
const _search = useState("");
|
||||
return (
|
||||
<SwarmServiceTasksTable
|
||||
id={deployment.info.swarm_id}
|
||||
service_id={service?.ID}
|
||||
titleOther={Selector}
|
||||
_search={_search}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,7 +97,11 @@ type MinExecutionType = Exclude<
|
||||
| "RemoveSwarmNodes"
|
||||
| "RemoveSwarmStacks"
|
||||
| "RemoveSwarmServices"
|
||||
| "CreateSwarmConfig"
|
||||
| "RotateSwarmConfig"
|
||||
| "RemoveSwarmConfigs"
|
||||
| "CreateSwarmSecret"
|
||||
| "RotateSwarmSecret"
|
||||
| "RemoveSwarmSecrets"
|
||||
>;
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ export const ServerTabs = ({ id }: { id: string }) => {
|
||||
const terminalDisabled =
|
||||
!specificTerminal || (server_info?.terminals_disabled ?? true);
|
||||
|
||||
const stacks =
|
||||
useRead("ListStacks", {}).data?.filter(
|
||||
(stack) => stack.info.server_id === id
|
||||
) ?? [];
|
||||
const noStacks = stacks.length === 0;
|
||||
const deployments =
|
||||
useRead("ListDeployments", {}).data?.filter(
|
||||
(deployment) => deployment.info.server_id === id
|
||||
@@ -40,11 +45,6 @@ export const ServerTabs = ({ id }: { id: string }) => {
|
||||
(repo) => repo.info.server_id === id
|
||||
) ?? [];
|
||||
const noRepos = repos.length === 0;
|
||||
const stacks =
|
||||
useRead("ListStacks", {}).data?.filter(
|
||||
(stack) => stack.info.server_id === id
|
||||
) ?? [];
|
||||
const noStacks = stacks.length === 0;
|
||||
|
||||
const noResources = noDeployments && noRepos && noStacks;
|
||||
|
||||
@@ -88,7 +88,15 @@ export const ServerTabs = ({ id }: { id: string }) => {
|
||||
case "Docker":
|
||||
return <ServerInfo id={id} titleOther={Selector} />;
|
||||
case "Resources":
|
||||
return <ServerTabsResources id={id} Selector={Selector} />;
|
||||
return (
|
||||
<ServerTabsResources
|
||||
id={id}
|
||||
stacks={stacks}
|
||||
deployments={deployments}
|
||||
repos={repos}
|
||||
Selector={Selector}
|
||||
/>
|
||||
);
|
||||
case "Terminals":
|
||||
return <ServerTabsTerminals id={id} Selector={Selector} />;
|
||||
}
|
||||
@@ -97,39 +105,22 @@ export const ServerTabs = ({ id }: { id: string }) => {
|
||||
const ServerTabsResources = ({
|
||||
Selector,
|
||||
id,
|
||||
stacks,
|
||||
deployments,
|
||||
repos,
|
||||
}: {
|
||||
Selector: ReactNode;
|
||||
id: string;
|
||||
stacks: Types.StackListItem[];
|
||||
deployments: Types.DeploymentListItem[];
|
||||
repos: Types.RepoListItem[];
|
||||
}) => {
|
||||
const is_admin = useUser().data?.admin ?? false;
|
||||
const disable_non_admin_create =
|
||||
useRead("GetCoreInfo", {}).data?.disable_non_admin_create ?? true;
|
||||
|
||||
const deployments =
|
||||
useRead("ListDeployments", {}).data?.filter(
|
||||
(deployment) => deployment.info.server_id === id
|
||||
) ?? [];
|
||||
const repos =
|
||||
useRead("ListRepos", {}).data?.filter(
|
||||
(repo) => repo.info.server_id === id
|
||||
) ?? [];
|
||||
const stacks =
|
||||
useRead("ListStacks", {}).data?.filter(
|
||||
(stack) => stack.info.server_id === id
|
||||
) ?? [];
|
||||
|
||||
return (
|
||||
<Section titleOther={Selector}>
|
||||
<Section
|
||||
title="Deployments"
|
||||
actions={
|
||||
(is_admin || !disable_non_admin_create) && (
|
||||
<ResourceComponents.Deployment.New server_id={id} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<DeploymentTable deployments={deployments} />
|
||||
</Section>
|
||||
<Section
|
||||
title="Stacks"
|
||||
actions={
|
||||
@@ -140,6 +131,16 @@ const ServerTabsResources = ({
|
||||
>
|
||||
<StackTable stacks={stacks} />
|
||||
</Section>
|
||||
<Section
|
||||
title="Deployments"
|
||||
actions={
|
||||
(is_admin || !disable_non_admin_create) && (
|
||||
<ResourceComponents.Deployment.New server_id={id} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<DeploymentTable deployments={deployments} />
|
||||
</Section>
|
||||
<Section
|
||||
title="Repos"
|
||||
actions={
|
||||
|
||||
@@ -54,7 +54,7 @@ export const StackLogs = ({
|
||||
const StackLogsInner = ({
|
||||
id,
|
||||
titleOther,
|
||||
services,
|
||||
services: __services,
|
||||
setServices,
|
||||
useSwarm,
|
||||
}: {
|
||||
@@ -64,38 +64,27 @@ const StackLogsInner = ({
|
||||
setServices: (state: string[] | LocalStorageSetter<string[]>) => void;
|
||||
useSwarm: boolean;
|
||||
}) => {
|
||||
const _services = __services.filter((s) => s.selected).map((s) => s.service);
|
||||
const services =
|
||||
useSwarm && !_services.length ? [__services[0].service] : _services;
|
||||
return (
|
||||
<LogSection
|
||||
regular_logs={(timestamps, stream, tail, poll) =>
|
||||
NoSearchLogs(
|
||||
id,
|
||||
services.filter((s) => s.selected).map((s) => s.service),
|
||||
tail,
|
||||
timestamps,
|
||||
stream,
|
||||
poll
|
||||
)
|
||||
NoSearchLogs(id, services, tail, timestamps, stream, poll)
|
||||
}
|
||||
search_logs={(timestamps, terms, invert, poll) =>
|
||||
SearchLogs(
|
||||
id,
|
||||
services.filter((s) => s.selected).map((s) => s.service),
|
||||
terms,
|
||||
invert,
|
||||
timestamps,
|
||||
poll
|
||||
)
|
||||
SearchLogs(id, services, terms, invert, timestamps, poll)
|
||||
}
|
||||
titleOther={titleOther}
|
||||
extraParams={
|
||||
useSwarm ? (
|
||||
<StackLogsSingleServiceSelector
|
||||
services={services}
|
||||
services={__services}
|
||||
setServices={setServices}
|
||||
/>
|
||||
) : (
|
||||
<StackLogsMultiServiceSelector
|
||||
services={services}
|
||||
services={__services}
|
||||
setServices={setServices}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { SwarmTable } from "./table";
|
||||
import {
|
||||
swarm_state_intention,
|
||||
stroke_color_class_by_intention,
|
||||
swarm_node_state_intention,
|
||||
swarm_task_state_intention,
|
||||
} from "@lib/color";
|
||||
import { cn, updateLogToHtml } from "@lib/utils";
|
||||
import { Types } from "komodo_client";
|
||||
@@ -24,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
|
||||
import { Card } from "@ui/card";
|
||||
import { SwarmTabs } from "./tabs";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const useSwarm = (id?: string) =>
|
||||
useRead("ListSwarms", {}, { refetchInterval: 10_000 }).data?.find(
|
||||
@@ -143,26 +146,123 @@ export type SwarmResourceType =
|
||||
| "Stack";
|
||||
|
||||
export const SWARM_ICONS: {
|
||||
[type in SwarmResourceType]: React.FC<{ size?: number; className?: string }>;
|
||||
[type in SwarmResourceType]: React.FC<{
|
||||
swarm_id?: string;
|
||||
resource_id?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}>;
|
||||
} = {
|
||||
Node: ({ size, className }) => (
|
||||
<Diamond className={cn(`w-${size} h-${size}`, className)} />
|
||||
),
|
||||
Service: ({ size, className }) => (
|
||||
<FolderCode className={cn(`w-${size} h-${size}`, className)} />
|
||||
),
|
||||
Task: ({ size, className }) => (
|
||||
<ListTodo className={cn(`w-${size} h-${size}`, className)} />
|
||||
),
|
||||
Secret: ({ size, className }) => (
|
||||
<KeyRound className={cn(`w-${size} h-${size}`, className)} />
|
||||
),
|
||||
Config: ({ size, className }) => (
|
||||
<Settings className={cn(`w-${size} h-${size}`, className)} />
|
||||
),
|
||||
Stack: ({ size, className }) => (
|
||||
<SquareStack className={cn(`w-${size} h-${size}`, className)} />
|
||||
),
|
||||
Node: ({ swarm_id, resource_id, size, className }) => {
|
||||
const state = useRead(
|
||||
"ListSwarmNodes",
|
||||
{ swarm: swarm_id! },
|
||||
{ enabled: !!swarm_id }
|
||||
).data?.find((node) => resource_id && node.ID === resource_id)?.State;
|
||||
return (
|
||||
<Diamond
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
stroke_color_class_by_intention(swarm_node_state_intention(state)),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Stack: ({ swarm_id, resource_id, size, className }) => {
|
||||
const state = useRead(
|
||||
"ListSwarmStacks",
|
||||
{ swarm: swarm_id! },
|
||||
{ enabled: !!swarm_id }
|
||||
).data?.find((stack) => resource_id && stack.Name === resource_id)?.State;
|
||||
return (
|
||||
<SquareStack
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
stroke_color_class_by_intention(swarm_state_intention(state)),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Service: ({ swarm_id, resource_id, size, className }) => {
|
||||
const state = useRead(
|
||||
"ListSwarmServices",
|
||||
{ swarm: swarm_id! },
|
||||
{ enabled: !!swarm_id }
|
||||
).data?.find(
|
||||
(service) =>
|
||||
resource_id &&
|
||||
(service.ID === resource_id || service.Name === resource_id)
|
||||
)?.State;
|
||||
return (
|
||||
<FolderCode
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
stroke_color_class_by_intention(swarm_state_intention(state)),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Task: ({ swarm_id, resource_id, size, className }) => {
|
||||
const task = useRead(
|
||||
"ListSwarmTasks",
|
||||
{ swarm: swarm_id! },
|
||||
{ enabled: !!swarm_id }
|
||||
).data?.find((task) => resource_id && task.ID === resource_id);
|
||||
return (
|
||||
<ListTodo
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
stroke_color_class_by_intention(
|
||||
swarm_task_state_intention(task?.State, task?.DesiredState)
|
||||
),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Config: ({ swarm_id, resource_id, size, className }) => {
|
||||
const inUse = useRead(
|
||||
"ListSwarmConfigs",
|
||||
{ swarm: swarm_id! },
|
||||
{ enabled: !!swarm_id }
|
||||
).data?.find(
|
||||
(config) =>
|
||||
resource_id &&
|
||||
(config.ID === resource_id || config.Name === resource_id)
|
||||
)?.InUse;
|
||||
return (
|
||||
<Settings
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
stroke_color_class_by_intention(inUse ? "Good" : "Critical"),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Secret: ({ swarm_id, resource_id, size, className }) => {
|
||||
const inUse = useRead(
|
||||
"ListSwarmSecrets",
|
||||
{ swarm: swarm_id! },
|
||||
{ enabled: !!swarm_id }
|
||||
).data?.find(
|
||||
(secret) =>
|
||||
resource_id &&
|
||||
(secret.ID === resource_id || secret.Name === resource_id)
|
||||
)?.InUse;
|
||||
return (
|
||||
<KeyRound
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
stroke_color_class_by_intention(inUse ? "Good" : "Critical"),
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SwarmResourceLink = ({
|
||||
@@ -170,20 +270,28 @@ export const SwarmResourceLink = ({
|
||||
swarm_id,
|
||||
resource_id,
|
||||
name,
|
||||
extra,
|
||||
}: {
|
||||
type: SwarmResourceType;
|
||||
swarm_id: string;
|
||||
resource_id: string | undefined;
|
||||
name: string | undefined;
|
||||
extra?: ReactNode;
|
||||
}) => {
|
||||
const Icon = SWARM_ICONS[type];
|
||||
return (
|
||||
<Link
|
||||
to={`/swarms/${swarm_id}/swarm-${type.toLowerCase()}/${resource_id}`}
|
||||
className="flex gap-2 items-center hover:underline"
|
||||
className="flex items-center gap-2 text-sm hover:underline py-1"
|
||||
>
|
||||
<Icon size={4} />
|
||||
{name ?? "Unknown"}
|
||||
<Icon swarm_id={swarm_id} resource_id={resource_id} size={4} />
|
||||
<div
|
||||
title={name}
|
||||
className="max-w-[250px] lg:max-w-[300px] overflow-hidden overflow-ellipsis break-words text-nowrap"
|
||||
>
|
||||
{name ?? "Unknown"}
|
||||
</div>
|
||||
{extra && <div className="no-underline">{extra}</div>}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { Section } from "@components/layouts";
|
||||
import { useRead } from "@lib/hooks";
|
||||
import { useExecute, usePermissions, useRead } from "@lib/hooks";
|
||||
import { DataTable, SortableHeader } from "@ui/data-table";
|
||||
import { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Dispatch, ReactNode, SetStateAction, useMemo, useState } from "react";
|
||||
import { PlusCircle, Search } from "lucide-react";
|
||||
import { Input } from "@ui/input";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { cn, filterBySplit } from "@lib/utils";
|
||||
import { SwarmResourceLink } from "..";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/dialog";
|
||||
import { Label } from "@ui/label";
|
||||
import { Button } from "@ui/button";
|
||||
import { language_from_path, MonacoEditor } from "@components/monaco";
|
||||
import { Badge } from "@ui/badge";
|
||||
|
||||
export const SwarmConfigs = ({
|
||||
id,
|
||||
@@ -32,6 +47,7 @@ export const SwarmConfigs = ({
|
||||
titleOther={titleOther}
|
||||
actions={
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<CreateSwarmConfig id={id} />
|
||||
<div className="relative">
|
||||
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
|
||||
<Input
|
||||
@@ -58,8 +74,13 @@ export const SwarmConfigs = ({
|
||||
<SwarmResourceLink
|
||||
type="Config"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.ID}
|
||||
resource_id={row.original.Name}
|
||||
name={row.original.Name}
|
||||
extra={
|
||||
!row.original.InUse && (
|
||||
<Badge variant="destructive">Unused</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -87,3 +108,81 @@ export const SwarmConfigs = ({
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateSwarmConfig = ({ id }: { id: string }) => {
|
||||
const { canExecute } = usePermissions({ type: "Swarm", id });
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [data, setData] = useState("");
|
||||
const { toast } = useToast();
|
||||
const nav = useNavigate();
|
||||
const { mutate, isPending } = useExecute("CreateSwarmConfig", {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Created swarm config." });
|
||||
setName("");
|
||||
setData("");
|
||||
setOpen(false);
|
||||
nav(`/swarms/${id}/swarm-config/${name}`);
|
||||
},
|
||||
});
|
||||
const language = useMemo(() => language_from_path(name), [name]);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
className={cn("items-center gap-2", !canExecute && "hidden")}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
New Config <PlusCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[90vw] max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Swarm Config</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>Enter a unique name and data for the new Swarm Config.</p>
|
||||
<p>
|
||||
For better file syntax support, choose a name with appropriate
|
||||
file extension, like <code>config.yaml</code>
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="config-name">Name</Label>
|
||||
<Input
|
||||
id="config-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full lg:w-[300px]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="config-data">Data</Label>
|
||||
<MonacoEditor
|
||||
id="config-data"
|
||||
value={data}
|
||||
onValueChange={setData}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutate({ swarm: id, name, data });
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,18 +11,16 @@ import { SwarmNodes } from "./nodes";
|
||||
import { SwarmSecrets } from "./secrets";
|
||||
import { SwarmServices } from "./services";
|
||||
import { SwarmTasks } from "./tasks";
|
||||
import { SwarmInspect } from "./inspect";
|
||||
import { SwarmConfigs } from "./configs";
|
||||
import { SwarmStacks } from "./stacks";
|
||||
|
||||
type SwarmInfoView =
|
||||
| "Inspect"
|
||||
| "Nodes"
|
||||
| "Stacks"
|
||||
| "Services"
|
||||
| "Tasks"
|
||||
| "Secrets"
|
||||
| "Configs";
|
||||
| "Configs"
|
||||
| "Secrets";
|
||||
|
||||
export const SwarmInfo = ({
|
||||
id,
|
||||
@@ -35,7 +33,7 @@ export const SwarmInfo = ({
|
||||
const state = useSwarm(id)?.info.state ?? Types.SwarmState.Unknown;
|
||||
const [view, setView] = useLocalStorage<SwarmInfoView>(
|
||||
"swarm-info-view-v1",
|
||||
"Inspect"
|
||||
"Nodes"
|
||||
);
|
||||
|
||||
if (state === Types.SwarmState.Unknown) {
|
||||
@@ -50,9 +48,6 @@ export const SwarmInfo = ({
|
||||
|
||||
const tabsNoContent = useMemo<TabNoContent<SwarmInfoView>[]>(
|
||||
() => [
|
||||
{
|
||||
value: "Inspect",
|
||||
},
|
||||
{
|
||||
value: "Nodes",
|
||||
},
|
||||
@@ -66,10 +61,10 @@ export const SwarmInfo = ({
|
||||
value: "Tasks",
|
||||
},
|
||||
{
|
||||
value: "Secrets",
|
||||
value: "Configs",
|
||||
},
|
||||
{
|
||||
value: "Configs",
|
||||
value: "Secrets",
|
||||
},
|
||||
],
|
||||
[]
|
||||
@@ -86,8 +81,6 @@ export const SwarmInfo = ({
|
||||
|
||||
const Component = () => {
|
||||
switch (view) {
|
||||
case "Inspect":
|
||||
return <SwarmInspect id={id} titleOther={Selector} />;
|
||||
case "Nodes":
|
||||
return <SwarmNodes id={id} titleOther={Selector} _search={_search} />;
|
||||
case "Stacks":
|
||||
@@ -98,10 +91,10 @@ export const SwarmInfo = ({
|
||||
);
|
||||
case "Tasks":
|
||||
return <SwarmTasks id={id} titleOther={Selector} _search={_search} />;
|
||||
case "Secrets":
|
||||
return <SwarmSecrets id={id} titleOther={Selector} _search={_search} />;
|
||||
case "Configs":
|
||||
return <SwarmConfigs id={id} titleOther={Selector} _search={_search} />;
|
||||
case "Secrets":
|
||||
return <SwarmSecrets id={id} titleOther={Selector} _search={_search} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ import { Search } from "lucide-react";
|
||||
import { Input } from "@ui/input";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { SwarmResourceLink } from "..";
|
||||
import { StatusBadge } from "@components/util";
|
||||
import {
|
||||
swarm_node_availability_intention,
|
||||
swarm_node_role_intention,
|
||||
swarm_node_state_intention,
|
||||
} from "@lib/color";
|
||||
|
||||
export const SwarmNodes = ({
|
||||
id,
|
||||
@@ -77,21 +83,38 @@ export const SwarmNodes = ({
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Role" />
|
||||
),
|
||||
cell: ({ row }) => row.original.Role ?? "Unknown",
|
||||
},
|
||||
{
|
||||
accessorKey: "Availability",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Availability" />
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
text={row.original.Role}
|
||||
intent={swarm_node_role_intention(row.original.Role)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => row.original.Availability ?? "Unknown",
|
||||
},
|
||||
{
|
||||
accessorKey: "State",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="State" />
|
||||
),
|
||||
cell: ({ row }) => row.original.State ?? "Unknown",
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
text={row.original.State}
|
||||
intent={swarm_node_state_intention(row.original.State)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Availability",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Availability" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
text={row.original.Availability}
|
||||
intent={swarm_node_availability_intention(
|
||||
row.original.Availability
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "UpdatedAt",
|
||||
@@ -104,17 +127,17 @@ export const SwarmNodes = ({
|
||||
: "Unknown",
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "CreatedAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.CreatedAt
|
||||
? new Date(row.original.CreatedAt).toLocaleString()
|
||||
: "Unknown",
|
||||
size: 200,
|
||||
},
|
||||
// {
|
||||
// accessorKey: "CreatedAt",
|
||||
// header: ({ column }) => (
|
||||
// <SortableHeader column={column} title="Created" />
|
||||
// ),
|
||||
// cell: ({ row }) =>
|
||||
// row.original.CreatedAt
|
||||
// ? new Date(row.original.CreatedAt).toLocaleString()
|
||||
// : "Unknown",
|
||||
// size: 200,
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { Section } from "@components/layouts";
|
||||
import { useRead } from "@lib/hooks";
|
||||
import { useExecute, usePermissions, useRead } from "@lib/hooks";
|
||||
import { DataTable, SortableHeader } from "@ui/data-table";
|
||||
import { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Dispatch, ReactNode, SetStateAction, useMemo, useState } from "react";
|
||||
import { PlusCircle, Search } from "lucide-react";
|
||||
import { Input } from "@ui/input";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { cn, filterBySplit } from "@lib/utils";
|
||||
import { SwarmResourceLink } from "..";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/dialog";
|
||||
import { Button } from "@ui/button";
|
||||
import { Label } from "@ui/label";
|
||||
import { language_from_path, MonacoEditor } from "@components/monaco";
|
||||
import { Badge } from "@ui/badge";
|
||||
|
||||
export const SwarmSecrets = ({
|
||||
id,
|
||||
@@ -32,6 +47,7 @@ export const SwarmSecrets = ({
|
||||
titleOther={titleOther}
|
||||
actions={
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<CreateSwarmSecret id={id} />
|
||||
<div className="relative">
|
||||
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
|
||||
<Input
|
||||
@@ -58,8 +74,13 @@ export const SwarmSecrets = ({
|
||||
<SwarmResourceLink
|
||||
type="Secret"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.ID}
|
||||
resource_id={row.original.Name}
|
||||
name={row.original.Name}
|
||||
extra={
|
||||
!row.original.InUse && (
|
||||
<Badge variant="destructive">Unused</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
@@ -119,3 +140,80 @@ export const SwarmSecrets = ({
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateSwarmSecret = ({ id }: { id: string }) => {
|
||||
const { canExecute } = usePermissions({ type: "Swarm", id });
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [data, setData] = useState("");
|
||||
const { toast } = useToast();
|
||||
const nav = useNavigate();
|
||||
const { mutate, isPending } = useExecute("CreateSwarmSecret", {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Created swarm secret." });
|
||||
setName("");
|
||||
setData("");
|
||||
setOpen(false);
|
||||
nav(`/swarms/${id}/swarm-secret/${name}`);
|
||||
},
|
||||
});
|
||||
const language = useMemo(() => language_from_path(name), [name]);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
className={cn("items-center gap-2", !canExecute && "hidden")}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
New Secret <PlusCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[90vw] max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Swarm Secret</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>Enter a unique name and data for the new Swarm Secret.</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="secret-name">Name</Label>
|
||||
<Input
|
||||
id="secret-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full lg:w-[300px]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="secret-data">Data</Label>
|
||||
<MonacoEditor
|
||||
id="secret-data"
|
||||
value={data}
|
||||
onValueChange={setData}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="font-extrabold">
|
||||
Note: the data cannot be viewed again after creation.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutate({ swarm: id, name, data });
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Search } from "lucide-react";
|
||||
import { Input } from "@ui/input";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { SwarmResourceLink } from "..";
|
||||
import { StatusBadge } from "@components/util";
|
||||
import { swarm_state_intention } from "@lib/color";
|
||||
|
||||
export const SwarmStacks = ({
|
||||
id,
|
||||
@@ -63,17 +65,23 @@ export const SwarmStacks = ({
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Services",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Services" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "State",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="State" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
text={row.original.State}
|
||||
intent={swarm_state_intention(row.original.State)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Services",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Services" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { SwarmComponents, SwarmResourceLink } from ".";
|
||||
import { Types } from "komodo_client";
|
||||
import { useRead, useSelectedResources } from "@lib/hooks";
|
||||
import { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { filterBySplit, filterMultitermBySplit } from "@lib/utils";
|
||||
import { Section } from "@components/layouts";
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@ui/input";
|
||||
import { StatusBadge } from "@components/util";
|
||||
import { swarm_task_state_intention } from "@lib/color";
|
||||
|
||||
export const SwarmTable = ({ swarms }: { swarms: Types.SwarmListItem[] }) => {
|
||||
const [_, setSelectedResources] = useSelectedResources("Swarm");
|
||||
@@ -137,95 +139,30 @@ export const SwarmServicesTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SwarmStackServicesTable = ({
|
||||
export const SwarmServiceTasksTable = ({
|
||||
id,
|
||||
services,
|
||||
service_id,
|
||||
titleOther,
|
||||
_search,
|
||||
}: {
|
||||
id: string;
|
||||
services: Types.SwarmStackServiceListItem[];
|
||||
service_id: string | undefined;
|
||||
titleOther: ReactNode;
|
||||
_search: [string, Dispatch<SetStateAction<string>>];
|
||||
}) => {
|
||||
const [search, setSearch] = _search;
|
||||
const filtered = filterBySplit(
|
||||
services,
|
||||
search,
|
||||
(service) => service.Name ?? service.ID ?? "Unknown"
|
||||
);
|
||||
const tasks =
|
||||
useRead(
|
||||
"ListSwarmTasks",
|
||||
{ swarm: id },
|
||||
{ enabled: !!service_id }
|
||||
).data?.filter((task) => service_id && task.ServiceID === service_id) ?? [];
|
||||
return (
|
||||
<Section
|
||||
<SwarmTasksTable
|
||||
id={id}
|
||||
tasks={tasks}
|
||||
titleOther={titleOther}
|
||||
actions={
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="search..."
|
||||
className="pl-8 w-[200px] lg:w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
containerClassName="min-h-[60vh]"
|
||||
tableKey="swarm-services"
|
||||
data={filtered}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "Name",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<SwarmResourceLink
|
||||
type="Service"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.Name}
|
||||
name={row.original.Name}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "ID",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Id" />
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "Image",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Image" />
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "Mode",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Mode" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Replicas",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Replicas" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Ports",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Ports" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
_search={_search}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -241,7 +178,6 @@ export const SwarmTasksTable = ({
|
||||
_search: [string, Dispatch<SetStateAction<string>>];
|
||||
}) => {
|
||||
const [search, setSearch] = _search;
|
||||
|
||||
const nodes =
|
||||
useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 })
|
||||
.data ?? [];
|
||||
@@ -256,12 +192,11 @@ export const SwarmTasksTable = ({
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = filterBySplit(
|
||||
tasks,
|
||||
search,
|
||||
(task) =>
|
||||
task.Name ?? task.service?.Name ?? task.node?.Hostname ?? "Unknown"
|
||||
);
|
||||
const filtered = filterMultitermBySplit(tasks, search, (task) => [
|
||||
task.ID,
|
||||
task.node?.Hostname,
|
||||
task.service?.Name,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Section
|
||||
@@ -298,6 +233,21 @@ export const SwarmTasksTable = ({
|
||||
name={row.original.ID}
|
||||
/>
|
||||
),
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "node.Hostname",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Node" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<SwarmResourceLink
|
||||
type="Node"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.node?.ID}
|
||||
name={row.original.node?.Hostname}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
@@ -315,32 +265,35 @@ export const SwarmTasksTable = ({
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "node.Hostname",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Node" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<SwarmResourceLink
|
||||
type="Node"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.node?.ID}
|
||||
name={row.original.node?.Hostname}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "State",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="State" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
text={row.original.State}
|
||||
intent={swarm_task_state_intention(
|
||||
row.original.State,
|
||||
row.original.DesiredState
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "DesiredState",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Desired State" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
text={row.original.DesiredState}
|
||||
intent={swarm_task_state_intention(
|
||||
row.original.State,
|
||||
row.original.DesiredState
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "UpdatedAt",
|
||||
@@ -353,128 +306,17 @@ export const SwarmTasksTable = ({
|
||||
: "Unknown",
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "CreatedAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.CreatedAt
|
||||
? new Date(row.original.CreatedAt).toLocaleString()
|
||||
: "Unknown",
|
||||
size: 200,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export const SwarmStackTasksTable = ({
|
||||
id,
|
||||
tasks: _tasks,
|
||||
titleOther,
|
||||
_search,
|
||||
}: {
|
||||
id: string;
|
||||
tasks: Types.SwarmStackTaskListItem[];
|
||||
titleOther: ReactNode;
|
||||
_search: [string, Dispatch<SetStateAction<string>>];
|
||||
}) => {
|
||||
const [search, setSearch] = _search;
|
||||
|
||||
const nodes =
|
||||
useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 })
|
||||
.data ?? [];
|
||||
const tasks = _tasks.map((task) => {
|
||||
return {
|
||||
...task,
|
||||
node: nodes.find(
|
||||
(node) =>
|
||||
(task.Node ?? false) &&
|
||||
(task.Node === node.ID ||
|
||||
task.Node === node.Hostname ||
|
||||
task.Node === node.Name)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = filterBySplit(
|
||||
tasks,
|
||||
search,
|
||||
(task) => task.Name ?? task.node?.Hostname ?? "Unknown"
|
||||
);
|
||||
|
||||
return (
|
||||
<Section
|
||||
titleOther={titleOther}
|
||||
actions={
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="search..."
|
||||
className="pl-8 w-[200px] lg:w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
containerClassName="min-h-[60vh]"
|
||||
tableKey="swarm-tasks"
|
||||
data={filtered}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "ID",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Id" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<SwarmResourceLink
|
||||
type="Task"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.ID}
|
||||
name={row.original.ID}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "node.Hostname",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Node" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<SwarmResourceLink
|
||||
type="Node"
|
||||
swarm_id={id}
|
||||
resource_id={row.original.node?.ID}
|
||||
name={row.original.node?.Hostname}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "Image",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Image" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "CurrentState",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="State" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "DesiredState",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Desired State" />
|
||||
),
|
||||
},
|
||||
// {
|
||||
// accessorKey: "CreatedAt",
|
||||
// header: ({ column }) => (
|
||||
// <SortableHeader column={column} title="Created" />
|
||||
// ),
|
||||
// cell: ({ row }) =>
|
||||
// row.original.CreatedAt
|
||||
// ? new Date(row.original.CreatedAt).toLocaleString()
|
||||
// : "Unknown",
|
||||
// size: 200,
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useLocalStorage } from "@lib/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { useLocalStorage, usePermissions, useRead, useUser } from "@lib/hooks";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import {
|
||||
MobileFriendlyTabsSelector,
|
||||
TabNoContent,
|
||||
} from "@ui/mobile-friendly-tabs";
|
||||
import { SwarmConfig } from "./config";
|
||||
import { SwarmInfo } from "./info";
|
||||
import { Section } from "@components/layouts";
|
||||
import { ResourceComponents } from "..";
|
||||
import { StackTable } from "../stack/table";
|
||||
import { DeploymentTable } from "../deployment/table";
|
||||
import { Types } from "komodo_client";
|
||||
import { SwarmInspect } from "./info/inspect";
|
||||
|
||||
type SwarmTabsView = "Config" | "Info";
|
||||
type SwarmTabsView = "Config" | "Info" | "Resources" | "Inspect";
|
||||
|
||||
export const SwarmTabs = ({ id }: { id: string }) => {
|
||||
const [view, setView] = useLocalStorage<SwarmTabsView>(
|
||||
@@ -15,7 +21,19 @@ export const SwarmTabs = ({ id }: { id: string }) => {
|
||||
"Config"
|
||||
);
|
||||
|
||||
// const swarm_info = useSwarm(id)?.info;
|
||||
const { specificInspect } = usePermissions({ type: "Swarm", id });
|
||||
|
||||
const stacks =
|
||||
useRead("ListStacks", {}).data?.filter(
|
||||
(stack) => stack.info.swarm_id === id
|
||||
) ?? [];
|
||||
const noStacks = stacks.length === 0;
|
||||
const deployments =
|
||||
useRead("ListDeployments", {}).data?.filter(
|
||||
(deployment) => deployment.info.swarm_id === id
|
||||
) ?? [];
|
||||
const noDeployments = deployments.length === 0;
|
||||
const noResources = noDeployments && noStacks;
|
||||
|
||||
const tabs = useMemo<TabNoContent<SwarmTabsView>[]>(
|
||||
() => [
|
||||
@@ -25,6 +43,14 @@ export const SwarmTabs = ({ id }: { id: string }) => {
|
||||
{
|
||||
value: "Info",
|
||||
},
|
||||
{
|
||||
value: "Inspect",
|
||||
disabled: !specificInspect,
|
||||
},
|
||||
{
|
||||
value: "Resources",
|
||||
disabled: noResources,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
@@ -43,5 +69,57 @@ export const SwarmTabs = ({ id }: { id: string }) => {
|
||||
return <SwarmConfig id={id} titleOther={Selector} />;
|
||||
case "Info":
|
||||
return <SwarmInfo id={id} titleOther={Selector} />;
|
||||
case "Inspect":
|
||||
return <SwarmInspect id={id} titleOther={Selector} />;
|
||||
case "Resources":
|
||||
return (
|
||||
<SwarmTabsResources
|
||||
id={id}
|
||||
stacks={stacks}
|
||||
deployments={deployments}
|
||||
Selector={Selector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SwarmTabsResources = ({
|
||||
Selector,
|
||||
id,
|
||||
stacks,
|
||||
deployments,
|
||||
}: {
|
||||
Selector: ReactNode;
|
||||
id: string;
|
||||
stacks: Types.StackListItem[];
|
||||
deployments: Types.DeploymentListItem[];
|
||||
}) => {
|
||||
const is_admin = useUser().data?.admin ?? false;
|
||||
const disable_non_admin_create =
|
||||
useRead("GetCoreInfo", {}).data?.disable_non_admin_create ?? true;
|
||||
|
||||
return (
|
||||
<Section titleOther={Selector}>
|
||||
<Section
|
||||
title="Stacks"
|
||||
actions={
|
||||
(is_admin || !disable_non_admin_create) && (
|
||||
<ResourceComponents.Stack.New swarm_id={id} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<StackTable stacks={stacks} />
|
||||
</Section>
|
||||
<Section
|
||||
title="Deployments"
|
||||
actions={
|
||||
(is_admin || !disable_non_admin_create) && (
|
||||
<ResourceComponents.Deployment.New swarm_id={id} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<DeploymentTable deployments={deployments} />
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -726,7 +726,7 @@ export const DockerResourceLink = ({
|
||||
server_id: string;
|
||||
name: string | undefined;
|
||||
id?: string;
|
||||
type: "container" | "network" | "image" | "volume";
|
||||
type: DockerResourceType;
|
||||
extra?: ReactNode;
|
||||
muted?: boolean;
|
||||
}) => {
|
||||
@@ -745,7 +745,7 @@ export const DockerResourceLink = ({
|
||||
<Icon server_id={server_id} name={type === "image" ? id : name} />
|
||||
<div
|
||||
title={name}
|
||||
className="max-w-[250px] lg:max-w-[300px] overflow-hidden overflow-ellipsis break-words"
|
||||
className="max-w-[250px] lg:max-w-[300px] overflow-hidden overflow-ellipsis break-words text-nowrap"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
@@ -144,6 +144,87 @@ export const swarm_state_intention: (
|
||||
}
|
||||
};
|
||||
|
||||
export const swarm_node_state_intention: (
|
||||
state?: Types.NodeState
|
||||
) => ColorIntention = (state) => {
|
||||
switch (state) {
|
||||
case Types.NodeState.READY:
|
||||
return "Good";
|
||||
case Types.NodeState.DOWN:
|
||||
return "Warning";
|
||||
case Types.NodeState.DISCONNECTED:
|
||||
return "Critical";
|
||||
case Types.NodeState.UNKNOWN:
|
||||
return "Neutral";
|
||||
case undefined:
|
||||
return "None";
|
||||
}
|
||||
};
|
||||
|
||||
export const swarm_node_availability_intention: (
|
||||
state?: Types.NodeSpecAvailabilityEnum
|
||||
) => ColorIntention = (state) => {
|
||||
switch (state) {
|
||||
case Types.NodeSpecAvailabilityEnum.ACTIVE:
|
||||
return "Good";
|
||||
case Types.NodeSpecAvailabilityEnum.DRAIN:
|
||||
return "Warning";
|
||||
case Types.NodeSpecAvailabilityEnum.PAUSE:
|
||||
return "Critical";
|
||||
case Types.NodeSpecAvailabilityEnum.EMPTY:
|
||||
return "Neutral";
|
||||
case undefined:
|
||||
return "None";
|
||||
}
|
||||
};
|
||||
|
||||
export const swarm_node_role_intention: (
|
||||
state?: Types.NodeSpecRoleEnum
|
||||
) => ColorIntention = (state) => {
|
||||
switch (state) {
|
||||
case Types.NodeSpecRoleEnum.MANAGER:
|
||||
return "Good";
|
||||
case Types.NodeSpecRoleEnum.WORKER:
|
||||
return "Neutral";
|
||||
case Types.NodeSpecRoleEnum.EMPTY:
|
||||
return "None";
|
||||
case undefined:
|
||||
return "None";
|
||||
}
|
||||
};
|
||||
|
||||
export const swarm_task_state_intention: (
|
||||
state?: Types.TaskState,
|
||||
desired?: Types.TaskState
|
||||
) => ColorIntention = (state, desired) => {
|
||||
// Case when its desired running
|
||||
if (desired === Types.TaskState.RUNNING) {
|
||||
if (state === Types.TaskState.RUNNING) {
|
||||
return "Good";
|
||||
} else {
|
||||
return "Critical";
|
||||
}
|
||||
}
|
||||
|
||||
// Case when its desired shutdown
|
||||
if (desired === Types.TaskState.SHUTDOWN) {
|
||||
// If you want it shutdown, then running is critical.
|
||||
if (state === Types.TaskState.RUNNING) {
|
||||
return "Critical";
|
||||
} else {
|
||||
// Otherwise, it is "Down", give neutral color
|
||||
return "Neutral";
|
||||
}
|
||||
}
|
||||
|
||||
// Others
|
||||
if (state === desired) {
|
||||
return "Good";
|
||||
} else {
|
||||
return "Critical";
|
||||
}
|
||||
};
|
||||
|
||||
export const server_state_intention: (
|
||||
state?: Types.ServerState,
|
||||
hasVersionMismatch?: boolean
|
||||
|
||||
@@ -241,6 +241,26 @@ export const filterBySplit = <T>(
|
||||
);
|
||||
};
|
||||
|
||||
export const filterMultitermBySplit = <T>(
|
||||
items: T[] | undefined,
|
||||
search: string,
|
||||
extract: (item: T) => (string | undefined)[]
|
||||
) => {
|
||||
const split = search.toLowerCase().split(" ");
|
||||
return (
|
||||
(split.length
|
||||
? items?.filter((item) => {
|
||||
const target = extract(item)
|
||||
.filter((str) => str)
|
||||
.map((str) => str!.toLowerCase());
|
||||
return split.every(
|
||||
(term) => target.findIndex((t) => t.includes(term)) !== -1
|
||||
);
|
||||
})
|
||||
: items) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const sync_no_changes = (sync: Types.ResourceSync) => {
|
||||
return (
|
||||
(sync.info?.pending_deploy?.to_deploy ?? 0) === 0 &&
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { useRef, useState } from "react";
|
||||
import { ThemeToggle } from "@ui/theme";
|
||||
import { KOMODO_BASE_URL, sanitize_query } from "@main";
|
||||
import { KeyRound, X } from "lucide-react";
|
||||
import { KeyRound, Loader2, X } from "lucide-react";
|
||||
import { cn, preparePasskeyCredential } from "@lib/utils";
|
||||
import { Types } from "komodo_client";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
@@ -37,8 +37,10 @@ const login_with_oauth = (provider: OauthProvider) => {
|
||||
};
|
||||
|
||||
export default function Login({
|
||||
passkeyIsPending: _passkeyIsPending,
|
||||
totpIsPending: _totpIsPending,
|
||||
}: {
|
||||
passkeyIsPending?: boolean;
|
||||
totpIsPending?: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
@@ -46,7 +48,11 @@ export default function Login({
|
||||
const userInvalidate = useUserInvalidate();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const totpFormRef = useRef<HTMLFormElement>(null);
|
||||
const [passkeyIsPending, setPasskeyPending] = useState(
|
||||
_passkeyIsPending ?? false
|
||||
);
|
||||
const [totpIsPending, setTotpPending] = useState(_totpIsPending ?? false);
|
||||
const secondFactorPending = passkeyIsPending || totpIsPending;
|
||||
|
||||
// If signing in another user, need to redirect away from /login manually
|
||||
const maybeNavigate = location.pathname.startsWith("/login")
|
||||
@@ -91,6 +97,7 @@ export default function Login({
|
||||
case "Jwt":
|
||||
return onSuccess(data);
|
||||
case "Passkey":
|
||||
setPasskeyPending(true);
|
||||
return navigator.credentials
|
||||
.get(preparePasskeyCredential(data))
|
||||
.then((credential) => completePasskeyLogin({ credential }))
|
||||
@@ -147,11 +154,11 @@ export default function Login({
|
||||
completeTotpLogin({ code: creds.code });
|
||||
};
|
||||
|
||||
const no_auth_configured =
|
||||
const noAuthConfigured =
|
||||
options !== undefined &&
|
||||
Object.values(options).every((value) => value === false);
|
||||
|
||||
const show_sign_up = options !== undefined && !options.registration_disabled;
|
||||
const showSignUp = options !== undefined && !options.registration_disabled;
|
||||
|
||||
// Otherwise just standard login
|
||||
return (
|
||||
@@ -174,7 +181,8 @@ export default function Login({
|
||||
<CardDescription>Log In</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{!totpIsPending && (
|
||||
|
||||
{!secondFactorPending && (
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
@@ -204,7 +212,7 @@ export default function Login({
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{no_auth_configured && (
|
||||
{noAuthConfigured && (
|
||||
<Button variant="destructive" size="icon">
|
||||
{" "}
|
||||
<X className="w-4 h-4" />{" "}
|
||||
@@ -213,7 +221,8 @@ export default function Login({
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
{options?.local && !totpIsPending && (
|
||||
|
||||
{options?.local && !secondFactorPending && (
|
||||
<form ref={formRef} onSubmit={handleSubmit} autoComplete="on">
|
||||
<CardContent className="flex flex-col justify-center w-full gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -238,7 +247,7 @@ export default function Login({
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-4 w-full justify-end">
|
||||
{show_sign_up && (
|
||||
{showSignUp && (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
@@ -260,6 +269,17 @@ export default function Login({
|
||||
</CardFooter>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{passkeyIsPending && (
|
||||
<CardContent className="flex flex-col justify-center items-center w-full gap-4">
|
||||
<KeyRound className="w-16 h-16" />
|
||||
<div className="flex items-center gap-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<h2>Provide your passkey to finish login</h2>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{totpIsPending && (
|
||||
<form
|
||||
ref={totpFormRef}
|
||||
@@ -268,7 +288,10 @@ export default function Login({
|
||||
>
|
||||
<CardContent className="flex flex-col justify-center w-full gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="code">2FA Code</Label>
|
||||
<Label htmlFor="code" className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
2FA Code{" "}
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
@@ -291,7 +314,8 @@ export default function Login({
|
||||
</CardFooter>
|
||||
</form>
|
||||
)}
|
||||
{no_auth_configured && (
|
||||
|
||||
{noAuthConfigured && (
|
||||
<CardContent className="w-full gap-2 text-muted-foreground text-sm">
|
||||
No login methods have been configured. See the
|
||||
<a
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { ConfirmButton } from "@components/util";
|
||||
import { useRead, useSetTitle, useUser, useWrite } from "@lib/hooks";
|
||||
import { ConfirmButton, StatusBadge } from "@components/util";
|
||||
import {
|
||||
useLoginOptions,
|
||||
useManageUser,
|
||||
useRead,
|
||||
useSetTitle,
|
||||
useUser,
|
||||
} from "@lib/hooks";
|
||||
import { Button } from "@ui/button";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { Loader2, User, Eye, EyeOff, KeyRound, UserPen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
User,
|
||||
Eye,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
UserPen,
|
||||
Trash,
|
||||
CirclePlus,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Input } from "@ui/input";
|
||||
import { ApiKeysTable } from "@components/api-keys/table";
|
||||
import { Section } from "@components/layouts";
|
||||
@@ -12,6 +27,21 @@ import { Types } from "komodo_client";
|
||||
import { CreateKey, DeleteKey } from "./api-key";
|
||||
import { EnrollTotp } from "./totp";
|
||||
import { EnrollPasskey } from "./passkey";
|
||||
import { KOMODO_BASE_URL } from "@main";
|
||||
import { DataTable } from "@ui/data-table";
|
||||
import { Switch } from "@ui/switch";
|
||||
import { Label } from "@ui/label";
|
||||
import { cn } from "@lib/utils";
|
||||
|
||||
type LinkedLoginProvider = "Local" | "Github" | "Google" | "Oidc";
|
||||
|
||||
const useLinkWithOauth = () => {
|
||||
const { mutateAsync } = useManageUser("BeginThirdPartyLoginLink");
|
||||
return (provider: LinkedLoginProvider) =>
|
||||
mutateAsync({}).then(() =>
|
||||
location.replace(`${KOMODO_BASE_URL}/auth/${provider.toLowerCase()}/link`)
|
||||
);
|
||||
};
|
||||
|
||||
export const Profile = () => {
|
||||
useSetTitle("Profile");
|
||||
@@ -30,21 +60,67 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
|
||||
const { refetch: refetchUser } = useUser();
|
||||
const { toast } = useToast();
|
||||
const keys = useRead("ListApiKeys", {}).data ?? [];
|
||||
const options = useLoginOptions().data;
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [password, setPassword] = useState("");
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
const { mutate: updateUsername } = useWrite("UpdateUserUsername", {
|
||||
const { mutate: updateUsername } = useManageUser("UpdateUsername", {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Username updated." });
|
||||
refetchUser();
|
||||
},
|
||||
});
|
||||
const { mutate: updatePassword } = useWrite("UpdateUserPassword", {
|
||||
const { mutate: updatePassword } = useManageUser("UpdatePassword", {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Password updated." });
|
||||
setPassword("");
|
||||
},
|
||||
});
|
||||
const { mutate: update_skip_2fa } = useManageUser("UpdateThirdPartySkip2fa", {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Updated user third party skip 2fa." });
|
||||
refetchUser();
|
||||
},
|
||||
});
|
||||
const { mutate: unlink } = useManageUser("UnlinkLogin", {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Unlinked login." });
|
||||
refetchUser();
|
||||
},
|
||||
});
|
||||
const thirdPartyProviders: Array<{
|
||||
provider: LinkedLoginProvider;
|
||||
enabled: boolean;
|
||||
linked: boolean;
|
||||
}> = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
provider: "Local" as LinkedLoginProvider,
|
||||
enabled: !!options?.local,
|
||||
linked: !!user?.linked_logins?.Local,
|
||||
},
|
||||
{
|
||||
provider: "Google" as LinkedLoginProvider,
|
||||
enabled: !!options?.google,
|
||||
linked: !!user?.linked_logins?.Google,
|
||||
},
|
||||
{
|
||||
provider: "Github" as LinkedLoginProvider,
|
||||
enabled: !!options?.github,
|
||||
linked: !!user?.linked_logins?.Github,
|
||||
},
|
||||
{
|
||||
provider: "Oidc" as LinkedLoginProvider,
|
||||
enabled: !!options?.oidc,
|
||||
linked: !!user?.linked_logins?.Oidc,
|
||||
},
|
||||
].filter(
|
||||
({ enabled, provider }) => enabled && user.config.type !== provider
|
||||
),
|
||||
[user, options]
|
||||
);
|
||||
const linkWithOauth = useLinkWithOauth();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Profile */}
|
||||
@@ -55,7 +131,7 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
|
||||
<UserProfile user={user} />
|
||||
|
||||
{/* Update Username */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="text-muted-foreground font-mono">Username:</div>
|
||||
<div className="w-[200px] lg:w-[300px]">
|
||||
<Input
|
||||
@@ -73,8 +149,8 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
|
||||
</div>
|
||||
|
||||
{/* Update Password */}
|
||||
{user.config.type === "Local" && (
|
||||
<div className="flex items-center gap-4">
|
||||
{options?.local && (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="text-muted-foreground font-mono">Password:</div>
|
||||
<div className="w-[200px] lg:w-[300px] flex items-center gap-2">
|
||||
<Input
|
||||
@@ -107,15 +183,87 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{/* Linked Logins */}
|
||||
{!!thirdPartyProviders.length && (
|
||||
<Section title="Linked Logins" icon={<KeyRound className="w-4 h-4" />}>
|
||||
<DataTable
|
||||
tableKey="linked-logins-v1"
|
||||
data={thirdPartyProviders}
|
||||
columns={[
|
||||
{ header: "Provider", accessorKey: "provider" },
|
||||
{
|
||||
header: "Linked",
|
||||
cell: ({
|
||||
row: {
|
||||
original: { linked },
|
||||
},
|
||||
}) => (
|
||||
<StatusBadge
|
||||
text={linked ? "Linked" : "Unlinked"}
|
||||
intent={linked ? "Good" : "Critical"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Link",
|
||||
cell: ({
|
||||
row: {
|
||||
original: { provider, linked },
|
||||
},
|
||||
}) =>
|
||||
linked ? (
|
||||
<ConfirmButton
|
||||
title={`Unlink ${provider}`}
|
||||
icon={<Trash className="w-4 h-4" />}
|
||||
variant="destructive"
|
||||
onClick={() => unlink({ provider })}
|
||||
/>
|
||||
) : provider === "Local" ? (
|
||||
<>Set password above to enable.</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex gap-2 px-3 items-center"
|
||||
onClick={() => linkWithOauth(provider)}
|
||||
disabled={!!user.linked_logins?.[provider]}
|
||||
>
|
||||
Link {provider}
|
||||
<CirclePlus className="w-4 h-4" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 2FA */}
|
||||
<Section title="2FA" icon={<KeyRound className="w-4 h-4" />}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-x-6 gap-y-4 flex-wrap">
|
||||
<EnrollPasskey user={user} />
|
||||
<EnrollTotp user={user} />
|
||||
{/* <StatusBadge
|
||||
text={user.totp?.confirmed_at ? "Enrolled" : "Not Enrolled"}
|
||||
intent={user.totp?.confirmed_at ? "Good" : "Critical"}
|
||||
/> */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
!user.passkey?.created_at && !user.totp?.confirmed_at && "hidden"
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="update-skip-2fa"
|
||||
checked={user.third_party_skip_2fa}
|
||||
onCheckedChange={(skip) => update_skip_2fa({ skip })}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="update-skip-2fa"
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
Skip 2FA for third party logins
|
||||
<StatusBadge
|
||||
text={user.third_party_skip_2fa ? "ENABLED" : "DISABLED"}
|
||||
intent={user.third_party_skip_2fa ? "Good" : "Critical"}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -133,11 +133,33 @@ const StackServicePageInner = ({
|
||||
<SwarmResourceLink
|
||||
type="Service"
|
||||
swarm_id={stack.info.swarm_id}
|
||||
resource_id={swarm_service.ID}
|
||||
resource_id={swarm_service.Name}
|
||||
name={swarm_service.Name}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{swarm_service?.Configs.map((config) => (
|
||||
<Fragment key={config}>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Config"
|
||||
swarm_id={stack.info.swarm_id}
|
||||
resource_id={config}
|
||||
name={config}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{swarm_service?.Secrets.map((secret) => (
|
||||
<Fragment key={secret}>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Secret"
|
||||
swarm_id={stack.info.swarm_id}
|
||||
resource_id={secret}
|
||||
name={secret}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* SERVER ONLY */}
|
||||
@@ -217,7 +239,7 @@ const StackServicePageInner = ({
|
||||
|
||||
<div className="mt-8 flex flex-col gap-12">
|
||||
{/* Actions */}
|
||||
{canExecute && (
|
||||
{canExecute && !stack?.info.swarm_id && (
|
||||
<Section title="Execute (Service)" icon={<Zap className="w-4 h-4" />}>
|
||||
<div className="flex gap-4 items-center flex-wrap">
|
||||
{Object.entries(Actions).map(([key, Action]) => (
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
TabNoContent,
|
||||
} from "@ui/mobile-friendly-tabs";
|
||||
import { Types } from "komodo_client";
|
||||
import { useMemo } from "react";
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { StackServiceInspect } from "./inspect";
|
||||
import { StackServiceLogs } from "./log";
|
||||
import { SwarmServiceTasksTable } from "@components/resources/swarm/table";
|
||||
|
||||
type StackServiceTabsView = "Log" | "Inspect" | "Terminals";
|
||||
type StackServiceTabsView = "Tasks" | "Log" | "Inspect" | "Terminals";
|
||||
|
||||
export const StackServiceTabs = ({
|
||||
stack,
|
||||
@@ -36,13 +37,17 @@ export const StackServiceTabs = ({
|
||||
|
||||
const container_terminals_disabled =
|
||||
useServer(stack.info.server_id)?.info.container_terminals_disabled ?? false;
|
||||
|
||||
const logDisabled = !specificLogs || down;
|
||||
const inspectDisabled = !specificInspect || down;
|
||||
|
||||
const terminalDisabled =
|
||||
!specificTerminal ||
|
||||
container_terminals_disabled ||
|
||||
container?.state !== Types.ContainerStateStatusEnum.Running;
|
||||
|
||||
const view =
|
||||
(!stack.info.swarm_id && _view === "Tasks") ||
|
||||
(inspectDisabled && _view === "Inspect") ||
|
||||
(terminalDisabled && _view === "Terminals")
|
||||
? "Log"
|
||||
@@ -50,6 +55,10 @@ export const StackServiceTabs = ({
|
||||
|
||||
const tabs = useMemo<TabNoContent[]>(
|
||||
() => [
|
||||
{
|
||||
value: "Tasks",
|
||||
hidden: !swarm_service,
|
||||
},
|
||||
{
|
||||
value: "Log",
|
||||
disabled: logDisabled,
|
||||
@@ -64,7 +73,7 @@ export const StackServiceTabs = ({
|
||||
hidden: !container,
|
||||
},
|
||||
],
|
||||
[logDisabled, inspectDisabled, terminalDisabled]
|
||||
[!swarm_service, logDisabled, inspectDisabled, terminalDisabled]
|
||||
);
|
||||
|
||||
const Selector = (
|
||||
@@ -88,6 +97,14 @@ export const StackServiceTabs = ({
|
||||
);
|
||||
|
||||
switch (view) {
|
||||
case "Tasks":
|
||||
return (
|
||||
<StackServiceTasksTable
|
||||
stack={stack}
|
||||
swarm_service={swarm_service}
|
||||
Selector={Selector}
|
||||
/>
|
||||
);
|
||||
case "Log":
|
||||
return (
|
||||
<StackServiceLogs
|
||||
@@ -110,3 +127,23 @@ export const StackServiceTabs = ({
|
||||
return <ContainerTerminals target={target} titleOther={Selector} />;
|
||||
}
|
||||
};
|
||||
|
||||
const StackServiceTasksTable = ({
|
||||
stack,
|
||||
swarm_service,
|
||||
Selector,
|
||||
}: {
|
||||
stack: Types.StackListItem;
|
||||
swarm_service: Types.SwarmServiceListItem | undefined;
|
||||
Selector: ReactNode;
|
||||
}) => {
|
||||
const _search = useState("");
|
||||
return (
|
||||
<SwarmServiceTasksTable
|
||||
id={stack.info.swarm_id}
|
||||
service_id={swarm_service?.ID}
|
||||
titleOther={Selector}
|
||||
_search={_search}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { ResourceLink } from "@components/resources/common";
|
||||
import { PageHeaderName } from "@components/util";
|
||||
import { usePermissions, useRead, useSetTitle } from "@lib/hooks";
|
||||
import {
|
||||
ResourceDescription,
|
||||
ResourceLink,
|
||||
ResourcePageHeader,
|
||||
} from "@components/resources/common";
|
||||
import {
|
||||
useExecute,
|
||||
useLocalStorage,
|
||||
usePermissions,
|
||||
useRead,
|
||||
useSetTitle,
|
||||
} from "@lib/hooks";
|
||||
import { Button } from "@ui/button";
|
||||
import { ChevronLeft, Loader2, Zap } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { ChevronLeft, History, Loader2 } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { language_from_path, MonacoEditor } from "@components/monaco";
|
||||
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
|
||||
import { Section } from "@components/layouts";
|
||||
import { ExportButton } from "@components/export";
|
||||
import { stroke_color_class_by_intention } from "@lib/color";
|
||||
import { ResourceNotifications } from "@pages/resource-notifications";
|
||||
import { Types } from "komodo_client";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
|
||||
import { RemoveSwarmResource } from "./remove";
|
||||
import { ConfirmUpdate } from "@components/config/util";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { Badge } from "@ui/badge";
|
||||
|
||||
export default function SwarmConfigPage() {
|
||||
const { id, config: __config } = useParams() as {
|
||||
@@ -16,19 +34,18 @@ export default function SwarmConfigPage() {
|
||||
};
|
||||
const _config = decodeURIComponent(__config);
|
||||
const swarm = useSwarm(id);
|
||||
const { data, isPending } = useRead("InspectSwarmConfig", {
|
||||
const inUse = useRead("ListSwarmConfigs", { swarm: id }).data?.find(
|
||||
(config) => config.ID === _config || config.Name === _config
|
||||
)?.InUse;
|
||||
const { data: config, isPending } = useRead("InspectSwarmConfig", {
|
||||
swarm: id,
|
||||
config: _config,
|
||||
});
|
||||
const config = data?.[0];
|
||||
useSetTitle(
|
||||
`${swarm?.name} | Config | ${config?.Spec?.Name ?? config?.ID ?? "Unknown"}`
|
||||
);
|
||||
const { canExecute } = usePermissions({
|
||||
const { canWrite } = usePermissions({
|
||||
type: "Swarm",
|
||||
id,
|
||||
});
|
||||
const nav = useNavigate();
|
||||
useSetTitle(`${swarm?.name} | Config | ${config?.Spec?.Name ?? "Unknown"}`);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -43,55 +60,266 @@ export default function SwarmConfigPage() {
|
||||
}
|
||||
|
||||
const Icon = SWARM_ICONS.Config;
|
||||
const intent = inUse ? "Good" : "Critical";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 mb-24">
|
||||
{/* HEADER */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* BACK */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="secondary"
|
||||
onClick={() => nav("/swarms/" + id)}
|
||||
>
|
||||
<ChevronLeft className="w-4" /> Back
|
||||
<div>
|
||||
<div className="w-full flex items-center justify-between mb-12">
|
||||
<Link to={"/swarms/" + id}>
|
||||
<Button className="gap-2" variant="secondary">
|
||||
<ChevronLeft className="w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TITLE */}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="mt-1">
|
||||
<Icon size={8} />
|
||||
</div>
|
||||
<PageHeaderName name={config?.Spec?.Name ?? config?.ID} />
|
||||
</div>
|
||||
|
||||
{/* INFO */}
|
||||
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
|
||||
Swarm Config
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
<ExportButton targets={[{ type: "Swarm", id }]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canExecute && config.ID && (
|
||||
<Section title="Execute" icon={<Zap className="w-4 h-4" />}>
|
||||
<div className="flex gap-4 items-center flex-wrap">
|
||||
<RemoveSwarmResource
|
||||
id={id}
|
||||
type="Config"
|
||||
resource_id={config.ID}
|
||||
resource_name={config.Spec?.Name}
|
||||
<div className="flex flex-col xl:flex-row gap-4">
|
||||
{/* HEADER */}
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 border rounded-md">
|
||||
<ResourcePageHeader
|
||||
type={undefined}
|
||||
id={undefined}
|
||||
intent={intent}
|
||||
icon={
|
||||
<Icon
|
||||
size={8}
|
||||
className={stroke_color_class_by_intention(intent)}
|
||||
/>
|
||||
}
|
||||
resource={undefined}
|
||||
name={config.Spec?.Name}
|
||||
state={!inUse && <Badge variant="destructive">Unused</Badge>}
|
||||
status={undefined}
|
||||
/>
|
||||
<div className="flex flex-col pb-2 px-4">
|
||||
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
|
||||
<div>Swarm Config</div>
|
||||
|
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
<ResourceDescription type="Swarm" id={id} disabled={!canWrite} />
|
||||
</div>
|
||||
{/** NOTIFICATIONS */}
|
||||
<ResourceNotifications type="Swarm" id={id} />
|
||||
</div>
|
||||
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(config, null, 2)}
|
||||
language="json"
|
||||
readOnly
|
||||
/>
|
||||
<div className="mt-8 flex flex-col gap-12">
|
||||
{/* Actions */}
|
||||
{config.ID && (
|
||||
<RemoveSwarmResource
|
||||
id={id}
|
||||
type="Config"
|
||||
resource_id={config.ID}
|
||||
resource_name={config.Spec?.Name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="pt-4">
|
||||
{swarm && <SwarmConfigTabs swarm={swarm} config={_config} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SwarmConfigTabsView = "Edit" | "Inspect";
|
||||
|
||||
const SwarmConfigTabs = ({
|
||||
swarm,
|
||||
config,
|
||||
}: {
|
||||
swarm: Types.SwarmListItem;
|
||||
config: string;
|
||||
}) => {
|
||||
const [_view, setView] = useLocalStorage<SwarmConfigTabsView>(
|
||||
`swarm-${swarm.id}-config-${config}-tabs-v2`,
|
||||
"Edit"
|
||||
);
|
||||
const { specificInspect } = usePermissions({
|
||||
type: "Swarm",
|
||||
id: swarm.id,
|
||||
});
|
||||
|
||||
const view = !specificInspect && _view === "Inspect" ? "Edit" : _view;
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "Edit",
|
||||
},
|
||||
{
|
||||
value: "Inspect",
|
||||
disabled: !specificInspect,
|
||||
},
|
||||
],
|
||||
[specificInspect]
|
||||
);
|
||||
|
||||
const Selector = (
|
||||
<MobileFriendlyTabsSelector
|
||||
tabs={tabs}
|
||||
value={view}
|
||||
onValueChange={setView as any}
|
||||
tabsTriggerClassname="w-[110px]"
|
||||
/>
|
||||
);
|
||||
|
||||
switch (view) {
|
||||
case "Edit":
|
||||
return (
|
||||
<SwarmConfigEdit
|
||||
swarm={swarm.id}
|
||||
config={config}
|
||||
titleOther={Selector}
|
||||
/>
|
||||
);
|
||||
case "Inspect":
|
||||
return (
|
||||
<SwarmConfigInspect
|
||||
swarm={swarm.id}
|
||||
config={config}
|
||||
titleOther={Selector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SwarmConfigEdit = ({
|
||||
swarm,
|
||||
config,
|
||||
titleOther,
|
||||
}: {
|
||||
swarm: string;
|
||||
config: string;
|
||||
titleOther: ReactNode;
|
||||
}) => {
|
||||
const {
|
||||
data: inspect,
|
||||
isPending,
|
||||
refetch,
|
||||
} = useRead("InspectSwarmConfig", {
|
||||
swarm,
|
||||
config,
|
||||
});
|
||||
const { canExecute } = usePermissions({
|
||||
type: "Swarm",
|
||||
id: swarm,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const { mutate: sendEdit } = useExecute("RotateSwarmConfig", {
|
||||
onSuccess: (res) => {
|
||||
toast({
|
||||
title: res.success ? "Config updated." : "Failed to update config.",
|
||||
variant: res.success ? undefined : "destructive",
|
||||
});
|
||||
setEdit({ edit: undefined });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
const [{ edit }, setEdit] = useLocalStorage<{ edit: string | undefined }>(
|
||||
`swarm-${swarm}-config-${config}-edit-v2`,
|
||||
{ edit: undefined }
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex justify-center w-full py-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="flex w-full py-4">Failed to inspect config.</div>;
|
||||
}
|
||||
|
||||
const name = inspect?.Spec?.Name;
|
||||
const data = inspect?.Spec?.Data;
|
||||
const language = name ? language_from_path(name) : undefined;
|
||||
|
||||
return (
|
||||
<Section
|
||||
titleOther={titleOther}
|
||||
actions={
|
||||
canExecute && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEdit({ edit: undefined });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
disabled={edit === undefined}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<ConfirmUpdate
|
||||
previous={{ contents: data }}
|
||||
content={{ contents: edit }}
|
||||
onConfirm={async () => {
|
||||
name && edit && sendEdit({ swarm, config: name, data: edit });
|
||||
}}
|
||||
disabled={edit === undefined}
|
||||
language={language}
|
||||
loading={isPending}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<MonacoEditor
|
||||
value={edit ?? data ?? "Failed to retrieve config data"}
|
||||
language={language}
|
||||
onValueChange={(edit) => setEdit({ edit })}
|
||||
readOnly={!canExecute}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const SwarmConfigInspect = ({
|
||||
swarm,
|
||||
config,
|
||||
titleOther,
|
||||
}: {
|
||||
swarm: string;
|
||||
config: string;
|
||||
titleOther: ReactNode;
|
||||
}) => {
|
||||
const { data: inspect, isPending } = useRead("InspectSwarmConfig", {
|
||||
swarm,
|
||||
config,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex justify-center w-full py-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="flex w-full py-4">Failed to inspect config.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section titleOther={titleOther}>
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(inspect, null, 2)}
|
||||
language="json"
|
||||
readOnly
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,12 +23,15 @@ export const RemoveSwarmResource = ({
|
||||
}) => {
|
||||
const nav = useNavigate();
|
||||
const { mutate: remove, isPending } = useExecute(`RemoveSwarm${type}s`, {
|
||||
onSuccess: () => nav("/swarms/" + id),
|
||||
onSuccess: () => {
|
||||
nav("/swarms/" + id);
|
||||
},
|
||||
});
|
||||
let key = `${type.toLowerCase()}s`;
|
||||
return (
|
||||
<ActionWithDialog
|
||||
name={resource_name ?? resource_id}
|
||||
variant="destructive"
|
||||
title="Remove"
|
||||
icon={<Trash className="h-4 w-4" />}
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { ResourceLink } from "@components/resources/common";
|
||||
import { PageHeaderName } from "@components/util";
|
||||
import { usePermissions, useRead, useSetTitle } from "@lib/hooks";
|
||||
import {
|
||||
ResourceDescription,
|
||||
ResourceLink,
|
||||
ResourcePageHeader,
|
||||
} from "@components/resources/common";
|
||||
import {
|
||||
useExecute,
|
||||
useLocalStorage,
|
||||
usePermissions,
|
||||
useRead,
|
||||
useSetTitle,
|
||||
} from "@lib/hooks";
|
||||
import { Button } from "@ui/button";
|
||||
import { ChevronLeft, Loader2, Zap } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { ChevronLeft, History, Loader2 } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { language_from_path, MonacoEditor } from "@components/monaco";
|
||||
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
|
||||
import { RemoveSwarmResource } from "./remove";
|
||||
import { Section } from "@components/layouts";
|
||||
import { ExportButton } from "@components/export";
|
||||
import { stroke_color_class_by_intention } from "@lib/color";
|
||||
import { ResourceNotifications } from "@pages/resource-notifications";
|
||||
import { Types } from "komodo_client";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
|
||||
import { RemoveSwarmResource } from "./remove";
|
||||
import { ConfirmUpdate } from "@components/config/util";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { Badge } from "@ui/badge";
|
||||
|
||||
export default function SwarmSecretPage() {
|
||||
const { id, secret: __secret } = useParams() as {
|
||||
@@ -16,18 +34,18 @@ export default function SwarmSecretPage() {
|
||||
};
|
||||
const _secret = decodeURIComponent(__secret);
|
||||
const swarm = useSwarm(id);
|
||||
const inUse = useRead("ListSwarmSecrets", { swarm: id }).data?.find(
|
||||
(secret) => secret.ID === _secret || secret.Name === _secret
|
||||
)?.InUse;
|
||||
const { data: secret, isPending } = useRead("InspectSwarmSecret", {
|
||||
swarm: id,
|
||||
secret: _secret,
|
||||
});
|
||||
useSetTitle(
|
||||
`${swarm?.name} | Secret | ${secret?.Spec?.Name ?? secret?.ID ?? "Unknown"}`
|
||||
);
|
||||
const { canExecute } = usePermissions({
|
||||
const { canWrite } = usePermissions({
|
||||
type: "Swarm",
|
||||
id,
|
||||
});
|
||||
const nav = useNavigate();
|
||||
useSetTitle(`${swarm?.name} | Secret | ${secret?.Spec?.Name ?? "Unknown"}`);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -42,55 +60,265 @@ export default function SwarmSecretPage() {
|
||||
}
|
||||
|
||||
const Icon = SWARM_ICONS.Secret;
|
||||
const intent = inUse ? "Good" : "Critical";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 mb-24">
|
||||
{/* HEADER */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* BACK */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="secondary"
|
||||
onClick={() => nav("/swarms/" + id)}
|
||||
>
|
||||
<ChevronLeft className="w-4" /> Back
|
||||
<div>
|
||||
<div className="w-full flex items-center justify-between mb-12">
|
||||
<Link to={"/swarms/" + id}>
|
||||
<Button className="gap-2" variant="secondary">
|
||||
<ChevronLeft className="w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TITLE */}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="mt-1">
|
||||
<Icon size={8} />
|
||||
</div>
|
||||
<PageHeaderName name={secret?.Spec?.Name ?? secret?.ID} />
|
||||
</div>
|
||||
|
||||
{/* INFO */}
|
||||
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
|
||||
Swarm Secret
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
<ExportButton targets={[{ type: "Swarm", id }]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canExecute && secret.ID && (
|
||||
<Section title="Execute" icon={<Zap className="w-4 h-4" />}>
|
||||
<div className="flex gap-4 items-center flex-wrap">
|
||||
<RemoveSwarmResource
|
||||
id={id}
|
||||
type="Secret"
|
||||
resource_id={secret.ID}
|
||||
resource_name={secret.Spec?.Name}
|
||||
<div className="flex flex-col xl:flex-row gap-4">
|
||||
{/* HEADER */}
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 border rounded-md">
|
||||
<ResourcePageHeader
|
||||
type={undefined}
|
||||
id={undefined}
|
||||
intent={intent}
|
||||
icon={
|
||||
<Icon
|
||||
size={8}
|
||||
className={stroke_color_class_by_intention(intent)}
|
||||
/>
|
||||
}
|
||||
resource={undefined}
|
||||
name={secret.Spec?.Name}
|
||||
state={!inUse && <Badge variant="destructive">Unused</Badge>}
|
||||
status={undefined}
|
||||
/>
|
||||
<div className="flex flex-col pb-2 px-4">
|
||||
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
|
||||
<div>Swarm Secret</div>
|
||||
|
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
<ResourceDescription type="Swarm" id={id} disabled={!canWrite} />
|
||||
</div>
|
||||
{/** NOTIFICATIONS */}
|
||||
<ResourceNotifications type="Swarm" id={id} />
|
||||
</div>
|
||||
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(secret, null, 2)}
|
||||
language="json"
|
||||
readOnly
|
||||
/>
|
||||
<div className="mt-8 flex flex-col gap-12">
|
||||
{/* Actions */}
|
||||
{secret.ID && (
|
||||
<RemoveSwarmResource
|
||||
id={id}
|
||||
type="Secret"
|
||||
resource_id={secret.ID}
|
||||
resource_name={secret.Spec?.Name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="pt-4">
|
||||
{swarm && <SwarmSecretTabs swarm={swarm} secret={_secret} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SwarmSecretTabsView = "Edit" | "Inspect";
|
||||
|
||||
const SwarmSecretTabs = ({
|
||||
swarm,
|
||||
secret,
|
||||
}: {
|
||||
swarm: Types.SwarmListItem;
|
||||
secret: string;
|
||||
}) => {
|
||||
const [_view, setView] = useLocalStorage<SwarmSecretTabsView>(
|
||||
`swarm-${swarm.id}-secret-${secret}-tabs-v2`,
|
||||
"Edit"
|
||||
);
|
||||
const { specificInspect } = usePermissions({
|
||||
type: "Swarm",
|
||||
id: swarm.id,
|
||||
});
|
||||
|
||||
const view = !specificInspect && _view === "Inspect" ? "Edit" : _view;
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "Edit",
|
||||
},
|
||||
{
|
||||
value: "Inspect",
|
||||
disabled: !specificInspect,
|
||||
},
|
||||
],
|
||||
[specificInspect]
|
||||
);
|
||||
|
||||
const Selector = (
|
||||
<MobileFriendlyTabsSelector
|
||||
tabs={tabs}
|
||||
value={view}
|
||||
onValueChange={setView as any}
|
||||
tabsTriggerClassname="w-[110px]"
|
||||
/>
|
||||
);
|
||||
|
||||
switch (view) {
|
||||
case "Edit":
|
||||
return (
|
||||
<SwarmSecretEdit
|
||||
swarm={swarm.id}
|
||||
secret={secret}
|
||||
titleOther={Selector}
|
||||
/>
|
||||
);
|
||||
case "Inspect":
|
||||
return (
|
||||
<SwarmSecretInspect
|
||||
swarm={swarm.id}
|
||||
secret={secret}
|
||||
titleOther={Selector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SwarmSecretEdit = ({
|
||||
swarm,
|
||||
secret,
|
||||
titleOther,
|
||||
}: {
|
||||
swarm: string;
|
||||
secret: string;
|
||||
titleOther: ReactNode;
|
||||
}) => {
|
||||
const {
|
||||
data: inspect,
|
||||
isPending,
|
||||
refetch,
|
||||
} = useRead("InspectSwarmSecret", {
|
||||
swarm,
|
||||
secret,
|
||||
});
|
||||
const { canExecute } = usePermissions({
|
||||
type: "Swarm",
|
||||
id: swarm,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const { mutate: sendEdit } = useExecute("RotateSwarmSecret", {
|
||||
onSuccess: (res) => {
|
||||
toast({
|
||||
title: res.success ? "Secret updated." : "Failed to update secret.",
|
||||
variant: res.success ? undefined : "destructive",
|
||||
});
|
||||
setEdit({ edit: undefined });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
const [{ edit }, setEdit] = useLocalStorage<{ edit: string | undefined }>(
|
||||
`swarm-${swarm}-secret-${secret}-edit`,
|
||||
{ edit: undefined }
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex justify-center w-full py-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
return <div className="flex w-full py-4">Failed to inspect secret.</div>;
|
||||
}
|
||||
|
||||
const name = inspect?.Spec?.Name;
|
||||
const language = name ? language_from_path(name) : undefined;
|
||||
|
||||
return (
|
||||
<Section
|
||||
titleOther={titleOther}
|
||||
actions={
|
||||
canExecute && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEdit({ edit: undefined });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
disabled={edit === undefined}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<ConfirmUpdate
|
||||
previous={{ contents: "" }}
|
||||
content={{ contents: edit }}
|
||||
onConfirm={async () => {
|
||||
name && edit && sendEdit({ swarm, secret: name, data: edit });
|
||||
}}
|
||||
disabled={edit === undefined}
|
||||
language={language}
|
||||
loading={isPending}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<MonacoEditor
|
||||
value={edit ?? "# Enter new secret and save to rotate"}
|
||||
language={language}
|
||||
onValueChange={(edit) => setEdit({ edit })}
|
||||
readOnly={!canExecute}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const SwarmSecretInspect = ({
|
||||
swarm,
|
||||
secret,
|
||||
titleOther,
|
||||
}: {
|
||||
swarm: string;
|
||||
secret: string;
|
||||
titleOther: ReactNode;
|
||||
}) => {
|
||||
const { data: inspect, isPending } = useRead("InspectSwarmSecret", {
|
||||
swarm,
|
||||
secret,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex justify-center w-full py-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
return <div className="flex w-full py-4">Failed to inspect secret.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section titleOther={titleOther}>
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(inspect, null, 2)}
|
||||
language="json"
|
||||
readOnly
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,11 @@ import { Button } from "@ui/button";
|
||||
import { ChevronLeft, Loader2, Zap } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
|
||||
import {
|
||||
SWARM_ICONS,
|
||||
SwarmResourceLink,
|
||||
useSwarm,
|
||||
} from "@components/resources/swarm";
|
||||
import { ExportButton } from "@components/export";
|
||||
import { Types } from "komodo_client";
|
||||
import {
|
||||
@@ -21,11 +25,12 @@ import {
|
||||
swarm_state_intention,
|
||||
} from "@lib/color";
|
||||
import { ResourceNotifications } from "@pages/resource-notifications";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { Fragment, ReactNode, useMemo, useState } from "react";
|
||||
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
|
||||
import { SwarmServiceLogs } from "./log";
|
||||
import { Section } from "@components/layouts";
|
||||
import { RemoveSwarmResource } from "./remove";
|
||||
import { SwarmServiceTasksTable } from "@components/resources/swarm/table";
|
||||
|
||||
export default function SwarmServicePage() {
|
||||
const { id, service: __service } = useParams() as {
|
||||
@@ -70,9 +75,10 @@ export default function SwarmServicePage() {
|
||||
}
|
||||
|
||||
const Icon = SWARM_ICONS.Service;
|
||||
const state = get_service_state_from_tasks(tasks);
|
||||
const state = service.State;
|
||||
const intention = swarm_state_intention(state);
|
||||
const strokeColor = stroke_color_class_by_intention(intention);
|
||||
const service_id = service.ID;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -103,9 +109,31 @@ export default function SwarmServicePage() {
|
||||
/>
|
||||
<div className="flex flex-col pb-2 px-4">
|
||||
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
<div>|</div>
|
||||
<div>Swarm Service</div>
|
||||
|
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
{service?.Configs.map((config) => (
|
||||
<Fragment key={config}>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Config"
|
||||
swarm_id={id}
|
||||
resource_id={config}
|
||||
name={config}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{service?.Secrets.map((secret) => (
|
||||
<Fragment key={secret}>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Secret"
|
||||
swarm_id={id}
|
||||
resource_id={secret}
|
||||
name={secret}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,35 +159,51 @@ export default function SwarmServicePage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="pt-4">
|
||||
{swarm && <SwarmServiceTabs swarm={swarm} service={_service} />}
|
||||
{swarm && (
|
||||
<SwarmServiceTabs
|
||||
swarm={swarm}
|
||||
service={_service}
|
||||
service_id={service_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SwarmServiceTabsView = "Log" | "Inspect";
|
||||
type SwarmServiceTabsView = "Tasks" | "Log" | "Inspect";
|
||||
|
||||
const SwarmServiceTabs = ({
|
||||
swarm,
|
||||
service,
|
||||
service_id,
|
||||
}: {
|
||||
swarm: Types.SwarmListItem;
|
||||
service: string;
|
||||
service_id: string | undefined;
|
||||
}) => {
|
||||
const _search = useState("");
|
||||
const [_view, setView] = useLocalStorage<SwarmServiceTabsView>(
|
||||
`swarm-${swarm.id}-service-${service}-tabs-v2`,
|
||||
"Log"
|
||||
"Tasks"
|
||||
);
|
||||
const { specificLogs, specificInspect } = usePermissions({
|
||||
type: "Swarm",
|
||||
id: swarm.id,
|
||||
});
|
||||
|
||||
const view = !specificInspect && _view === "Inspect" ? "Log" : _view;
|
||||
const view =
|
||||
(!specificLogs && _view === "Log") ||
|
||||
(!specificInspect && _view === "Inspect")
|
||||
? "Tasks"
|
||||
: _view;
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "Tasks",
|
||||
},
|
||||
{
|
||||
value: "Log",
|
||||
disabled: !specificLogs,
|
||||
@@ -182,6 +226,15 @@ const SwarmServiceTabs = ({
|
||||
);
|
||||
|
||||
switch (view) {
|
||||
case "Tasks":
|
||||
return (
|
||||
<SwarmServiceTasksTable
|
||||
id={swarm.id}
|
||||
service_id={service_id}
|
||||
titleOther={Selector}
|
||||
_search={_search}
|
||||
/>
|
||||
);
|
||||
case "Log":
|
||||
return (
|
||||
<SwarmServiceLogs
|
||||
@@ -238,14 +291,3 @@ const SwarmServiceInspect = ({
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const get_service_state_from_tasks = (
|
||||
tasks: Types.SwarmTaskListItem[]
|
||||
): Types.SwarmState => {
|
||||
for (const task of tasks) {
|
||||
if (task.State !== task.DesiredState) {
|
||||
return Types.SwarmState.Unhealthy;
|
||||
}
|
||||
}
|
||||
return Types.SwarmState.Healthy;
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
|
||||
import { Section } from "@components/layouts";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import {
|
||||
SwarmStackServicesTable,
|
||||
SwarmStackTasksTable,
|
||||
SwarmServicesTable,
|
||||
SwarmTasksTable,
|
||||
} from "@components/resources/swarm/table";
|
||||
import {
|
||||
Select,
|
||||
@@ -194,7 +194,7 @@ const SwarmStackTabs = ({
|
||||
switch (view) {
|
||||
case "Services":
|
||||
return (
|
||||
<SwarmStackServicesTable
|
||||
<SwarmServicesTable
|
||||
id={swarm.id}
|
||||
services={stack.Services}
|
||||
titleOther={Selector}
|
||||
@@ -203,7 +203,7 @@ const SwarmStackTabs = ({
|
||||
);
|
||||
case "Tasks":
|
||||
return (
|
||||
<SwarmStackTasksTable
|
||||
<SwarmTasksTable
|
||||
id={swarm.id}
|
||||
tasks={stack.Tasks}
|
||||
titleOther={Selector}
|
||||
|
||||
@@ -13,13 +13,17 @@ import { Button } from "@ui/button";
|
||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { SWARM_ICONS, SwarmResourceLink, useSwarm } from "@components/resources/swarm";
|
||||
import {
|
||||
SWARM_ICONS,
|
||||
SwarmResourceLink,
|
||||
useSwarm,
|
||||
} from "@components/resources/swarm";
|
||||
import { Types } from "komodo_client";
|
||||
import {
|
||||
stroke_color_class_by_intention,
|
||||
swarm_state_intention,
|
||||
} from "@lib/color";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { Fragment, ReactNode, useMemo } from "react";
|
||||
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
|
||||
import { SwarmServiceLogs } from "./log";
|
||||
import { Section } from "@components/layouts";
|
||||
@@ -107,19 +111,41 @@ export default function SwarmTaskPage() {
|
||||
|
|
||||
<ResourceLink type="Swarm" id={id} />
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Service"
|
||||
swarm_id={id}
|
||||
resource_id={service?.ID}
|
||||
name={service?.Name}
|
||||
/>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Node"
|
||||
swarm_id={id}
|
||||
resource_id={node?.ID}
|
||||
name={node?.Hostname}
|
||||
/>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Service"
|
||||
swarm_id={id}
|
||||
resource_id={service?.ID}
|
||||
name={service?.Name}
|
||||
/>
|
||||
{task?.Configs.map((config) => (
|
||||
<Fragment key={config}>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Config"
|
||||
swarm_id={id}
|
||||
resource_id={config}
|
||||
name={config}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{task?.Secrets.map((secret) => (
|
||||
<Fragment key={secret}>
|
||||
|
|
||||
<SwarmResourceLink
|
||||
type="Secret"
|
||||
swarm_id={id}
|
||||
resource_id={secret}
|
||||
name={secret}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-14
@@ -47,18 +47,15 @@ let jwt_redeem_sent = false;
|
||||
let passkey_sent = false;
|
||||
|
||||
/// returns whether to show login / loading screen depending on state of exchange token loop
|
||||
const useQueryState = () => {
|
||||
const useAuthState = () => {
|
||||
const { toast } = useToast();
|
||||
const onSuccess = ({ user_id, jwt }: Types.JwtResponse) => {
|
||||
LOGIN_TOKENS.add_and_change(user_id, jwt);
|
||||
sanitize_query_inner(search);
|
||||
};
|
||||
const { mutate: redeemJwt, isPending: redeem_pending } = useAuth(
|
||||
"ExchangeForJwt",
|
||||
{
|
||||
onSuccess,
|
||||
}
|
||||
);
|
||||
const { mutate: redeemJwt } = useAuth("ExchangeForJwt", {
|
||||
onSuccess,
|
||||
});
|
||||
const { mutate: completePasskeyLogin } = useAuth("CompletePasskeyLogin", {
|
||||
onSuccess,
|
||||
});
|
||||
@@ -95,7 +92,6 @@ const useQueryState = () => {
|
||||
|
||||
return {
|
||||
jwt_redeem_ready,
|
||||
redeem_pending,
|
||||
passkey_pending: !!passkey,
|
||||
totp: search.get("totp") === "true",
|
||||
};
|
||||
@@ -103,13 +99,9 @@ const useQueryState = () => {
|
||||
|
||||
export const Router = () => {
|
||||
// Handle exchange token loop to avoid showing login flash
|
||||
const { redeem_pending, passkey_pending, totp } = useQueryState();
|
||||
const { jwt_redeem_ready, passkey_pending, totp } = useAuthState();
|
||||
|
||||
if (totp) {
|
||||
return <Login totpIsPending />;
|
||||
}
|
||||
|
||||
if (redeem_pending || passkey_pending) {
|
||||
if (jwt_redeem_ready) {
|
||||
return (
|
||||
<div className="w-screen h-screen flex justify-center items-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
@@ -117,6 +109,10 @@ export const Router = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (passkey_pending || totp) {
|
||||
return <Login passkeyIsPending={passkey_pending} totpIsPending={totp} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
+33
-16
@@ -28,10 +28,11 @@ use komodo_client::entities::{
|
||||
};
|
||||
use mongo_indexed::{create_index, create_unique_index};
|
||||
use mungos::{
|
||||
by_id::update_one_by_id,
|
||||
init::MongoBuilder,
|
||||
mongodb::{
|
||||
Collection, Database,
|
||||
bson::{doc, oid::ObjectId},
|
||||
bson::{doc, oid::ObjectId, to_bson},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -118,28 +119,44 @@ impl Client {
|
||||
user: &User,
|
||||
password: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let UserConfig::Local { .. } = user.config else {
|
||||
return Err(anyhow!(
|
||||
"User is not a 'Local' (username / password) user"
|
||||
));
|
||||
};
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password cannot be empty."));
|
||||
}
|
||||
let id = ObjectId::from_str(&user.id)
|
||||
.context("User id not valid ObjectId.")?;
|
||||
let hashed_password =
|
||||
hash_password(password).context("Failed to hash password")?;
|
||||
self
|
||||
.users
|
||||
.update_one(
|
||||
doc! { "_id": id },
|
||||
doc! { "$set": {
|
||||
"config.data.password": hashed_password
|
||||
} },
|
||||
)
|
||||
//
|
||||
let update = match user.config {
|
||||
UserConfig::Service { .. } => {
|
||||
return Err(anyhow!(
|
||||
"Service Users cannot add additional login methods"
|
||||
));
|
||||
}
|
||||
// Update a primary 'Local' user's password directly.
|
||||
UserConfig::Local { .. } => {
|
||||
doc! {
|
||||
"$set": {
|
||||
"config.data.password": hashed_password
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update User with Local password as an entry in 'additional_logins'
|
||||
_ => {
|
||||
let bson = to_bson(&UserConfig::Local {
|
||||
password: hashed_password,
|
||||
})
|
||||
.context("Failed to serialize login method to bson")?;
|
||||
doc! {
|
||||
"$set": {
|
||||
"linked_logins.Local": bson
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
update_one_by_id(&self.users, &user.id, update, None)
|
||||
.await
|
||||
.context("Failed to update user password on database.")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user