Compare commits

..
12 Commits
Author SHA1 Message Date
Maxwell BeckerandGitHub e3d8e603ec 1.15.5 (#116)
* 1.15.5
- Update your user's username and password
- **Admin**: Delete Users

* update username / password / delete user backend

* bump version

* alerter default disabled

* delete users and update username / password

* set password "" after update
2024-10-11 19:42:43 -07:00
mbecker20 8b5c179473 account recover note 2024-10-11 19:16:01 -04:00
mbecker20 8582bc92da fix Destroy Before Deploy config 2024-10-10 04:17:17 -04:00
Maxwell BeckerandGitHub 8ee270d045 1.15.4 (#114)
* stack destroy before deploy option

* add timestamps. Fix log polling even when poll not selected

* Add build [[$VERSION]] support. VERSION build arg default

* fix clippy lint

* initialize `first_builder`

* run_komodo_command uses parse_multiline_command

* comment UI for $VERSION and new command feature

* bump some deps

* support multiline commands in pre_deploy / pre_build
2024-10-10 00:37:23 -07:00
Maxwell BeckerandGitHub 2cfae525e9 1.15.3 (#109)
* fix parser support single quote '

* add stack reclone toggle

* git clone with token uses token:<TOKEN> for gitlab compatability

* support stack pre deploy shell command

* rename compose down update log stage

* deployment configure registry login account

* local testing setup

* bump version to 1.15.3

* new resources auto assign server if only one

* better error log when try to create resource with duplicate name

* end description with .

* ConfirmUpdate multi language

* fix compose write to host logic

* improve instrumentation

* improve update diff when small array

improve 2

* fix compose env file passing when repo_dir is not absolute
2024-10-08 23:07:38 -07:00
mbecker20 80e5d2a972 frontend dev setup guide 2024-10-08 16:55:24 -04:00
mbecker20 6f22c011a6 builder / server template add correct additional line if empty params 2024-10-07 22:55:48 -04:00
mbecker20 401cccee79 config nav buttons secondary 2024-10-07 21:55:14 -04:00
mbecker20 654b923f98 fix broken link to periphery setup 2024-10-07 18:56:14 -04:00
mbecker20 61261be70f update docs, split connecting servers out of Core Setup 2024-10-07 18:54:00 -04:00
mbecker20 46418125e3 update docs for periphery systemd --user install 2024-10-07 18:53:43 -04:00
mbecker20 e029e94f0d 1.15.2 Pass KOMODO_OIDC_ADDITIONAL_AUDIENCES 2024-10-07 15:44:51 -04:00
95 changed files with 1896 additions and 721 deletions
+7 -1
View File
@@ -5,4 +5,10 @@ LICENSE
*.code-workspace
*/node_modules
*/dist
*/dist
creds.toml
.core-repos
.repos
.stacks
.ssl
+6 -3
View File
@@ -1,11 +1,14 @@
target
/frontend/build
node_modules
/lib/ts_client/build
node_modules
dist
.env
.env.development
.DS_Store
creds.toml
.syncs
.core-repos
.repos
.stacks
.DS_Store
.ssl
Generated
+54 -53
View File
@@ -41,7 +41,7 @@ dependencies = [
[[package]]
name = "alerter"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"axum",
@@ -201,9 +201,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
[[package]]
name = "aws-config"
version = "1.5.7"
version = "1.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8191fb3091fa0561d1379ef80333c3c7191c6f0435d986e85821bcf7acbd1126"
checksum = "7198e6f03240fdceba36656d8be440297b6b82270325908c7381f37d826a74f6"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -268,9 +268,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ec2"
version = "1.75.0"
version = "1.77.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6787d920877cca6a4ee3953093f6a47cefe26de95a4f7b3681c5850bfe657b4"
checksum = "4bb6f841697b994ec3a020c560b52693bc9fcb7b9c69210088ab56e03df23b5e"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -292,9 +292,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.44.0"
version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b90cfe6504115e13c41d3ea90286ede5aa14da294f3fe077027a6e83850843c"
checksum = "e33ae899566f3d395cbf42858e433930682cc9c1889fa89318896082fef45efb"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -314,9 +314,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.45.0"
version = "1.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167c0fad1f212952084137308359e8e4c4724d1c643038ce163f06de9662c1d0"
checksum = "f39c09e199ebd96b9f860b0fce4b6625f211e064ad7c8693b72ecf7ef03881e0"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -336,9 +336,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.44.0"
version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cb5f98188ec1435b68097daa2a37d74b9d17c9caa799466338a8d1544e71b9d"
checksum = "3d95f93a98130389eb6233b9d615249e543f6c24a68ca1f109af9ca5164a8765"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -887,9 +887,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.19"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615"
checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
dependencies = [
"clap_builder",
"clap_derive",
@@ -897,9 +897,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.19"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b"
checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54"
dependencies = [
"anstream",
"anstyle",
@@ -943,7 +943,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"komodo_client",
"run_command",
@@ -1149,18 +1149,18 @@ dependencies = [
[[package]]
name = "derive_builder"
version = "0.20.1"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd33f37ee6a119146a1781d3356a7c26028f83d779b2e04ecd45fdc75c76877b"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.1"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7431fa049613920234f22c47fdc33e6cf3ee83067091ea4277a3f8c4587aae38"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
@@ -1170,9 +1170,9 @@ dependencies = [
[[package]]
name = "derive_builder_macro"
version = "0.20.1"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abae7035bf79b9877b779505d8cf3749285b80c43941eda66604841889451dc"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.77",
@@ -1355,7 +1355,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"thiserror",
]
@@ -1439,7 +1439,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"serror",
]
@@ -1452,9 +1452,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
@@ -1467,9 +1467,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@@ -1477,15 +1477,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
@@ -1494,15 +1494,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@@ -1511,21 +1511,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@@ -1571,7 +1571,7 @@ checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
[[package]]
name = "git"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"command",
@@ -2192,7 +2192,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"clap",
@@ -2208,7 +2208,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2239,7 +2239,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2306,6 +2306,7 @@ dependencies = [
"bollard",
"clap",
"command",
"derive_variants",
"dotenvy",
"environment_file",
"envy",
@@ -2382,7 +2383,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"komodo_client",
@@ -2446,7 +2447,7 @@ dependencies = [
[[package]]
name = "migrator"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"dotenvy",
@@ -3101,7 +3102,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"komodo_client",
@@ -4249,9 +4250,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.31.4"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be"
checksum = "e3b5ae3f4f7d64646c46c4cae4e3f01d1c5d255c7406fdd7c7f999a94e488791"
dependencies = [
"core-foundation-sys",
"libc",
@@ -4879,7 +4880,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "update_logger"
version = "1.15.1"
version = "1.15.5"
dependencies = [
"anyhow",
"komodo_client",
+8 -8
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["bin/*", "lib/*", "client/core/rs", "client/periphery/rs"]
[workspace.package]
version = "1.15.1"
version = "1.15.5"
edition = "2021"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -44,8 +44,8 @@ svi = "1.0.1"
reqwest = { version = "0.12.8", features = ["json"] }
tokio = { version = "1.38.1", features = ["full"] }
tokio-util = "0.7.12"
futures = "0.3.30"
futures-util = "0.3.30"
futures = "0.3.31"
futures-util = "0.3.31"
# SERVER
axum-extra = { version = "0.9.4", features = ["typed-header"] }
@@ -76,7 +76,7 @@ opentelemetry = "0.25.0"
tracing = "0.1.40"
# CONFIG
clap = { version = "4.5.19", features = ["derive"] }
clap = { version = "4.5.20", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
@@ -95,14 +95,14 @@ hex = "0.4.3"
# SYSTEM
bollard = "0.17.1"
sysinfo = "0.31.4"
sysinfo = "0.32.0"
# CLOUD
aws-config = "1.5.7"
aws-sdk-ec2 = "1.75.0"
aws-config = "1.5.8"
aws-sdk-ec2 = "1.77.0"
# MISC
derive_builder = "0.20.1"
derive_builder = "0.20.2"
typeshare = "1.0.3"
octorust = "0.7.0"
dashmap = "6.1.0"
+21 -15
View File
@@ -10,36 +10,42 @@ use komodo_client::entities::{
ResourceTargetVariant,
};
use mungos::{find::find_collect, mongodb::bson::doc};
use tracing::Instrument;
use crate::{config::core_config, state::db_client};
mod discord;
mod slack;
#[instrument]
pub async fn send_alerts(alerts: &[Alert]) {
if alerts.is_empty() {
return;
}
let Ok(alerters) = find_collect(
&db_client().alerters,
doc! { "config.enabled": true },
None,
)
.await
.inspect_err(|e| {
error!(
let span =
info_span!("send_alerts", alerts = format!("{alerts:?}"));
async {
let Ok(alerters) = find_collect(
&db_client().alerters,
doc! { "config.enabled": true },
None,
)
.await
.inspect_err(|e| {
error!(
"ERROR sending alerts | failed to get alerters from db | {e:#}"
)
}) else {
return;
};
}) else {
return;
};
let handles =
alerts.iter().map(|alert| send_alert(&alerters, alert));
let handles =
alerts.iter().map(|alert| send_alert(&alerters, alert));
join_all(handles).await;
join_all(handles).await;
}
.instrument(span)
.await
}
#[instrument(level = "debug")]
+9 -2
View File
@@ -64,7 +64,7 @@ impl Resolve<RunBuild, (User, Update)> for State {
PermissionLevel::Execute,
)
.await?;
let vars_and_secrets = get_variables_and_secrets().await?;
let mut vars_and_secrets = get_variables_and_secrets().await?;
if build.config.builder_id.is_empty() {
return Err(anyhow!("Must attach builder to RunBuild"));
@@ -85,6 +85,14 @@ impl Resolve<RunBuild, (User, Update)> for State {
update.version = build.config.version;
update_update(update.clone()).await?;
// Add the $VERSION to variables. Use with [[$VERSION]]
if !vars_and_secrets.variables.contains_key("$VERSION") {
vars_and_secrets.variables.insert(
String::from("$VERSION"),
build.config.version.to_string(),
);
}
let git_token = git_token(
&build.config.git_provider,
&build.config.git_account,
@@ -171,7 +179,6 @@ impl Resolve<RunBuild, (User, Update)> for State {
};
// CLONE REPO
let secret_replacers = if !build.config.skip_secret_interp {
// Interpolate variables / secrets into pre build command
let mut global_replacers = HashSet::new();
+14 -2
View File
@@ -2,6 +2,7 @@ use std::time::Instant;
use anyhow::{anyhow, Context};
use axum::{middleware, routing::post, Extension, Router};
use derive_variants::{EnumVariants, ExtractVariant};
use formatting::format_serror;
use komodo_client::{
api::execute::*,
@@ -33,7 +34,10 @@ mod stack;
mod sync;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Resolver)]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolver, EnumVariants,
)]
#[variant_derive(Debug)]
#[resolver_target(State)]
#[resolver_args((User, Update))]
#[serde(tag = "type", content = "params")]
@@ -154,7 +158,15 @@ async fn handler(
Ok(Json(update))
}
#[instrument(name = "ExecuteRequest", skip(user, update), fields(user_id = user.id, update_id = update.id))]
#[instrument(
name = "ExecuteRequest",
skip(user, update),
fields(
user_id = user.id,
update_id = update.id,
request = format!("{:?}", request.extract_variant()))
)
]
async fn task(
req_id: Uuid,
request: ExecuteRequest,
+8
View File
@@ -19,6 +19,7 @@ use crate::{
add_interp_update_log,
interpolate_variables_secrets_into_extra_args,
interpolate_variables_secrets_into_string,
interpolate_variables_secrets_into_system_command,
},
periphery_client,
query::get_variables_and_secrets,
@@ -102,6 +103,13 @@ impl Resolve<DeployStack, (User, Update)> for State {
&mut secret_replacers,
)?;
interpolate_variables_secrets_into_system_command(
&vars_and_secrets,
&mut stack.config.pre_deploy,
&mut global_replacers,
&mut secret_replacers,
)?;
add_interp_update_log(
&mut update,
&global_replacers,
+8 -1
View File
@@ -88,7 +88,11 @@ const MAX_LOG_LENGTH: u64 = 5000;
impl Resolve<GetDeploymentLog, User> for State {
async fn resolve(
&self,
GetDeploymentLog { deployment, tail }: GetDeploymentLog,
GetDeploymentLog {
deployment,
tail,
timestamps,
}: GetDeploymentLog,
user: User,
) -> anyhow::Result<Log> {
let Deployment {
@@ -109,6 +113,7 @@ impl Resolve<GetDeploymentLog, User> for State {
.request(api::container::GetContainerLog {
name,
tail: cmp::min(tail, MAX_LOG_LENGTH),
timestamps,
})
.await
.context("failed at call to periphery")
@@ -123,6 +128,7 @@ impl Resolve<SearchDeploymentLog, User> for State {
terms,
combinator,
invert,
timestamps,
}: SearchDeploymentLog,
user: User,
) -> anyhow::Result<Log> {
@@ -146,6 +152,7 @@ impl Resolve<SearchDeploymentLog, User> for State {
terms,
combinator,
invert,
timestamps,
})
.await
.context("failed at call to periphery")
+4
View File
@@ -404,6 +404,7 @@ impl Resolve<GetContainerLog, User> for State {
server,
container,
tail,
timestamps,
}: GetContainerLog,
user: User,
) -> anyhow::Result<Log> {
@@ -417,6 +418,7 @@ impl Resolve<GetContainerLog, User> for State {
.request(periphery::container::GetContainerLog {
name: container,
tail: cmp::min(tail, MAX_LOG_LENGTH),
timestamps,
})
.await
.context("failed at call to periphery")
@@ -432,6 +434,7 @@ impl Resolve<SearchContainerLog, User> for State {
terms,
combinator,
invert,
timestamps,
}: SearchContainerLog,
user: User,
) -> anyhow::Result<Log> {
@@ -447,6 +450,7 @@ impl Resolve<SearchContainerLog, User> for State {
terms,
combinator,
invert,
timestamps,
})
.await
.context("failed at call to periphery")
+4
View File
@@ -70,6 +70,7 @@ impl Resolve<GetStackServiceLog, User> for State {
stack,
service,
tail,
timestamps,
}: GetStackServiceLog,
user: User,
) -> anyhow::Result<GetStackServiceLogResponse> {
@@ -85,6 +86,7 @@ impl Resolve<GetStackServiceLog, User> for State {
project: stack.project_name(false),
service,
tail,
timestamps,
})
.await
.context("failed to get stack service log from periphery")
@@ -100,6 +102,7 @@ impl Resolve<SearchStackServiceLog, User> for State {
terms,
combinator,
invert,
timestamps,
}: SearchStackServiceLog,
user: User,
) -> anyhow::Result<SearchStackServiceLogResponse> {
@@ -117,6 +120,7 @@ impl Resolve<SearchStackServiceLog, User> for State {
terms,
combinator,
invert,
timestamps,
})
.await
.context("failed to get stack service log from periphery")
+10 -1
View File
@@ -28,6 +28,7 @@ mod service_user;
mod stack;
mod sync;
mod tag;
mod user;
mod user_group;
mod variable;
@@ -40,6 +41,11 @@ mod variable;
#[resolver_args(User)]
#[serde(tag = "type", content = "params")]
pub enum WriteRequest {
// ==== USER ====
UpdateUserUsername(UpdateUserUsername),
UpdateUserPassword(UpdateUserPassword),
DeleteUser(DeleteUser),
// ==== SERVICE USER ====
CreateServiceUser(CreateServiceUser),
UpdateServiceUserDescription(UpdateServiceUserDescription),
@@ -188,7 +194,10 @@ async fn handler(
#[instrument(
name = "WriteRequest",
skip(user, request),
fields(user_id = user.id, request = format!("{:?}", request.extract_variant()))
fields(
user_id = user.id,
request = format!("{:?}", request.extract_variant())
)
)]
async fn task(
req_id: Uuid,
+130
View File
@@ -0,0 +1,130 @@
use std::str::FromStr;
use anyhow::{anyhow, Context};
use komodo_client::{
api::write::{
DeleteUser, DeleteUserResponse, UpdateUserPassword,
UpdateUserPasswordResponse, UpdateUserUsername,
UpdateUserUsernameResponse,
},
entities::{
user::{User, UserConfig},
NoData,
},
};
use mungos::mongodb::bson::{doc, oid::ObjectId};
use resolver_api::Resolve;
use crate::{
helpers::hash_password,
state::{db_client, State},
};
//
impl Resolve<UpdateUserUsername, User> for State {
async fn resolve(
&self,
UpdateUserUsername { username }: UpdateUserUsername,
user: User,
) -> anyhow::Result<UpdateUserUsernameResponse> {
if username.is_empty() {
return Err(anyhow!("Username cannot be empty."));
}
let db = db_client();
if db
.users
.find_one(doc! { "username": &username })
.await
.context("Failed to query for existing users")?
.is_some()
{
return Err(anyhow!("Username already taken."));
}
let id = ObjectId::from_str(&user.id)
.context("User id not valid ObjectId.")?;
db.users
.update_one(
doc! { "_id": id },
doc! { "$set": { "username": username } },
)
.await
.context("Failed to update user username on database.")?;
Ok(NoData {})
}
}
//
impl Resolve<UpdateUserPassword, User> for State {
async fn resolve(
&self,
UpdateUserPassword { password }: UpdateUserPassword,
user: User,
) -> anyhow::Result<UpdateUserPasswordResponse> {
let UserConfig::Local { .. } = user.config else {
return Err(anyhow!("User is not local 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)?;
db_client()
.users
.update_one(
doc! { "_id": id },
doc! { "$set": {
"config.data.password": hashed_password
} },
)
.await
.context("Failed to update user password on database.")?;
Ok(NoData {})
}
}
//
impl Resolve<DeleteUser, User> for State {
async fn resolve(
&self,
DeleteUser { user }: DeleteUser,
admin: User,
) -> anyhow::Result<DeleteUserResponse> {
if !admin.admin {
return Err(anyhow!("Calling user is not admin."));
}
if admin.username == user || admin.id == user {
return Err(anyhow!("User cannot delete themselves."));
}
let query = if let Ok(id) = ObjectId::from_str(&user) {
doc! { "_id": id }
} else {
doc! { "username": user }
};
let db = db_client();
let Some(user) = db
.users
.find_one(query.clone())
.await
.context("Failed to query database for users.")?
else {
return Err(anyhow!("No user found with given id / username"));
};
if user.super_admin {
return Err(anyhow!("Cannot delete a super admin user."));
}
if user.admin && !admin.super_admin {
return Err(anyhow!(
"Only a Super Admin can delete an admin user."
));
}
db.users
.delete_one(query)
.await
.context("Failed to delete user from database")?;
Ok(user)
}
}
+6 -7
View File
@@ -16,12 +16,10 @@ use resolver_api::Resolve;
use crate::{
config::core_config,
state::State,
state::{db_client, jwt_client},
helpers::hash_password,
state::{db_client, jwt_client, State},
};
const BCRYPT_COST: u32 = 10;
impl Resolve<CreateLocalUser, HeaderMap> for State {
#[instrument(name = "CreateLocalUser", skip(self))]
async fn resolve(
@@ -47,8 +45,7 @@ impl Resolve<CreateLocalUser, HeaderMap> for State {
return Err(anyhow!("Password cannot be empty string"));
}
let password = bcrypt::hash(password, BCRYPT_COST)
.context("failed to hash password")?;
let hashed_password = hash_password(password)?;
let no_users_exist =
db_client().users.find_one(Document::new()).await?.is_none();
@@ -71,7 +68,9 @@ impl Resolve<CreateLocalUser, HeaderMap> for State {
last_update_view: 0,
recents: Default::default(),
all: Default::default(),
config: UserConfig::Local { password },
config: UserConfig::Local {
password: hashed_password,
},
};
let user_id = db_client()
+14 -1
View File
@@ -152,8 +152,21 @@ async fn callback(
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(&client.id_token_verifier(), &nonce)
.claims(&verifier, &nonce)
.context("Failed to verify token claims")?;
// Verify the access token hash to ensure that the access token hasn't been substituted for
+3
View File
@@ -87,6 +87,9 @@ pub fn core_config() -> &'static CoreConfig {
.unwrap_or(config.oidc_client_secret),
oidc_use_full_email: env.komodo_oidc_use_full_email
.unwrap_or(config.oidc_use_full_email),
oidc_additional_audiences: maybe_read_list_from_file(env.komodo_oidc_additional_audiences_file,env
.komodo_oidc_additional_audiences)
.unwrap_or(config.oidc_additional_audiences),
google_oauth: OauthCredentials {
enabled: env
.komodo_google_oauth_enabled
+50 -14
View File
@@ -3,8 +3,9 @@ use std::{str::FromStr, time::Duration};
use anyhow::{anyhow, Context};
use futures::future::join_all;
use komodo_client::{
api::write::CreateServer,
api::write::{CreateBuilder, CreateServer},
entities::{
builder::{PartialBuilderConfig, PartialServerBuilderConfig},
komodo_timestamp,
permission::{Permission, PermissionLevel, UserTarget},
server::{PartialServerConfig, Server},
@@ -65,6 +66,15 @@ pub fn random_string(length: usize) -> String {
.collect()
}
const BCRYPT_COST: u32 = 10;
pub fn hash_password<P>(password: P) -> anyhow::Result<String>
where
P: AsRef<[u8]>,
{
bcrypt::hash(password, BCRYPT_COST)
.context("failed to hash password")
}
/// First checks db for token, then checks core config.
/// Only errors if db call errors.
/// Returns (token, use_https)
@@ -280,8 +290,8 @@ async fn startup_open_alert_cleanup() {
}
}
/// Ensures a default server exists with the defined address
pub async fn ensure_first_server() {
/// Ensures a default server / builder exists with the defined address
pub async fn ensure_first_server_and_builder() {
let first_server = &core_config().first_server;
if first_server.is_empty() {
return;
@@ -295,23 +305,49 @@ pub async fn ensure_first_server() {
else {
return;
};
if server.is_some() {
return;
}
let server = if let Some(server) = server {
server
} else {
match State
.resolve(
CreateServer {
name: format!("server-{}", random_string(5)),
config: PartialServerConfig {
address: Some(first_server.to_string()),
enabled: Some(true),
..Default::default()
},
},
system_user().to_owned(),
)
.await
{
Ok(server) => server,
Err(e) => {
error!("Failed to initialize 'first_server'. Failed to CreateServer. {e:?}");
return;
}
}
};
let Ok(None) = db.builders
.find_one(Document::new()).await
.inspect_err(|e| error!("Failed to initialize 'first_builder'. Failed to query db. {e:?}")) else {
return;
};
if let Err(e) = State
.resolve(
CreateServer {
name: format!("server-{}", random_string(5)),
config: PartialServerConfig {
address: Some(first_server.to_string()),
enabled: Some(true),
..Default::default()
},
CreateBuilder {
name: String::from("local"),
config: PartialBuilderConfig::Server(
PartialServerBuilderConfig {
server_id: Some(server.id),
},
),
},
system_user().to_owned(),
)
.await
{
error!("Failed to initialize 'first_server'. Failed to CreateServer. {e:?}");
error!("Failed to initialize 'first_builder'. Failed to CreateBuilder. {e:?}");
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ async fn app() -> anyhow::Result<()> {
);
tokio::join!(
// Maybe initialize first server
helpers::ensure_first_server(),
helpers::ensure_first_server_and_builder(),
// Cleanup open updates / invalid alerts
helpers::startup_cleanup(),
);
+1
View File
@@ -57,6 +57,7 @@ impl super::KomodoResource for Build {
version: build.config.version,
builder_id: build.config.builder_id,
git_provider: build.config.git_provider,
image_registry_domain: build.config.image_registry.domain,
repo: build.config.repo,
branch: build.config.branch,
built_hash: build.info.built_hash,
+12 -1
View File
@@ -15,7 +15,7 @@ use komodo_client::{
tag::Tag,
to_komodo_name,
update::Update,
user::User,
user::{system_user, User},
Operation, ResourceTarget, ResourceTargetVariant,
},
};
@@ -508,6 +508,17 @@ pub async fn create<T: KomodoResource>(
return Err(anyhow!("valid ObjectIds cannot be used as names."));
}
// Ensure an existing resource with same name doesn't already exist
// The database indexing also ensures this but doesn't give a good error message.
if list_full_for_user::<T>(Default::default(), system_user())
.await
.context("Failed to list all resources for duplicate name check")?
.into_iter()
.any(|r| r.name == name)
{
return Err(anyhow!("Must provide unique name for resource."));
}
let start_ts = komodo_timestamp();
T::validate_create_config(&mut config, user).await?;
+3 -3
View File
@@ -274,7 +274,7 @@ impl ToToml for Build {
config
.into_iter()
.map(|(key, value)| match key.as_str() {
"builder_id" => return Ok((String::from("builder"), value)),
"builder_id" => Ok((String::from("builder"), value)),
"version" => {
match (
&resource.config.version,
@@ -353,7 +353,7 @@ impl ToToml for ServerTemplate {
if empty_params {
// toml_pretty will remove empty map
// but in this case its needed to deserialize the enums.
toml.push_str("\nconfig.params = {}");
toml.push_str("\nparams = {}");
}
}
}
@@ -385,7 +385,7 @@ impl ToToml for Builder {
if empty_params {
// toml_pretty will remove empty map
// but in this case its needed to deserialize the enums.
toml.push_str("\nconfig.params = {}");
toml.push_str("\nparams = {}");
}
}
}
+1
View File
@@ -26,6 +26,7 @@ git.workspace = true
serror = { workspace = true, features = ["axum"] }
merge_config_files.workspace = true
async_timing_util.workspace = true
derive_variants.workspace = true
resolver_api.workspace = true
run_command.workspace = true
svi.workspace = true
+27 -12
View File
@@ -87,10 +87,18 @@ impl Resolve<build::Build> for State {
// Get command parts
let image_name =
get_image_name(&build).context("failed to make image name")?;
let build_args = parse_build_args(
&environment_vars_from_str(build_args)
.context("Invalid build_args")?,
);
// Add VERSION to build args (if not already there)
let mut build_args = environment_vars_from_str(build_args)
.context("Invalid build_args")?;
if !build_args.iter().any(|a| a.variable == "VERSION") {
build_args.push(EnvironmentVar {
variable: String::from("VERSION"),
value: build.config.version.to_string(),
});
}
let build_args = parse_build_args(&build_args);
let secret_args = environment_vars_from_str(secret_args)
.context("Invalid secret_args")?;
let _secret_args =
@@ -110,13 +118,16 @@ impl Resolve<build::Build> for State {
// Construct command
let command = format!(
"cd {} && docker{buildx} build{build_args}{_secret_args}{extra_args}{labels}{image_tags} -f {dockerfile_path} .{push_command}",
build_dir.display()
"docker{buildx} build{build_args}{_secret_args}{extra_args}{labels}{image_tags} -f {dockerfile_path} .{push_command}",
);
if *skip_secret_interp {
let build_log =
run_komodo_command("docker build", command).await;
let build_log = run_komodo_command(
"docker build",
build_dir.as_ref(),
command,
)
.await;
logs.push(build_log);
} else {
// Interpolate any missing secrets
@@ -131,8 +142,12 @@ impl Resolve<build::Build> for State {
)?;
replacers.extend(core_replacers);
let mut build_log =
run_komodo_command("docker build", command).await;
let mut build_log = run_komodo_command(
"docker build",
build_dir.as_ref(),
command,
)
.await;
build_log.command =
svi::replace_in_string(&build_log.command, &replacers);
build_log.stdout =
@@ -229,7 +244,7 @@ impl Resolve<PruneBuilders> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker builder prune -a -f");
Ok(run_komodo_command("prune builders", command).await)
Ok(run_komodo_command("prune builders", None, command).await)
}
}
@@ -243,6 +258,6 @@ impl Resolve<PruneBuildx> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker buildx prune -a -f");
Ok(run_komodo_command("prune buildx", command).await)
Ok(run_komodo_command("prune buildx", None, command).await)
}
}
+74 -69
View File
@@ -28,6 +28,7 @@ impl Resolve<ListComposeProjects, ()> for State {
let docker_compose = docker_compose();
let res = run_komodo_command(
"list projects",
None,
format!("{docker_compose} ls --all --format json"),
)
.await;
@@ -76,6 +77,61 @@ pub struct DockerComposeLsItem {
//
impl Resolve<GetComposeServiceLog> for State {
#[instrument(
name = "GetComposeServiceLog",
level = "debug",
skip(self)
)]
async fn resolve(
&self,
GetComposeServiceLog {
project,
service,
tail,
timestamps,
}: GetComposeServiceLog,
_: (),
) -> anyhow::Result<Log> {
let docker_compose = docker_compose();
let timestamps =
timestamps.then_some(" --timestamps").unwrap_or_default();
let command = format!(
"{docker_compose} -p {project} logs {service} --tail {tail}{timestamps}"
);
Ok(run_komodo_command("get stack log", None, command).await)
}
}
impl Resolve<GetComposeServiceLogSearch> for State {
#[instrument(
name = "GetComposeServiceLogSearch",
level = "debug",
skip(self)
)]
async fn resolve(
&self,
GetComposeServiceLogSearch {
project,
service,
terms,
combinator,
invert,
timestamps,
}: GetComposeServiceLogSearch,
_: (),
) -> anyhow::Result<Log> {
let docker_compose = docker_compose();
let grep = log_grep(&terms, combinator, invert);
let timestamps =
timestamps.then_some(" --timestamps").unwrap_or_default();
let command = format!("{docker_compose} -p {project} logs {service} --tail 5000{timestamps} 2>&1 | {grep}");
Ok(run_komodo_command("get stack log grep", None, command).await)
}
}
//
const DEFAULT_COMPOSE_CONTENTS: &str = "## 🦎 Hello Komodo 🦎
services:
hello_world:
@@ -95,6 +151,11 @@ services:
";
impl Resolve<GetComposeContentsOnHost, ()> for State {
#[instrument(
name = "GetComposeContentsOnHost",
level = "debug",
skip(self)
)]
async fn resolve(
&self,
GetComposeContentsOnHost {
@@ -115,16 +176,11 @@ impl Resolve<GetComposeContentsOnHost, ()> for State {
.context("Failed to initialize run directory")?;
}
let file_paths = file_paths
.iter()
.map(|path| {
run_directory.join(path).components().collect::<PathBuf>()
})
.collect::<Vec<_>>();
let mut res = GetComposeContentsOnHostResponse::default();
for full_path in &file_paths {
for path in file_paths {
let full_path =
run_directory.join(&path).components().collect::<PathBuf>();
if !full_path.exists() {
fs::write(&full_path, DEFAULT_COMPOSE_CONTENTS)
.await
@@ -136,74 +192,25 @@ impl Resolve<GetComposeContentsOnHost, ()> for State {
)
}) {
Ok(contents) => {
res.contents.push(FileContents {
path: full_path.display().to_string(),
contents,
});
// The path we store here has to be the same as incoming file path in the array,
// in order for WriteComposeContentsToHost to write to the correct path.
res.contents.push(FileContents { path, contents });
}
Err(e) => {
res.errors.push(FileContents {
path: full_path.display().to_string(),
path,
contents: format_serror(&e.into()),
});
}
}
}
Ok(res)
}
}
//
impl Resolve<GetComposeServiceLog> for State {
#[instrument(
name = "GetComposeServiceLog",
level = "debug",
skip(self)
)]
async fn resolve(
&self,
GetComposeServiceLog {
project,
service,
tail,
}: GetComposeServiceLog,
_: (),
) -> anyhow::Result<Log> {
let docker_compose = docker_compose();
let command = format!(
"{docker_compose} -p {project} logs {service} --tail {tail}"
);
Ok(run_komodo_command("get stack log", command).await)
}
}
impl Resolve<GetComposeServiceLogSearch> for State {
#[instrument(
name = "GetComposeServiceLogSearch",
level = "debug",
skip(self)
)]
async fn resolve(
&self,
GetComposeServiceLogSearch {
project,
service,
terms,
combinator,
invert,
}: GetComposeServiceLogSearch,
_: (),
) -> anyhow::Result<Log> {
let docker_compose = docker_compose();
let grep = log_grep(&terms, combinator, invert);
let command = format!("{docker_compose} -p {project} logs {service} --tail 5000 2>&1 | {grep}");
Ok(run_komodo_command("get stack log grep", command).await)
}
}
//
impl Resolve<WriteComposeContentsToHost> for State {
#[instrument(name = "WriteComposeContentsToHost", skip(self))]
async fn resolve(
@@ -216,13 +223,10 @@ impl Resolve<WriteComposeContentsToHost> for State {
}: WriteComposeContentsToHost,
_: (),
) -> anyhow::Result<Log> {
let root =
periphery_config().stack_dir.join(to_komodo_name(&name));
let run_directory = root.join(&run_directory);
let run_directory = run_directory.canonicalize().context(
"failed to validate run directory on host (canonicalize error)",
)?;
let file_path = run_directory
let file_path = periphery_config()
.stack_dir
.join(to_komodo_name(&name))
.join(&run_directory)
.join(file_path)
.components()
.collect::<PathBuf>();
@@ -292,6 +296,7 @@ impl Resolve<ComposeExecution> for State {
let docker_compose = docker_compose();
let log = run_komodo_command(
"compose command",
None,
format!("{docker_compose} -p {project} {command}"),
)
.await;
+52 -35
View File
@@ -42,11 +42,18 @@ impl Resolve<GetContainerLog> for State {
#[instrument(name = "GetContainerLog", level = "debug", skip(self))]
async fn resolve(
&self,
GetContainerLog { name, tail }: GetContainerLog,
GetContainerLog {
name,
tail,
timestamps,
}: GetContainerLog,
_: (),
) -> anyhow::Result<Log> {
let command = format!("docker logs {name} --tail {tail}");
Ok(run_komodo_command("get container log", command).await)
let timestamps =
timestamps.then_some(" --timestamps").unwrap_or_default();
let command =
format!("docker logs {name} --tail {tail}{timestamps}");
Ok(run_komodo_command("get container log", None, command).await)
}
}
@@ -65,13 +72,20 @@ impl Resolve<GetContainerLogSearch> for State {
terms,
combinator,
invert,
timestamps,
}: GetContainerLogSearch,
_: (),
) -> anyhow::Result<Log> {
let grep = log_grep(&terms, combinator, invert);
let command =
format!("docker logs {name} --tail 5000 2>&1 | {grep}");
Ok(run_komodo_command("get container log grep", command).await)
let timestamps =
timestamps.then_some(" --timestamps").unwrap_or_default();
let command = format!(
"docker logs {name} --tail 5000{timestamps} 2>&1 | {grep}"
);
Ok(
run_komodo_command("get container log grep", None, command)
.await,
)
}
}
@@ -126,6 +140,7 @@ impl Resolve<StartContainer> for State {
Ok(
run_komodo_command(
"docker start",
None,
format!("docker start {name}"),
)
.await,
@@ -145,6 +160,7 @@ impl Resolve<RestartContainer> for State {
Ok(
run_komodo_command(
"docker restart",
None,
format!("docker restart {name}"),
)
.await,
@@ -164,6 +180,7 @@ impl Resolve<PauseContainer> for State {
Ok(
run_komodo_command(
"docker pause",
None,
format!("docker pause {name}"),
)
.await,
@@ -181,6 +198,7 @@ impl Resolve<UnpauseContainer> for State {
Ok(
run_komodo_command(
"docker unpause",
None,
format!("docker unpause {name}"),
)
.await,
@@ -198,10 +216,11 @@ impl Resolve<StopContainer> for State {
_: (),
) -> anyhow::Result<Log> {
let command = stop_container_command(&name, signal, time);
let log = run_komodo_command("docker stop", command).await;
let log = run_komodo_command("docker stop", None, command).await;
if log.stderr.contains("unknown flag: --signal") {
let command = stop_container_command(&name, None, time);
let mut log = run_komodo_command("docker stop", command).await;
let mut log =
run_komodo_command("docker stop", None, command).await;
log.stderr = format!(
"old docker version: unable to use --signal flag{}",
if !log.stderr.is_empty() {
@@ -230,12 +249,14 @@ impl Resolve<RemoveContainer> for State {
let command =
format!("{stop_command} && docker container rm {name}");
let log =
run_komodo_command("docker stop and remove", command).await;
run_komodo_command("docker stop and remove", None, command)
.await;
if log.stderr.contains("unknown flag: --signal") {
let stop_command = stop_container_command(&name, None, time);
let command =
format!("{stop_command} && docker container rm {name}");
let mut log = run_komodo_command("docker stop", command).await;
let mut log =
run_komodo_command("docker stop", None, command).await;
log.stderr = format!(
"old docker version: unable to use --signal flag{}",
if !log.stderr.is_empty() {
@@ -265,7 +286,7 @@ impl Resolve<RenameContainer> for State {
) -> anyhow::Result<Log> {
let new = to_komodo_name(&new_name);
let command = format!("docker rename {curr_name} {new}");
Ok(run_komodo_command("docker rename", command).await)
Ok(run_komodo_command("docker rename", None, command).await)
}
}
@@ -279,7 +300,7 @@ impl Resolve<PruneContainers> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker container prune -f");
Ok(run_komodo_command("prune containers", command).await)
Ok(run_komodo_command("prune containers", None, command).await)
}
}
@@ -297,14 +318,12 @@ impl Resolve<StartAllContainers> for State {
.await
.context("failed to list all containers on host")?;
let futures =
containers.iter().map(
|ContainerListItem { name, .. }| {
let command = format!("docker start {name}");
async move {
run_komodo_command(&command.clone(), command).await
}
},
);
containers.iter().map(|ContainerListItem { name, .. }| {
let command = format!("docker start {name}");
async move {
run_komodo_command(&command.clone(), None, command).await
}
});
Ok(join_all(futures).await)
}
}
@@ -322,14 +341,13 @@ impl Resolve<RestartAllContainers> for State {
.list_containers()
.await
.context("failed to list all containers on host")?;
let futures = containers.iter().map(
|ContainerListItem { name, .. }| {
let futures =
containers.iter().map(|ContainerListItem { name, .. }| {
let command = format!("docker restart {name}");
async move {
run_komodo_command(&command.clone(), command).await
run_komodo_command(&command.clone(), None, command).await
}
},
);
});
Ok(join_all(futures).await)
}
}
@@ -347,14 +365,13 @@ impl Resolve<PauseAllContainers> for State {
.list_containers()
.await
.context("failed to list all containers on host")?;
let futures = containers.iter().map(
|ContainerListItem { name, .. }| {
let futures =
containers.iter().map(|ContainerListItem { name, .. }| {
let command = format!("docker pause {name}");
async move {
run_komodo_command(&command.clone(), command).await
run_komodo_command(&command.clone(), None, command).await
}
},
);
});
Ok(join_all(futures).await)
}
}
@@ -372,14 +389,13 @@ impl Resolve<UnpauseAllContainers> for State {
.list_containers()
.await
.context("failed to list all containers on host")?;
let futures = containers.iter().map(
|ContainerListItem { name, .. }| {
let futures =
containers.iter().map(|ContainerListItem { name, .. }| {
let command = format!("docker unpause {name}");
async move {
run_komodo_command(&command.clone(), command).await
run_komodo_command(&command.clone(), None, command).await
}
},
);
});
Ok(join_all(futures).await)
}
}
@@ -401,6 +417,7 @@ impl Resolve<StopAllContainers> for State {
|ContainerListItem { name, .. }| async move {
run_komodo_command(
&format!("docker stop {name}"),
None,
stop_container_command(name, None, None),
)
.await
+3 -2
View File
@@ -87,7 +87,7 @@ impl Resolve<Deploy> for State {
debug!("docker run command: {command}");
if deployment.config.skip_secret_interp {
Ok(run_komodo_command("docker run", command).await)
Ok(run_komodo_command("docker run", None, command).await)
} else {
let command = svi::interpolate_variables(
&command,
@@ -107,7 +107,8 @@ impl Resolve<Deploy> for State {
};
replacers.extend(core_replacers);
let mut log = run_komodo_command("docker run", command).await;
let mut log =
run_komodo_command("docker run", None, command).await;
log.command = svi::replace_in_string(&log.command, &replacers);
log.stdout = svi::replace_in_string(&log.stdout, &replacers);
log.stderr = svi::replace_in_string(&log.stderr, &replacers);
+2 -2
View File
@@ -44,7 +44,7 @@ impl Resolve<DeleteImage> for State {
_: (),
) -> anyhow::Result<Log> {
let command = format!("docker image rm {name}");
Ok(run_komodo_command("delete image", command).await)
Ok(run_komodo_command("delete image", None, command).await)
}
}
@@ -58,6 +58,6 @@ impl Resolve<PruneImages> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker image prune -a -f");
Ok(run_komodo_command("prune images", command).await)
Ok(run_komodo_command("prune images", None, command).await)
}
}
+8 -4
View File
@@ -1,5 +1,6 @@
use anyhow::Context;
use command::run_komodo_command;
use derive_variants::EnumVariants;
use futures::TryFutureExt;
use komodo_client::entities::{update::Log, SystemCommand};
use periphery_client::api::{
@@ -30,7 +31,10 @@ mod network;
mod stats;
mod volume;
#[derive(Serialize, Deserialize, Debug, Clone, Resolver)]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolver, EnumVariants,
)]
#[variant_derive(Debug)]
#[serde(tag = "type", content = "params")]
#[resolver_target(State)]
#[allow(clippy::enum_variant_names, clippy::large_enum_variant)]
@@ -206,7 +210,7 @@ impl ResolveToString<ListSecrets> for State {
}
impl Resolve<GetDockerLists> for State {
#[instrument(name = "GetDockerLists", skip(self))]
#[instrument(name = "GetDockerLists", level = "debug", skip(self))]
async fn resolve(
&self,
GetDockerLists {}: GetDockerLists,
@@ -251,7 +255,7 @@ impl Resolve<RunCommand> for State {
} else {
format!("cd {path} && {command}")
};
run_komodo_command("run command", command).await
run_komodo_command("run command", None, command).await
})
.await
.context("failure in spawned task")
@@ -266,6 +270,6 @@ impl Resolve<PruneSystem> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker system prune -a -f --volumes");
Ok(run_komodo_command("prune system", command).await)
Ok(run_komodo_command("prune system", None, command).await)
}
}
+3 -3
View File
@@ -34,7 +34,7 @@ impl Resolve<CreateNetwork> for State {
None => String::new(),
};
let command = format!("docker network create{driver} {name}");
Ok(run_komodo_command("create network", command).await)
Ok(run_komodo_command("create network", None, command).await)
}
}
@@ -48,7 +48,7 @@ impl Resolve<DeleteNetwork> for State {
_: (),
) -> anyhow::Result<Log> {
let command = format!("docker network rm {name}");
Ok(run_komodo_command("delete network", command).await)
Ok(run_komodo_command("delete network", None, command).await)
}
}
@@ -62,6 +62,6 @@ impl Resolve<PruneNetworks> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker network prune -f");
Ok(run_komodo_command("prune networks", command).await)
Ok(run_komodo_command("prune networks", None, command).await)
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ impl Resolve<DeleteVolume> for State {
_: (),
) -> anyhow::Result<Log> {
let command = format!("docker volume rm {name}");
Ok(run_komodo_command("delete volume", command).await)
Ok(run_komodo_command("delete volume", None, command).await)
}
}
@@ -42,6 +42,6 @@ impl Resolve<PruneVolumes> for State {
_: (),
) -> anyhow::Result<Log> {
let command = String::from("docker volume prune -a -f");
Ok(run_komodo_command("prune volumes", command).await)
Ok(run_komodo_command("prune volumes", None, command).await)
}
}
+158 -46
View File
@@ -10,14 +10,16 @@ use komodo_client::entities::{
};
use periphery_client::api::{
compose::ComposeUpResponse,
git::{PullOrCloneRepo, RepoActionResponse},
git::{CloneRepo, PullOrCloneRepo, RepoActionResponse},
};
use resolver_api::Resolve;
use tokio::fs;
use crate::{
config::periphery_config, docker::docker_login,
helpers::parse_extra_args, State,
config::periphery_config,
docker::docker_login,
helpers::{interpolate_variables, parse_extra_args},
State,
};
pub fn docker_compose() -> &'static str {
@@ -71,7 +73,7 @@ pub async fn compose_up(
return Err(anyhow!("A compose file doesn't exist after writing stack. Ensure the run_directory and file_paths are correct."));
}
for (_, full_path) in &file_paths {
for (path, full_path) in &file_paths {
let file_contents =
match fs::read_to_string(&full_path).await.with_context(|| {
format!(
@@ -86,7 +88,7 @@ pub async fn compose_up(
.push(Log::error("read compose file", error.clone()));
// This should only happen for repo stacks, ie remote error
res.remote_errors.push(FileContents {
path: full_path.display().to_string(),
path: path.to_string(),
contents: error,
});
return Err(anyhow!(
@@ -95,13 +97,12 @@ pub async fn compose_up(
}
};
res.file_contents.push(FileContents {
path: full_path.display().to_string(),
path: path.to_string(),
contents: file_contents,
});
}
let docker_compose = docker_compose();
let run_dir = run_directory.display();
let service_arg = service
.as_ref()
.map(|service| format!(" {service}"))
@@ -137,7 +138,7 @@ pub async fn compose_up(
}
let env_file = env_file_path
.map(|path| format!(" --env-file {}", path.display()))
.map(|path| format!(" --env-file {path}"))
.unwrap_or_default();
// Build images before destroying to minimize downtime.
@@ -146,10 +147,15 @@ pub async fn compose_up(
let build_extra_args =
parse_extra_args(&stack.config.build_extra_args);
let command = format!(
"cd {run_dir} && {docker_compose} -p {project_name} -f {file_args}{env_file} build{build_extra_args}{service_arg}",
"{docker_compose} -p {project_name} -f {file_args}{env_file} build{build_extra_args}{service_arg}",
);
if stack.config.skip_secret_interp {
let log = run_komodo_command("compose build", command).await;
let log = run_komodo_command(
"compose build",
run_directory.as_ref(),
command,
)
.await;
res.logs.push(log);
} else {
let (command, mut replacers) = svi::interpolate_variables(
@@ -160,8 +166,12 @@ pub async fn compose_up(
).context("failed to interpolate periphery secrets into stack build command")?;
replacers.extend(core_replacers.clone());
let mut log =
run_komodo_command("compose build", command).await;
let mut log = run_komodo_command(
"compose build",
run_directory.as_ref(),
command,
)
.await;
log.command = svi::replace_in_string(&log.command, &replacers);
log.stdout = svi::replace_in_string(&log.stdout, &replacers);
@@ -177,13 +187,15 @@ pub async fn compose_up(
}
}
//
if stack.config.auto_pull {
// Pull images before destroying to minimize downtime.
// If this fails, do not continue.
let log = run_komodo_command(
"compose pull",
run_directory.as_ref(),
format!(
"cd {run_dir} && {docker_compose} -p {project_name} -f {file_args}{env_file} pull{service_arg}",
"{docker_compose} -p {project_name} -f {file_args}{env_file} pull{service_arg}",
),
)
.await;
@@ -197,20 +209,78 @@ pub async fn compose_up(
}
}
// Take down the existing containers.
// This one tries to use the previously deployed service name, to ensure the right stack is taken down.
compose_down(&last_project_name, service, res)
.await
.context("failed to destroy existing containers")?;
if !stack.config.pre_deploy.command.is_empty() {
let pre_deploy_path =
run_directory.join(&stack.config.pre_deploy.path);
if !stack.config.skip_secret_interp {
let (full_command, mut replacers) =
interpolate_variables(&stack.config.pre_deploy.command)
.context(
"failed to interpolate secrets into pre_deploy command",
)?;
replacers.extend(core_replacers.to_owned());
let mut pre_deploy_log = run_komodo_command(
"pre deploy",
pre_deploy_path.as_ref(),
&full_command,
)
.await;
pre_deploy_log.command =
svi::replace_in_string(&pre_deploy_log.command, &replacers);
pre_deploy_log.stdout =
svi::replace_in_string(&pre_deploy_log.stdout, &replacers);
pre_deploy_log.stderr =
svi::replace_in_string(&pre_deploy_log.stderr, &replacers);
tracing::debug!(
"run Stack pre_deploy command | command: {} | cwd: {:?}",
pre_deploy_log.command,
pre_deploy_path
);
res.logs.push(pre_deploy_log);
} else {
let pre_deploy_log = run_komodo_command(
"pre deploy",
pre_deploy_path.as_ref(),
&stack.config.pre_deploy.command,
)
.await;
tracing::debug!(
"run Stack pre_deploy command | command: {} | cwd: {:?}",
&stack.config.pre_deploy.command,
pre_deploy_path
);
res.logs.push(pre_deploy_log);
}
if !all_logs_success(&res.logs) {
return Err(anyhow!(
"Failed at running pre_deploy command, stopping the run."
));
}
}
if stack.config.destroy_before_deploy
// Also check if project name changed, which also requires taking down.
|| last_project_name != project_name
{
// Take down the existing containers.
// This one tries to use the previously deployed service name, to ensure the right stack is taken down.
compose_down(&last_project_name, service, res)
.await
.context("failed to destroy existing containers")?;
}
// Run compose up
let extra_args = parse_extra_args(&stack.config.extra_args);
let command = format!(
"cd {run_dir} && {docker_compose} -p {project_name} -f {file_args}{env_file} up -d{extra_args}{service_arg}",
"{docker_compose} -p {project_name} -f {file_args}{env_file} up -d{extra_args}{service_arg}",
);
let log = if stack.config.skip_secret_interp {
run_komodo_command("compose up", command).await
run_komodo_command("compose up", run_directory.as_ref(), command)
.await
} else {
let (command, mut replacers) = svi::interpolate_variables(
&command,
@@ -220,7 +290,12 @@ pub async fn compose_up(
).context("failed to interpolate periphery secrets into stack run command")?;
replacers.extend(core_replacers);
let mut log = run_komodo_command("compose up", command).await;
let mut log = run_komodo_command(
"compose up",
run_directory.as_ref(),
command,
)
.await;
log.command = svi::replace_in_string(&log.command, &replacers);
log.stdout = svi::replace_in_string(&log.stdout, &replacers);
@@ -237,11 +312,11 @@ pub async fn compose_up(
/// Either writes the stack file_contents to a file, or clones the repo.
/// Returns (run_directory, env_file_path)
async fn write_stack(
stack: &Stack,
async fn write_stack<'a>(
stack: &'a Stack,
git_token: Option<String>,
res: &mut ComposeUpResponse,
) -> anyhow::Result<(PathBuf, Option<PathBuf>)> {
) -> anyhow::Result<(PathBuf, Option<&'a str>)> {
let root = periphery_config()
.stack_dir
.join(to_komodo_name(&stack.name));
@@ -265,7 +340,7 @@ async fn write_stack(
.config
.skip_secret_interp
.then_some(&periphery_config().secrets),
&run_directory,
run_directory.as_ref(),
&mut res.logs,
)
.await
@@ -275,7 +350,14 @@ async fn write_stack(
return Err(anyhow!("failed to write environment file"));
}
};
Ok((run_directory, env_file_path))
Ok((
run_directory,
// Env file paths are already relative to run directory,
// so need to pass original env_file_path here.
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
} else if stack.config.repo.is_empty() {
if stack.config.file_contents.trim().is_empty() {
return Err(anyhow!("Must either input compose file contents directly, or use file one host / git repo options."));
@@ -296,7 +378,7 @@ async fn write_stack(
.config
.skip_secret_interp
.then_some(&periphery_config().secrets),
&run_directory,
run_directory.as_ref(),
&mut res.logs,
)
.await
@@ -324,7 +406,12 @@ async fn write_stack(
format!("failed to write compose file to {file_path:?}")
})?;
Ok((run_directory, env_file_path))
Ok((
run_directory,
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
} else {
// ================
// REPO BASED FILES
@@ -362,27 +449,46 @@ async fn write_stack(
}
};
let clone_or_pull_res = if stack.config.reclone {
State
.resolve(
CloneRepo {
args,
git_token,
environment: env_vars,
env_file_path: stack.config.env_file_path.clone(),
skip_secret_interp: stack.config.skip_secret_interp,
// repo replacer only needed for on_clone / on_pull,
// which aren't available for stacks
replacers: Default::default(),
},
(),
)
.await
} else {
State
.resolve(
PullOrCloneRepo {
args,
git_token,
environment: env_vars,
env_file_path: stack.config.env_file_path.clone(),
skip_secret_interp: stack.config.skip_secret_interp,
// repo replacer only needed for on_clone / on_pull,
// which aren't available for stacks
replacers: Default::default(),
},
(),
)
.await
};
let RepoActionResponse {
logs,
commit_hash,
commit_message,
env_file_path,
} = match State
.resolve(
PullOrCloneRepo {
args,
git_token,
environment: env_vars,
env_file_path: stack.config.env_file_path.clone(),
skip_secret_interp: stack.config.skip_secret_interp,
// repo replacer only needed for on_clone / on_pull,
// which aren't available for stacks
replacers: Default::default(),
},
(),
)
.await
{
} = match clone_or_pull_res {
Ok(res) => res,
Err(e) => {
let error = format_serror(
@@ -407,7 +513,12 @@ async fn write_stack(
return Err(anyhow!("Stopped after repo pull failure"));
}
Ok((run_directory, env_file_path))
Ok((
run_directory,
env_file_path
.is_some()
.then_some(&stack.config.env_file_path),
))
}
}
@@ -422,7 +533,8 @@ async fn compose_down(
.map(|service| format!(" {service}"))
.unwrap_or_default();
let log = run_komodo_command(
"destroy container",
"compose down",
None,
format!("{docker_compose} -p {project} down{service_arg}"),
)
.await;
+1 -1
View File
@@ -935,7 +935,7 @@ pub async fn docker_login(
#[instrument]
pub async fn pull_image(image: &str) -> Log {
let command = format!("docker pull {image}");
run_komodo_command("docker pull", command).await
run_komodo_command("docker pull", None, command).await
}
pub fn stop_container_command(
+11
View File
@@ -66,3 +66,14 @@ pub fn log_grep(
}
}
}
pub fn interpolate_variables(
input: &str,
) -> svi::Result<(String, Vec<(String, String)>)> {
svi::interpolate_variables(
input,
&periphery_config().secrets,
svi::Interpolator::DoubleBrackets,
true,
)
}
+5 -10
View File
@@ -1,4 +1,4 @@
use std::{net::SocketAddr, time::Instant};
use std::net::SocketAddr;
use anyhow::{anyhow, Context};
use axum::{
@@ -11,6 +11,7 @@ use axum::{
Router,
};
use axum_extra::{headers::ContentType, TypedHeader};
use derive_variants::ExtractVariant;
use resolver_api::Resolver;
use serror::{AddStatusCode, AddStatusCodeError, Json};
use uuid::Uuid;
@@ -40,13 +41,12 @@ async fn handler(
Ok((TypedHeader(ContentType::json()), res??))
}
#[instrument(name = "PeripheryHandler")]
async fn task(
req_id: Uuid,
request: crate::api::PeripheryRequest,
) -> anyhow::Result<String> {
let timer = Instant::now();
let variant = request.extract_variant();
let res =
State
.resolve_request(request, ())
@@ -59,16 +59,12 @@ async fn task(
});
if let Err(e) = &res {
warn!("request {req_id} error: {e:#}");
warn!("request {req_id} | type: {variant:?} | error: {e:#}");
}
let elapsed = timer.elapsed();
debug!("request {req_id} | resolve time: {elapsed:?}");
res
}
#[instrument(level = "debug")]
async fn guard_request_by_passkey(
req: Request<Body>,
next: Next,
@@ -100,7 +96,6 @@ async fn guard_request_by_passkey(
}
}
#[instrument(level = "debug")]
async fn guard_request_by_ip(
req: Request<Body>,
next: Next,
+1 -1
View File
@@ -128,7 +128,7 @@ pub struct UnpauseStack {
//
/// Starts the target stack. `docker compose stop`. Response: [Update]
/// Stops the target stack. `docker compose stop`. Response: [Update]
#[typeshare]
#[derive(
Debug,
@@ -120,6 +120,9 @@ pub struct GetDeploymentLog {
/// Max: 5000.
#[serde(default = "default_tail")]
pub tail: U64,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
fn default_tail() -> u64 {
@@ -156,6 +159,9 @@ pub struct SearchDeploymentLog {
/// Invert the results, ie return all lines that DON'T match the terms / combinator.
#[serde(default)]
pub invert: bool,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
#[typeshare]
+6
View File
@@ -303,6 +303,9 @@ pub struct GetContainerLog {
/// Max: 5000.
#[serde(default = "default_tail")]
pub tail: U64,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
fn default_tail() -> u64 {
@@ -341,6 +344,9 @@ pub struct SearchContainerLog {
/// Invert the results, ie return all lines that DON'T match the terms / combinator.
#[serde(default)]
pub invert: bool,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
#[typeshare]
+6
View File
@@ -69,6 +69,9 @@ pub struct GetStackServiceLog {
/// Max: 5000.
#[serde(default = "default_tail")]
pub tail: U64,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
fn default_tail() -> u64 {
@@ -107,6 +110,9 @@ pub struct SearchStackServiceLog {
/// Invert the results, ie return all lines that DON'T match the terms / combinator.
#[serde(default)]
pub invert: bool,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
#[typeshare]
+58 -1
View File
@@ -3,12 +3,69 @@ use resolver_api::derive::Request;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::user::User;
use crate::entities::{user::User, NoData};
use super::KomodoWriteRequest;
//
/// **Only for local users**. Update the calling users username.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Request, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(UpdateUserUsernameResponse)]
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, Request, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(UpdateUserPasswordResponse)]
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.
/// No users can delete a Super Admin user.
/// User cannot delete themselves.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Request, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(DeleteUserResponse)]
pub struct DeleteUser {
/// User id or username
#[serde(alias = "username", alias = "id")]
pub user: String,
}
#[typeshare]
pub type DeleteUserResponse = User;
//
/// **Admin only.** Create a service user.
/// Response: [User].
#[typeshare]
+3 -8
View File
@@ -37,9 +37,8 @@ pub type _PartialAlerterConfig = PartialAlerterConfig;
#[partial(skip_serializing_none, from, diff)]
pub struct AlerterConfig {
/// Whether the alerter is enabled
#[serde(default = "default_enabled")]
#[builder(default = "default_enabled()")]
#[partial_default(default_enabled())]
#[serde(default)]
#[builder(default)]
pub enabled: bool,
/// Where to route the alert messages.
@@ -73,14 +72,10 @@ impl AlerterConfig {
}
}
fn default_enabled() -> bool {
true
}
impl Default for AlerterConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
enabled: Default::default(),
endpoint: Default::default(),
alert_types: Default::default(),
resources: Default::default(),
+2
View File
@@ -33,6 +33,8 @@ pub struct BuildListItemInfo {
pub builder_id: String,
/// The git provider domain
pub git_provider: String,
/// The image registry domain
pub image_registry_domain: String,
/// The repo used as the source of the build
pub repo: String,
/// The branch of the repo
@@ -116,6 +116,10 @@ pub struct Env {
pub komodo_oidc_client_secret_file: Option<PathBuf>,
/// Override `oidc_use_full_email`
pub komodo_oidc_use_full_email: Option<bool>,
/// Override `oidc_additional_audiences`
pub komodo_oidc_additional_audiences: Option<Vec<String>>,
/// Override `oidc_additional_audiences` from file
pub komodo_oidc_additional_audiences_file: Option<PathBuf>,
/// Override `google_oauth.enabled`
pub komodo_google_oauth_enabled: Option<bool>,
@@ -344,6 +348,11 @@ pub struct CoreConfig {
#[serde(default)]
pub oidc_use_full_email: bool,
/// Your OIDC provider may set additional audiences other than `client_id`,
/// they must be added here to make claims verification work.
#[serde(default)]
pub oidc_additional_audiences: Vec<String>,
// =========
// = Oauth =
// =========
@@ -548,6 +557,11 @@ impl CoreConfig {
&config.oidc_client_secret,
),
oidc_use_full_email: config.oidc_use_full_email,
oidc_additional_audiences: config
.oidc_additional_audiences
.iter()
.map(|aud| empty_or_redacted(aud))
.collect(),
google_oauth: OauthCredentials {
enabled: config.google_oauth.enabled,
id: empty_or_redacted(&config.google_oauth.id),
@@ -87,7 +87,7 @@ pub struct Env {
/// If not provided, will use Default config.
///
/// Note. This is overridden if the equivalent arg is passed in [CliArgs].
#[serde(default)]
#[serde(default, alias = "periphery_config_path")]
pub periphery_config_paths: Vec<String>,
/// If specifying folders, use this to narrow down which
/// files will be matched to parse into the final [PeripheryConfig].
@@ -95,7 +95,7 @@ pub struct Env {
/// provided to `config_keywords` will be included.
///
/// Note. This is overridden if the equivalent arg is passed in [CliArgs].
#[serde(default)]
#[serde(default, alias = "periphery_config_keyword")]
pub periphery_config_keywords: Vec<String>,
/// Will merge nested config object (eg. secrets, providers) across multiple
+9 -7
View File
@@ -164,7 +164,11 @@ pub fn get_image_name(
}
pub fn to_komodo_name(name: &str) -> String {
name.to_lowercase().replace([' ', '.'], "_")
name
.to_lowercase()
.replace([' ', '.'], "_")
.trim()
.to_string()
}
/// Unix timestamp in milliseconds as i64
@@ -215,7 +219,7 @@ impl SystemCommand {
}
pub fn is_none(&self) -> bool {
self.path.is_empty() || self.command.is_empty()
self.command.is_empty()
}
}
@@ -610,7 +614,7 @@ impl CloneArgs {
pub fn path(&self, repo_dir: &Path) -> PathBuf {
let path = match &self.destination {
Some(destination) => PathBuf::from(&destination),
None => repo_dir.join(&to_komodo_name(&self.name)),
None => repo_dir.join(to_komodo_name(&self.name)),
};
path.components().collect::<PathBuf>()
}
@@ -620,7 +624,7 @@ impl CloneArgs {
access_token: Option<&str>,
) -> anyhow::Result<String> {
let access_token_at = match &access_token {
Some(token) => format!("{token}@"),
Some(token) => format!("token:{token}@"),
None => String::new(),
};
let protocol = if self.https { "https" } else { "http" };
@@ -646,9 +650,7 @@ impl CloneArgs {
.join(self.provider.replace('/', "-"))
.join(repo.replace('/', "-"))
.join(self.branch.replace('/', "-"))
.join(
self.commit.as_ref().map(String::as_str).unwrap_or("latest"),
);
.join(self.commit.as_deref().unwrap_or("latest"));
Ok(res)
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ pub struct ProcedureStage {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// The executions in the stage
#[serde(default)]
#[serde(default, alias = "execution")]
pub executions: Vec<EnabledExecution>,
}
+20 -1
View File
@@ -11,7 +11,7 @@ use typeshare::typeshare;
use super::{
docker::container::ContainerListItem,
resource::{Resource, ResourceListItem, ResourceQuery},
to_komodo_name, FileContents,
to_komodo_name, FileContents, SystemCommand,
};
#[typeshare]
@@ -213,6 +213,11 @@ pub struct StackConfig {
#[builder(default)]
pub run_build: bool,
/// Whether to run `docker compose down` before `compose up`.
#[serde(default)]
#[builder(default)]
pub destroy_before_deploy: bool,
/// Whether to skip secret interpolation into the stack environment variables.
#[serde(default)]
#[builder(default)]
@@ -284,6 +289,12 @@ pub struct StackConfig {
#[builder(default)]
pub commit: String,
/// By default, the Stack will `git pull` the repo after it is first cloned.
/// If this option is enabled, the repo folder will be deleted and recloned instead.
#[serde(default)]
#[builder(default)]
pub reclone: bool,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
@@ -312,6 +323,11 @@ pub struct StackConfig {
#[builder(default)]
pub registry_account: String,
/// The optional command to run before the Stack is deployed.
#[serde(default)]
#[builder(default)]
pub pre_deploy: SystemCommand,
/// The extra arguments to pass after `docker compose up -d`.
/// If empty, no extra arguments will be passed.
#[serde(default)]
@@ -410,10 +426,12 @@ impl Default for StackConfig {
file_contents: Default::default(),
auto_pull: default_auto_pull(),
ignore_services: Default::default(),
pre_deploy: Default::default(),
extra_args: Default::default(),
environment: Default::default(),
env_file_path: default_env_file_path(),
run_build: Default::default(),
destroy_before_deploy: Default::default(),
build_extra_args: Default::default(),
skip_secret_interp: Default::default(),
git_provider: default_git_provider(),
@@ -421,6 +439,7 @@ impl Default for StackConfig {
repo: Default::default(),
branch: default_branch(),
commit: Default::default(),
reclone: Default::default(),
git_account: Default::default(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
+8 -7
View File
@@ -27,8 +27,8 @@ pub fn parse_key_value_list(
.trim_start_matches('-')
.trim();
// Remove wrapping quotes (from yaml list)
let line = if let Some(line) = line.strip_prefix('"') {
line.strip_suffix('"').unwrap_or(line)
let line = if let Some(line) = line.strip_prefix(['"', '\'']) {
line.strip_suffix(['"', '\'']).unwrap_or(line)
} else {
line
};
@@ -43,11 +43,12 @@ pub fn parse_key_value_list(
.map(|(key, value)| {
let value = value.trim();
// Remove wrapping quotes around value
if let Some(value) = value.strip_prefix('"') {
value.strip_suffix('"').unwrap_or(value)
} else {
value
};
let value =
if let Some(value) = value.strip_prefix(['"', '\'']) {
value.strip_suffix(['"', '\'']).unwrap_or(value)
} else {
value
};
(key.trim().to_string(), value.trim().to_string())
})?;
anyhow::Ok((key, value))
+5
View File
@@ -172,6 +172,11 @@ export type ReadResponses = {
};
export type WriteResponses = {
// ==== USER ====
UpdateUserUsername: Types.UpdateUserUsername;
UpdateUserPassword: Types.UpdateUserPassword;
DeleteUser: Types.DeleteUser;
// ==== SERVICE USER ====
CreateServiceUser: Types.CreateServiceUserResponse;
UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse;
+64 -3
View File
@@ -438,8 +438,6 @@ export interface BuildConfig {
webhook_secret?: string;
/** The optional command run after repo clone and before docker build. */
pre_build?: SystemCommand;
/** Configuration for the registry to push the built image to. */
image_registry?: ImageRegistryConfig;
/**
* The path of the docker build context relative to the root of the repo.
* Default: "." (the root of the repo).
@@ -447,6 +445,8 @@ export interface BuildConfig {
build_path: string;
/** The path of the dockerfile relative to the build path. */
dockerfile_path: string;
/** Configuration for the registry to push the built image to. */
image_registry?: ImageRegistryConfig;
/** Whether to skip secret interpolation in the build_args. */
skip_secret_interp?: boolean;
/** Whether to use buildx to build (eg `docker buildx build ...`) */
@@ -512,6 +512,8 @@ export interface BuildListItemInfo {
builder_id: string;
/** The git provider domain */
git_provider: string;
/** The image registry domain */
image_registry_domain: string;
/** The repo used as the source of the build */
repo: string;
/** The branch of the repo */
@@ -2321,6 +2323,8 @@ export interface StackConfig {
* Combine with build_extra_args for custom behaviors.
*/
run_build?: boolean;
/** Whether to run `docker compose down` before `compose up`. */
destroy_before_deploy?: boolean;
/** Whether to skip secret interpolation into the stack environment variables. */
skip_secret_interp?: boolean;
/**
@@ -2365,6 +2369,11 @@ export interface StackConfig {
branch: string;
/** Optionally set a specific commit hash. */
commit?: string;
/**
* By default, the Stack will `git pull` the repo after it is first cloned.
* If this option is enabled, the repo folder will be deleted and recloned instead.
*/
reclone?: boolean;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
@@ -2378,6 +2387,8 @@ export interface StackConfig {
registry_provider?: string;
/** Used with `registry_provider` to login to a registry before docker compose up. */
registry_account?: string;
/** The optional command to run before the Stack is deployed. */
pre_deploy?: SystemCommand;
/**
* The extra arguments to pass after `docker compose up -d`.
* If empty, no extra arguments will be passed.
@@ -3105,6 +3116,12 @@ export type DeleteSyncWebhookResponse = NoData;
export type UpdateTagsOnResourceResponse = NoData;
export type UpdateUserUsernameResponse = NoData;
export type UpdateUserPasswordResponse = NoData;
export type DeleteUserResponse = User;
export type CreateServiceUserResponse = User;
export type UpdateServiceUserDescriptionResponse = User;
@@ -3788,7 +3805,7 @@ export interface UnpauseStack {
service?: string;
}
/** Starts the target stack. `docker compose stop`. Response: [Update] */
/** Stops the target stack. `docker compose stop`. Response: [Update] */
export interface StopStack {
/** Id or name */
stack: string;
@@ -4103,6 +4120,8 @@ export interface GetDeploymentLog {
* Max: 5000.
*/
tail: U64;
/** Enable `--timestamps` */
timestamps?: boolean;
}
export enum SearchCombinator {
@@ -4130,6 +4149,8 @@ export interface SearchDeploymentLog {
combinator?: SearchCombinator;
/** Invert the results, ie return all lines that DON'T match the terms / combinator. */
invert?: boolean;
/** Enable `--timestamps` */
timestamps?: boolean;
}
/**
@@ -4596,6 +4617,8 @@ export interface GetContainerLog {
* Max: 5000.
*/
tail: U64;
/** Enable `--timestamps` */
timestamps?: boolean;
}
/**
@@ -4620,6 +4643,8 @@ export interface SearchContainerLog {
combinator?: SearchCombinator;
/** Invert the results, ie return all lines that DON'T match the terms / combinator. */
invert?: boolean;
/** Enable `--timestamps` */
timestamps?: boolean;
}
/** Inspect a docker container on the server. Response: [Container]. */
@@ -4810,6 +4835,8 @@ export interface GetStackServiceLog {
* Max: 5000.
*/
tail: U64;
/** Enable `--timestamps` */
timestamps?: boolean;
}
/**
@@ -4834,6 +4861,8 @@ export interface SearchStackServiceLog {
combinator?: SearchCombinator;
/** Invert the results, ie return all lines that DON'T match the terms / combinator. */
invert?: boolean;
/** Enable `--timestamps` */
timestamps?: boolean;
}
/**
@@ -6051,6 +6080,35 @@ export interface UpdateTagsOnResource {
tags: string[];
}
/**
* **Only for local users**. Update the calling users username.
* Response: [NoData].
*/
export interface UpdateUserUsername {
username: string;
}
/**
* **Only for local users**. Update the calling users password.
* Response: [NoData].
*/
export interface UpdateUserPassword {
password: string;
}
/**
* **Admin only**. Delete a user.
* Admins can delete any non-admin user.
* Only Super Admin can delete an admin.
* No users can delete a Super Admin user.
* User cannot delete themselves.
* Response: [NoData].
*/
export interface DeleteUser {
/** User id or username */
user: string;
}
/**
* **Admin only.** Create a service user.
* Response: [User].
@@ -6671,6 +6729,9 @@ export type UserRequest =
| { type: "DeleteApiKey", params: DeleteApiKey };
export type WriteRequest =
| { type: "UpdateUserUsername", params: UpdateUserUsername }
| { type: "UpdateUserPassword", params: UpdateUserPassword }
| { type: "DeleteUser", params: DeleteUser }
| { type: "CreateServiceUser", params: CreateServiceUser }
| { type: "UpdateServiceUserDescription", params: UpdateServiceUserDescription }
| { type: "CreateApiKeyForServiceUser", params: CreateApiKeyForServiceUser }
+7 -1
View File
@@ -44,9 +44,12 @@ pub struct GetComposeServiceLog {
pub project: String,
/// The service name
pub service: String,
/// pass `--tail` for only recent log contents
/// Pass `--tail` for only recent log contents. Max of 5000
#[serde(default = "default_tail")]
pub tail: u64,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
fn default_tail() -> u64 {
@@ -72,6 +75,9 @@ pub struct GetComposeServiceLogSearch {
/// Invert the search (search for everything not matching terms)
#[serde(default)]
pub invert: bool,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
//
+6
View File
@@ -23,6 +23,9 @@ pub struct GetContainerLog {
pub name: String,
#[serde(default = "default_tail")]
pub tail: u64,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
fn default_tail() -> u64 {
@@ -40,6 +43,9 @@ pub struct GetContainerLogSearch {
pub combinator: SearchCombinator,
#[serde(default)]
pub invert: bool,
/// Enable `--timestamps`
#[serde(default)]
pub timestamps: bool,
}
//
+3
View File
@@ -82,6 +82,9 @@ KOMODO_OIDC_ENABLED=false
# KOMODO_OIDC_CLIENT_SECRET= # Alt: KOMODO_OIDC_CLIENT_SECRET_FILE
## Make usernames the full email.
# KOMODO_OIDC_USE_FULL_EMAIL=true
## Add additional trusted audiences for token claims verification.
## Supports comma separated list, and passing with _FILE (for compose secrets).
# KOMODO_OIDC_ADDITIONAL_AUDIENCES=abc,123 # Alt: KOMODO_OIDC_ADDITIONAL_AUDIENCES_FILE
## Github Oauth
KOMODO_GITHUB_OAUTH_ENABLED=false
+9 -2
View File
@@ -74,7 +74,7 @@ repo_directory = "/repo-cache"
##
## NOTE:
## Komodo has no API to recover account logins, but if this happens you can doctor the database using Mongo Compass.
## Create a new user, login to the database with Compass, note down your old users username and _id.
## Create a new Komodo user (Sign Up button), login to the database with Compass, note down your old users username and _id.
## Then delete the old user, and update the new user to have the same username and _id.
## Make sure to set `enabled: true` and maybe `admin: true` on the new user as well, while using Compass.
##
@@ -161,10 +161,17 @@ oidc_client_secret = ""
## If true, use the full email for usernames.
## Otherwise, the @address will be stripped,
## making usernames more concise.
## Default: false.
## Env: KOMODO_OIDC_USE_FULL_EMAIL
## Default: false.
oidc_use_full_email = false
## Some providers attach other audiences in addition to the client_id.
## If you have this issue, `Invalid audiences: `...` is not a trusted audience"`,
## you can add the audience `...` to the list here (assuming it should be trusted).
## Env: KOMODO_OIDC_ADDITIONAL_AUDIENCES or KOMODO_OIDC_ADDITIONAL_AUDIENCES_FILE
## Default: empty
oidc_additional_audiences = []
#########
# OAUTH #
#########
+1 -1
View File
@@ -6,7 +6,7 @@ Komodo just needs a bit of information in order to build your image.
Komodo supports cloning repos over http/s, from any provider that supports cloning private repos using `git clone https://<Token>@git-provider.net/<Owner>/<Repo>`.
Accounts / access tokens can be configured in either the [core config](../setup/advanced.mdx#mount-a-config-file)
or in the [periphery config](../setup/connect-servers.mdx#manual-install-steps---binaries).
or in the [periphery config](../connect-servers.mdx#manual-install-steps---binaries).
### Repo configuration
To specify the git repo to build, just give it the name of the repo and the branch under *repo config*. The name is given like `mbecker20/komodo`, it includes the username / organization that owns the repo.
@@ -2,12 +2,10 @@
Connecting a server to Komodo has 2 steps:
1. Install the Periphery agent on the server
2. Adding the server to Komodo via the core API
1. Install the Periphery agent on the server (either binary or container).
2. Add the server to Komodo via the Core API / UI.
Once step 1. is complete, you can just connect the server to Komodo Core from the UI.
## Install
## Install Periphery
You can install Periphery as a systemd managed process, run it as a [docker container](https://github.com/mbecker20/komodo/pkgs/container/periphery), or do whatever you want with the binary.
+2 -2
View File
@@ -16,7 +16,7 @@ const sidebars: SidebarsConfig = {
"resources",
{
type: "category",
label: "Setup Komodo",
label: "Setup Komodo Core",
link: {
type: "doc",
id: "setup/index",
@@ -26,9 +26,9 @@ const sidebars: SidebarsConfig = {
"setup/postgres",
"setup/sqlite",
"setup/advanced",
"setup/connect-servers",
],
},
"connect-servers",
{
type: "category",
label: "Build Images",
+19 -20
View File
@@ -1,27 +1,26 @@
# React + TypeScript + Vite
# Komodo Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Komodo JS stack uses Yarn + Vite + React + Tailwind + shadcn/ui
Currently, two official plugins are available:
## Setup Dev Environment
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
The frontend depends on the local package `@komodo/client` located at `/client/core/ts`.
This must first be built and prepared for yarn link.
## Expanding the ESLint configuration
The following command should setup everything up (run with /frontend as working directory):
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
```sh
cd ../client/core/ts && yarn && yarn build && yarn link && \
cd ../../../frontend && yarn link @komodo/client && yarn
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
You can make a new file `.env.development` (gitignored) which holds:
```sh
VITE_KOMODO_HOST=https://demo.komo.do
```
You can point it to any Komodo host you like, including the demo.
Now you can start the dev frontend server:
```sh
yarn dev
```
+8 -1
View File
@@ -4,6 +4,7 @@ import {
ConfirmUpdate,
} from "@components/config/util";
import { Section } from "@components/layouts";
import { MonacoLanguage } from "@components/monaco";
import { Types } from "@komodo/client";
import { cn } from "@lib/utils";
import { Button } from "@ui/button";
@@ -31,6 +32,7 @@ export const ConfigLayout = <
onReset,
selector,
titleOther,
file_contents_language,
}: {
original: T;
config: Partial<T>;
@@ -40,6 +42,7 @@ export const ConfigLayout = <
onReset: () => void;
selector?: ReactNode;
titleOther?: ReactNode;
file_contents_language?: MonacoLanguage;
}) => {
const titleProps = titleOther
? { titleOther }
@@ -74,6 +77,7 @@ export const ConfigLayout = <
content={config}
onConfirm={onConfirm}
disabled={disabled}
file_contents_language={file_contents_language}
/>
)}
</div>
@@ -120,6 +124,7 @@ export const Config = <T,>({
components,
selector,
titleOther,
file_contents_language,
}: {
resource_id: string;
resource_type: Types.ResourceTarget["type"];
@@ -134,6 +139,7 @@ export const Config = <T,>({
string, // sidebar key
ConfigComponent<T>[] | false | undefined
>;
file_contents_language?: MonacoLanguage;
}) => {
// let component_keys = keys(components);
// const [_show, setShow] = useLocalStorage(
@@ -164,6 +170,7 @@ export const Config = <T,>({
}}
onReset={() => set({})}
selector={selector}
file_contents_language={file_contents_language}
>
<div className="flex gap-6">
<div className="hidden xl:block relative pr-6 border-r">
@@ -186,7 +193,7 @@ export const Config = <T,>({
key={section + item.label}
>
<Button
variant="outline"
variant="secondary"
className="justify-end w-full"
size="sm"
>
+47 -16
View File
@@ -46,7 +46,11 @@ import {
soft_text_color_class_by_intention,
text_color_class_by_intention,
} from "@lib/color";
import { MonacoDiffEditor, MonacoEditor } from "@components/monaco";
import {
MonacoDiffEditor,
MonacoEditor,
MonacoLanguage,
} from "@components/monaco";
export const ConfigItem = ({
label,
@@ -398,6 +402,7 @@ export const AccountSelector = ({
provider,
selected,
onSelect,
placeholder = "Select Account",
}: {
disabled: boolean;
type: "Server" | "Builder" | "None";
@@ -406,6 +411,7 @@ export const AccountSelector = ({
provider: string;
selected: string | undefined;
onSelect: (id: string) => void;
placeholder?: string;
}) => {
const [db_request, config_request]:
| ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
@@ -447,7 +453,7 @@ export const AccountSelector = ({
className="w-full lg:w-[200px] max-w-[50%]"
disabled={disabled}
>
<SelectValue placeholder="Select Account" />
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={"Empty"}>None</SelectItem>
@@ -471,12 +477,16 @@ export const AccountSelectorConfig = (params: {
provider: string;
selected: string | undefined;
onSelect: (id: string) => void;
placeholder: string;
placeholder?: string;
description?: string;
}) => {
return (
<ConfigItem
label="Account"
description="Select the account used to log in to the provider"
description={
params.description ??
"Select the account used to log in to the provider"
}
>
<AccountSelector {...params} />
</ConfigItem>
@@ -569,6 +579,8 @@ interface ConfirmUpdateProps<T> {
content: Partial<T>;
onConfirm: () => void;
disabled: boolean;
language?: MonacoLanguage;
file_contents_language?: MonacoLanguage;
}
export function ConfirmUpdate<T>({
@@ -576,6 +588,8 @@ export function ConfirmUpdate<T>({
content,
onConfirm,
disabled,
language,
file_contents_language,
}: ConfirmUpdateProps<T>) {
const [open, set] = useState(false);
useCtrlKeyListener("s", () => {
@@ -608,6 +622,8 @@ export function ConfirmUpdate<T>({
_key={key as any}
val={val as any}
previous={previous}
language={language}
file_contents_language={file_contents_language}
/>
))}
</div>
@@ -629,23 +645,24 @@ function ConfirmUpdateItem<T>({
_key,
val: _val,
previous,
language,
file_contents_language,
}: {
_key: keyof T;
val: T[keyof T];
previous: T;
language?: MonacoLanguage;
file_contents_language?: MonacoLanguage;
}) {
const [show, setShow] = useState(true);
const val =
typeof _val === "string"
? _key === "environment" ||
_key === "build_args" ||
_key === "secret_args"
? _val
.split("\n")
.filter((line) => !line.startsWith("#"))
.map((line) => line.split(" #")[0])
.join("\n")
: _val
? _val
: Array.isArray(_val)
? _val.length > 0 &&
["string", "number", "boolean"].includes(typeof _val[0])
? JSON.stringify(_val)
: JSON.stringify(_val, null, 2)
: JSON.stringify(_val, null, 2);
const prev_val =
typeof previous[_key] === "string"
@@ -653,7 +670,12 @@ function ConfirmUpdateItem<T>({
: _key === "environment" ||
_key === "build_args" ||
_key === "secret_args"
? env_to_text(previous[_key] as any) ?? ""
? env_to_text(previous[_key] as any) ?? "" // For backward compat with 1.14
: Array.isArray(previous[_key])
? previous[_key].length > 0 &&
["string", "number", "boolean"].includes(typeof previous[_key][0])
? JSON.stringify(previous[_key])
: JSON.stringify(previous[_key], null, 2)
: JSON.stringify(previous[_key], null, 2);
const showDiff =
val?.includes("\n") ||
@@ -676,7 +698,16 @@ function ConfirmUpdateItem<T>({
<MonacoDiffEditor
original={prev_val}
modified={val}
language="toml"
language={
language ??
(["environment", "build_args", "secret_args"].includes(
_key as string
)
? "key_value"
: _key === "file_contents"
? file_contents_language
: "json")
}
/>
) : (
<pre style={{ minHeight: 0 }}>
@@ -718,7 +749,7 @@ export const SystemCommand = ({
/>
</div>
<MonacoEditor
value={value?.command}
value={value?.command || " # Add multiple commands on new lines. Supports comments.\n "}
language="shell"
onValueChange={(command) => set({ ...(value || {}), command })}
readOnly={disabled}
+5 -1
View File
@@ -28,7 +28,11 @@ export const KeysTable = ({
}) => {
return (
<div className="flex items-center gap-2">
<Input className="w-40" value={key} disabled />
<Input
className="w-[100px] lg:w-[200px] overflow-ellipsis"
value={key}
disabled
/>
<CopyButton content={key} />
</div>
);
+7 -4
View File
@@ -163,6 +163,7 @@ export const PageXlRow = ({
interface SectionProps {
title?: ReactNode;
icon?: ReactNode;
titleRight?: ReactNode;
titleOther?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
@@ -173,6 +174,7 @@ interface SectionProps {
export const Section = ({
title,
icon,
titleRight,
titleOther,
actions,
children,
@@ -189,6 +191,7 @@ export const Section = ({
<div className="px-2 flex items-center gap-2 text-muted-foreground">
{icon}
{title && <h2 className="text-xl">{title}</h2>}
{titleRight}
</div>
) : (
titleOther
@@ -203,13 +206,13 @@ export const NewLayout = ({
entityType,
children,
enabled,
onSuccess,
onConfirm,
onOpenChange,
}: {
entityType: string;
children: ReactNode;
enabled: boolean;
onSuccess: () => Promise<unknown>;
onConfirm: () => Promise<unknown>;
onOpenChange?: (open: boolean) => void;
}) => {
const [open, set] = useState(false);
@@ -234,10 +237,10 @@ export const NewLayout = ({
<div className="flex flex-col gap-4 py-8">{children}</div>
<DialogFooter>
<Button
variant="outline"
variant="secondary"
onClick={async () => {
setLoading(true);
await onSuccess();
await onConfirm();
setLoading(false);
set(false);
}}
@@ -100,7 +100,7 @@ export const BuildConfig = ({
<ConfigInput
className="text-lg w-[200px]"
label="Version"
description="Version the image tag using server (major.minor.patch)"
description="Version the image with major.minor.patch. It can be interpolated using [[$VERSION]]."
placeholder="0.0.0"
value={version}
onChange={(version) => set({ version: version as any })}
@@ -115,9 +115,17 @@ export const BuildComponents: RequiredResourceComponents = {
New: () => {
const user = useUser().data;
const builders = useRead("ListBuilders", {}).data;
if (!user) return null;
if (!user.admin && !user.create_build_permissions) return null;
return <NewResource type="Build" />;
return (
<NewResource
type="Build"
builder_id={
builders && builders.length === 1 ? builders[0].id : undefined
}
/>
);
},
Table: ({ resources }) => (
@@ -74,7 +74,7 @@ export const BuilderComponents: RequiredResourceComponents = {
return (
<NewLayout
entityType="Builder"
onSuccess={async () => {
onConfirm={async () => {
if (!type) return;
const id = (await mutateAsync({ name, config: { type, params: {} } }))
._id?.$oid!;
+24 -7
View File
@@ -119,7 +119,11 @@ export const ResourceSelector = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="secondary" className="flex justify-start gap-2 w-fit max-w-[200px]" disabled={disabled}>
<Button
variant="secondary"
className="flex justify-start gap-2 w-fit max-w-[200px]"
disabled={disabled}
>
{name || `Select ${type}`}
{!disabled && <ChevronsUpDown className="w-3 h-3" />}
</Button>
@@ -258,16 +262,19 @@ export const NewResource = ({
type,
readable_type,
server_id,
builder_id,
build_id,
name: _name = "",
}: {
type: UsableResource;
readable_type?: string;
server_id?: string;
builder_id?: string;
build_id?: string;
name?: string;
}) => {
const nav = useNavigate();
const { toast } = useToast();
const { mutateAsync } = useWrite(`Create${type}`);
const [name, setName] = useState(_name);
const type_display =
@@ -276,7 +283,7 @@ export const NewResource = ({
: type === "ResourceSync"
? "resource-sync"
: type.toLowerCase();
const config: Types._PartialDeploymentConfig =
const config: Types._PartialDeploymentConfig | Types._PartialRepoConfig =
type === "Deployment"
? {
server_id,
@@ -287,15 +294,19 @@ export const NewResource = ({
: type === "Stack"
? { server_id }
: type === "Repo"
? { server_id }
? { server_id, builder_id }
: type === "Build"
? { builder_id }
: {};
const onConfirm = async () => {
if (!name) toast({ title: "Name cannot be empty" });
const id = (await mutateAsync({ name, config }))._id?.$oid!;
nav(`/${usableResourcePath(type)}/${id}`);
};
return (
<NewLayout
entityType={readable_type ?? type}
onSuccess={async () => {
const id = (await mutateAsync({ name, config }))._id?.$oid!;
nav(`/${usableResourcePath(type)}/${id}`);
}}
onConfirm={onConfirm}
enabled={!!name}
onOpenChange={() => setName("")}
>
@@ -305,6 +316,12 @@ export const NewResource = ({
placeholder={`${type_display}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (!name) return;
if (e.key === "Enter") {
onConfirm();
}
}}
/>
</div>
</NewLayout>
@@ -2,6 +2,7 @@ import { useRead, useWrite } from "@lib/hooks";
import { Types } from "@komodo/client";
import { ReactNode, useState } from "react";
import {
AccountSelectorConfig,
AddExtraArgMenu,
ConfigItem,
ConfigList,
@@ -19,6 +20,7 @@ import {
DefaultTerminationSignal,
TerminationTimeout,
} from "./components/term-signal";
import { extract_registry_domain } from "@lib/utils";
export const DeploymentConfig = ({
id,
@@ -31,6 +33,7 @@ export const DeploymentConfig = ({
target: { type: "Deployment", id },
}).data;
const config = useRead("GetDeployment", { deployment: id }).data?.config;
const builds = useRead("ListBuilds", {}).data;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
const [update, set] = useState<Partial<Types.DeploymentConfig>>({});
@@ -100,6 +103,37 @@ export const DeploymentConfig = ({
image: (value, set) => (
<ImageConfig image={value} set={set} disabled={disabled} />
),
image_registry_account: (account, set) => {
const image = update.image ?? config.image;
const provider =
image?.type === "Image" && image.params.image
? extract_registry_domain(image.params.image)
: image?.type === "Build" && image.params.build_id
? builds?.find((b) => b.id === image.params.build_id)?.info
.image_registry_domain
: undefined;
return (
<AccountSelectorConfig
id={update.server_id ?? config.server_id ?? undefined}
type="Server"
account_type="docker"
provider={provider ?? "docker.io"}
selected={account}
onSelect={(image_registry_account) =>
set({ image_registry_account })
}
disabled={disabled}
placeholder={
image?.type === "Build" ? "Same as Build" : undefined
}
description={
image?.type === "Build"
? "Select an alternate account used to log in to the provider"
: undefined
}
/>
);
},
redeploy_on_build: (update.image?.type ?? config.image?.type) ===
"Build" && {
description: "Automatically redeploy when the image is built.",
@@ -182,6 +216,53 @@ export const DeploymentConfig = ({
),
},
},
{
label: "Restart",
labelHidden: true,
components: {
restart: (value, set) => (
<RestartModeSelector
selected={value}
set={set}
disabled={disabled}
/>
),
},
},
],
advanced: [
{
label: "Command",
labelHidden: true,
components: {
command: (value, set) => (
<ConfigItem
label="Command"
description={
<div className="flex flex-row flex-wrap gap-2">
<div>Replace the CMD, or extend the ENTRYPOINT.</div>
<Link
to="https://docs.docker.com/engine/reference/run/#commands-and-arguments"
target="_blank"
className="text-primary"
>
See docker docs.
{/* <Button variant="link" className="p-0">
</Button> */}
</Link>
</div>
}
>
<MonacoEditor
value={value}
language="shell"
onValueChange={(command) => set({ command })}
readOnly={disabled}
/>
</ConfigItem>
),
},
},
{
label: "Labels",
description: "Attach --labels to the container.",
@@ -197,19 +278,6 @@ export const DeploymentConfig = ({
),
},
},
{
label: "Restart",
labelHidden: true,
components: {
restart: (value, set) => (
<RestartModeSelector
selected={value}
set={set}
disabled={disabled}
/>
),
},
},
{
label: "Extra Args",
labelHidden: true,
@@ -255,38 +323,6 @@ export const DeploymentConfig = ({
),
},
},
{
label: "Command",
labelHidden: true,
components: {
command: (value, set) => (
<ConfigItem
label="Command"
description={
<div className="flex flex-row flex-wrap gap-2">
<div>Replace the CMD, or extend the ENTRYPOINT.</div>
<Link
to="https://docs.docker.com/engine/reference/run/#commands-and-arguments"
target="_blank"
className="text-primary"
>
See docker docs.
{/* <Button variant="link" className="p-0">
</Button> */}
</Link>
</div>
}
>
<MonacoEditor
value={value}
language="shell"
onValueChange={(command) => set({ command })}
readOnly={disabled}
/>
</ConfigItem>
),
},
},
{
label: "Termination",
boldLabel: false,
@@ -1,7 +1,7 @@
import { useLocalStorage, useRead } from "@lib/hooks";
import { Types } from "@komodo/client";
import { RequiredResourceComponents } from "@types";
import { AlertTriangle, HardDrive, Rocket, Server } from "lucide-react";
import { HardDrive, Rocket, Server } from "lucide-react";
import { cn } from "@lib/utils";
import { useServer } from "../server";
import {
@@ -22,7 +22,6 @@ import { DeleteResource, NewResource, ResourceLink } from "../common";
import { RunBuild } from "../build/actions";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { DeploymentConfig } from "./config";
import { Link } from "react-router-dom";
import { DashboardPieChart } from "@pages/home/dashboard";
import { ResourcePageHeader, StatusBadge } from "@components/util";
@@ -145,9 +144,21 @@ export const DeploymentComponents: RequiredResourceComponents = {
);
},
New: ({ server_id, build_id }) => (
<NewResource type="Deployment" server_id={server_id} build_id={build_id} />
),
New: ({ server_id: _server_id, build_id }) => {
const servers = useRead("ListServers", {}).data;
const server_id = _server_id
? _server_id
: servers && servers.length === 1
? servers[0].id
: undefined;
return (
<NewResource
type="Deployment"
server_id={server_id}
build_id={build_id}
/>
);
},
Table: ({ resources }) => {
return (
@@ -192,17 +203,6 @@ export const DeploymentComponents: RequiredResourceComponents = {
</div>
);
},
Alerts: ({ id }) => {
return (
<Link
to={`/deployments/${id}/alerts`}
className="flex gap-2 items-center"
>
<AlertTriangle className="w-4 h-4" />
Alerts
</Link>
);
},
},
Actions: {
@@ -43,6 +43,10 @@ const DeploymentLogsInner = ({
const [invert, setInvert] = useState(false);
const [search, setSearch] = useState("");
const [poll, setPoll] = useLocalStorage("log-poll-v1", false);
const [timestamps, setTimestamps] = useLocalStorage(
"log-timestamps-v1",
false
);
const addTerm = () => {
if (!search.length) return;
@@ -61,8 +65,8 @@ const DeploymentLogsInner = ({
};
const { Log, refetch, stderr } = terms.length
? SearchLogs(id, terms, invert)
: NoSearchLogs(id, tail, stream);
? SearchLogs(id, terms, invert, timestamps)
: NoSearchLogs(id, tail, timestamps, stream);
useEffect(() => {
const interval = setInterval(() => {
@@ -77,9 +81,8 @@ const DeploymentLogsInner = ({
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<div className="text-muted-foreground flex gap-1">
<div>Invert</div>
<div className="hidden xl:block">Search</div>
<div className="text-muted-foreground flex gap-1 text-sm">
Invert
</div>
<Switch checked={invert} onCheckedChange={setInvert} />
</div>
@@ -126,9 +129,19 @@ const DeploymentLogsInner = ({
<Button variant="secondary" size="icon" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2">
<div className="text-muted-foreground">Poll</div>
<Switch checked={poll} onCheckedChange={setPoll} />
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setTimestamps((t) => !t)}
>
<div className="text-muted-foreground text-sm">Timestamps</div>
<Switch checked={timestamps} />
</div>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setPoll((p) => !p)}
>
<div className="text-muted-foreground text-sm">Poll</div>
<Switch checked={poll} />
</div>
<TailLengthSelector
selected={tail}
@@ -143,12 +156,17 @@ const DeploymentLogsInner = ({
);
};
const NoSearchLogs = (id: string, tail: string, stream: string) => {
const { data: log, refetch } = useRead(
"GetDeploymentLog",
{ deployment: id, tail: Number(tail) },
{ refetchInterval: 30000 }
);
const NoSearchLogs = (
id: string,
tail: string,
timestamps: boolean,
stream: string
) => {
const { data: log, refetch } = useRead("GetDeploymentLog", {
deployment: id,
tail: Number(tail),
timestamps,
});
return {
Log: (
<div className="relative">
@@ -160,12 +178,18 @@ const NoSearchLogs = (id: string, tail: string, stream: string) => {
};
};
const SearchLogs = (id: string, terms: string[], invert: boolean) => {
const SearchLogs = (
id: string,
terms: string[],
invert: boolean,
timestamps: boolean
) => {
const { data: log, refetch } = useRead("SearchDeploymentLog", {
deployment: id,
terms,
combinator: Types.SearchCombinator.And,
invert,
timestamps,
});
return {
Log: (
@@ -57,6 +57,7 @@ export const ResourceSyncConfig = ({
onSave={async () => {
await mutateAsync({ id, config: update });
}}
file_contents_language="toml"
components={{
"": [
{
@@ -58,7 +58,7 @@ export const ServerTemplateComponents: RequiredResourceComponents = {
return (
<NewLayout
entityType="Server Template"
onSuccess={async () => {
onConfirm={async () => {
if (!type) return;
const id = (await mutateAsync({ name, config: { type, params: {} } }))
._id?.$oid!;
@@ -6,6 +6,7 @@ import {
ConfigList,
InputList,
ProviderSelectorConfig,
SystemCommand,
} from "@components/config/util";
import { Types } from "@komodo/client";
import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks";
@@ -233,10 +234,24 @@ export const StackConfig = ({
placeholder: "Compose project name",
boldLabel: true,
description:
"Optionally set a different compose project name. It should match the compose project name on your host.",
"Optionally set a different compose project name. If importing existing stack, this should match the compose project name on your host.",
},
},
},
{
label: "Pre Deploy",
description:
"Execute a shell command before running docker compose up. The 'path' is relative to the Run Directory",
components: {
pre_deploy: (value, set) => (
<SystemCommand
value={value}
set={(value) => set({ pre_deploy: value })}
disabled={disabled}
/>
),
},
},
{
label: "Extra Args",
labelHidden: true,
@@ -374,6 +389,17 @@ export const StackConfig = ({
),
},
},
{
label: "Destroy",
labelHidden: true,
components: {
destroy_before_deploy: {
label: "Destroy Before Deploy",
description:
"Ensure 'docker compose down' is run before redeploying the Stack.",
},
},
},
];
if (mode === undefined) {
@@ -467,6 +493,10 @@ export const StackConfig = ({
description:
"Switch to a specific hash after cloning the branch.",
},
reclone: {
description:
"Delete the repo folder and clone it again, instead of using 'git pull'.",
},
},
},
{
@@ -718,6 +748,7 @@ export const StackConfig = ({
await mutateAsync({ id, config: update });
}}
components={components}
file_contents_language="yaml"
/>
);
};
@@ -151,7 +151,15 @@ export const StackComponents: RequiredResourceComponents = {
);
},
New: ({ server_id }) => <NewResource type="Stack" server_id={server_id} />,
New: ({ server_id: _server_id }) => {
const servers = useRead("ListServers", {}).data;
const server_id = _server_id
? _server_id
: servers && servers.length === 1
? servers[0].id
: undefined;
return <NewResource type="Stack" server_id={server_id} />;
},
Table: ({ resources }) => (
<StackTable stacks={resources as Types.StackListItem[]} />
@@ -80,7 +80,7 @@ export const StackInfo = ({
</CardHeader>
</Card>
)}
{/* Update deployed contents with diff */}
{/* {!is_down && deployed_contents.length > 0 && (
<Card>
@@ -189,6 +189,7 @@ export const StackInfo = ({
}
}}
disabled={!edits[content.path]}
language="yaml"
/>
</div>
)}
+3 -3
View File
@@ -17,7 +17,7 @@ export const NewUserGroup = () => {
return (
<NewLayout
entityType="User Group"
onSuccess={() => mutateAsync({ name })}
onConfirm={() => mutateAsync({ name })}
enabled={!!name}
onOpenChange={() => setName("")}
>
@@ -46,14 +46,14 @@ export const NewServiceUser = () => {
return (
<NewLayout
entityType="Service User"
onSuccess={() => mutateAsync({ username, description: "" })}
onConfirm={() => mutateAsync({ username, description: "" })}
enabled={!!username}
onOpenChange={() => setUsername("")}
>
<div className="grid md:grid-cols-2">
Username
<Input
placeholder="user-group-name"
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
@@ -80,7 +80,11 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={() => onOpenChange(false)}>
<Button
variant="secondary"
className="gap-4"
onClick={() => onOpenChange(false)}
>
Confirm <Check className="w-4" />
</Button>
</DialogFooter>
@@ -103,7 +107,10 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
Expiry
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-36 justify-between px-3">
<Button
variant="outline"
className="w-36 justify-between px-3"
>
{expires}
</Button>
</DropdownMenuTrigger>
@@ -125,7 +132,12 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={submit} disabled={isPending}>
<Button
variant="secondary"
className="gap-4"
onClick={submit}
disabled={isPending}
>
Submit
{isPending ? (
<Loader2 className="w-4 animate-spin" />
+37 -3
View File
@@ -5,21 +5,30 @@ import { useNavigate } from "react-router-dom";
import { ColumnDef } from "@tanstack/react-table";
import { MinusCircle } from "lucide-react";
import { ConfirmButton } from "@components/util";
import { useUser } from "@lib/hooks";
export const UserTable = ({
users,
onUserRemove,
onUserDelete,
userDeleteDisabled,
onSelfClick,
}: {
users: Types.User[];
onUserRemove?: (user_id: string) => void;
onUserDelete?: (user_id: string) => void;
userDeleteDisabled?: (user_id: string) => boolean;
onSelfClick?: () => void;
}) => {
const user = useUser().data;
const nav = useNavigate();
const columns: ColumnDef<Types.User, "User" | "Admin">[] = [
const columns: ColumnDef<Types.User, "User" | "Admin" | "Super Admin">[] = [
{ header: "Username", accessorKey: "username" },
{ header: "Type", accessorKey: "config.type" },
{
header: "Level",
accessorFn: (user) => (user.admin ? "Admin" : "User"),
accessorFn: (user) =>
user.admin ? (user.super_admin ? "Super Admin" : "Admin") : "User",
},
{
header: "Enabled",
@@ -51,12 +60,37 @@ export const UserTable = ({
),
});
}
if (onUserDelete) {
columns.push({
header: "Delete",
cell: ({ row }) => (
<ConfirmButton
title="Delete"
variant="destructive"
icon={<MinusCircle className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
onUserDelete(row.original._id?.$oid!);
}}
disabled={
row.original._id?.$oid
? userDeleteDisabled?.(row.original._id.$oid) ?? true
: true
}
/>
),
});
}
return (
<DataTable
tableKey="users"
data={users}
columns={columns}
onRowClick={(user) => nav(`/users/${user._id!.$oid}`)}
onRowClick={(row) =>
row._id?.$oid === user?._id?.$oid
? onSelfClick?.()
: nav(`/users/${row._id!.$oid}`)
}
/>
);
};
+10
View File
@@ -244,3 +244,13 @@ export const is_service_user = (user_id: string) => {
user_id === "Repo Manager"
);
};
export const extract_registry_domain = (image_name: string) => {
if (!image_name) return "docker.io";
const maybe_domain = image_name.split("/")[0];
if (maybe_domain.includes(".")) {
return maybe_domain
} else {
return "docker.io"
}
}
@@ -25,6 +25,10 @@ export const ContainerLogs = ({
const [invert, setInvert] = useState(false);
const [search, setSearch] = useState("");
const [poll, setPoll] = useLocalStorage("log-poll-v1", false);
const [timestamps, setTimestamps] = useLocalStorage(
"log-timestamps-v1",
false
);
const addTerm = () => {
if (!search.length) return;
@@ -43,8 +47,8 @@ export const ContainerLogs = ({
};
const { Log, refetch, stderr } = terms.length
? SearchLogs(id, container_name, terms, invert)
: NoSearchLogs(id, container_name, tail, stream);
? SearchLogs(id, container_name, terms, invert, timestamps)
: NoSearchLogs(id, container_name, tail, timestamps, stream);
useEffect(() => {
const interval = setInterval(() => {
@@ -61,10 +65,7 @@ export const ContainerLogs = ({
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<div className="text-muted-foreground flex gap-1">
<div>Invert</div>
<div className="hidden xl:block">Search</div>
</div>
<div className="text-muted-foreground flex gap-1">Invert</div>
<Switch checked={invert} onCheckedChange={setInvert} />
</div>
{terms.map((term, index) => (
@@ -110,9 +111,19 @@ export const ContainerLogs = ({
<Button variant="secondary" size="icon" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2">
<div className="text-muted-foreground">Poll</div>
<Switch checked={poll} onCheckedChange={setPoll} />
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setTimestamps((t) => !t)}
>
<div className="text-muted-foreground text-sm">Timestamps</div>
<Switch checked={timestamps} />
</div>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setPoll((p) => !p)}
>
<div className="text-muted-foreground text-sm">Poll</div>
<Switch checked={poll} />
</div>
<TailLengthSelector
selected={tail}
@@ -131,13 +142,15 @@ const NoSearchLogs = (
id: string,
container: string,
tail: string,
timestamps: boolean,
stream: string
) => {
const { data: log, refetch } = useRead(
"GetContainerLog",
{ server: id, container, tail: Number(tail) },
{ refetchInterval: 30000 }
);
const { data: log, refetch } = useRead("GetContainerLog", {
server: id,
container,
tail: Number(tail),
timestamps,
});
return {
Log: (
<div className="relative">
@@ -153,7 +166,8 @@ const SearchLogs = (
id: string,
container: string,
terms: string[],
invert: boolean
invert: boolean,
timestamps: boolean
) => {
const { data: log, refetch } = useRead("SearchContainerLog", {
server: id,
@@ -161,6 +175,7 @@ const SearchLogs = (
terms,
combinator: Types.SearchCombinator.And,
invert,
timestamps,
});
return {
Log: (
+13 -23
View File
@@ -1,18 +1,18 @@
import { useLocalStorage, useUser } from "@lib/hooks";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { CreateVariable, Variables } from "./variables";
import { CreateTag, Tags } from "./tags";
import { Variables } from "./variables";
import { Tags } from "./tags";
import { UsersPage } from "./users";
import { CreateKey, Keys } from "./keys";
import { Profile } from "./profile";
import { Page } from "@components/layouts";
import { useState } from "react";
import { Input } from "@ui/input";
import { ProvidersPage } from "./providers";
import { ExportButton } from "@components/export";
export const Settings = () => {
const user = useUser().data;
const [view, setView] = useLocalStorage("settings-view-v0", "Variables");
const [search, setSearch] = useState("");
const [view, setView] = useLocalStorage<
"Variables" | "Tags" | "Providers" | "Users" | "Profile"
>("settings-view-v1", "Variables");
const currentView =
(view === "Users" || view === "Providers") && !user?.admin
? "Variables"
@@ -21,7 +21,7 @@ export const Settings = () => {
<Page>
<Tabs
value={currentView}
onValueChange={setView}
onValueChange={setView as any}
className="flex flex-col gap-6"
>
<div className="flex items-center justify-between">
@@ -32,20 +32,10 @@ export const Settings = () => {
<TabsTrigger value="Providers">Providers</TabsTrigger>
)}
{user?.admin && <TabsTrigger value="Users">Users</TabsTrigger>}
<TabsTrigger value="Api Keys">Api Keys</TabsTrigger>
<TabsTrigger value="Profile">Profile</TabsTrigger>
</TabsList>
{currentView === "Variables" && <CreateVariable />}
{currentView === "Tags" && <CreateTag />}
{currentView === "Api Keys" && <CreateKey />}
{currentView === "Users" && (
<Input
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[250px]"
/>
)}
{currentView === "Variables" && <ExportButton include_variables />}
</div>
<TabsContent value="Variables">
@@ -61,11 +51,11 @@ export const Settings = () => {
)}
{user?.admin && (
<TabsContent value="Users">
<UsersPage search={search} />
<UsersPage goToProfile={() => setView("Profile")} />
</TabsContent>
)}
<TabsContent value="Api Keys">
<Keys />
<TabsContent value="Profile">
<Profile />
</TabsContent>
</Tabs>
</Page>
@@ -1,5 +1,12 @@
import { ConfirmButton, CopyButton } from "@components/util";
import { useInvalidate, useManageUser, useRead, useSetTitle } from "@lib/hooks";
import {
useInvalidate,
useManageUser,
useRead,
useSetTitle,
useUser,
useWrite,
} from "@lib/hooks";
import {
Dialog,
DialogContent,
@@ -10,7 +17,17 @@ import {
} from "@ui/dialog";
import { Button } from "@ui/button";
import { useToast } from "@ui/use-toast";
import { Trash, PlusCircle, Loader2, Check } from "lucide-react";
import {
Trash,
PlusCircle,
Loader2,
Check,
User,
Eye,
EyeOff,
KeyRound,
UserPen,
} from "lucide-react";
import { useState } from "react";
import { Input } from "@ui/input";
import {
@@ -21,18 +38,143 @@ import {
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { KeysTable } from "@components/keys/table";
import { Section } from "@components/layouts";
import { Card, CardHeader } from "@ui/card";
import { Types } from "@komodo/client";
export const Keys = () => {
useSetTitle("Api Keys");
export const Profile = () => {
useSetTitle("Profile");
const user = useUser().data;
if (!user) {
return (
<div className="w-full h-[400px] flex justify-center items-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
return <ProfileInner user={user} />;
};
const ProfileInner = ({ user }: { user: Types.User }) => {
const { refetch: refetchUser } = useUser();
const { toast } = useToast();
const keys = useRead("ListApiKeys", {}).data ?? [];
return <KeysTable keys={keys} DeleteKey={DeleteKey} />;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState("");
const [hidePassword, setHidePassword] = useState(true);
const { mutate: updateUsername } = useWrite("UpdateUserUsername", {
onSuccess: () => {
toast({ title: "Username updated." });
refetchUser();
},
});
const { mutate: updatePassword } = useWrite("UpdateUserPassword", {
onSuccess: () => {
toast({ title: "Password updated." });
setPassword("");
},
});
return (
<div className="flex flex-col gap-6">
{/* Profile */}
<Section title="Profile" icon={<User className="w-4 h-4" />}>
<Card>
<CardHeader className="gap-4">
{/* Profile Info */}
<UserProfile user={user} />
{/* Update Username */}
<div className="flex items-center gap-4">
<div className="text-muted-foreground font-mono">Username:</div>
<div className="w-[200px] lg:w-[300px]">
<Input
placeholder="Input username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<ConfirmButton
title="Update Username"
icon={<UserPen className="w-4 h-4" />}
onClick={() => updateUsername({ username })}
disabled={!username || username === user.username}
/>
</div>
{/* Update Password */}
{user.config.type === "Local" && (
<div className="flex items-center gap-4">
<div className="text-muted-foreground font-mono">Password:</div>
<div className="w-[200px] lg:w-[300px] flex items-center gap-2">
<Input
placeholder="Input password"
type={hidePassword ? "password" : "text"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
size="icon"
variant="outline"
onClick={() => setHidePassword((curr) => !curr)}
>
{hidePassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
<ConfirmButton
title="Update Password"
icon={<UserPen className="w-4 h-4" />}
onClick={() => updatePassword({ password })}
disabled={!password}
/>
</div>
)}
</CardHeader>
</Card>
</Section>
{/* Api Keys */}
<Section title="Api Keys" icon={<KeyRound className="w-4 h-4" />}>
<div>
<CreateKey />
</div>
<KeysTable keys={keys} DeleteKey={DeleteKey} />
</Section>
</div>
);
};
const UserProfile = ({ user }: { user: Types.User }) => {
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="font-mono text-muted-foreground">Type:</div>
{user.config.type}
<div className="font-mono text-muted-foreground">|</div>
<div className="font-mono text-muted-foreground">Admin:</div>
{user.admin ? "True" : "False"}
{user.admin && (
<>
<div className="font-mono text-muted-foreground">|</div>
<div className="font-mono text-muted-foreground">Super Admin:</div>
{user.super_admin ? "True" : "False"}
</>
)}
</div>
);
};
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
type ExpiresOptions = "90 days" | "180 days" | "1 year" | "never";
export const CreateKey = () => {
const CreateKey = () => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [expires, setExpires] = useState<ExpiresOptions>("never");
@@ -86,7 +228,11 @@ export const CreateKey = () => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={() => onOpenChange(false)}>
<Button
variant="secondary"
className="gap-4"
onClick={() => onOpenChange(false)}
>
Confirm <Check className="w-4" />
</Button>
</DialogFooter>
@@ -109,7 +255,10 @@ export const CreateKey = () => {
Expiry
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-36 justify-between px-3">
<Button
className="w-36 justify-between px-3"
variant="outline"
>
{expires}
</Button>
</DropdownMenuTrigger>
@@ -131,7 +280,12 @@ export const CreateKey = () => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={submit} disabled={isPending}>
<Button
variant="secondary"
className="gap-4"
onClick={submit}
disabled={isPending}
>
Submit
{isPending ? (
<Loader2 className="w-4 animate-spin" />
@@ -162,6 +316,7 @@ const DeleteKey = ({ api_key }: { api_key: string }) => {
return (
<ConfirmButton
title="Delete"
variant="destructive"
icon={<Trash className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
+46 -40
View File
@@ -21,12 +21,21 @@ import {
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import { useToast } from "@ui/use-toast";
import { Check, Loader2, PlusCircle, Trash } from "lucide-react";
import {
Check,
GitBranch,
HardDrive,
Loader2,
PlusCircle,
Search,
Trash,
} from "lucide-react";
import { ChangeEvent, ReactNode, useState } from "react";
import { Section } from "@components/layouts";
export const ProvidersPage = () => {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
<Providers type="GitProvider" />
<Providers type="DockerRegistry" />
</div>
@@ -70,42 +79,31 @@ const Providers = ({ type }: { type: "GitProvider" | "DockerRegistry" }) => {
},
});
return (
<div className="flex flex-col gap-4">
<Section
title={type === "DockerRegistry" ? "Registry Accounts" : "Git Accounts"}
icon={
type === "DockerRegistry" ? (
<HardDrive className="w-4 h-4" />
) : (
<GitBranch className="w-4 h-4" />
)
}
>
{/* Create / Search */}
<div className="flex items-center justify-between">
<h2>
{type === "DockerRegistry" ? "Registry Accounts" : "Git Accounts"}
</h2>
<div className="flex items-center gap-2">
<CreateAccount type={type} />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
className="pl-8 w-[200px] lg:w-[300px]"
/>
<CreateAccount type={type} />
</div>
</div>
{updateMenuData && (
<TextUpdateMenu
title={updateMenuData.title}
titleRight={updateMenuData.titleRight}
placeholder={updateMenuData.placeholder}
value={updateMenuData.value}
onUpdate={updateMenuData.onUpdate}
triggerClassName="w-full"
disabled={disabled}
open={!!updateMenuData}
setOpen={(open) => {
if (!open) {
setUpdateMenuData(false);
}
}}
triggerHidden
/>
)}
{/** ACCOUNTS */}
{/* ACCOUNTS */}
<DataTable
tableKey={type + "-accounts"}
data={filtered}
@@ -234,17 +232,25 @@ const Providers = ({ type }: { type: "GitProvider" | "DockerRegistry" }) => {
},
]}
/>
{/** SECRETS */}
{/* {secrets.length && (
<div className="flex items-center gap-2 text-muted-foreground">
<div>Core Secrets:</div>
{secrets.map((secret) => (
<Badge variant="secondary">{secret}</Badge>
))}
</div>
)} */}
</div>
{updateMenuData && (
<TextUpdateMenu
title={updateMenuData.title}
titleRight={updateMenuData.titleRight}
placeholder={updateMenuData.placeholder}
value={updateMenuData.value}
onUpdate={updateMenuData.onUpdate}
triggerClassName="w-full"
disabled={disabled}
open={!!updateMenuData}
setOpen={(open) => {
if (!open) {
setUpdateMenuData(false);
}
}}
triggerHidden
/>
)}
</Section>
);
};
+13 -7
View File
@@ -11,7 +11,7 @@ import {
import { Button } from "@ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/card";
import { useToast } from "@ui/use-toast";
import { Trash, PlusCircle, Loader2, Check } from "lucide-react";
import { Trash, PlusCircle, Loader2, Check, Search } from "lucide-react";
import { useState } from "react";
import { Input } from "@ui/input";
import { UpdateUser } from "@components/updates/details";
@@ -26,12 +26,18 @@ export const Tags = () => {
return (
<div className="flex flex-col gap-4">
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
/>
<div className="flex items-center justify-between">
<CreateTag />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
<DataTable
tableKey="tags"
data={tags?.filter((tag) => tag.name.includes(search)) ?? []}
+113 -49
View File
@@ -2,68 +2,132 @@ import { ExportButton } from "@components/export";
import { Section } from "@components/layouts";
import { NewServiceUser, NewUserGroup } from "@components/users/new";
import { UserTable } from "@components/users/table";
import { useRead, useSetTitle } from "@lib/hooks";
import {
useInvalidate,
useRead,
useSetTitle,
useUser,
useWrite,
} from "@lib/hooks";
import { DeleteUserGroup } from "@pages/user-group";
import { DataTable } from "@ui/data-table";
import { User, Users } from "lucide-react";
import { Input } from "@ui/input";
import { useToast } from "@ui/use-toast";
import { Search, User, Users } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export const UsersPage = ({ search }: { search: string }) => {
export const UsersPage = ({ goToProfile }: { goToProfile: () => void }) => {
useSetTitle("Users");
const nav = useNavigate();
const groups = useRead("ListUserGroups", {}).data;
const users = useRead("ListUsers", {}).data;
const searchSplit = search.split(" ");
return (
<div className="flex flex-col gap-6">
{/* User Groups */}
<Section
title="User Groups"
icon={<Users className="w-4 h-4" />}
actions={
<div className="flex items-center gap-4">
{groups && groups.length ? (
<UserGroupsSection />
<UsersSection goToProfile={goToProfile} />
</div>
);
};
const UserGroupsSection = () => {
const nav = useNavigate();
const groups = useRead("ListUserGroups", {}).data;
const [search, setSearch] = useState("");
const searchSplit = search.split(" ");
return (
<Section title="User Groups" icon={<Users className="w-4 h-4" />}>
<div className="flex items-center justify-between">
<NewUserGroup />
<div className="flex items-center gap-4">
{groups && groups.length > 0 && (
<div className="flex items-center gap-4">
<ExportButton
user_groups={groups
?.map((group) => group._id?.$oid!)
.filter((id) => id)}
/>
) : undefined}
<NewUserGroup />
</div>
)}
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
</div>
<DataTable
tableKey="user-groups"
data={
groups?.filter((group) =>
searchSplit.every((term) => group.name.includes(term))
) ?? []
}
>
<DataTable
tableKey="user-groups"
data={
groups?.filter((group) =>
searchSplit.every((term) => group.name.includes(term))
) ?? []
}
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Members",
accessorFn: (group) => group.users.length,
},
]}
onRowClick={(group) => nav(`/user-groups/${group._id!.$oid}`)}
/>
</Section>
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Members",
accessorFn: (group) => group.users.length,
},
{
header: "Delete",
cell: ({ row: { original: group } }) => (
<DeleteUserGroup group={group} />
),
},
]}
onRowClick={(group) => nav(`/user-groups/${group._id!.$oid}`)}
/>
</Section>
);
};
{/* Users */}
<Section
title="Users"
icon={<User className="w-4 h-4" />}
actions={<NewServiceUser />}
>
<UserTable
users={
users?.filter((user) =>
searchSplit.every((term) => user.username.includes(term))
) ?? []
}
/>
</Section>
</div>
const UsersSection = ({ goToProfile }: { goToProfile: () => void }) => {
const user = useUser().data;
const inv = useInvalidate();
const { toast } = useToast();
const { mutate: deleteUser } = useWrite("DeleteUser", {
onSuccess: () => {
toast({ title: "User deleted." });
inv(["ListUsers"]);
},
});
const users = useRead("ListUsers", {}).data;
const [search, setSearch] = useState("");
const searchSplit = search.split(" ");
return (
<Section title="Users" icon={<User className="w-4 h-4" />}>
<div className="flex items-center justify-between">
<NewServiceUser />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
<UserTable
users={
users?.filter((user) =>
searchSplit.every((term) => user.username.includes(term))
) ?? []
}
onUserDelete={
user?.admin ? (user_id) => deleteUser({ user: user_id }) : undefined
}
userDeleteDisabled={(user_id) => {
const toDelete = users?.find((user) => user._id?.$oid === user_id);
if (!toDelete) return true;
if (!toDelete.admin) return false;
if (toDelete.super_admin) return true;
return !user?.super_admin;
}}
onSelfClick={goToProfile}
/>
</Section>
);
};
+16 -12
View File
@@ -1,4 +1,3 @@
import { ExportButton } from "@components/export";
import { ConfirmButton, CopyButton, TextUpdateMenu } from "@components/util";
import {
useInvalidate,
@@ -22,7 +21,7 @@ import {
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import { useToast } from "@ui/use-toast";
import { Check, Loader2, PlusCircle, Trash } from "lucide-react";
import { Check, Loader2, PlusCircle, Search, Trash } from "lucide-react";
import { useState } from "react";
export const Variables = () => {
@@ -71,14 +70,17 @@ export const Variables = () => {
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
/>
<ExportButton include_variables />
<div className="flex justify-between gap-4">
<CreateVariable />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
{updateMenuData && (
@@ -101,6 +103,7 @@ export const Variables = () => {
{/** VARIABLES */}
<div className="max-w-full overflow-auto">
{/* <div className="w-full min-w-[1200px]"> */}
<DataTable
tableKey="variables"
data={filtered}
@@ -196,11 +199,12 @@ export const Variables = () => {
},
{
header: "Delete",
size: 200,
cell: ({ row }) => <DeleteVariable name={row.original.name} />,
minSize: 200,
},
]}
/>
{/* </div> */}
</div>
{/** SECRETS */}
@@ -216,7 +220,7 @@ export const Variables = () => {
);
};
export const CreateVariable = () => {
const CreateVariable = () => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
+31 -14
View File
@@ -45,6 +45,10 @@ const StackLogsInner = ({
const [invert, setInvert] = useState(false);
const [search, setSearch] = useState("");
const [poll, setPoll] = useLocalStorage("log-poll-v1", false);
const [timestamps, setTimestamps] = useLocalStorage(
"log-timestamps-v1",
false
);
const addTerm = () => {
if (!search.length) return;
@@ -63,8 +67,8 @@ const StackLogsInner = ({
};
const { Log, refetch, stderr } = terms.length
? SearchLogs(id, service, terms, invert)
: NoSearchLogs(id, service, tail, stream);
? SearchLogs(id, service, terms, invert, timestamps)
: NoSearchLogs(id, service, tail, timestamps, stream);
useEffect(() => {
const interval = setInterval(() => {
@@ -81,9 +85,8 @@ const StackLogsInner = ({
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<div className="text-muted-foreground flex gap-1">
<div>Invert</div>
<div className="hidden xl:block">Search</div>
<div className="text-muted-foreground flex gap-1 text-sm">
Invert
</div>
<Switch checked={invert} onCheckedChange={setInvert} />
</div>
@@ -130,9 +133,19 @@ const StackLogsInner = ({
<Button variant="secondary" size="icon" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2">
<div className="text-muted-foreground">Poll</div>
<Switch checked={poll} onCheckedChange={setPoll} />
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setTimestamps((t) => !t)}
>
<div className="text-muted-foreground text-sm">Timestamps</div>
<Switch checked={timestamps} />
</div>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setPoll((p) => !p)}
>
<div className="text-muted-foreground text-sm">Poll</div>
<Switch checked={poll} />
</div>
<TailLengthSelector
selected={tail}
@@ -151,13 +164,15 @@ const NoSearchLogs = (
id: string,
service: string,
tail: string,
timestamps: boolean,
stream: string
) => {
const { data: log, refetch } = useRead(
"GetStackServiceLog",
{ stack: id, service, tail: Number(tail) },
{ refetchInterval: 30000 }
);
const { data: log, refetch } = useRead("GetStackServiceLog", {
stack: id,
service,
tail: Number(tail),
timestamps,
});
return {
Log: (
<div className="relative">
@@ -173,7 +188,8 @@ const SearchLogs = (
id: string,
service: string,
terms: string[],
invert: boolean
invert: boolean,
timestamps: boolean
) => {
const { data: log, refetch } = useRead("SearchStackServiceLog", {
stack: id,
@@ -181,6 +197,7 @@ const SearchLogs = (
terms,
combinator: Types.SearchCombinator.And,
invert,
timestamps,
});
return {
Log: (
+1 -1
View File
@@ -206,7 +206,7 @@ const UserAvatar = ({ avatar }: { avatar: string | undefined }) =>
<User className="w-4" />
);
const DeleteUserGroup = ({ group }: { group: Types.UserGroup }) => {
export const DeleteUserGroup = ({ group }: { group: Types.UserGroup }) => {
const nav = useNavigate();
const inv = useInvalidate();
const { toast } = useToast();
+1 -1
View File
@@ -136,7 +136,7 @@ const ApiKeysTable = ({ user_id }: { user_id: string }) => {
const keys = useRead("ListApiKeysForServiceUser", { user: user_id }).data;
return (
<Card>
<CardHeader className="border-b pb-6 flex justify-between">
<CardHeader className="border-b pb-6 flex flex-row items-center gap-4">
Api Keys <CreateKeyForServiceUser user_id={user_id} />
</CardHeader>
<CardContent>
+5 -9
View File
@@ -73,14 +73,13 @@ export function DataTable<TData, TValue>({
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const size = header.column.getSize();
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
"relative whitespace-nowrap bg-background border-b border-r last:border-r-0"
// `w-[${header.column.getSize()}px]`
)}
className="relative whitespace-nowrap bg-background border-b border-r last:border-r-0"
style={{ width: `${size}px` }}
>
{header.isPlaceholder
? null
@@ -111,11 +110,8 @@ export function DataTable<TData, TValue>({
return (
<TableCell
key={cell.id}
// className="p-4 border-x first:border-r first:border-l-0 last:border-l last:border-r-0"
className={cn(
"p-4 overflow-hidden overflow-ellipsis",
size && `w-[${size}px]`
)}
className="p-4 overflow-hidden overflow-ellipsis"
style={{ width: `${size}px` }}
>
{flexRender(
cell.column.columnDef.cell,
+46 -1
View File
@@ -1,12 +1,57 @@
use std::path::Path;
use komodo_client::entities::{komodo_timestamp, update::Log};
use run_command::{async_run_command, CommandOutput};
pub async fn run_komodo_command(stage: &str, command: String) -> Log {
/// Parses commands out of multiline string
/// and chains them together with '&&'
///
/// Supports full line and end of line comments. See [parse_multiline_command].
pub async fn run_komodo_command(
stage: &str,
path: impl Into<Option<&Path>>,
command: impl AsRef<str>,
) -> Log {
let command = parse_multiline_command(command);
let command = if let Some(path) = path.into() {
format!("cd {} && {command}", path.display(),)
} else {
command
};
let start_ts = komodo_timestamp();
let output = async_run_command(&command).await;
output_into_log(stage, command, start_ts, output)
}
/// Parses commands out of multiline string
/// and chains them together with '&&'
///
/// Supports full line and end of line comments.
///
/// ## Example:
/// ```sh
/// # comments supported
/// sh ./shell1.sh # end of line supported
/// sh ./shell2.sh
/// # print done
/// echo done
/// ```
/// becomes
/// ```sh
/// sh ./shell1.sh && sh ./shell2.sh && echo done
/// ```
pub fn parse_multiline_command(command: impl AsRef<str>) -> String {
command
.as_ref()
.split('\n')
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(|line| line.split(" #").next())
.map(str::trim)
.collect::<Vec<_>>()
.join(" && ")
}
pub fn output_into_log(
stage: &str,
command: String,
+13 -20
View File
@@ -39,8 +39,7 @@ where
{
let args: CloneArgs = clone_args.into();
let repo_dir = args.path(repo_dir);
let repo_url =
args.remote_url(access_token.as_ref().map(String::as_str))?;
let repo_url = args.remote_url(access_token.as_deref())?;
let mut logs = clone_inner(
&repo_url,
@@ -100,7 +99,7 @@ where
};
if let Some(command) = args.on_clone {
if !command.path.is_empty() && !command.command.is_empty() {
if !command.command.is_empty() {
let on_clone_path = repo_dir.join(&command.path);
if let Some(secrets) = secrets {
let (full_command, mut replacers) =
@@ -116,7 +115,8 @@ where
replacers.extend(core_replacers.to_owned());
let mut on_clone_log = run_komodo_command(
"on clone",
format!("cd {} && {full_command}", on_clone_path.display()),
on_clone_path.as_ref(),
full_command,
)
.await;
@@ -137,11 +137,8 @@ where
} else {
let on_clone_log = run_komodo_command(
"on clone",
format!(
"cd {} && {}",
on_clone_path.display(),
command.command
),
on_clone_path.as_ref(),
&command.command,
)
.await;
tracing::debug!(
@@ -154,7 +151,7 @@ where
}
}
if let Some(command) = args.on_pull {
if !command.path.is_empty() && !command.command.is_empty() {
if !command.command.is_empty() {
let on_pull_path = repo_dir.join(&command.path);
if let Some(secrets) = secrets {
let (full_command, mut replacers) =
@@ -170,7 +167,8 @@ where
replacers.extend(core_replacers.to_owned());
let mut on_pull_log = run_komodo_command(
"on pull",
format!("cd {} && {full_command}", on_pull_path.display()),
on_pull_path.as_ref(),
&full_command,
)
.await;
@@ -191,11 +189,8 @@ where
} else {
let on_pull_log = run_komodo_command(
"on pull",
format!(
"cd {} && {}",
on_pull_path.display(),
command.command
),
on_pull_path.as_ref(),
&command.command,
)
.await;
tracing::debug!(
@@ -256,10 +251,8 @@ async fn clone_inner(
if let Some(commit) = commit {
let reset_log = run_komodo_command(
"set commit",
format!(
"cd {} && git reset --hard {commit}",
destination.display()
),
destination,
format!("git reset --hard {commit}",),
)
.await;
logs.push(reset_log);
+18 -21
View File
@@ -38,16 +38,13 @@ where
{
let args: CloneArgs = clone_args.into();
let path = args.path(repo_dir);
let path_display = path.display();
let repo_url =
args.remote_url(access_token.as_ref().map(String::as_str))?;
let repo_url = args.remote_url(access_token.as_deref())?;
// Set remote url
let mut set_remote = run_komodo_command(
"set git remote",
format!(
"cd {path_display} && git remote set-url origin {repo_url}"
),
path.as_ref(),
format!("git remote set-url origin {repo_url}"),
)
.await;
@@ -70,7 +67,8 @@ where
let checkout = run_komodo_command(
"checkout branch",
format!("cd {path_display} && git checkout -f {}", args.branch),
path.as_ref(),
format!("git checkout -f {}", args.branch),
)
.await;
@@ -83,12 +81,12 @@ where
});
}
let command = format!(
"cd {path_display} && git pull --rebase --force origin {}",
args.branch
);
let pull_log = run_komodo_command("git pull", command).await;
let pull_log = run_komodo_command(
"git pull",
path.as_ref(),
format!("git pull --rebase --force origin {}", args.branch),
)
.await;
let mut logs = vec![pull_log];
@@ -104,7 +102,8 @@ where
if let Some(commit) = args.commit {
let reset_log = run_komodo_command(
"set commit",
format!("cd {path_display} && git reset --hard {commit}"),
path.as_ref(),
format!("git reset --hard {commit}"),
)
.await;
logs.push(reset_log);
@@ -144,7 +143,7 @@ where
};
if let Some(command) = args.on_pull {
if !command.path.is_empty() && !command.command.is_empty() {
if !command.command.is_empty() {
let on_pull_path = path.join(&command.path);
if let Some(secrets) = secrets {
let (full_command, mut replacers) =
@@ -174,7 +173,8 @@ where
replacers.extend(core_replacers.to_owned());
let mut on_pull_log = run_komodo_command(
"on pull",
format!("cd {} && {full_command}", on_pull_path.display()),
on_pull_path.as_ref(),
&full_command,
)
.await;
@@ -195,11 +195,8 @@ where
} else {
let on_pull_log = run_komodo_command(
"on pull",
format!(
"cd {} && {}",
on_pull_path.display(),
command.command
),
on_pull_path.as_ref(),
&command.command,
)
.await;
tracing::debug!(
+6 -2
View File
@@ -30,6 +30,10 @@ docker compose -p komodo-dev -f test.compose.yaml build"""
description = "runs core --release pointing to test.core.config.toml"
cmd = "KOMODO_CONFIG_PATH=test.core.config.toml cargo run -p komodo_core --release"
[test-periphery]
description = "runs periphery --release pointing to test.periphery.config.toml"
cmd = "PERIPHERY_CONFIG_PATH=test.periphery.config.toml cargo run -p komodo_periphery --release"
[docsite-start]
path = "docsite"
cmd = "yarn start"
@@ -38,5 +42,5 @@ cmd = "yarn start"
path = "docsite"
cmd = "yarn deploy"
[rustdoc-server]
cmd = "cargo watch -s 'cargo doc --no-deps -p komodo_client' & http --quiet target/doc"
# [rustdoc-server]
# cmd = "cargo watch -s 'cargo doc --no-deps -p komodo_client' & http --quiet target/doc"
+14 -4
View File
@@ -25,9 +25,6 @@ Will install to paths:
*Note*. The user running periphery must be a member of the docker group, in order to use the docker cli without sudo.
*Note*. Ensure the user running periphery has write access to the configure [repo directory](https://github.com/mbecker20/komodo/blob/main/config/periphery.config.toml).
This allows periphery to clone repos and write compose files.
```sh
curl -sSL https://raw.githubusercontent.com/mbecker20/komodo/main/scripts/setup-periphery.py | python3 - --user
```
@@ -35,4 +32,17 @@ curl -sSL https://raw.githubusercontent.com/mbecker20/komodo/main/scripts/setup-
Will install to paths:
- periphery (binary) -> `$HOME/.local/bin`
- periphery.service -> `$HOME/.config/systemd/user/periphery.service`
- periphery.config.toml -> `$HOME/.config/komodo/periphery.config.toml`
- periphery.config.toml -> `$HOME/.config/komodo/periphery.config.toml`
*Note*. Ensure the user running periphery has write permissions to the configured folders `repo_dir`, `stack_dir`, and `ssl_key_file` / `ssl_cert_file` parent folder.
This allows periphery to clone repos, write compose files, and generate ssl certs.
For example in `periphery.config.toml`, running under `ubuntu` user:
```toml
repo_dir = "/home/ubuntu/.komodo/repos"
stack_dir = "/home/ubuntu/.komodo/stacks"
ssl_enabled = true
ssl_key_file = "/home/ubuntu/.komodo/ssl/key.pem"
ssl_cert_file = "/home/ubuntu/.komodo/ssl/cert.pem"
```
+19 -11
View File
@@ -1,14 +1,18 @@
###########################
# 🦎 KOMODO CORE CONFIG 🦎 #
###########################
title = "Komodo Test"
host = "http://localhost:9121"
port = 9121
passkey = "a_random_passkey"
repo_directory = "/Users/max/komodo-repos"
repo_directory = ".core-repos"
frontend_path = "frontend/dist"
first_server = "http://localhost:8121"
disable_confirm_dialog = true
enable_new_users = true
# database.address = "localhost:27017"
# database.address = "ferretdb.komodo.orb.local"
# database.address = "ferretdb.komodo-dev.orb.local"
database.address = "test-ferretdb.orb.local"
# database.username = "admin"
# database.password = "admin"
@@ -18,16 +22,20 @@ local_auth = true
jwt_secret = "your_random_secret"
jwt_ttl = "2-wk"
oidc_enabled = true
oidc_provider = "http://server.authentik2.orb.local:9000/application/o/komodo"
oidc_client_id = "komodo"
oidc_client_secret = "komodo"
oidc_enabled = false
# oidc_provider = "http://server.authentik2.orb.local:9000/application/o/komodo"
# oidc_client_id = "komodo"
# oidc_client_secret = "komodo"
webhook_secret = "a_random_webhook_secret"
stack_poll_interval = "1-min"
sync_poll_interval = "1-min"
build_poll_interval = "1-min"
repo_poll_interval = "1-min"
monitoring_interval = "5-sec"
resource_poll_interval = "1-min"
###########
# LOGGING #
###########
logging.level = "info"
logging.stdio = "standard"
logging.otlp_endpoint = "http://tempo.grafana.orb.local:4317"
logging.opentelemetry_service_name = "Komodo"
+24
View File
@@ -0,0 +1,24 @@
################################
# 🦎 KOMODO PERIPHERY CONFIG 🦎 #
################################
port = 8121
repo_dir = ".repos"
stack_dir = ".stacks"
stats_polling_rate = "1-sec"
legacy_compose_cli = false
include_disk_mounts = []
############
# Security #
############
ssl_enabled = false
ssl_key_file = ".ssl/key.pem"
ssl_cert_file = ".ssl/cert.pem"
###########
# LOGGING #
###########
logging.level = "info"
logging.stdio = "standard"
logging.otlp_endpoint = "http://tempo.grafana.orb.local:4317"
logging.opentelemetry_service_name = "Periphery"