Compare commits

..

3 Commits

Author SHA1 Message Date
mbecker20
76f2f61be5 1.17.3 fix Build pre_build functionality. 2025-04-24 22:03:46 -04:00
Maxwell Becker
b43e2918da 1.17.2 (#409)
* start on cron schedules

* rust 1.86.0

* config periphery directories easier with PERIPHERY_ROOT_DIRECTORY

* schedule backend

* fix config switch toggling through disabled

* procedure schedule working

* implement schedules for actions

* update schedule immediately after last run

* improve config update logs using toml diffs backend

* improve the config update logs with TOML diff view

* add schedule alerting

* version 1.17.2

* Set TZ in core env

* dev-1

* better term signal labels

* sync configurable pending alert send

* fix monaco editor height on larger screen

* poll update until complete on client

update lib

* add logger.pretty option for both core and periphery

* fix pretty

* configure schedule alert

* configure failure alert

* dev-3

* 1.17.2

* fmt

* added pushover alerter (#421)

* fix up pushover

* fix some clippy

---------

Co-authored-by: Alex Shore <alex@shore.me.uk>
2025-04-18 23:14:10 -07:00
Etienne
f45205011e Fix compose.env (#415)
Flip "stacks" and "repos" paths
2025-04-15 12:11:48 -07:00
79 changed files with 2529 additions and 720 deletions

124
Cargo.lock generated
View File

@@ -106,9 +106,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.97"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arc-swap"
@@ -898,7 +898,7 @@ dependencies = [
[[package]]
name = "cache"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"tokio",
@@ -951,6 +951,27 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402"
dependencies = [
"parse-zoneinfo",
"phf_codegen",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1038,7 +1059,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"formatting",
@@ -1114,6 +1135,15 @@ dependencies = [
"libc",
]
[[package]]
name = "croner"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38fd53511eaf0b00a185613875fee58b208dfce016577d0ad4bb548e1c4fb3ee"
dependencies = [
"chrono",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1484,6 +1514,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "english-to-cron"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a13a7d5e0ab3872c3ee478366eae624d89ab953d30276b0eee08169774ceb73"
dependencies = [
"regex",
]
[[package]]
name = "enum-as-inner"
version = "0.6.1"
@@ -1498,7 +1537,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"thiserror 2.0.12",
]
@@ -1567,7 +1606,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"serror",
]
@@ -1729,7 +1768,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "git"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"cache",
@@ -2466,7 +2505,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"clap",
@@ -2482,7 +2521,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2513,7 +2552,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"arc-swap",
@@ -2527,10 +2566,14 @@ dependencies = [
"base64 0.22.1",
"bcrypt",
"cache",
"chrono",
"chrono-tz",
"command",
"croner",
"dashmap",
"derive_variants",
"dotenvy",
"english-to-cron",
"environment_file",
"envy",
"formatting",
@@ -2577,7 +2620,7 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2689,7 +2732,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"komodo_client",
@@ -3346,6 +3389,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "parse_link_header"
version = "0.3.3"
@@ -3413,7 +3465,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"komodo_client",
@@ -3425,6 +3477,44 @@ dependencies = [
"tracing",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -3877,7 +3967,7 @@ dependencies = [
[[package]]
name = "response"
version = "1.17.1"
version = "1.17.3"
dependencies = [
"anyhow",
"axum",
@@ -4468,6 +4558,12 @@ dependencies = [
"time",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"

View File

@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "1.17.1"
version = "1.17.3"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -67,7 +67,7 @@ serde_yaml = "0.9.34"
toml = "0.8.20"
# ERROR
anyhow = "1.0.97"
anyhow = "1.0.98"
thiserror = "2.0.12"
# LOGGING
@@ -107,6 +107,12 @@ aws-config = "1.6.1"
aws-sdk-ec2 = "1.121.1"
aws-credential-types = "1.2.2"
## CRON
english-to-cron = "0.1.4"
chrono-tz = "0.10.3"
chrono = "0.4.40"
croner = "2.1.0"
# MISC
derive_builder = "0.20.2"
typeshare = "1.0.4"

View File

@@ -1,7 +1,7 @@
## Builds the Komodo Core and Periphery binaries
## for a specific architecture.
FROM rust:1.85.1-bullseye AS builder
FROM rust:1.86.0-bullseye AS builder
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -23,5 +23,5 @@ COPY --from=builder /builder/target/release/core /core
COPY --from=builder /builder/target/release/periphery /periphery
LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo
LABEL org.opencontainers.image.description="Komodo Periphery"
LABEL org.opencontainers.image.description="Komodo Binaries"
LABEL org.opencontainers.image.licenses=GPL-3.0

View File

@@ -39,7 +39,9 @@ svi.workspace = true
# external
aws-credential-types.workspace = true
ordered_hash_map.workspace = true
english-to-cron.workspace = true
openidconnect.workspace = true
jsonwebtoken.workspace = true
axum-server.workspace = true
urlencoding.workspace = true
aws-sdk-ec2.workspace = true
@@ -50,6 +52,7 @@ tower-http.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
typeshare.workspace = true
chrono-tz.workspace = true
octorust.workspace = true
wildcard.workspace = true
arc-swap.workspace = true
@@ -60,6 +63,8 @@ futures.workspace = true
nom_pem.workspace = true
dotenvy.workspace = true
anyhow.workspace = true
croner.workspace = true
chrono.workspace = true
bcrypt.workspace = true
base64.workspace = true
rustls.workspace = true
@@ -73,5 +78,4 @@ envy.workspace = true
rand.workspace = true
hmac.workspace = true
sha2.workspace = true
jsonwebtoken.workspace = true
hex.workspace = true

View File

@@ -1,7 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
# Build Core
FROM rust:1.85.1-bullseye AS core-builder
FROM rust:1.86.0-bullseye AS core-builder
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./

View File

@@ -189,6 +189,24 @@ pub async fn send_alert(
let link = resource_link(ResourceTargetVariant::Repo, id);
format!("{level} | Repo build for **{name}** failed\n{link}")
}
AlertData::ProcedureFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Procedure, id);
format!("{level} | Procedure **{name}** failed\n{link}")
}
AlertData::ActionFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Action, id);
format!("{level} | Action **{name}** failed\n{link}")
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let link = resource_link(*resource_type, id);
format!(
"{level} | **{name}** ({resource_type}) | Scheduled run started 🕝\n{link}"
)
}
AlertData::None {} => Default::default(),
};
if !content.is_empty() {

View File

@@ -18,8 +18,9 @@ use crate::helpers::query::get_variables_and_secrets;
use crate::{config::core_config, state::db_client};
mod discord;
mod slack;
mod ntfy;
mod pushover;
mod slack;
#[instrument(level = "debug")]
pub async fn send_alerts(alerts: &[Alert]) {
@@ -131,7 +132,18 @@ pub async fn send_alert_to_alerter(
}
AlerterEndpoint::Ntfy(NtfyAlerterEndpoint { url }) => {
ntfy::send_alert(url, alert).await.with_context(|| {
format!("Failed to send alert to ntfy Alerter {}", alerter.name)
format!(
"Failed to send alert to ntfy Alerter {}",
alerter.name
)
})
}
AlerterEndpoint::Pushover(PushoverAlerterEndpoint { url }) => {
pushover::send_alert(url, alert).await.with_context(|| {
format!(
"Failed to send alert to Pushover Alerter {}",
alerter.name
)
})
}
}

View File

@@ -202,6 +202,24 @@ pub async fn send_alert(
let link = resource_link(ResourceTargetVariant::Repo, id);
format!("{level} | Repo build for {} failed\n{link}", name,)
}
AlertData::ProcedureFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Procedure, id);
format!("{level} | Procedure {name} failed\n{link}")
}
AlertData::ActionFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Action, id);
format!("{level} | Action {name} failed\n{link}")
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let link = resource_link(*resource_type, id);
format!(
"{level} | {name} ({resource_type}) | Scheduled run started 🕝\n{link}"
)
}
AlertData::None {} => Default::default(),
};

View File

@@ -0,0 +1,270 @@
use std::sync::OnceLock;
use super::*;
#[instrument(level = "debug")]
pub async fn send_alert(
url: &str,
alert: &Alert,
) -> anyhow::Result<()> {
let level = fmt_level(alert.level);
let content = match &alert.data {
AlertData::Test { id, name } => {
let link = resource_link(ResourceTargetVariant::Alerter, id);
format!(
"{level} | If you see this message, then Alerter {} is working\n{link}",
name,
)
}
AlertData::ServerUnreachable {
id,
name,
region,
err,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | {}{} is now reachable\n{link}",
name, region
)
}
SeverityLevel::Critical => {
let err = err
.as_ref()
.map(|e| format!("\nerror: {:#?}", e))
.unwrap_or_default();
format!(
"{level} | {}{} is unreachable ❌\n{link}{err}",
name, region
)
}
_ => unreachable!(),
}
}
AlertData::ServerCpu {
id,
name,
region,
percentage,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
format!(
"{level} | {}{} cpu usage at {percentage:.1}%\n{link}",
name, region,
)
}
AlertData::ServerMem {
id,
name,
region,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
let percentage = 100.0 * used_gb / total_gb;
format!(
"{level} | {}{} memory usage at {percentage:.1}%💾\n\nUsing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
name, region,
)
}
AlertData::ServerDisk {
id,
name,
region,
path,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
let percentage = 100.0 * used_gb / total_gb;
format!(
"{level} | {}{} disk usage at {percentage:.1}%💿\nmount point: {:?}\nusing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
name, region, path,
)
}
AlertData::ContainerStateChange {
id,
name,
server_id: _server_id,
server_name,
from,
to,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
let to_state = fmt_docker_container_state(to);
format!(
"📦Deployment {} is now {}\nserver: {}\nprevious: {}\n{link}",
name, to_state, server_name, from,
)
}
AlertData::DeploymentImageUpdateAvailable {
id,
name,
server_id: _server_id,
server_name,
image,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
format!(
"⬆ Deployment {} has an update available\nserver: {}\nimage: {}\n{link}",
name, server_name, image,
)
}
AlertData::DeploymentAutoUpdated {
id,
name,
server_id: _server_id,
server_name,
image,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
format!(
"⬆ Deployment {} was updated automatically\nserver: {}\nimage: {}\n{link}",
name, server_name, image,
)
}
AlertData::StackStateChange {
id,
name,
server_id: _server_id,
server_name,
from,
to,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
let to_state = fmt_stack_state(to);
format!(
"🥞 Stack {} is now {}\nserver: {}\nprevious: {}\n{link}",
name, to_state, server_name, from,
)
}
AlertData::StackImageUpdateAvailable {
id,
name,
server_id: _server_id,
server_name,
service,
image,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
format!(
"⬆ Stack {} has an update available\nserver: {}\nservice: {}\nimage: {}\n{link}",
name, server_name, service, image,
)
}
AlertData::StackAutoUpdated {
id,
name,
server_id: _server_id,
server_name,
images,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
let images_label =
if images.len() > 1 { "images" } else { "image" };
let images_str = images.join(", ");
format!(
"⬆ Stack {} was updated automatically ⏫\nserver: {}\n{}: {}\n{link}",
name, server_name, images_label, images_str,
)
}
AlertData::AwsBuilderTerminationFailed {
instance_id,
message,
} => {
format!(
"{level} | Failed to terminate AWS builder instance\ninstance id: {}\n{}",
instance_id, message,
)
}
AlertData::ResourceSyncPendingUpdates { id, name } => {
let link =
resource_link(ResourceTargetVariant::ResourceSync, id);
format!(
"{level} | Pending resource sync updates on {}\n{link}",
name,
)
}
AlertData::BuildFailed { id, name, version } => {
let link = resource_link(ResourceTargetVariant::Build, id);
format!(
"{level} | Build {name} failed\nversion: v{version}\n{link}",
)
}
AlertData::RepoBuildFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Repo, id);
format!("{level} | Repo build for {} failed\n{link}", name,)
}
AlertData::ProcedureFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Procedure, id);
format!("{level} | Procedure {name} failed\n{link}")
}
AlertData::ActionFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Action, id);
format!("{level} | Action {name} failed\n{link}")
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let link = resource_link(*resource_type, id);
format!(
"{level} | {name} ({resource_type}) | Scheduled run started 🕝\n{link}"
)
}
AlertData::None {} => Default::default(),
};
if !content.is_empty() {
send_message(url, content).await?;
}
Ok(())
}
async fn send_message(
url: &str,
content: String,
) -> anyhow::Result<()> {
// pushover needs all information to be encoded in the URL. At minimum they need
// the user key, the application token, and the message (url encoded).
// other optional params here: https://pushover.net/api (just add them to the
// webhook url along with the application token and the user key).
let content = [("message", content)];
let response = http_client()
.post(url)
.form(&content)
.send()
.await
.context("Failed to send message")?;
let status = response.status();
if status.is_success() {
debug!("pushover alert sent successfully: {}", status);
Ok(())
} else {
let text = response.text().await.with_context(|| {
format!(
"Failed to send message to pushover | {} | failed to get response text",
status
)
})?;
Err(anyhow!(
"Failed to send message to pushover | {} | {}",
status,
text
))
}
}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}

View File

@@ -373,9 +373,7 @@ pub async fn send_alert(
let text = format!("{level} | Build {name} has failed");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"build name: *{name}*\nversion: *v{version}*",
)),
Block::section(format!("version: *v{version}*",)),
Block::section(resource_link(
ResourceTargetVariant::Build,
id,
@@ -388,7 +386,6 @@ pub async fn send_alert(
format!("{level} | Repo build for *{name}* has *failed*");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!("repo name: *{name}*",)),
Block::section(resource_link(
ResourceTargetVariant::Repo,
id,
@@ -396,6 +393,42 @@ pub async fn send_alert(
];
(text, blocks.into())
}
AlertData::ProcedureFailed { id, name } => {
let text = format!("{level} | Procedure *{name}* has *failed*");
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(
ResourceTargetVariant::Procedure,
id,
)),
];
(text, blocks.into())
}
AlertData::ActionFailed { id, name } => {
let text = format!("{level} | Action *{name}* has *failed*");
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(
ResourceTargetVariant::Action,
id,
)),
];
(text, blocks.into())
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let text = format!(
"{level} | *{name}* ({resource_type}) | Scheduled run started 🕝"
);
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(*resource_type, id)),
];
(text, blocks.into())
}
AlertData::None {} => Default::default(),
};
if !text.is_empty() {
@@ -411,7 +444,7 @@ pub async fn send_alert(
&mut global_replacers,
&mut secret_replacers,
)?;
let slack = ::slack::Client::new(url_interpolated);
slack.send_message(text, blocks).await.map_err(|e| {
let replacers =

View File

@@ -13,8 +13,13 @@ use komodo_client::{
user::{CreateApiKey, CreateApiKeyResponse, DeleteApiKey},
},
entities::{
action::Action, config::core::CoreConfig,
permission::PermissionLevel, update::Update, user::action_user,
action::Action,
alert::{Alert, AlertData, SeverityLevel},
config::core::CoreConfig,
komodo_timestamp,
permission::PermissionLevel,
update::Update,
user::action_user,
},
};
use mungos::{by_id::update_one_by_id, mongodb::bson::to_document};
@@ -22,6 +27,7 @@ use resolver_api::Resolve;
use tokio::fs;
use crate::{
alert::send_alerts,
api::{execute::ExecuteRequest, user::UserArgs},
config::core_config,
helpers::{
@@ -178,6 +184,26 @@ impl Resolve<ExecuteArgs> for RunAction {
update_update(update.clone()).await?;
if !update.success && action.config.failure_alert {
warn!("action unsuccessful, alerting...");
let target = update.target.clone();
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::ActionFailed {
id: action.id,
name: action.name,
},
};
send_alerts(&[alert]).await
});
}
Ok(update)
}
}

View File

@@ -6,8 +6,12 @@ use komodo_client::{
BatchExecutionResponse, BatchRunProcedure, RunProcedure,
},
entities::{
permission::PermissionLevel, procedure::Procedure,
update::Update, user::User,
alert::{Alert, AlertData, SeverityLevel},
komodo_timestamp,
permission::PermissionLevel,
procedure::Procedure,
update::Update,
user::User,
},
};
use mungos::{by_id::update_one_by_id, mongodb::bson::to_document};
@@ -15,6 +19,7 @@ use resolver_api::Resolve;
use tokio::sync::Mutex;
use crate::{
alert::send_alerts,
helpers::{procedure::execute_procedure, update::update_update},
resource::{self, refresh_procedure_state_cache},
state::{action_states, db_client},
@@ -137,6 +142,26 @@ fn resolve_inner(
update_update(update.clone()).await?;
if !update.success && procedure.config.failure_alert {
warn!("procedure unsuccessful, alerting...");
let target = update.target.clone();
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::ProcedureFailed {
id: procedure.id,
name: procedure.name,
},
};
send_alerts(&[alert]).await
});
}
Ok(update)
})
}

View File

@@ -432,9 +432,7 @@ async fn get_on_host_periphery(
match builder.config {
BuilderConfig::Aws(_) => {
return Err(anyhow!(
"Files on host doesn't work with AWS builder"
));
Err(anyhow!("Files on host doesn't work with AWS builder"))
}
BuilderConfig::Url(config) => {
let periphery = PeripheryClient::new(

View File

@@ -829,7 +829,9 @@ impl Resolve<WriteArgs> for RefreshResourceSyncPending {
.context("failed to open existing pending resource sync updates alert")
.inspect_err(|e| warn!("{e:#}"))
.ok();
send_alerts(&[alert]).await;
if sync.config.pending_alert {
send_alerts(&[alert]).await;
}
}
// CLOSE ALERT
(Some(existing), false) => {

View File

@@ -194,6 +194,7 @@ pub fn core_config() -> &'static CoreConfig {
stdio: env
.komodo_logging_stdio
.unwrap_or(config.logging.stdio),
pretty: env.komodo_logging_pretty.unwrap_or(config.logging.pretty),
otlp_endpoint: env
.komodo_logging_otlp_endpoint
.unwrap_or(config.logging.otlp_endpoint),

View File

@@ -23,6 +23,7 @@ mod helpers;
mod listener;
mod monitor;
mod resource;
mod schedule;
mod stack;
mod state;
mod sync;
@@ -59,6 +60,7 @@ async fn app() -> anyhow::Result<()> {
resource::spawn_repo_state_refresh_loop();
resource::spawn_procedure_state_refresh_loop();
resource::spawn_action_state_refresh_loop();
schedule::spawn_schedule_executor();
helpers::prune::spawn_prune_loop();
// Setup static frontend services
@@ -86,9 +88,9 @@ async fn app() -> anyhow::Result<()> {
)
.into_make_service();
let addr = format!("{}:{}", core_config().bind_ip, core_config().port);
let socket_addr =
SocketAddr::from_str(&addr)
let addr =
format!("{}:{}", core_config().bind_ip, core_config().port);
let socket_addr = SocketAddr::from_str(&addr)
.context("failed to parse listen address")?;
if config.ssl_enabled {

View File

@@ -2,7 +2,7 @@ use std::time::Duration;
use anyhow::Context;
use komodo_client::entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
action::{
Action, ActionConfig, ActionConfigDiff, ActionInfo,
ActionListItem, ActionListItemInfo, ActionQuerySpecifics,
@@ -17,7 +17,12 @@ use mungos::{
mongodb::{Collection, bson::doc, options::FindOneOptions},
};
use crate::state::{action_state_cache, action_states, db_client};
use crate::{
schedule::{
cancel_schedule, get_schedule_item_info, update_schedule,
},
state::{action_state_cache, action_states, db_client},
};
impl super::KomodoResource for Action {
type Config = ActionConfig;
@@ -31,6 +36,10 @@ impl super::KomodoResource for Action {
ResourceTargetVariant::Action
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Action(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().actions
@@ -40,6 +49,9 @@ impl super::KomodoResource for Action {
action: Resource<Self::Config, Self::Info>,
) -> Self::ListItem {
let state = get_action_state(&action.id).await;
let (next_scheduled_run, schedule_error) = get_schedule_item_info(
&ResourceTarget::Action(action.id.clone()),
);
ActionListItem {
name: action.name,
id: action.id,
@@ -48,6 +60,8 @@ impl super::KomodoResource for Action {
info: ActionListItemInfo {
state,
last_run_at: action.info.last_run_at,
next_scheduled_run,
schedule_error,
},
}
}
@@ -83,9 +97,10 @@ impl super::KomodoResource for Action {
}
async fn post_create(
_created: &Resource<Self::Config, Self::Info>,
created: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
update_schedule(created);
refresh_action_state_cache().await;
Ok(())
}
@@ -131,9 +146,10 @@ impl super::KomodoResource for Action {
}
async fn post_delete(
_resource: &Resource<Self::Config, Self::Info>,
resource: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
cancel_schedule(&ResourceTarget::Action(resource.id.clone()));
Ok(())
}
}

View File

@@ -1,6 +1,6 @@
use derive_variants::ExtractVariant;
use komodo_client::entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
alerter::{
Alerter, AlerterConfig, AlerterConfigDiff, AlerterListItem,
AlerterListItemInfo, AlerterQuerySpecifics, PartialAlerterConfig,
@@ -25,6 +25,10 @@ impl super::KomodoResource for Alerter {
ResourceTargetVariant::Alerter
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Alerter(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().alerters

View File

@@ -5,7 +5,7 @@ use formatting::format_serror;
use komodo_client::{
api::write::RefreshBuildCache,
entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
build::{
Build, BuildConfig, BuildConfigDiff, BuildInfo, BuildListItem,
BuildListItemInfo, BuildQuerySpecifics, BuildState,
@@ -44,6 +44,10 @@ impl super::KomodoResource for Build {
ResourceTargetVariant::Build
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Build(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().builds

View File

@@ -1,6 +1,6 @@
use anyhow::Context;
use komodo_client::entities::{
MergePartial, Operation, ResourceTargetVariant,
MergePartial, Operation, ResourceTarget, ResourceTargetVariant,
builder::{
Builder, BuilderConfig, BuilderConfigDiff, BuilderConfigVariant,
BuilderListItem, BuilderListItemInfo, BuilderQuerySpecifics,
@@ -31,6 +31,10 @@ impl super::KomodoResource for Builder {
ResourceTargetVariant::Builder
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Builder(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().builders

View File

@@ -1,7 +1,7 @@
use anyhow::Context;
use formatting::format_serror;
use komodo_client::entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
build::Build,
deployment::{
Deployment, DeploymentConfig, DeploymentConfigDiff,
@@ -43,6 +43,10 @@ impl super::KomodoResource for Deployment {
ResourceTargetVariant::Deployment
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Deployment(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().deployments

View File

@@ -29,7 +29,7 @@ use mungos::{
options::FindOptions,
},
};
use partial_derive2::{Diff, FieldDiff, MaybeNone, PartialDiff};
use partial_derive2::{Diff, MaybeNone, PartialDiff};
use resolver_api::Resolve;
use serde::{Serialize, de::DeserializeOwned};
@@ -107,6 +107,7 @@ pub trait KomodoResource {
type QuerySpecifics: AddFilters + Default + std::fmt::Debug;
fn resource_type() -> ResourceTargetVariant;
fn resource_target(id: impl Into<String>) -> ResourceTarget;
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>;
@@ -693,13 +694,17 @@ pub async fn update<T: KomodoResource>(
return Ok(resource);
}
let mut diff_log = String::from("diff");
for FieldDiff { field, from, to } in diff.iter_field_diffs() {
diff_log.push_str(&format!(
"\n\n<span class=\"text-muted-foreground\">field</span>: '{field}'\n<span class=\"text-muted-foreground\">from</span>: <span class=\"text-red-700 dark:text-red-400\">{from}</span>\n<span class=\"text-muted-foreground\">to</span>: <span class=\"text-green-700 dark:text-green-400\">{to}</span>",
));
// Leave this Result unhandled for now
let prev_toml = ExportResourcesToToml {
targets: vec![T::resource_target(&resource.id)],
..Default::default()
}
.resolve(&ReadArgs {
user: system_user().to_owned(),
})
.await
.map_err(|e| e.error)
.context("Failed to export resource toml before update");
// This minimizes the update against the existing config
let config: T::PartialConfig = diff.into();
@@ -715,13 +720,35 @@ pub async fn update<T: KomodoResource>(
.await
.context("failed to update resource on database")?;
let curr_toml = ExportResourcesToToml {
targets: vec![T::resource_target(&id)],
..Default::default()
}
.resolve(&ReadArgs {
user: system_user().to_owned(),
})
.await
.map_err(|e| e.error)
.context("Failed to export resource toml after update");
let mut update = make_update(
resource_target::<T>(id),
T::update_operation(),
user,
);
update.push_simple_log("update config", diff_log);
match prev_toml {
Ok(res) => update.prev_toml = res.toml,
Err(e) => update
// These logs are pushed with success == true, so user still knows the update was succesful.
.push_simple_log("Failed export", format_serror(&e.into())),
}
match curr_toml {
Ok(res) => update.current_toml = res.toml,
Err(e) => update
// These logs are pushed with success == true, so user still knows the update was succesful.
.push_simple_log("Failed export", format_serror(&e.into())),
}
let updated = get::<T>(id_or_name).await?;

View File

@@ -4,7 +4,7 @@ use anyhow::{Context, anyhow};
use komodo_client::{
api::execute::Execution,
entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
action::Action,
alerter::Alerter,
build::Build,
@@ -31,6 +31,9 @@ use mungos::{
use crate::{
config::core_config,
schedule::{
cancel_schedule, get_schedule_item_info, update_schedule,
},
state::{action_states, db_client, procedure_state_cache},
};
@@ -46,6 +49,10 @@ impl super::KomodoResource for Procedure {
ResourceTargetVariant::Procedure
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Procedure(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().procedures
@@ -55,6 +62,9 @@ impl super::KomodoResource for Procedure {
procedure: Resource<Self::Config, Self::Info>,
) -> Self::ListItem {
let state = get_procedure_state(&procedure.id).await;
let (next_scheduled_run, schedule_error) = get_schedule_item_info(
&ResourceTarget::Procedure(procedure.id.clone()),
);
ProcedureListItem {
name: procedure.name,
id: procedure.id,
@@ -63,6 +73,8 @@ impl super::KomodoResource for Procedure {
info: ProcedureListItemInfo {
stages: procedure.config.stages.len() as i64,
state,
next_scheduled_run,
schedule_error,
},
}
}
@@ -94,9 +106,10 @@ impl super::KomodoResource for Procedure {
}
async fn post_create(
_created: &Resource<Self::Config, Self::Info>,
created: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
update_schedule(created);
refresh_procedure_state_cache().await;
Ok(())
}
@@ -142,9 +155,10 @@ impl super::KomodoResource for Procedure {
}
async fn post_delete(
_resource: &Resource<Self::Config, Self::Info>,
resource: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
cancel_schedule(&ResourceTarget::Procedure(resource.id.clone()));
Ok(())
}
}

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use anyhow::Context;
use formatting::format_serror;
use komodo_client::entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
builder::Builder,
permission::PermissionLevel,
repo::{
@@ -44,6 +44,10 @@ impl super::KomodoResource for Repo {
ResourceTargetVariant::Repo
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Repo(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().repos

View File

@@ -1,6 +1,6 @@
use anyhow::Context;
use komodo_client::entities::{
Operation, ResourceTargetVariant, komodo_timestamp,
Operation, ResourceTarget, ResourceTargetVariant, komodo_timestamp,
resource::Resource,
server::{
PartialServerConfig, Server, ServerConfig, ServerConfigDiff,
@@ -29,6 +29,10 @@ impl super::KomodoResource for Server {
ResourceTargetVariant::Server
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Server(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().servers

View File

@@ -1,5 +1,5 @@
use komodo_client::entities::{
MergePartial, Operation, ResourceTargetVariant,
MergePartial, Operation, ResourceTarget, ResourceTargetVariant,
resource::Resource,
server_template::{
PartialServerTemplateConfig, ServerTemplate,
@@ -29,6 +29,10 @@ impl super::KomodoResource for ServerTemplate {
ResourceTargetVariant::ServerTemplate
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::ServerTemplate(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().server_templates

View File

@@ -3,7 +3,7 @@ use formatting::format_serror;
use komodo_client::{
api::write::RefreshStackCache,
entities::{
Operation, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant,
permission::PermissionLevel,
resource::Resource,
server::Server,
@@ -44,6 +44,10 @@ impl super::KomodoResource for Stack {
ResourceTargetVariant::Stack
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::Stack(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().stacks

View File

@@ -3,7 +3,8 @@ use formatting::format_serror;
use komodo_client::{
api::write::RefreshResourceSyncPending,
entities::{
Operation, ResourceTargetVariant, komodo_timestamp,
Operation, ResourceTarget, ResourceTargetVariant,
komodo_timestamp,
resource::Resource,
sync::{
PartialResourceSyncConfig, ResourceSync, ResourceSyncConfig,
@@ -36,6 +37,10 @@ impl super::KomodoResource for ResourceSync {
ResourceTargetVariant::ResourceSync
}
fn resource_target(id: impl Into<String>) -> ResourceTarget {
ResourceTarget::ResourceSync(id.into())
}
fn coll() -> &'static Collection<Resource<Self::Config, Self::Info>>
{
&db_client().resource_syncs

378
bin/core/src/schedule.rs Normal file
View File

@@ -0,0 +1,378 @@
use std::{
collections::HashMap,
sync::{OnceLock, RwLock},
};
use anyhow::{Context, anyhow};
use async_timing_util::Timelength;
use chrono::Local;
use formatting::format_serror;
use komodo_client::{
api::execute::{RunAction, RunProcedure},
entities::{
ResourceTarget, ResourceTargetVariant, ScheduleFormat,
action::Action,
alert::{Alert, AlertData, SeverityLevel},
komodo_timestamp,
procedure::Procedure,
user::{action_user, procedure_user},
},
};
use mungos::find::find_collect;
use resolver_api::Resolve;
use crate::{
alert::send_alerts,
api::execute::{ExecuteArgs, ExecuteRequest},
helpers::update::init_execution_update,
state::db_client,
};
pub fn spawn_schedule_executor() {
// Executor thread
tokio::spawn(async move {
loop {
let current_time = async_timing_util::wait_until_timelength(
Timelength::OneSecond,
0,
)
.await as i64;
let mut lock = schedules().write().unwrap();
let drained = lock.drain().collect::<Vec<_>>();
for (target, next_run) in drained {
match next_run {
Ok(next_run_time) if current_time >= next_run_time => {
tokio::spawn(async move {
match &target {
ResourceTarget::Action(id) => {
let action = match crate::resource::get::<Action>(
id,
)
.await
{
Ok(action) => action,
Err(e) => {
warn!(
"Scheduled action run on {id} failed | failed to get procedure | {e:?}"
);
return;
}
};
let request =
ExecuteRequest::RunAction(RunAction {
action: id.clone(),
});
let update = match init_execution_update(
&request,
action_user(),
)
.await
{
Ok(update) => update,
Err(e) => {
error!(
"Failed to make update for scheduled action run, action {id} is not being run | {e:#}"
);
return;
}
};
let ExecuteRequest::RunAction(request) = request
else {
unreachable!()
};
if let Err(e) = request
.resolve(&ExecuteArgs {
user: action_user().to_owned(),
update,
})
.await
{
warn!(
"Scheduled action run on {id} failed | {e:?}"
);
}
update_schedule(&action);
if action.config.schedule_alert {
let alert = Alert {
id: Default::default(),
target,
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Ok,
data: AlertData::ScheduleRun {
resource_type: ResourceTargetVariant::Action,
id: action.id,
name: action.name,
},
};
send_alerts(&[alert]).await
}
}
ResourceTarget::Procedure(id) => {
let procedure = match crate::resource::get::<
Procedure,
>(id)
.await
{
Ok(procedure) => procedure,
Err(e) => {
warn!(
"Scheduled procedure run on {id} failed | failed to get procedure | {e:?}"
);
return;
}
};
let request =
ExecuteRequest::RunProcedure(RunProcedure {
procedure: id.clone(),
});
let update = match init_execution_update(
&request,
procedure_user(),
)
.await
{
Ok(update) => update,
Err(e) => {
error!(
"Failed to make update for scheduled procedure run, procedure {id} is not being run | {e:#}"
);
return;
}
};
let ExecuteRequest::RunProcedure(request) = request
else {
unreachable!()
};
if let Err(e) = request
.resolve(&ExecuteArgs {
user: procedure_user().to_owned(),
update,
})
.await
{
warn!(
"Scheduled procedure run on {id} failed | {e:?}"
);
}
update_schedule(&procedure);
if procedure.config.schedule_alert {
let alert = Alert {
id: Default::default(),
target,
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Ok,
data: AlertData::ScheduleRun {
resource_type:
ResourceTargetVariant::Procedure,
id: procedure.id,
name: procedure.name,
},
};
send_alerts(&[alert]).await
}
}
_ => unreachable!(),
}
});
}
other => {
lock.insert(target, other);
continue;
}
};
}
}
});
// Updater thread
tokio::spawn(async move {
update_schedules().await;
loop {
async_timing_util::wait_until_timelength(
Timelength::FiveMinutes,
500,
)
.await;
update_schedules().await
}
});
}
type UnixTimestampMs = i64;
type Schedules =
HashMap<ResourceTarget, Result<UnixTimestampMs, String>>;
fn schedules() -> &'static RwLock<Schedules> {
static SCHEDULES: OnceLock<RwLock<Schedules>> = OnceLock::new();
SCHEDULES.get_or_init(Default::default)
}
pub fn get_schedule_item_info(
target: &ResourceTarget,
) -> (Option<i64>, Option<String>) {
match schedules().read().unwrap().get(target) {
Some(Ok(time)) => (Some(*time), None),
Some(Err(e)) => (None, Some(e.clone())),
None => (None, None),
}
}
pub fn cancel_schedule(target: &ResourceTarget) {
schedules().write().unwrap().remove(target);
}
pub async fn update_schedules() {
let (procedures, actions) = tokio::join!(
find_collect(&db_client().procedures, None, None),
find_collect(&db_client().actions, None, None),
);
let procedures = match procedures
.context("failed to get all procedures from db")
{
Ok(procedures) => procedures,
Err(e) => {
error!("failed to get procedures for schedule update | {e:#}");
Vec::new()
}
};
let actions =
match actions.context("failed to get all actions from db") {
Ok(actions) => actions,
Err(e) => {
error!("failed to get actions for schedule update | {e:#}");
Vec::new()
}
};
// clear out any schedules which don't match to existing resources
{
let mut lock = schedules().write().unwrap();
lock.retain(|target, _| match target {
ResourceTarget::Action(id) => {
actions.iter().any(|action| &action.id == id)
}
ResourceTarget::Procedure(id) => {
procedures.iter().any(|procedure| &procedure.id == id)
}
_ => unreachable!(),
});
}
for procedure in procedures {
update_schedule(&procedure);
}
for action in actions {
update_schedule(&action);
}
}
/// Re/spawns the schedule for the given procedure
pub fn update_schedule(schedule: impl HasSchedule) {
// Cancel any existing schedule for the procedure
cancel_schedule(&schedule.target());
if !schedule.enabled() || schedule.schedule().is_empty() {
return;
}
schedules().write().unwrap().insert(
schedule.target(),
find_next_occurrence(schedule)
.map_err(|e| format_serror(&e.into())),
);
}
/// Finds the next run occurence in UTC ms.
fn find_next_occurrence(
schedule: impl HasSchedule,
) -> anyhow::Result<i64> {
let cron = match schedule.format() {
ScheduleFormat::Cron => croner::Cron::new(schedule.schedule())
.with_seconds_required()
.with_dom_and_dow()
.parse()
.context("Failed to parse schedule CRON")?,
ScheduleFormat::English => {
let cron =
english_to_cron::str_cron_syntax(schedule.schedule())
.map_err(|e| {
anyhow!("Failed to parse english to cron | {e:?}")
})?
.split(' ')
// croner does not accept year
.take(6)
.collect::<Vec<_>>()
.join(" ");
croner::Cron::new(&cron)
.with_seconds_required()
.with_dom_and_dow()
.parse()
.with_context(|| {
format!("Failed to parse schedule CRON: {cron}")
})?
}
};
let next = if schedule.timezone().is_empty() {
let tz_time = chrono::Local::now().with_timezone(&Local);
cron
.find_next_occurrence(&tz_time, false)
.context("Failed to find next run time")?
.timestamp_millis()
} else {
let tz: chrono_tz::Tz = schedule
.timezone()
.parse()
.context("Failed to parse schedule timezone")?;
let tz_time = chrono::Local::now().with_timezone(&tz);
cron
.find_next_occurrence(&tz_time, false)
.context("Failed to find next run time")?
.timestamp_millis()
};
Ok(next)
}
pub trait HasSchedule {
fn target(&self) -> ResourceTarget;
fn enabled(&self) -> bool;
fn format(&self) -> ScheduleFormat;
fn schedule(&self) -> &str;
fn timezone(&self) -> &str;
}
impl HasSchedule for &Procedure {
fn target(&self) -> ResourceTarget {
ResourceTarget::Procedure(self.id.clone())
}
fn enabled(&self) -> bool {
self.config.schedule_enabled
}
fn format(&self) -> ScheduleFormat {
self.config.schedule_format
}
fn schedule(&self) -> &str {
&self.config.schedule
}
fn timezone(&self) -> &str {
&self.config.schedule_timezone
}
}
impl HasSchedule for &Action {
fn target(&self) -> ResourceTarget {
ResourceTarget::Action(self.id.clone())
}
fn enabled(&self) -> bool {
self.config.schedule_enabled
}
fn format(&self) -> ScheduleFormat {
self.config.schedule_format
}
fn schedule(&self) -> &str {
&self.config.schedule
}
fn timezone(&self) -> &str {
&self.config.schedule_timezone
}
}

View File

@@ -424,7 +424,7 @@ fn build_cache_for_deployment<'a>(
let deployed_version = status
.container
.as_ref()
.and_then(|c| c.image.as_ref()?.split(':').last())
.and_then(|c| c.image.as_ref()?.split(':').next_back())
.unwrap_or("0.0.0");
match build_version_cache.get(build_id) {
Some(version) if deployed_version != version => {

View File

@@ -2,7 +2,7 @@ use std::{collections::HashMap, str::FromStr};
use anyhow::anyhow;
use komodo_client::entities::{
ResourceTarget, ResourceTargetVariant,
ResourceTargetVariant,
action::Action,
alerter::Alerter,
build::Build,
@@ -55,8 +55,6 @@ pub struct ToUpdateItem<T: Default> {
}
pub trait ResourceSyncTrait: ToToml + Sized {
fn resource_target(id: String) -> ResourceTarget;
/// To exclude resource syncs with "file_contents" (they aren't compatible)
fn include_resource(
name: &String,

View File

@@ -4,7 +4,7 @@ use formatting::{Color, bold, colored, muted};
use komodo_client::{
api::execute::Execution,
entities::{
ResourceTarget, ResourceTargetVariant,
ResourceTargetVariant,
action::Action,
alerter::Alerter,
build::Build,
@@ -40,10 +40,6 @@ use super::{
};
impl ResourceSyncTrait for Server {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Server(id)
}
fn get_diff(
original: Self::Config,
update: Self::PartialConfig,
@@ -56,10 +52,6 @@ impl ResourceSyncTrait for Server {
impl ExecuteResourceSync for Server {}
impl ResourceSyncTrait for Deployment {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Deployment(id)
}
fn get_diff(
mut original: Self::Config,
update: Self::PartialConfig,
@@ -93,10 +85,6 @@ impl ResourceSyncTrait for Deployment {
impl ExecuteResourceSync for Deployment {}
impl ResourceSyncTrait for Stack {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Stack(id)
}
fn get_diff(
mut original: Self::Config,
update: Self::PartialConfig,
@@ -116,10 +104,6 @@ impl ResourceSyncTrait for Stack {
impl ExecuteResourceSync for Stack {}
impl ResourceSyncTrait for Build {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Build(id)
}
fn get_diff(
mut original: Self::Config,
update: Self::PartialConfig,
@@ -149,10 +133,6 @@ impl ResourceSyncTrait for Build {
impl ExecuteResourceSync for Build {}
impl ResourceSyncTrait for Repo {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Repo(id)
}
fn get_diff(
mut original: Self::Config,
update: Self::PartialConfig,
@@ -179,10 +159,6 @@ impl ResourceSyncTrait for Repo {
impl ExecuteResourceSync for Repo {}
impl ResourceSyncTrait for Alerter {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Alerter(id)
}
fn get_diff(
original: Self::Config,
update: Self::PartialConfig,
@@ -195,10 +171,6 @@ impl ResourceSyncTrait for Alerter {
impl ExecuteResourceSync for Alerter {}
impl ResourceSyncTrait for Builder {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Builder(id)
}
fn get_diff(
mut original: Self::Config,
update: Self::PartialConfig,
@@ -220,10 +192,6 @@ impl ResourceSyncTrait for Builder {
impl ExecuteResourceSync for Builder {}
impl ResourceSyncTrait for ServerTemplate {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::ServerTemplate(id)
}
fn get_diff(
original: Self::Config,
update: Self::PartialConfig,
@@ -236,10 +204,6 @@ impl ResourceSyncTrait for ServerTemplate {
impl ExecuteResourceSync for ServerTemplate {}
impl ResourceSyncTrait for Action {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Action(id)
}
fn get_diff(
original: Self::Config,
update: Self::PartialConfig,
@@ -252,10 +216,6 @@ impl ResourceSyncTrait for Action {
impl ExecuteResourceSync for Action {}
impl ResourceSyncTrait for ResourceSync {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::ResourceSync(id)
}
fn include_resource(
name: &String,
config: &Self::Config,
@@ -341,10 +301,6 @@ impl ResourceSyncTrait for ResourceSync {
impl ExecuteResourceSync for ResourceSync {}
impl ResourceSyncTrait for Procedure {
fn resource_target(id: String) -> ResourceTarget {
ResourceTarget::Procedure(id)
}
fn get_diff(
mut original: Self::Config,
update: Self::PartialConfig,

View File

@@ -1,6 +1,6 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.85.1-bullseye AS builder
FROM rust:1.86.0-bullseye AS builder
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./

View File

@@ -46,7 +46,7 @@ impl Resolve<super::Args> for GetDockerfileContentsOnHost {
} = self;
let root =
periphery_config().build_dir.join(to_komodo_name(&name));
periphery_config().build_dir().join(to_komodo_name(&name));
let build_dir =
root.join(&build_path).components().collect::<PathBuf>();
@@ -91,7 +91,7 @@ impl Resolve<super::Args> for WriteDockerfileContentsToHost {
contents,
} = self;
let full_path = periphery_config()
.build_dir
.build_dir()
.join(to_komodo_name(&name))
.join(&build_path)
.join(dockerfile_path)
@@ -180,7 +180,7 @@ impl Resolve<super::Args> for build::Build {
let name = to_komodo_name(name);
let build_path =
periphery_config().build_dir.join(&name).join(build_path);
periphery_config().build_dir().join(&name).join(build_path);
let dockerfile_path = optional_string(dockerfile_path)
.unwrap_or("Dockerfile".to_owned());
@@ -248,7 +248,7 @@ impl Resolve<super::Args> for build::Build {
} {
let success = log.success;
logs.push(log);
if success {
if !success {
return Ok(logs);
}
};

View File

@@ -138,7 +138,7 @@ impl Resolve<super::Args> for GetComposeContentsOnHost {
file_paths,
} = self;
let root =
periphery_config().stack_dir.join(to_komodo_name(&name));
periphery_config().stack_dir().join(to_komodo_name(&name));
let run_directory =
root.join(&run_directory).components().collect::<PathBuf>();
@@ -196,7 +196,7 @@ impl Resolve<super::Args> for WriteComposeContentsToHost {
contents,
} = self;
let file_path = periphery_config()
.stack_dir
.stack_dir()
.join(to_komodo_name(&name))
.join(&run_directory)
.join(file_path)

View File

@@ -20,7 +20,7 @@ impl Resolve<super::Args> for GetLatestCommit {
) -> serror::Result<LatestCommit> {
let repo_path = match self.path {
Some(p) => PathBuf::from(p),
None => periphery_config().repo_dir.join(self.name),
None => periphery_config().repo_dir().join(self.name),
};
if !repo_path.is_dir() {
return Err(
@@ -70,13 +70,13 @@ impl Resolve<super::Args> for CloneRepo {
),
};
let parent_dir = if args.is_build {
&periphery_config().build_dir
periphery_config().build_dir()
} else {
&periphery_config().repo_dir
periphery_config().repo_dir()
};
git::clone(
args,
parent_dir,
&parent_dir,
token,
&environment,
&env_file_path,
@@ -140,13 +140,13 @@ impl Resolve<super::Args> for PullRepo {
),
};
let parent_dir = if args.is_build {
&periphery_config().build_dir
periphery_config().build_dir()
} else {
&periphery_config().repo_dir
periphery_config().repo_dir()
};
git::pull(
args,
parent_dir,
&parent_dir,
token,
&environment,
&env_file_path,
@@ -210,13 +210,13 @@ impl Resolve<super::Args> for PullOrCloneRepo {
),
};
let parent_dir = if args.is_build {
&periphery_config().build_dir
periphery_config().build_dir()
} else {
&periphery_config().repo_dir
periphery_config().repo_dir()
};
git::pull_or_clone(
args,
parent_dir,
&parent_dir,
token,
&environment,
&env_file_path,
@@ -252,11 +252,10 @@ impl Resolve<super::Args> for RenameRepo {
curr_name,
new_name,
} = self;
let renamed = fs::rename(
periphery_config().repo_dir.join(&curr_name),
periphery_config().repo_dir.join(&new_name),
)
.await;
let repo_dir = periphery_config().repo_dir();
let renamed =
fs::rename(repo_dir.join(&curr_name), repo_dir.join(&new_name))
.await;
let msg = match renamed {
Ok(_) => String::from("Renamed Repo directory on Server"),
Err(_) => format!("No Repo cloned at {curr_name} to rename"),
@@ -274,9 +273,9 @@ impl Resolve<super::Args> for DeleteRepo {
// If using custom clone path, it will be passed by core instead of name.
// So the join will resolve to just the absolute path.
let root = if is_build {
&periphery_config().build_dir
periphery_config().build_dir()
} else {
&periphery_config().repo_dir
periphery_config().repo_dir()
};
let full_path = root.join(&name);
let deleted =

View File

@@ -430,7 +430,7 @@ pub async fn write_stack(
Option<Vec<(String, String)>>,
)> {
let root = periphery_config()
.stack_dir
.stack_dir()
.join(to_komodo_name(&stack.name));
let run_directory = root.join(&stack.config.run_directory);
// This will remove any intermediate '/./' in the path, which is a problem for some OS.

View File

@@ -36,9 +36,12 @@ pub fn periphery_config() -> &'static PeripheryConfig {
PeripheryConfig {
port: env.periphery_port.unwrap_or(config.port),
bind_ip: env.periphery_bind_ip.unwrap_or(config.bind_ip),
repo_dir: env.periphery_repo_dir.unwrap_or(config.repo_dir),
stack_dir: env.periphery_stack_dir.unwrap_or(config.stack_dir),
build_dir: env.periphery_build_dir.unwrap_or(config.build_dir),
root_directory: env
.periphery_root_directory
.unwrap_or(config.root_directory),
repo_dir: env.periphery_repo_dir.or(config.repo_dir),
stack_dir: env.periphery_stack_dir.or(config.stack_dir),
build_dir: env.periphery_build_dir.or(config.build_dir),
stats_polling_rate: env
.periphery_stats_polling_rate
.unwrap_or(config.stats_polling_rate),
@@ -54,6 +57,9 @@ pub fn periphery_config() -> &'static PeripheryConfig {
stdio: env
.periphery_logging_stdio
.unwrap_or(config.logging.stdio),
pretty: env
.periphery_logging_pretty
.unwrap_or(config.logging.pretty),
otlp_endpoint: env
.periphery_logging_otlp_endpoint
.unwrap_or(config.logging.otlp_endpoint),
@@ -80,10 +86,10 @@ pub fn periphery_config() -> &'static PeripheryConfig {
.unwrap_or(config.ssl_enabled),
ssl_key_file: env
.periphery_ssl_key_file
.unwrap_or(config.ssl_key_file),
.or(config.ssl_key_file),
ssl_cert_file: env
.periphery_ssl_cert_file
.unwrap_or(config.ssl_cert_file),
.or(config.ssl_cert_file),
secrets: config.secrets,
git_providers: config.git_providers,
docker_registries: config.docker_registries,

View File

@@ -101,7 +101,7 @@ pub async fn pull_or_clone_stack(
}
let root = periphery_config()
.stack_dir
.stack_dir()
.join(to_komodo_name(&stack.name));
let mut args: CloneArgs = stack.into();

View File

@@ -26,10 +26,13 @@ async fn app() -> anyhow::Result<()> {
stats::spawn_system_stats_polling_thread();
let addr = format!("{}:{}", config::periphery_config().bind_ip, config::periphery_config().port);
let addr = format!(
"{}:{}",
config::periphery_config().bind_ip,
config::periphery_config().port
);
let socket_addr =
SocketAddr::from_str(&addr)
let socket_addr = SocketAddr::from_str(&addr)
.context("failed to parse listen address")?;
let app = router::router()
@@ -43,8 +46,8 @@ async fn app() -> anyhow::Result<()> {
ssl::ensure_certs().await;
info!("Komodo Periphery starting on https://{}", socket_addr);
let ssl_config = RustlsConfig::from_pem_file(
&config.ssl_cert_file,
&config.ssl_key_file,
config.ssl_cert_file(),
config.ssl_key_file(),
)
.await
.context("Invalid ssl cert / key")?;

View File

@@ -2,7 +2,8 @@ use crate::config::periphery_config;
pub async fn ensure_certs() {
let config = periphery_config();
if !config.ssl_cert_file.is_file() || !config.ssl_key_file.is_file()
if !config.ssl_cert_file().is_file()
|| !config.ssl_key_file().is_file()
{
generate_self_signed_ssl_certs().await
}
@@ -14,16 +15,19 @@ async fn generate_self_signed_ssl_certs() {
let config = periphery_config();
let ssl_key_file = config.ssl_key_file();
let ssl_cert_file = config.ssl_cert_file();
// ensure cert folders exist
if let Some(parent) = config.ssl_key_file.parent() {
if let Some(parent) = ssl_key_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Some(parent) = config.ssl_cert_file.parent() {
if let Some(parent) = ssl_cert_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
let key_path = &config.ssl_key_file.display();
let cert_path = &config.ssl_cert_file.display();
let key_path = ssl_key_file.display();
let cert_path = ssl_cert_file.display();
let command = format!(
"openssl req -x509 -newkey rsa:4096 -keyout {key_path} -out {cert_path} -sha256 -days 3650 -nodes -subj \"/C=XX/CN=periphery\""

View File

@@ -13,7 +13,10 @@ use crate::{
entities::I64,
};
use super::resource::{Resource, ResourceListItem, ResourceQuery};
use super::{
ScheduleFormat,
resource::{Resource, ResourceListItem, ResourceQuery},
};
#[typeshare]
pub type ActionListItem = ResourceListItem<ActionListItemInfo>;
@@ -25,6 +28,12 @@ pub struct ActionListItemInfo {
pub last_run_at: I64,
/// Whether last action run successful
pub state: ActionState,
/// If the procedure has schedule enabled, this is the
/// next scheduled run time in unix ms.
pub next_scheduled_run: Option<I64>,
/// If there is an error parsing schedule expression,
/// it will be given here.
pub schedule_error: Option<String>,
}
#[typeshare]
@@ -62,15 +71,55 @@ pub type _PartialActionConfig = PartialActionConfig;
#[partial_derive(Serialize, Deserialize, Debug, Clone, Default)]
#[partial(skip_serializing_none, from, diff)]
pub struct ActionConfig {
/// Typescript file contents using pre-initialized `komodo` client.
/// Supports variable / secret interpolation.
#[serde(default, deserialize_with = "file_contents_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_file_contents_deserializer"
))]
/// Choose whether to specify schedule as regular CRON, or using the english to CRON parser.
#[serde(default)]
#[builder(default)]
pub file_contents: String,
pub schedule_format: ScheduleFormat,
/// Optionally provide a schedule for the procedure to run on.
///
/// There are 2 ways to specify a schedule:
///
/// 1. Regular CRON expression:
///
/// (second, minute, hour, day, month, day-of-week)
/// ```
/// 0 0 0 1,15 * ?
/// ```
///
/// 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
///
/// ```
/// at midnight on the 1st and 15th of the month
/// ```
#[serde(default)]
#[builder(default)]
pub schedule: String,
/// Whether schedule is enabled if one is provided.
/// Can be used to temporarily disable the schedule.
#[serde(default = "default_schedule_enabled")]
#[builder(default = "default_schedule_enabled()")]
#[partial_default(default_schedule_enabled())]
pub schedule_enabled: bool,
/// Optional. A TZ Identifier. If not provided, will use Core local timezone.
/// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
#[serde(default)]
#[builder(default)]
pub schedule_timezone: String,
/// Whether to send alerts when the schedule was run.
#[serde(default = "default_schedule_alert")]
#[builder(default = "default_schedule_alert()")]
#[partial_default(default_schedule_alert())]
pub schedule_alert: bool,
/// Whether to send alerts when this action fails.
#[serde(default = "default_failure_alert")]
#[builder(default = "default_failure_alert()")]
#[partial_default(default_failure_alert())]
pub failure_alert: bool,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
@@ -83,6 +132,28 @@ pub struct ActionConfig {
#[serde(default)]
#[builder(default)]
pub webhook_secret: String,
/// Typescript file contents using pre-initialized `komodo` client.
/// Supports variable / secret interpolation.
#[serde(default, deserialize_with = "file_contents_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_file_contents_deserializer"
))]
#[builder(default)]
pub file_contents: String,
}
fn default_schedule_enabled() -> bool {
true
}
fn default_schedule_alert() -> bool {
true
}
fn default_failure_alert() -> bool {
true
}
fn default_webhook_enabled() -> bool {
@@ -98,9 +169,15 @@ impl ActionConfig {
impl Default for ActionConfig {
fn default() -> Self {
Self {
file_contents: Default::default(),
schedule_format: Default::default(),
schedule: Default::default(),
schedule_enabled: default_schedule_enabled(),
schedule_timezone: Default::default(),
schedule_alert: default_schedule_alert(),
failure_alert: default_failure_alert(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
file_contents: Default::default(),
}
}
}

View File

@@ -8,8 +8,8 @@ use typeshare::typeshare;
use crate::entities::{I64, MongoId};
use super::{
_Serror, ResourceTarget, Version, deployment::DeploymentState,
stack::StackState,
_Serror, ResourceTarget, ResourceTargetVariant, Version,
deployment::DeploymentState, stack::StackState,
};
/// Representation of an alert in the system.
@@ -260,6 +260,32 @@ pub enum AlertData {
/// The name of the repo
name: String,
},
/// A procedure has failed
ProcedureFailed {
/// The id of the procedure
id: String,
/// The name of the procedure
name: String,
},
/// An action has failed
ActionFailed {
/// The id of the action
id: String,
/// The name of the action
name: String,
},
/// A schedule was run
ScheduleRun {
/// Procedure or Action
resource_type: ResourceTargetVariant,
/// The resource id
id: String,
/// The resource name
name: String,
},
}
impl Default for AlertData {

View File

@@ -114,6 +114,9 @@ pub enum AlerterEndpoint {
/// Send alert to Ntfy
Ntfy(NtfyAlerterEndpoint),
/// Send alert to Pushover
Pushover(PushoverAlerterEndpoint),
}
impl Default for AlerterEndpoint {
@@ -222,9 +225,33 @@ fn default_ntfy_url() -> String {
String::from("http://localhost:8080/komodo")
}
/// Configuration for a Pushover alerter.
#[typeshare]
#[derive(
Debug, Clone, PartialEq, Serialize, Deserialize, Builder,
)]
pub struct PushoverAlerterEndpoint {
/// The pushover URL including application and user tokens in parameters.
#[serde(default = "default_pushover_url")]
#[builder(default = "default_pushover_url()")]
pub url: String,
}
impl Default for PushoverAlerterEndpoint {
fn default() -> Self {
Self {
url: default_pushover_url(),
}
}
}
fn default_pushover_url() -> String {
String::from(
"https://api.pushover.net/1/messages.json?token=XXXXXXXXXXXXX&user=XXXXXXXXXXXXX",
)
}
// QUERY
#[typeshare]
pub type AlerterQuery = ResourceQuery<AlerterQuerySpecifics>;

View File

@@ -43,7 +43,7 @@ pub struct BuildListItemInfo {
/// Whether build is in files on host mode.
pub files_on_host: bool,
/// The git provider domain
pub git_provider: Option<String>,
/// The repo used as the source of the build

View File

@@ -85,6 +85,8 @@ pub struct Env {
pub komodo_logging_level: Option<LogLevel>,
/// Override `logging.stdio`
pub komodo_logging_stdio: Option<StdioLogMode>,
/// Override `logging.pretty`
pub komodo_logging_pretty: Option<bool>,
/// Override `logging.otlp_endpoint`
pub komodo_logging_otlp_endpoint: Option<String>,
/// Override `logging.opentelemetry_service_name`
@@ -260,7 +262,7 @@ pub struct CoreConfig {
/// IP address the core server binds to.
/// Default: [::].
#[serde(default = "default_core_bind_ip")]
#[serde(default = "default_core_bind_ip")]
pub bind_ip: String,
/// Sent in auth header with req to periphery.

View File

@@ -116,6 +116,8 @@ pub struct Env {
pub periphery_port: Option<u16>,
/// Override `bind_ip`
pub periphery_bind_ip: Option<String>,
/// Override `root_directory`
pub periphery_root_directory: Option<PathBuf>,
/// Override `repo_dir`
pub periphery_repo_dir: Option<PathBuf>,
/// Override `stack_dir`
@@ -132,6 +134,8 @@ pub struct Env {
pub periphery_logging_level: Option<LogLevel>,
/// Override `logging.stdio`
pub periphery_logging_stdio: Option<StdioLogMode>,
/// Override `logging.pretty`
pub periphery_logging_pretty: Option<bool>,
/// Override `logging.otlp_endpoint`
pub periphery_logging_otlp_endpoint: Option<String>,
/// Override `logging.opentelemetry_service_name`
@@ -171,20 +175,33 @@ pub struct PeripheryConfig {
#[serde(default = "default_periphery_bind_ip")]
pub bind_ip: String,
/// The directory Komodo will use as the default root for the specific (repo, stack, build) directories.
///
/// repo: ${root_directory}/repos
/// stack: ${root_directory}/stacks
/// build: ${root_directory}/builds
///
/// Note. These can each be overridden with a specific directory
/// by specifying `repo_dir`, `stack_dir`, or `build_dir` explicitly
///
/// Default: `/etc/komodo`
#[serde(default = "default_root_directory")]
pub root_directory: PathBuf,
/// The system directory where Komodo managed repos will be cloned.
/// Default: `/etc/komodo/repos`
#[serde(default = "default_repo_dir")]
pub repo_dir: PathBuf,
/// If not provided, will default to `${root_directory}/repos`.
/// Default: empty
pub repo_dir: Option<PathBuf>,
/// The system directory where stacks will managed.
/// Default: `/etc/komodo/stacks`
#[serde(default = "default_stack_dir")]
pub stack_dir: PathBuf,
/// If not provided, will default to `${root_directory}/stacks`.
/// Default: empty
pub stack_dir: Option<PathBuf>,
/// The system directory where builds will managed.
/// Default: `/etc/komodo/builds`
#[serde(default = "default_build_dir")]
pub build_dir: PathBuf,
/// If not provided, will default to `${root_directory}/builds`.
/// Default: empty
pub build_dir: Option<PathBuf>,
/// The rate at which the system stats will be polled to update the cache.
/// Default: `5-sec`
@@ -244,14 +261,12 @@ pub struct PeripheryConfig {
pub ssl_enabled: bool,
/// Path to the ssl key.
/// Default: `/etc/komodo/ssl/periphery/key.pem`.
#[serde(default = "default_ssl_key_file")]
pub ssl_key_file: PathBuf,
/// Default: `${root_directory}/ssl/key.pem`.
pub ssl_key_file: Option<PathBuf>,
/// Path to the ssl cert.
/// Default: `/etc/komodo/ssl/periphery/cert.pem`.
#[serde(default = "default_ssl_cert_file")]
pub ssl_cert_file: PathBuf,
/// Default: `${root_directory}/ssl/cert.pem`.
pub ssl_cert_file: Option<PathBuf>,
}
fn default_periphery_port() -> u16 {
@@ -262,16 +277,8 @@ fn default_periphery_bind_ip() -> String {
"[::]".to_string()
}
fn default_repo_dir() -> PathBuf {
"/etc/komodo/repos".parse().unwrap()
}
fn default_stack_dir() -> PathBuf {
"/etc/komodo/stacks".parse().unwrap()
}
fn default_build_dir() -> PathBuf {
"/etc/komodo/builds".parse().unwrap()
fn default_root_directory() -> PathBuf {
"/etc/komodo".parse().unwrap()
}
fn default_stats_polling_rate() -> Timelength {
@@ -282,22 +289,15 @@ fn default_ssl_enabled() -> bool {
false
}
fn default_ssl_key_file() -> PathBuf {
"/etc/komodo/ssl/key.pem".parse().unwrap()
}
fn default_ssl_cert_file() -> PathBuf {
"/etc/komodo/ssl/cert.pem".parse().unwrap()
}
impl Default for PeripheryConfig {
fn default() -> Self {
Self {
port: default_periphery_port(),
bind_ip: default_periphery_bind_ip(),
repo_dir: default_repo_dir(),
stack_dir: default_stack_dir(),
build_dir: default_build_dir(),
root_directory: default_root_directory(),
repo_dir: None,
stack_dir: None,
build_dir: None,
stats_polling_rate: default_stats_polling_rate(),
legacy_compose_cli: Default::default(),
logging: Default::default(),
@@ -309,8 +309,8 @@ impl Default for PeripheryConfig {
git_providers: Default::default(),
docker_registries: Default::default(),
ssl_enabled: default_ssl_enabled(),
ssl_key_file: default_ssl_key_file(),
ssl_cert_file: default_ssl_cert_file(),
ssl_key_file: None,
ssl_cert_file: None,
}
}
}
@@ -320,6 +320,7 @@ impl PeripheryConfig {
PeripheryConfig {
port: self.port,
bind_ip: self.bind_ip.clone(),
root_directory: self.root_directory.clone(),
repo_dir: self.repo_dir.clone(),
stack_dir: self.stack_dir.clone(),
build_dir: self.build_dir.clone(),
@@ -378,4 +379,44 @@ impl PeripheryConfig {
ssl_cert_file: self.ssl_cert_file.clone(),
}
}
pub fn repo_dir(&self) -> PathBuf {
if let Some(dir) = &self.repo_dir {
dir.to_owned()
} else {
self.root_directory.join("repos")
}
}
pub fn stack_dir(&self) -> PathBuf {
if let Some(dir) = &self.stack_dir {
dir.to_owned()
} else {
self.root_directory.join("stacks")
}
}
pub fn build_dir(&self) -> PathBuf {
if let Some(dir) = &self.build_dir {
dir.to_owned()
} else {
self.root_directory.join("builds")
}
}
pub fn ssl_key_file(&self) -> PathBuf {
if let Some(dir) = &self.ssl_key_file {
dir.to_owned()
} else {
self.root_directory.join("ssl/key.pem")
}
}
pub fn ssl_cert_file(&self) -> PathBuf {
if let Some(dir) = &self.ssl_cert_file {
dir.to_owned()
} else {
self.root_directory.join("ssl/cert.pem")
}
}
}

View File

@@ -10,6 +10,9 @@ pub struct LogConfig {
#[serde(default)]
pub stdio: StdioLogMode,
#[serde(default)]
pub pretty: bool,
/// Enable opentelemetry exporting
#[serde(default)]
pub otlp_endpoint: String,
@@ -27,6 +30,7 @@ impl Default for LogConfig {
Self {
level: Default::default(),
stdio: Default::default(),
pretty: Default::default(),
otlp_endpoint: Default::default(),
opentelemetry_service_name: default_opentelemetry_service_name(
),

View File

@@ -999,3 +999,13 @@ impl ResourceTargetVariant {
}
}
}
#[typeshare]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
)]
pub enum ScheduleFormat {
#[default]
English,
Cron,
}

View File

@@ -9,7 +9,7 @@ use typeshare::typeshare;
use crate::api::execute::Execution;
use super::{
I64,
I64, ScheduleFormat,
resource::{Resource, ResourceListItem, ResourceQuery},
};
@@ -23,6 +23,12 @@ pub struct ProcedureListItemInfo {
pub stages: I64,
/// Reflect whether last run successful / currently running.
pub state: ProcedureState,
/// If the procedure has schedule enabled, this is the
/// next scheduled run time in unix ms.
pub next_scheduled_run: Option<I64>,
/// If there is an error parsing schedule expression,
/// it will be given here.
pub schedule_error: Option<String>,
}
#[typeshare]
@@ -61,6 +67,56 @@ pub struct ProcedureConfig {
#[builder(default)]
pub stages: Vec<ProcedureStage>,
/// Choose whether to specify schedule as regular CRON, or using the english to CRON parser.
#[serde(default)]
#[builder(default)]
pub schedule_format: ScheduleFormat,
/// Optionally provide a schedule for the procedure to run on.
///
/// There are 2 ways to specify a schedule:
///
/// 1. Regular CRON expression:
///
/// (second, minute, hour, day, month, day-of-week)
/// ```
/// 0 0 0 1,15 * ?
/// ```
///
/// 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
///
/// ```
/// at midnight on the 1st and 15th of the month
/// ```
#[serde(default)]
#[builder(default)]
pub schedule: String,
/// Whether schedule is enabled if one is provided.
/// Can be used to temporarily disable the schedule.
#[serde(default = "default_schedule_enabled")]
#[builder(default = "default_schedule_enabled()")]
#[partial_default(default_schedule_enabled())]
pub schedule_enabled: bool,
/// Optional. A TZ Identifier. If not provided, will use Core local timezone.
/// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
#[serde(default)]
#[builder(default)]
pub schedule_timezone: String,
/// Whether to send alerts when the schedule was run.
#[serde(default = "default_schedule_alert")]
#[builder(default = "default_schedule_alert()")]
#[partial_default(default_schedule_alert())]
pub schedule_alert: bool,
/// Whether to send alerts when this procedure fails.
#[serde(default = "default_failure_alert")]
#[builder(default = "default_failure_alert()")]
#[partial_default(default_failure_alert())]
pub failure_alert: bool,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
@@ -80,6 +136,18 @@ impl ProcedureConfig {
}
}
fn default_schedule_enabled() -> bool {
true
}
fn default_schedule_alert() -> bool {
true
}
fn default_failure_alert() -> bool {
true
}
fn default_webhook_enabled() -> bool {
true
}
@@ -88,6 +156,12 @@ impl Default for ProcedureConfig {
fn default() -> Self {
Self {
stages: Default::default(),
schedule_format: Default::default(),
schedule: Default::default(),
schedule_enabled: default_schedule_enabled(),
schedule_timezone: Default::default(),
schedule_alert: default_schedule_alert(),
failure_alert: default_failure_alert(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
}

View File

@@ -271,6 +271,13 @@ pub struct ResourceSyncConfig {
#[builder(default)]
pub include_user_groups: bool,
/// Whether sync should send alert when it enters Pending state.
/// Default: true
#[serde(default = "default_pending_alert")]
#[builder(default = "default_pending_alert()")]
#[partial_default(default_pending_alert())]
pub pending_alert: bool,
/// Manage the file contents in the UI.
#[serde(default, deserialize_with = "file_contents_deserializer")]
#[partial_attr(serde(
@@ -318,6 +325,10 @@ fn default_include_resources() -> bool {
true
}
fn default_pending_alert() -> bool {
true
}
impl Default for ResourceSyncConfig {
fn default() -> Self {
Self {
@@ -338,6 +349,7 @@ impl Default for ResourceSyncConfig {
delete: Default::default(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
pending_alert: default_pending_alert(),
}
}
}

View File

@@ -72,6 +72,12 @@ pub struct Update {
/// Some unstructured, operation specific data. Not for general usage.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub other_data: String,
/// If the update is for resource config update, give the previous toml contents
#[serde(default, skip_serializing_if = "String::is_empty")]
pub prev_toml: String,
/// If the update is for resource config update, give the current (at time of Update) toml contents
#[serde(default, skip_serializing_if = "String::is_empty")]
pub current_toml: String,
}
impl Update {

View File

@@ -170,4 +170,42 @@ impl KomodoClient {
self.reqwest = reqwest;
self
}
/// Poll an [Update][entities::update::Update] (returned by the `execute` calls) until the
/// [UpdateStatus][entities::update::UpdateStatus] is `Complete`, and then return it.
#[cfg(not(feature = "blocking"))]
pub async fn poll_update_until_complete(
&self,
update_id: impl Into<String>,
) -> anyhow::Result<entities::update::Update> {
let update_id = update_id.into();
loop {
let update = self
.read(api::read::GetUpdate {
id: update_id.clone(),
})
.await?;
if update.status == entities::update::UpdateStatus::Complete {
return Ok(update);
}
}
}
/// Poll an [Update][entities::update::Update] (returned by the `execute` calls) until the
/// [UpdateStatus][entities::update::UpdateStatus] is `Complete`, and then return it.
#[cfg(feature = "blocking")]
pub fn poll_update_until_complete(
&self,
update_id: impl Into<String>,
) -> anyhow::Result<entities::update::Update> {
let update_id = update_id.into();
loop {
let update = self.read(api::read::GetUpdate {
id: update_id.clone(),
})?;
if update.status == entities::update::UpdateStatus::Complete {
return Ok(update);
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.17.1",
"version": "1.17.3",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",

View File

@@ -7,9 +7,12 @@ import {
} from "./responses.js";
import {
AuthRequest,
BatchExecutionResponse,
ExecuteRequest,
ReadRequest,
Update,
UpdateListItem,
UpdateStatus,
UserRequest,
WriteRequest,
WsLoginMessage,
@@ -155,6 +158,42 @@ export function KomodoClient(url: string, options: InitOptions) {
ExecuteResponses[Req["type"]]
>("/execute", { type, params });
const execute_and_poll = async <
T extends ExecuteRequest["type"],
Req extends Extract<ExecuteRequest, { type: T }>
>(
type: T,
params: Req["params"]
) => {
const res = await execute(type, params);
// Check if its a batch of updates or a single update;
if (Array.isArray(res)) {
const batch = res as any as BatchExecutionResponse;
return await Promise.all(
batch.map(async (item) => {
if (item.status === "Err") {
return item;
}
return await poll_update_until_complete(item.data._id?.$oid!);
})
);
} else {
// it is a single update
const update = res as any as Update;
return await poll_update_until_complete(update._id?.$oid!);
}
};
const poll_update_until_complete = async (update_id: string) => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const update = await read("GetUpdate", { id: update_id });
if (update.status === UpdateStatus.Complete) {
return update;
}
}
};
const core_version = () => read("GetVersion", {}).then((res) => res.version);
const subscribe_to_update_websocket = async ({
@@ -290,9 +329,29 @@ export function KomodoClient(url: string, options: InitOptions) {
* });
* ```
*
* NOTE. These calls return immediately when the update is created, NOT when the execution task finishes.
* To have the call only return when the task finishes, use [execute_and_poll_until_complete].
*
* https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html
*/
execute,
/**
* Call the `/execute` api, and poll the update until the task has completed.
*
* ```
* const update = await komodo.execute_and_poll("DeployStack", {
* stack: "my-stack"
* });
* ```
*
* https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html
*/
execute_and_poll,
/**
* Poll an Update (returned by the `execute` calls) until the `status` is `Complete`.
* https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status.
*/
poll_update_until_complete,
/** Returns the version of Komodo Core the client is calling to. */
core_version,
/**

View File

@@ -51,12 +51,47 @@ export interface Resource<Config, Info> {
base_permission?: PermissionLevel;
}
export enum ScheduleFormat {
English = "English",
Cron = "Cron",
}
export interface ActionConfig {
/** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */
schedule_format?: ScheduleFormat;
/**
* Typescript file contents using pre-initialized `komodo` client.
* Supports variable / secret interpolation.
* Optionally provide a schedule for the procedure to run on.
*
* There are 2 ways to specify a schedule:
*
* 1. Regular CRON expression:
*
* (second, minute, hour, day, month, day-of-week)
* ```
* 0 0 0 1,15 * ?
* ```
*
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
*
* ```
* at midnight on the 1st and 15th of the month
* ```
*/
file_contents?: string;
schedule?: string;
/**
* Whether schedule is enabled if one is provided.
* Can be used to temporarily disable the schedule.
*/
schedule_enabled: boolean;
/**
* Optional. A TZ Identifier. If not provided, will use Core local timezone.
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
*/
schedule_timezone?: string;
/** Whether to send alerts when the schedule was run. */
schedule_alert: boolean;
/** Whether to send alerts when this action fails. */
failure_alert: boolean;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
@@ -64,6 +99,11 @@ export interface ActionConfig {
* If its an empty string, use the default secret from the config.
*/
webhook_secret?: string;
/**
* Typescript file contents using pre-initialized `komodo` client.
* Supports variable / secret interpolation.
*/
file_contents?: string;
}
export interface ActionInfo {
@@ -102,6 +142,16 @@ export interface ActionListItemInfo {
last_run_at: I64;
/** Whether last action run successful */
state: ActionState;
/**
* If the procedure has schedule enabled, this is the
* next scheduled run time in unix ms.
*/
next_scheduled_run?: I64;
/**
* If there is an error parsing schedule expression,
* it will be given here.
*/
schedule_error?: string;
}
export type ActionListItem = ResourceListItem<ActionListItemInfo>;
@@ -135,7 +185,9 @@ export type AlerterEndpoint =
/** Send alert to a Discord app */
| { type: "Discord", params: DiscordAlerterEndpoint }
/** Send alert to Ntfy */
| { type: "Ntfy", params: NtfyAlerterEndpoint };
| { type: "Ntfy", params: NtfyAlerterEndpoint }
/** Send alert to Pushover */
| { type: "Pushover", params: PushoverAlerterEndpoint };
/** Used to reference a specific resource across all resource types */
export type ResourceTarget =
@@ -544,6 +596,41 @@ export interface ProcedureStage {
export interface ProcedureConfig {
/** The stages to be run by the procedure. */
stages?: ProcedureStage[];
/** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */
schedule_format?: ScheduleFormat;
/**
* Optionally provide a schedule for the procedure to run on.
*
* There are 2 ways to specify a schedule:
*
* 1. Regular CRON expression:
*
* (second, minute, hour, day, month, day-of-week)
* ```
* 0 0 0 1,15 * ?
* ```
*
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
*
* ```
* at midnight on the 1st and 15th of the month
* ```
*/
schedule?: string;
/**
* Whether schedule is enabled if one is provided.
* Can be used to temporarily disable the schedule.
*/
schedule_enabled: boolean;
/**
* Optional. A TZ Identifier. If not provided, will use Core local timezone.
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
*/
schedule_timezone?: string;
/** Whether to send alerts when the schedule was run. */
schedule_alert: boolean;
/** Whether to send alerts when this procedure fails. */
failure_alert: boolean;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
@@ -1146,6 +1233,29 @@ export type AlertData =
id: string;
/** The name of the repo */
name: string;
}}
/** A procedure has failed */
| { type: "ProcedureFailed", data: {
/** The id of the procedure */
id: string;
/** The name of the procedure */
name: string;
}}
/** An action has failed */
| { type: "ActionFailed", data: {
/** The id of the action */
id: string;
/** The name of the action */
name: string;
}}
/** A schedule was run */
| { type: "ScheduleRun", data: {
/** Procedure or Action */
resource_type: ResourceTarget["type"];
/** The resource id */
id: string;
/** The resource name */
name: string;
}};
/** Representation of an alert in the system. */
@@ -1424,6 +1534,11 @@ export interface ResourceSyncConfig {
include_variables?: boolean;
/** Whether sync should include user groups. */
include_user_groups?: boolean;
/**
* Whether sync should send alert when it enters Pending state.
* Default: true
*/
pending_alert: boolean;
/** Manage the file contents in the UI. */
file_contents?: string;
}
@@ -2198,6 +2313,10 @@ export interface Update {
commit_hash?: string;
/** Some unstructured, operation specific data. Not for general usage. */
other_data?: string;
/** If the update is for resource config update, give the previous toml contents */
prev_toml?: string;
/** If the update is for resource config update, give the current (at time of Update) toml contents */
current_toml?: string;
}
export type GetUpdateResponse = Update;
@@ -3286,6 +3405,16 @@ export interface ProcedureListItemInfo {
stages: I64;
/** Reflect whether last run successful / currently running. */
state: ProcedureState;
/**
* If the procedure has schedule enabled, this is the
* next scheduled run time in unix ms.
*/
next_scheduled_run?: I64;
/**
* If there is an error parsing schedule expression,
* it will be given here.
*/
schedule_error?: string;
}
export type ProcedureListItem = ResourceListItem<ProcedureListItemInfo>;
@@ -6609,6 +6738,12 @@ export interface PushRecentlyViewed {
resource: ResourceTarget;
}
/** Configuration for a Pushover alerter. */
export interface PushoverAlerterEndpoint {
/** The pushover URL including application and user tokens in parameters. */
url: string;
}
/** Trigger a refresh of the cached latest hash and message. */
export interface RefreshBuildCache {
/** Id or name */

View File

@@ -106,6 +106,6 @@ pub struct RenameRepo {
#[error(serror::Error)]
pub struct DeleteRepo {
pub name: String,
/// Clears
/// Clears
pub is_build: bool,
}

View File

@@ -21,6 +21,10 @@ KOMODO_DB_PASSWORD=admin
## Configure a secure passkey to authenticate between Core / Periphery.
KOMODO_PASSKEY=a_random_passkey
## Set your time zone for schedules
## https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TZ=Etc/UTC
#=-------------------------=#
#= Komodo Core Environment =#
#=-------------------------=#
@@ -122,19 +126,13 @@ PERIPHERY_PASSKEYS=${KOMODO_PASSKEY}
## Specify the root directory used by Periphery agent.
PERIPHERY_ROOT_DIRECTORY=/etc/komodo
PERIPHERY_REPO_DIR=$PERIPHERY_ROOT_DIRECTORY/stacks
PERIPHERY_STACK_DIR=$PERIPHERY_ROOT_DIRECTORY/repos
PERIPHERY_BUILD_DIR=$PERIPHERY_ROOT_DIRECTORY/builds
## Enable SSL using self signed certificates.
## Connect to Periphery at https://address:8120.
PERIPHERY_SSL_ENABLED=true
PERIPHERY_SSL_KEY_FILE=$PERIPHERY_ROOT_DIRECTORY/ssl/key.pem
PERIPHERY_SSL_CERT_FILE=$PERIPHERY_ROOT_DIRECTORY/ssl/cert.pem
## If the disk size is overreporting, can use one of these to
## whitelist / blacklist the disks to filter them, whichever is easier.
## Accepts comma separated list of paths.
## Usually whitelisting just /etc/hostname gives correct size.
PERIPHERY_INCLUDE_DISK_MOUNTS=/etc/hostname
# PERIPHERY_EXCLUDE_DISK_MOUNTS=/snap,/etc/repos
# PERIPHERY_EXCLUDE_DISK_MOUNTS=/snap,/etc/repos

View File

@@ -15,11 +15,7 @@ services:
driver: ${COMPOSE_LOGGING_DRIVER:-local}
## https://komo.do/docs/connect-servers#configuration
environment:
PERIPHERY_REPO_DIR: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}/repos
PERIPHERY_STACK_DIR: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}/stacks
PERIPHERY_BUILD_DIR: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}/builds
PERIPHERY_SSL_KEY_FILE: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}/ssl/key.pem
PERIPHERY_SSL_CERT_FILE: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}/ssl/cert.pem
PERIPHERY_ROOT_DIRECTORY: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}
## Pass the same passkey as used by the Komodo Core connecting to this Periphery agent.
PERIPHERY_PASSKEYS: abc123
## If the disk size is overreporting, can use one of these to

View File

@@ -341,12 +341,18 @@ webhook_base_url = ""
## Default: info
logging.level = "info"
## Specify the logging format for stdout / stderr.
## Specify the logging format.
## Env: KOMODO_LOGGING_STDIO
## Options: standard, json, none
## Default: standard
logging.stdio = "standard"
## Specify whether logging is more human readable.
## Note. Single logs will span multiple lines.
## Env: KOMODO_LOGGING_PRETTY
## Default: false
logging.pretty = false
## Optionally specify a opentelemetry otlp endpoint to send traces to.
## Example: http://localhost:4317
## Env: KOMODO_LOGGING_OTLP_ENDPOINT

View File

@@ -24,23 +24,29 @@ port = 8120
## Default: [::]
bind_ip = "[::]"
## The directory periphery will use to manage repos.
## The directory periphery will use as the default base for the directories it uses.
## The periphery user must have write access to this directory.
## Env: PERIPHERY_ROOT_DIRECTORY
## Default: /etc/komodo
root_directory = "/etc/komodo"
## Optional. Override the directory periphery will use to manage repos.
## The periphery user must have write access to this directory.
## Env: PERIPHERY_REPO_DIR
## Default: /etc/komodo/repos
repo_dir = "/etc/komodo/repos"
## Default: ${root_directory}/repos
# repo_dir = "/etc/komodo/repos"
## The directory periphery will use to manage stacks.
## Optional. Override the directory periphery will use to manage stacks.
## The periphery user must have write access to this directory.
## Env: PERIPHERY_STACK_DIR
## Default: /etc/komodo/stacks
stack_dir = "/etc/komodo/stacks"
## Default: ${root_directory}/stacks
# stack_dir = "/etc/komodo/stacks"
## The directory periphery will use to manage builds.
## Optional. Override the directory periphery will use to manage builds.
## The periphery user must have write access to this directory.
## Env: PERIPHERY_BUILD_DIR
## Default: /etc/komodo/builds
build_dir = "/etc/komodo/builds"
## Default: ${root_directory}/builds
# build_dir = "/etc/komodo/builds"
## How often Periphery polls the host for system stats,
## like CPU / memory usage.
@@ -116,6 +122,12 @@ logging.level = "info"
## Default: standard
logging.stdio = "standard"
## Specify whether logging is more human readable.
## Note. Single logs will span multiple lines.
## Env: PERIPHERY_LOGGING_PRETTY
## Default: false
logging.pretty = false
## Specify a opentelemetry otlp endpoint to send traces to.
## Example: http://localhost:4317.
## Env: PERIPHERY_LOGGING_OTLP_ENDPOINT

View File

@@ -19,5 +19,5 @@ FROM scratch
COPY --from=builder /builder/frontend/dist /frontend
LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo
LABEL org.opencontainers.image.description="Komodo Periphery"
LABEL org.opencontainers.image.description="Komodo Frontend"
LABEL org.opencontainers.image.licenses=GPL-3.0

View File

@@ -1,5 +1,5 @@
import { AuthResponses, ExecuteResponses, ReadResponses, UserResponses, WriteResponses } from "./responses.js";
import { AuthRequest, ExecuteRequest, ReadRequest, UpdateListItem, UserRequest, WriteRequest } from "./types.js";
import { AuthRequest, ExecuteRequest, ReadRequest, Update, UpdateListItem, UserRequest, WriteRequest } from "./types.js";
export * as Types from "./types.js";
type InitOptions = {
type: "jwt";
@@ -86,11 +86,36 @@ export declare function KomodoClient(url: string, options: InitOptions): {
* });
* ```
*
* NOTE. These calls return immediately when the update is created, NOT when the execution task finishes.
* To have the call only return when the task finishes, use [execute_and_poll_until_complete].
*
* https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html
*/
execute: <T extends ExecuteRequest["type"], Req extends Extract<ExecuteRequest, {
type: T;
}>>(type: T, params: Req["params"]) => Promise<ExecuteResponses[Req["type"]]>;
/**
* Call the `/execute` api, and poll the update until the task has completed.
*
* ```
* const update = await komodo.execute_and_poll("DeployStack", {
* stack: "my-stack"
* });
* ```
*
* https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html
*/
execute_and_poll: <T extends ExecuteRequest["type"], Req extends Extract<ExecuteRequest, {
type: T;
}>>(type: T, params: Req["params"]) => Promise<Update | (Update | {
status: "Err";
data: import("./types.js").BatchExecutionResponseItemErr;
})[]>;
/**
* Poll an Update (returned by the `execute` calls) until the `status` is `Complete`.
* https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status.
*/
poll_update_until_complete: (update_id: string) => Promise<Update>;
/** Returns the version of Komodo Core the client is calling to. */
core_version: () => Promise<string>;
/**

View File

@@ -1,3 +1,4 @@
import { UpdateStatus, } from "./types.js";
export * as Types from "./types.js";
export class CancelToken {
cancelled;
@@ -74,6 +75,33 @@ export function KomodoClient(url, options) {
const read = async (type, params) => await request("/read", { type, params });
const write = async (type, params) => await request("/write", { type, params });
const execute = async (type, params) => await request("/execute", { type, params });
const execute_and_poll = async (type, params) => {
const res = await execute(type, params);
// Check if its a batch of updates or a single update;
if (Array.isArray(res)) {
const batch = res;
return await Promise.all(batch.map(async (item) => {
if (item.status === "Err") {
return item;
}
return await poll_update_until_complete(item.data._id?.$oid);
}));
}
else {
// it is a single update
const update = res;
return await poll_update_until_complete(update._id?.$oid);
}
};
const poll_update_until_complete = async (update_id) => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const update = await read("GetUpdate", { id: update_id });
if (update.status === UpdateStatus.Complete) {
return update;
}
}
};
const core_version = () => read("GetVersion", {}).then((res) => res.version);
const subscribe_to_update_websocket = async ({ on_update, on_login, on_close, retry_timeout_ms = 5_000, cancel = new CancelToken(), on_cancel, }) => {
while (true) {
@@ -117,7 +145,7 @@ export function KomodoClient(url, options) {
// Sleep for a bit before checking for websocket closed
await new Promise((resolve) => setTimeout(resolve, 500));
}
// Sleep for a bit before retrying to avoid spam.
// Sleep for a bit before retrying connection to avoid spam.
await new Promise((resolve) => setTimeout(resolve, retry_timeout_ms));
}
catch (error) {
@@ -186,9 +214,29 @@ export function KomodoClient(url, options) {
* });
* ```
*
* NOTE. These calls return immediately when the update is created, NOT when the execution task finishes.
* To have the call only return when the task finishes, use [execute_and_poll_until_complete].
*
* https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html
*/
execute,
/**
* Call the `/execute` api, and poll the update until the task has completed.
*
* ```
* const update = await komodo.execute_and_poll("DeployStack", {
* stack: "my-stack"
* });
* ```
*
* https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html
*/
execute_and_poll,
/**
* Poll an Update (returned by the `execute` calls) until the `status` is `Complete`.
* https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status.
*/
poll_update_until_complete,
/** Returns the version of Komodo Core the client is calling to. */
core_version,
/**

View File

@@ -42,12 +42,46 @@ export interface Resource<Config, Info> {
*/
base_permission?: PermissionLevel;
}
export declare enum ScheduleFormat {
English = "English",
Cron = "Cron"
}
export interface ActionConfig {
/** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */
schedule_format?: ScheduleFormat;
/**
* Typescript file contents using pre-initialized `komodo` client.
* Supports variable / secret interpolation.
* Optionally provide a schedule for the procedure to run on.
*
* There are 2 ways to specify a schedule:
*
* 1. Regular CRON expression:
*
* (second, minute, hour, day, month, day-of-week)
* ```
* 0 0 0 1,15 * ?
* ```
*
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
*
* ```
* at midnight on the 1st and 15th of the month
* ```
*/
file_contents?: string;
schedule?: string;
/**
* Whether schedule is enabled if one is provided.
* Can be used to temporarily disable the schedule.
*/
schedule_enabled: boolean;
/**
* Optional. A TZ Identifier. If not provided, will use Core local timezone.
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
*/
schedule_timezone?: string;
/** Whether to send alerts when the schedule was run. */
schedule_alert: boolean;
/** Whether to send alerts when this action fails. */
failure_alert: boolean;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
@@ -55,6 +89,11 @@ export interface ActionConfig {
* If its an empty string, use the default secret from the config.
*/
webhook_secret?: string;
/**
* Typescript file contents using pre-initialized `komodo` client.
* Supports variable / secret interpolation.
*/
file_contents?: string;
}
export interface ActionInfo {
/** When action was last run */
@@ -88,6 +127,16 @@ export interface ActionListItemInfo {
last_run_at: I64;
/** Whether last action run successful */
state: ActionState;
/**
* If the procedure has schedule enabled, this is the
* next scheduled run time in unix ms.
*/
next_scheduled_run?: I64;
/**
* If there is an error parsing schedule expression,
* it will be given here.
*/
schedule_error?: string;
}
export type ActionListItem = ResourceListItem<ActionListItemInfo>;
export declare enum TagBehavior {
@@ -127,6 +176,11 @@ export type AlerterEndpoint =
| {
type: "Ntfy";
params: NtfyAlerterEndpoint;
}
/** Send alert to Pushover */
| {
type: "Pushover";
params: PushoverAlerterEndpoint;
};
/** Used to reference a specific resource across all resource types */
export type ResourceTarget = {
@@ -668,6 +722,41 @@ export interface ProcedureStage {
export interface ProcedureConfig {
/** The stages to be run by the procedure. */
stages?: ProcedureStage[];
/** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */
schedule_format?: ScheduleFormat;
/**
* Optionally provide a schedule for the procedure to run on.
*
* There are 2 ways to specify a schedule:
*
* 1. Regular CRON expression:
*
* (second, minute, hour, day, month, day-of-week)
* ```
* 0 0 0 1,15 * ?
* ```
*
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
*
* ```
* at midnight on the 1st and 15th of the month
* ```
*/
schedule?: string;
/**
* Whether schedule is enabled if one is provided.
* Can be used to temporarily disable the schedule.
*/
schedule_enabled: boolean;
/**
* Optional. A TZ Identifier. If not provided, will use Core local timezone.
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
*/
schedule_timezone?: string;
/** Whether to send alerts when the schedule was run. */
schedule_alert: boolean;
/** Whether to send alerts when this procedure fails. */
failure_alert: boolean;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
/**
@@ -1284,6 +1373,38 @@ export type AlertData =
/** The name of the repo */
name: string;
};
}
/** A procedure has failed */
| {
type: "ProcedureFailed";
data: {
/** The id of the procedure */
id: string;
/** The name of the procedure */
name: string;
};
}
/** An action has failed */
| {
type: "ActionFailed";
data: {
/** The id of the action */
id: string;
/** The name of the action */
name: string;
};
}
/** A schedule was run */
| {
type: "ScheduleRun";
data: {
/** Procedure or Action */
resource_type: ResourceTarget["type"];
/** The resource id */
id: string;
/** The resource name */
name: string;
};
};
/** Representation of an alert in the system. */
export interface Alert {
@@ -1532,6 +1653,11 @@ export interface ResourceSyncConfig {
include_variables?: boolean;
/** Whether sync should include user groups. */
include_user_groups?: boolean;
/**
* Whether sync should send alert when it enters Pending state.
* Default: true
*/
pending_alert: boolean;
/** Manage the file contents in the UI. */
file_contents?: string;
}
@@ -2283,6 +2409,10 @@ export interface Update {
commit_hash?: string;
/** Some unstructured, operation specific data. Not for general usage. */
other_data?: string;
/** If the update is for resource config update, give the previous toml contents */
prev_toml?: string;
/** If the update is for resource config update, give the current (at time of Update) toml contents */
current_toml?: string;
}
export type GetUpdateResponse = Update;
/**
@@ -3259,6 +3389,16 @@ export interface ProcedureListItemInfo {
stages: I64;
/** Reflect whether last run successful / currently running. */
state: ProcedureState;
/**
* If the procedure has schedule enabled, this is the
* next scheduled run time in unix ms.
*/
next_scheduled_run?: I64;
/**
* If there is an error parsing schedule expression,
* it will be given here.
*/
schedule_error?: string;
}
export type ProcedureListItem = ResourceListItem<ProcedureListItemInfo>;
export type ListProceduresResponse = ProcedureListItem[];
@@ -6225,6 +6365,11 @@ export interface PushRecentlyViewed {
/** The target to push. */
resource: ResourceTarget;
}
/** Configuration for a Pushover alerter. */
export interface PushoverAlerterEndpoint {
/** The pushover URL including application and user tokens in parameters. */
url: string;
}
/** Trigger a refresh of the cached latest hash and message. */
export interface RefreshBuildCache {
/** Id or name */

View File

@@ -13,6 +13,11 @@ export var PermissionLevel;
/** Can update the resource configuration */
PermissionLevel["Write"] = "Write";
})(PermissionLevel || (PermissionLevel = {}));
export var ScheduleFormat;
(function (ScheduleFormat) {
ScheduleFormat["English"] = "English";
ScheduleFormat["Cron"] = "Cron";
})(ScheduleFormat || (ScheduleFormat = {}));
export var ActionState;
(function (ActionState) {
/** Unknown case */

View File

@@ -93,7 +93,7 @@ export type PrimitiveConfigArgs = {
placeholder?: string;
label?: string;
boldLabel?: boolean;
description?: string;
description?: ReactNode;
};
export type ConfigComponent<T> = {

View File

@@ -119,7 +119,7 @@ export const ConfigInput = ({
label: string;
boldLabel?: boolean;
value: string | number | undefined;
description?: string;
description?: ReactNode;
disabled?: boolean;
placeholder?: string;
onChange?: (value: string) => void;
@@ -168,7 +168,7 @@ export const ConfigSwitch = ({
label: string;
boldLabel?: boolean;
value: boolean | undefined;
description?: string;
description?: ReactNode;
disabled: boolean;
onChange: (value: boolean) => void;
}) => (
@@ -180,7 +180,7 @@ export const ConfigSwitch = ({
>
<div
className="py-2 flex flex-row gap-4 items-center text-sm cursor-pointer"
onClick={() => onChange(!checked)}
onClick={() => !disabled && onChange(!checked)}
>
{/* <div
className={cn(
@@ -205,83 +205,6 @@ export const ConfigSwitch = ({
</ConfigItem>
);
export const DoubleInput = <
T extends object,
K extends keyof T,
L extends T[K] extends string | number | undefined ? K : never,
R extends T[K] extends string | number | undefined ? K : never,
>({
disabled,
values,
leftval,
leftpl,
rightval,
rightpl,
// addName,
onLeftChange,
onRightChange,
// onAdd,
onRemove,
containerClassName,
inputClassName,
}: {
disabled: boolean;
values: T[] | undefined;
leftval: L;
leftpl: string;
rightval: R;
rightpl: string;
// addName: string;
onLeftChange: (value: T[L], i: number) => void;
onRightChange: (value: T[R], i: number) => void;
// onAdd: () => void;
onRemove: (i: number) => void;
containerClassName?: string;
inputClassName?: string;
}) => {
return (
<div className={cn("flex flex-col gap-4", containerClassName)}>
{values?.map((value, i) => (
<div
className="flex items-center justify-between gap-4 flex-wrap"
key={i}
>
<Input
className={inputClassName}
value={value[leftval] as any}
placeholder={leftpl}
onChange={(e) => onLeftChange(e.target.value as T[L], i)}
disabled={disabled}
/>
:
<Input
className={inputClassName}
value={value[rightval] as any}
placeholder={rightpl}
onChange={(e) => onRightChange(e.target.value as T[R], i)}
disabled={disabled}
/>
{!disabled && (
<Button variant="secondary" onClick={() => onRemove(i)}>
<MinusCircle className="w-4 h-4" />
</Button>
)}
</div>
))}
{/* {!disabled && (
<Button
variant="secondary"
className="flex items-center gap-2 w-[200px] place-self-end"
onClick={onAdd}
>
<PlusCircle className="w-4 h-4" />
Add {addName}
</Button>
)} */}
</div>
);
};
export const ProviderSelector = ({
disabled,
account_type,

View File

@@ -10,7 +10,6 @@ import * as pluginYaml from "prettier/plugins/yaml";
import { useWindowDimensions } from "@lib/hooks";
const MIN_EDITOR_HEIGHT = 56;
// const MAX_EDITOR_HEIGHT = 500;
export type MonacoLanguage =
| "yaml"
@@ -102,13 +101,9 @@ export const MonacoEditor = ({
const contentHeight = line_count * 18 + 30;
const containerNode = editor.getContainerDomNode();
// containerNode.style.height = `${Math.max(
// Math.ceil(contentHeight),
// minHeight ?? MIN_EDITOR_HEIGHT
// )}px`;
containerNode.style.height = `${Math.min(
Math.max(Math.ceil(contentHeight), minHeight ?? MIN_EDITOR_HEIGHT),
Math.floor(dimensions.height * (3 / 5))
containerNode.style.height = `${Math.max(
Math.min(contentHeight, Math.floor(dimensions.height * 0.5)),
minHeight ?? MIN_EDITOR_HEIGHT
)}px`;
}, [editor, line_count]);
@@ -182,8 +177,9 @@ export const MonacoDiffEditor = ({
if (!editor) return;
const contentHeight = line_count * 18 + 30;
const node = editor.getContainerDomNode();
node.style.height = `${Math.max(
Math.min(Math.ceil(contentHeight), MAX_DIFF_HEIGHT),
Math.min(contentHeight, MAX_DIFF_HEIGHT),
MIN_DIFF_HEIGHT
)}px`;
}, [editor, line_count]);

View File

@@ -10,12 +10,23 @@ import { Config } from "@components/config";
import { MonacoEditor } from "@components/monaco";
import { SecretsSearch } from "@components/config/env_vars";
import { Button } from "@ui/button";
import { ConfigItem, WebhookBuilder } from "@components/config/util";
import {
ConfigItem,
ConfigSwitch,
WebhookBuilder,
} from "@components/config/util";
import { Input } from "@ui/input";
import { useState } from "react";
import { CopyWebhook } from "../common";
import { ActionInfo } from "./info";
import { Switch } from "@ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
const ACTION_GIT_PROVIDER = "Action";
@@ -45,7 +56,6 @@ export const ActionConfig = ({ id }: { id: string }) => {
return (
<Config
disabled={disabled}
disableSidebar
original={config}
update={update}
set={set}
@@ -57,13 +67,6 @@ export const ActionConfig = ({ id }: { id: string }) => {
{
label: "Action File",
description: "Manage the action file contents here.",
// actions: (
// <ShowHideButton
// show={show.file}
// setShow={(file) => setShow({ ...show, file })}
// />
// ),
// contentHidden: !show.file,
components: {
file_contents: (file_contents, set) => {
return (
@@ -103,6 +106,101 @@ export const ActionConfig = ({ id }: { id: string }) => {
},
},
},
{
label: "Alert",
labelHidden: true,
components: {
failure_alert: {
boldLabel: true,
description: "Send an alert any time the Procedure fails",
},
},
},
{
label: "Schedule",
description:
"Configure the Procedure to run at defined times using English or CRON.",
components: {
schedule_enabled: (schedule_enabled, set) => (
<ConfigSwitch
label="Enabled"
value={
(update.schedule ?? config.schedule)
? schedule_enabled
: false
}
disabled={disabled || !(update.schedule ?? config.schedule)}
onChange={(schedule_enabled) => set({ schedule_enabled })}
/>
),
schedule_format: (schedule_format, set) => (
<ConfigItem
label="Format"
description="Choose whether to provide English or CRON schedule expression"
>
<Select
value={schedule_format}
onValueChange={(schedule_format) =>
set({
schedule_format:
schedule_format as Types.ScheduleFormat,
})
}
disabled={disabled}
>
<SelectTrigger className="w-[200px]" disabled={disabled}>
<SelectValue placeholder="Select Format" />
</SelectTrigger>
<SelectContent>
{Object.values(Types.ScheduleFormat).map((mode) => (
<SelectItem
key={mode}
value={mode!}
className="cursor-pointer"
>
{mode}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
),
schedule: {
label: "Expression",
description:
(update.schedule_format ?? config.schedule_format) ===
"Cron" ? (
<div className="pt-1 flex flex-col gap-1">
<code>
second - minute - hour - day - month - day-of-week
</code>
</div>
) : (
<div className="pt-1 flex flex-col gap-1">
<code>Examples:</code>
<code>- Run every day at 4:00 pm</code>
<code>
- Run at 21:00 on the 1st and 15th of the month
</code>
<code>- Every Sunday at midnight</code>
</div>
),
placeholder:
(update.schedule_format ?? config.schedule_format) === "Cron"
? "0 0 0 ? * SUN"
: "Enter English expression",
},
schedule_timezone: {
label: "Timezone",
description:
"Optional. Enter specific IANA timezone for schedule expression. If not provided, uses the Core timezone.",
placeholder: "Enter IANA timezone",
},
schedule_alert: {
description: "Send an alert when the scheduled run occurs",
},
},
},
{
label: "Webhook",
description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`,

View File

@@ -5,7 +5,7 @@ import {
} from "@components/util";
import { useExecute, useRead } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Clapperboard } from "lucide-react";
import { Clapperboard, Clock } from "lucide-react";
import { ActionConfig } from "./config";
import { ActionTable } from "./table";
import { DeleteResource, NewResource } from "../common";
@@ -13,11 +13,13 @@ import {
action_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn } from "@lib/utils";
import { cn, updateLogToHtml } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@pages/home/dashboard";
import { RenameResource } from "@components/config/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { Card } from "@ui/card";
const useAction = (id?: string) =>
useRead("ListActions", {}).data?.find((d) => d.id === id);
@@ -78,7 +80,47 @@ export const ActionComponents: RequiredResourceComponents = {
Status: {},
Info: {},
Info: {
Schedule: ({ id }) => {
const next_scheduled_run = useAction(id)?.info.next_scheduled_run;
return (
<div className="flex gap-2 items-center">
<Clock className="w-4 h-4" />
Next Run:
<div className="font-bold">
{next_scheduled_run
? new Date(next_scheduled_run).toLocaleString()
: "Not Scheduled"}
</div>
</div>
);
},
ScheduleErrors: ({ id }) => {
const error = useAction(id)?.info.schedule_error;
if (!error) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 bg-destructive/75 hover:bg-destructive transition-colors cursor-pointer">
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Schedule Error
</div>
</Card>
</TooltipTrigger>
<TooltipContent className="w-[400px]">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(error),
}}
className="max-h-[500px] overflow-y-auto"
/>
</TooltipContent>
</Tooltip>
);
},
},
Actions: {
RunAction: ({ id }) => {

View File

@@ -14,6 +14,7 @@ const ENDPOINT_TYPES: Types.AlerterEndpoint["type"][] = [
"Discord",
"Slack",
"Ntfy",
"Pushover",
];
export const EndpointConfig = ({
@@ -70,5 +71,7 @@ const default_url = (type: Types.AlerterEndpoint["type"]) => {
? "https://discord.com/api/webhooks/XXXXXXXXXXXX/XXXX-XXXXXXXXXX"
: type === "Ntfy"
? "https://ntfy.sh/komodo"
: type === "Pushover"
? "https://api.pushover.net/1/messages.json?token=XXXXXXXXXXXXX&user=XXXXXXXXXXXXX"
: "";
};

View File

@@ -90,103 +90,4 @@ export const TerminationTimeout = ({
</div>
</ConfigItem>
);
};
// export const TermSignalLabels = ({
// args,
// set,
// disabled,
// }: {
// args: Types.TerminationSignalLabel[];
// set: (input: Partial<Types.DeploymentConfig>) => void;
// disabled: boolean;
// }) => {
// const signals = Object.values(Types.TerminationSignal)
// .filter((signal) => args.every((arg) => arg.signal !== signal))
// .reverse();
// return (
// <ConfigItem label="Signal Labels" className="items-start">
// <div className="grid gap-2">
// {args.map((label, i) => (
// <div key={label.signal} className="flex gap-4 items-center w-full">
// <Input
// placeholder="Label this termination signal"
// value={label.label}
// onChange={(e) =>
// set({
// term_signal_labels: args.map((item, index) =>
// index === i ? { ...item, label: e.target.value } : item
// ),
// })
// }
// disabled={disabled}
// />
// <Select
// value={label.signal}
// onValueChange={(value) =>
// set({
// term_signal_labels: args.map((item, index) =>
// index === i
// ? { ...item, signal: value as Types.TerminationSignal }
// : item
// ),
// })
// }
// disabled={disabled}
// >
// <SelectTrigger className="w-[200px]" disabled={disabled}>
// {label.signal}
// </SelectTrigger>
// <SelectContent>
// <SelectGroup>
// {signals.map((term_signal) => (
// <SelectItem
// key={term_signal}
// value={term_signal}
// className="cursor-pointer"
// >
// {term_signal}
// </SelectItem>
// ))}
// </SelectGroup>
// </SelectContent>
// </Select>
// {!disabled && (
// <Button
// variant="outline"
// size="icon"
// onClick={() =>
// set({
// term_signal_labels: args.filter((_, index) => i !== index),
// })
// }
// className="p-2"
// >
// <MinusCircle className="w-4 h-4" />
// </Button>
// )}
// </div>
// ))}
// {!disabled && signals.length > 0 && (
// <Button
// className="justify-self-end p-2"
// variant="outline"
// size="icon"
// onClick={() =>
// set({
// term_signal_labels: [
// ...args,
// { label: "", signal: signals[0] },
// ],
// })
// }
// >
// <PlusCircle className="w-4 h-4" />
// </Button>
// )}
// </div>
// </ConfigItem>
// );
// };
};

View File

@@ -365,7 +365,7 @@ export const DeploymentConfig = ({
description="Choose between multiple signals when stopping"
>
<MonacoEditor
value={value || " # SIGTERM: your label\n"}
value={value || DEFAULT_TERM_SIGNAL_LABELS}
language="key_value"
onValueChange={(term_signal_labels) =>
set({ term_signal_labels })
@@ -381,3 +381,9 @@ export const DeploymentConfig = ({
/>
);
};
export const DEFAULT_TERM_SIGNAL_LABELS = ` # SIGTERM: sigterm label
# SIGQUIT: sigquit label
# SIGINT: sigint label
# SIGHUP: sighup label
`;

View File

@@ -1,10 +1,3 @@
import {
ConfigInput,
ConfigItem,
ConfigSwitch,
WebhookBuilder,
} from "@components/config/util";
import { Section } from "@components/layouts";
import {
useLocalStorage,
useRead,
@@ -13,31 +6,33 @@ import {
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { Config } from "@components/config";
import { Button } from "@ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
ConfigItem,
ConfigSwitch,
WebhookBuilder,
} from "@components/config/util";
import { Input } from "@ui/input";
import { useEffect, useState } from "react";
import { CopyWebhook, ResourceSelector } from "../common";
import { ConfigLayout } from "@components/config";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import { Button } from "@ui/button";
import { Switch } from "@ui/switch";
import {
ArrowDown,
ArrowUp,
ChevronsUpDown,
Info,
Minus,
MinusCircle,
Plus,
PlusCircle,
SearchX,
Settings,
} from "lucide-react";
import { useToast } from "@ui/use-toast";
import { TextUpdateMenuMonaco } from "@components/util";
import { Card } from "@ui/card";
import { filterBySplit } from "@lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import { fmt_upper_camelcase } from "@lib/formatting";
import {
Command,
CommandEmpty,
@@ -46,8 +41,6 @@ import {
CommandItem,
CommandList,
} from "@ui/command";
import { Switch } from "@ui/switch";
import { DataTable } from "@ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -56,180 +49,304 @@ import {
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import { filterBySplit } from "@lib/utils";
import { useToast } from "@ui/use-toast";
import { fmt_upper_camelcase } from "@lib/formatting";
import { TextUpdateMenuMonaco } from "@components/util";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { DataTable } from "@ui/data-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
export const ProcedureConfig = ({ id }: { id: string }) => {
const procedure = useRead("GetProcedure", { procedure: id }).data;
if (!procedure) return null;
return <ProcedureConfigInner procedure={procedure} />;
type ExecutionType = Types.Execution["type"];
type ExecutionConfigComponent<
T extends ExecutionType,
P = Extract<Types.Execution, { type: T }>["params"],
> = React.FC<{
params: P;
setParams: React.Dispatch<React.SetStateAction<P>>;
disabled: boolean;
}>;
type MinExecutionType = Exclude<
ExecutionType,
| "StartContainer"
| "RestartContainer"
| "PauseContainer"
| "UnpauseContainer"
| "StopContainer"
| "DestroyContainer"
| "DeleteNetwork"
| "DeleteImage"
| "DeleteVolume"
| "TestAlerter"
>;
type ExecutionConfigParams<T extends MinExecutionType> = Extract<
Types.Execution,
{ type: T }
>["params"];
type ExecutionConfigs = {
[ExType in MinExecutionType]: {
Component: ExecutionConfigComponent<ExType>;
params: ExecutionConfigParams<ExType>;
};
};
const PROCEDURE_GIT_PROVIDER = "Procedure";
const ProcedureConfigInner = ({
procedure,
}: {
procedure: Types.Procedure;
}) => {
const new_stage = (next_index: number) => ({
name: `Stage ${next_index}`,
enabled: true,
executions: [default_enabled_execution()],
});
const default_enabled_execution: () => Types.EnabledExecution = () => ({
enabled: true,
execution: {
type: "None",
params: {},
},
});
export const ProcedureConfig = ({ id }: { id: string }) => {
const [branch, setBranch] = useState("main");
const [config, setConfig] = useLocalStorage<Partial<Types.ProcedureConfig>>(
`procedure-${procedure._id?.$oid}-update-v1`,
{}
);
const perms = useRead("GetPermissionLevel", {
target: { type: "Procedure", id: procedure._id?.$oid! },
target: { type: "Procedure", id },
}).data;
const procedure = useRead("GetProcedure", { procedure: id }).data;
const config = procedure?.config;
const name = procedure?.name;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
const [update, set] = useLocalStorage<Partial<Types.ProcedureConfig>>(
`procedure-${id}-update-v1`,
{}
);
const { mutateAsync } = useWrite("UpdateProcedure");
const { integrations } = useWebhookIntegrations();
const [id_or_name] = useWebhookIdOrName();
const webhook_integration = integrations[PROCEDURE_GIT_PROVIDER] ?? "Github";
const stages = config.stages || procedure.config?.stages || [];
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
const webhook_integration = integrations[PROCEDURE_GIT_PROVIDER] ?? "Github";
const stages = update.stages || procedure.config?.stages || [];
const add_stage = () =>
setConfig((config) => ({
set((config) => ({
...config,
stages: [...stages, new_stage()],
stages: [...stages, new_stage(stages.length + 1)],
}));
return (
<div className="flex flex-col gap-8">
<ConfigLayout
original={procedure.config}
titleOther={
<div className="flex items-center gap-4 text-muted-foreground">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4" />
<h2 className="text-xl">Config</h2>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">
<Info className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div>
The executions in a stage are all run in parallel. The stages
themselves are run sequentially.
<Config
disabled={disabled}
original={config}
update={update}
set={set}
onSave={async () => {
await mutateAsync({ id, config: update });
}}
components={{
"": [
{
label: "Stages",
description:
"The executions in a stage are all run in parallel. The stages themselves are run sequentially.",
components: {
stages: (stages, set) => (
<div className="flex flex-col gap-4">
{stages &&
stages.map((stage, index) => (
<Stage
stage={stage}
setStage={(stage) =>
set({
stages: stages.map((s, i) =>
index === i ? stage : s
),
})
}
removeStage={() =>
set({
stages: stages.filter((_, i) => index !== i),
})
}
moveUp={
index === 0
? undefined
: () =>
set({
stages: stages.map((stage, i) => {
// Make sure its not the first row
if (i === index && index !== 0) {
return stages[index - 1];
} else if (i === index - 1) {
// Reverse the entry, moving this row "Up"
return stages[index];
} else {
return stage;
}
}),
})
}
moveDown={
index === stages.length - 1
? undefined
: () =>
set({
stages: stages.map((stage, i) => {
// The index also cannot be the last index, which cannot be moved down
if (
i === index &&
index !== stages.length - 1
) {
return stages[index + 1];
} else if (i === index + 1) {
// Move the row "Down"
return stages[index];
} else {
return stage;
}
}),
})
}
insertAbove={() =>
set({
stages: [
...stages.slice(0, index),
new_stage(index + 1),
...stages.slice(index),
],
})
}
insertBelow={() =>
set({
stages: [
...stages.slice(0, index + 1),
new_stage(index + 2),
...stages.slice(index + 1),
],
})
}
disabled={disabled}
/>
))}
<Button
variant="secondary"
onClick={add_stage}
className="w-fit"
disabled={disabled}
>
Add Stage
</Button>
</div>
</TooltipContent>
</Tooltip>
</div>
}
disabled={disabled}
update={config}
onConfirm={async () => {
await mutateAsync({ id: procedure._id!.$oid, config });
setConfig({});
}}
onReset={() => setConfig({})}
>
{stages.map((stage, index) => (
<Stage
stage={stage}
setStage={(stage) =>
setConfig((config) => ({
...config,
stages: stages.map((s, i) => (index === i ? stage : s)),
}))
}
removeStage={() =>
setConfig((config) => ({
...config,
stages: stages.filter((_, i) => index !== i),
}))
}
moveUp={
index === 0
? undefined
: () =>
setConfig((config) => ({
...config,
stages: stages.map((stage, i) => {
// Make sure its not the first row
if (i === index && index !== 0) {
return stages[index - 1];
} else if (i === index - 1) {
// Reverse the entry, moving this row "Up"
return stages[index];
} else {
return stage;
}
}),
}))
}
moveDown={
index === stages.length - 1
? undefined
: () =>
setConfig((config) => ({
...config,
stages: stages.map((stage, i) => {
// The index also cannot be the last index, which cannot be moved down
if (i === index && index !== stages.length - 1) {
return stages[index + 1];
} else if (i === index + 1) {
// Move the row "Down"
return stages[index];
} else {
return stage;
}
}),
}))
}
insertAbove={() =>
setConfig((config) => ({
...config,
stages: [
...stages.slice(0, index),
new_stage(),
...stages.slice(index),
],
}))
}
insertBelow={() =>
setConfig((config) => ({
...config,
stages: [
...stages.slice(0, index + 1),
new_stage(),
...stages.slice(index + 1),
],
}))
}
disabled={disabled}
/>
))}
<Button
variant="secondary"
onClick={add_stage}
className="w-fit"
disabled={disabled}
>
Add Stage
</Button>
</ConfigLayout>
<Section>
<Card>
<CardHeader>
<CardTitle>Webhook</CardTitle>
<CardDescription>
Copy the webhook given here, and configure your
{webhook_integration}-style repo provider to send webhooks to
Komodo
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<ConfigItem>
),
},
},
{
label: "Alert",
labelHidden: true,
components: {
failure_alert: {
boldLabel: true,
description: "Send an alert any time the Procedure fails",
},
},
},
{
label: "Schedule",
description:
"Configure the Procedure to run at defined times using English or CRON.",
components: {
schedule_enabled: (schedule_enabled, set) => (
<ConfigSwitch
label="Enabled"
value={
(update.schedule ?? config.schedule)
? schedule_enabled
: false
}
disabled={disabled || !(update.schedule ?? config.schedule)}
onChange={(schedule_enabled) => set({ schedule_enabled })}
/>
),
schedule_format: (schedule_format, set) => (
<ConfigItem
label="Format"
description="Choose whether to provide English or CRON schedule expression"
>
<Select
value={schedule_format}
onValueChange={(schedule_format) =>
set({
schedule_format:
schedule_format as Types.ScheduleFormat,
})
}
disabled={disabled}
>
<SelectTrigger className="w-[200px]" disabled={disabled}>
<SelectValue placeholder="Select Format" />
</SelectTrigger>
<SelectContent>
{Object.values(Types.ScheduleFormat).map((mode) => (
<SelectItem
key={mode}
value={mode!}
className="cursor-pointer"
>
{mode}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
),
schedule: {
label: "Expression",
description:
(update.schedule_format ?? config.schedule_format) ===
"Cron" ? (
<div className="pt-1 flex flex-col gap-1">
<code>
second - minute - hour - day - month - day-of-week
</code>
</div>
) : (
<div className="pt-1 flex flex-col gap-1">
<code>Examples:</code>
<code>- Run every day at 4:00 pm</code>
<code>
- Run at 21:00 on the 1st and 15th of the month
</code>
<code>- Every Sunday at midnight</code>
</div>
),
placeholder:
(update.schedule_format ?? config.schedule_format) === "Cron"
? "0 0 0 ? * SUN"
: "Enter English expression",
},
schedule_timezone: {
label: "Timezone",
description:
"Optional. Enter specific IANA timezone for schedule expression. If not provided, uses the Core timezone.",
placeholder: "Enter IANA timezone",
},
schedule_alert: {
description: "Send an alert when the scheduled run occurs",
},
},
},
{
label: "Webhook",
description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`,
components: {
["Builder" as any]: () => (
<WebhookBuilder git_provider={PROCEDURE_GIT_PROVIDER}>
<div className="text-nowrap text-muted-foreground text-sm">
Listen on branch:
@@ -259,40 +376,26 @@ const ProcedureConfigInner = ({
</div>
</div>
</WebhookBuilder>
</ConfigItem>
<ConfigItem label="Webhook Url - Run">
<CopyWebhook
integration={webhook_integration}
path={`/procedure/${id_or_name === "Id" ? procedure._id?.$oid! : procedure.name}/${branch}`}
/>
</ConfigItem>
<ConfigSwitch
label="Webhook Enabled"
value={
config.webhook_enabled ?? procedure.config?.webhook_enabled
}
disabled={disabled}
onChange={(webhook_enabled) =>
setConfig({ ...config, webhook_enabled })
}
/>
<ConfigInput
label="Custom Secret"
description="Provide a custom webhook secret for this resource, or use the global default."
placeholder="Input custom secret"
value={
config.webhook_secret ?? procedure.config?.webhook_secret
}
disabled={disabled}
onChange={(webhook_secret) =>
setConfig({ ...config, webhook_secret })
}
/>
</div>
</CardContent>
</Card>
</Section>
</div>
),
["run" as any]: () => (
<ConfigItem label="Webhook Url - Run">
<CopyWebhook
integration={webhook_integration}
path={`/procedure/${id_or_name === "Id" ? id : name}/${branch}`}
/>
</ConfigItem>
),
webhook_enabled: true,
webhook_secret: {
description:
"Provide a custom webhook secret for this resource, or use the global default.",
placeholder: "Input custom secret",
},
},
},
],
}}
/>
);
};
@@ -542,20 +645,6 @@ const Stage = ({
);
};
const new_stage = () => ({
name: "Stage",
enabled: true,
executions: [default_enabled_execution()],
});
const default_enabled_execution: () => Types.EnabledExecution = () => ({
enabled: true,
execution: {
type: "None",
params: {},
},
});
const ExecutionTypeSelector = ({
type,
onSelect,
@@ -612,43 +701,6 @@ const ExecutionTypeSelector = ({
);
};
type ExecutionType = Types.Execution["type"];
type ExecutionConfigComponent<
T extends ExecutionType,
P = Extract<Types.Execution, { type: T }>["params"],
> = React.FC<{
params: P;
setParams: React.Dispatch<React.SetStateAction<P>>;
disabled: boolean;
}>;
type MinExecutionType = Exclude<
ExecutionType,
| "StartContainer"
| "RestartContainer"
| "PauseContainer"
| "UnpauseContainer"
| "StopContainer"
| "DestroyContainer"
| "DeleteNetwork"
| "DeleteImage"
| "DeleteVolume"
| "TestAlerter"
>;
type ExecutionConfigParams<T extends MinExecutionType> = Extract<
Types.Execution,
{ type: T }
>["params"];
type ExecutionConfigs = {
[ExType in MinExecutionType]: {
Component: ExecutionConfigComponent<ExType>;
params: ExecutionConfigParams<ExType>;
};
};
const TARGET_COMPONENTS: ExecutionConfigs = {
None: {
params: {},

View File

@@ -5,7 +5,7 @@ import {
} from "@components/util";
import { useExecute, useRead } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Route } from "lucide-react";
import { Clock, Route } from "lucide-react";
import { ProcedureConfig } from "./config";
import { ProcedureTable } from "./table";
import { DeleteResource, NewResource } from "../common";
@@ -13,11 +13,13 @@ import {
procedure_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn } from "@lib/utils";
import { cn, updateLogToHtml } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@pages/home/dashboard";
import { RenameResource } from "@components/config/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { Card } from "@ui/card";
const useProcedure = (id?: string) =>
useRead("ListProcedures", {}).data?.find((d) => d.id === id);
@@ -64,7 +66,9 @@ export const ProcedureComponents: RequiredResourceComponents = {
New: () => <NewResource type="Procedure" />,
GroupActions: () => <GroupActions type="Procedure" actions={["RunProcedure"]} />,
GroupActions: () => (
<GroupActions type="Procedure" actions={["RunProcedure"]} />
),
Table: ({ resources }) => (
<ProcedureTable procedures={resources as Types.ProcedureListItem[]} />
@@ -83,7 +87,45 @@ export const ProcedureComponents: RequiredResourceComponents = {
Status: {},
Info: {
Stages: ({ id }) => <div>Stages: {useProcedure(id)?.info.stages}</div>,
Schedule: ({ id }) => {
const next_scheduled_run = useProcedure(id)?.info.next_scheduled_run;
return (
<div className="flex gap-2 items-center">
<Clock className="w-4 h-4" />
Next Run:
<div className="font-bold">
{next_scheduled_run
? new Date(next_scheduled_run).toLocaleString()
: "Not Scheduled"}
</div>
</div>
);
},
ScheduleErrors: ({ id }) => {
const error = useProcedure(id)?.info.schedule_error;
if (!error) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 bg-destructive/75 hover:bg-destructive transition-colors cursor-pointer">
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Schedule Error
</div>
</Card>
</TooltipTrigger>
<TooltipContent className="w-[400px]">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(error),
}}
className="max-h-[500px] overflow-y-auto"
/>
</TooltipContent>
</Tooltip>
);
},
},
Actions: {
@@ -129,7 +171,7 @@ export const ProcedureComponents: RequiredResourceComponents = {
icon={<ProcedureIcon id={id} size={8} />}
name={procedure?.name}
state={procedure?.info.state}
status={procedure?.info.stages.toString() + "Stages"}
status={`${procedure?.info.stages} Stage${procedure?.info.stages === 1 ? "" : "s"}`}
/>
);
},

View File

@@ -188,6 +188,11 @@ export const ResourceSyncConfig = ({
description:
"Enabled managed mode / the 'Commit' button. Commit is the 'reverse' of Execute, and will update the sync file with your configs updated in the UI.",
},
pending_alert: {
label: "Pending Alerts",
description:
"Send a message to your Alerters when the Sync has Pending Changes",
},
},
};

View File

@@ -36,6 +36,7 @@ import { UsableResource } from "@types";
import { UserAvatar } from "@components/util";
import { ResourceName } from "@components/resources/common";
import { useWebsocketMessages } from "@lib/socket";
import { MonacoDiffEditor } from "@components/monaco";
export const UpdateUser = ({
user_id,
@@ -203,6 +204,21 @@ const UpdateDetailsContent = ({
</SheetDescription>
</SheetHeader>
<div className="grid gap-2 max-h-[calc(85vh-110px)] overflow-y-auto">
{update.prev_toml && update.current_toml && (
<Card>
<CardHeader>
<CardTitle>Changes made</CardTitle>
</CardHeader>
<CardContent>
<MonacoDiffEditor
original={update.prev_toml}
modified={update.current_toml}
language="toml"
readOnly
/>
</CardContent>
</Card>
)}
{update.logs?.map((log, i) => (
<Card key={i}>
<CardHeader className="flex-col">

View File

@@ -16,19 +16,38 @@ pub fn init(config: &LogConfig) -> anyhow::Result<()> {
let use_otel = !config.otlp_endpoint.is_empty();
match (config.stdio, use_otel) {
(StdioLogMode::Standard, true) => {
match (config.stdio, use_otel, config.pretty) {
(StdioLogMode::Standard, true, true) => {
let tracer = otel::tracer(
&config.otlp_endpoint,
config.opentelemetry_service_name.clone(),
);
registry
.with(tracing_subscriber::fmt::layer())
.with(
tracing_subscriber::fmt::layer()
.pretty()
.with_file(false)
.with_line_number(false),
)
.with(OpenTelemetryLayer::new(tracer))
.try_init()
}
(StdioLogMode::Standard, true, false) => {
let tracer = otel::tracer(
&config.otlp_endpoint,
config.opentelemetry_service_name.clone(),
);
registry
.with(
tracing_subscriber::fmt::layer()
.with_file(false)
.with_line_number(false),
)
.with(OpenTelemetryLayer::new(tracer))
.try_init()
}
(StdioLogMode::Json, true) => {
(StdioLogMode::Json, true, _) => {
let tracer = otel::tracer(
&config.otlp_endpoint,
config.opentelemetry_service_name.clone(),
@@ -39,23 +58,34 @@ pub fn init(config: &LogConfig) -> anyhow::Result<()> {
.try_init()
}
(StdioLogMode::None, true) => {
(StdioLogMode::Standard, false, true) => registry
.with(
tracing_subscriber::fmt::layer()
.pretty()
.with_file(false)
.with_line_number(false),
)
.try_init(),
(StdioLogMode::Standard, false, false) => registry
.with(
tracing_subscriber::fmt::layer()
.with_file(false)
.with_line_number(false),
)
.try_init(),
(StdioLogMode::Json, false, _) => registry
.with(tracing_subscriber::fmt::layer().json())
.try_init(),
(StdioLogMode::None, true, _) => {
let tracer = otel::tracer(
&config.otlp_endpoint,
config.opentelemetry_service_name.clone(),
);
registry.with(OpenTelemetryLayer::new(tracer)).try_init()
}
(StdioLogMode::Standard, false) => {
registry.with(tracing_subscriber::fmt::layer()).try_init()
}
(StdioLogMode::Json, false) => registry
.with(tracing_subscriber::fmt::layer().json())
.try_init(),
(StdioLogMode::None, false) => Ok(()),
(StdioLogMode::None, false, _) => Ok(()),
}
.context("failed to init logger")
}