mirror of
https://github.com/moghtech/komodo.git
synced 2026-08-08 20:29:28 -05:00
Compare commits
48
Commits
v2.1.1
...
v1.19.1-dev-14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8410ae64 | ||
|
|
0a2ee6ea43 | ||
|
|
fafae5d9d1 | ||
|
|
009221e288 | ||
|
|
b8022f279f | ||
|
|
2ca87c51f5 | ||
|
|
12601d49f4 | ||
|
|
284452e674 | ||
|
|
15ed767f65 | ||
|
|
9ec9f6e4a8 | ||
|
|
ff7bd5c96e | ||
|
|
4573881c9f | ||
|
|
4623f3ead2 | ||
|
|
3c2bbb0b52 | ||
|
|
b0f89a1b84 | ||
|
|
88c0e7e2e7 | ||
|
|
70af4a0b86 | ||
|
|
530b066717 | ||
|
|
7fbcee5dd1 | ||
|
|
bfef4725be | ||
|
|
05a9750f79 | ||
|
|
b006bef72c | ||
|
|
af17636137 | ||
|
|
d0571dcce0 | ||
|
|
cc9a534a8a | ||
|
|
112ae60264 | ||
|
|
fbc8fb3a58 | ||
|
|
ba85a5526d | ||
|
|
c5bc94b4d8 | ||
|
|
17f0dd4209 | ||
|
|
8528e4c1f7 | ||
|
|
10f82cdf2f | ||
|
|
ae7fb7f87f | ||
|
|
78451fc3e7 | ||
|
|
8b06ffb140 | ||
|
|
dab9aae61b | ||
|
|
7407f62cc5 | ||
|
|
627c56dbda | ||
|
|
829e8f360b | ||
|
|
38857cbc0b | ||
|
|
5bbd5510a1 | ||
|
|
8b1a3230c3 | ||
|
|
4fe84c17fb | ||
|
|
420c9c0569 | ||
|
|
6ee7a29f51 | ||
|
|
b7dab131fa | ||
|
|
0c30229e3e | ||
|
|
bcbe75ca5d |
Generated
+241
-231
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -8,7 +8,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.19.0"
|
||||
version = "1.19.1-dev-14"
|
||||
edition = "2024"
|
||||
authors = ["mbecker20 <becker.maxh@gmail.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -47,7 +47,7 @@ mungos = "3.2.1"
|
||||
svi = "1.2.0"
|
||||
|
||||
# ASYNC
|
||||
reqwest = { version = "0.12.22", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
|
||||
reqwest = { version = "0.12.23", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
|
||||
tokio = { version = "1.47.1", features = ["full"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io", "codec"] }
|
||||
tokio-stream = { version = "0.1.17", features = ["sync"] }
|
||||
@@ -59,7 +59,7 @@ arc-swap = "1.7.1"
|
||||
# SERVER
|
||||
tokio-tungstenite = { version = "0.27.0", features = ["rustls-tls-native-roots"] }
|
||||
axum-extra = { version = "0.10.1", features = ["typed-header"] }
|
||||
tower-http = { version = "0.6.4", features = ["fs", "cors"] }
|
||||
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
|
||||
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
|
||||
axum = { version = "0.8.4", features = ["ws", "json", "macros"] }
|
||||
|
||||
@@ -69,13 +69,13 @@ indexmap = { version = "2.10.0", features = ["serde"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
serde_yaml_ng = "0.10.0"
|
||||
serde_json = "1.0.142"
|
||||
serde_json = "1.0.143"
|
||||
serde_qs = "0.15.0"
|
||||
toml = "0.9.5"
|
||||
|
||||
# ERROR
|
||||
anyhow = "1.0.99"
|
||||
thiserror = "2.0.14"
|
||||
thiserror = "2.0.16"
|
||||
|
||||
# LOGGING
|
||||
opentelemetry-otlp = { version = "0.30.0", features = ["tls-roots", "reqwest-rustls"] }
|
||||
@@ -87,17 +87,17 @@ opentelemetry = "0.30.0"
|
||||
tracing = "0.1.41"
|
||||
|
||||
# CONFIG
|
||||
clap = { version = "4.5.43", features = ["derive"] }
|
||||
clap = { version = "4.5.45", features = ["derive"] }
|
||||
dotenvy = "0.15.7"
|
||||
envy = "0.4.2"
|
||||
|
||||
# CRYPTO / AUTH
|
||||
uuid = { version = "1.17.0", features = ["v4", "fast-rng", "serde"] }
|
||||
uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
|
||||
jsonwebtoken = { version = "9.3.1", default-features = false }
|
||||
openidconnect = "4.0.1"
|
||||
urlencoding = "2.1.3"
|
||||
nom_pem = "4.0.0"
|
||||
bcrypt = "0.17.0"
|
||||
bcrypt = "0.17.1"
|
||||
base64 = "0.22.1"
|
||||
rustls = "0.23.31"
|
||||
hmac = "0.12.1"
|
||||
@@ -112,7 +112,7 @@ sysinfo = "0.37.0"
|
||||
|
||||
# CLOUD
|
||||
aws-config = "1.8.5"
|
||||
aws-sdk-ec2 = "1.159.0"
|
||||
aws-sdk-ec2 = "1.160.0"
|
||||
aws-credential-types = "1.2.5"
|
||||
|
||||
## CRON
|
||||
@@ -133,3 +133,4 @@ colored = "3.0.0"
|
||||
regex = "1.11.1"
|
||||
bytes = "1.10.1"
|
||||
bson = "2.15.0"
|
||||
shell-escape = "0.1.5"
|
||||
@@ -292,7 +292,7 @@ impl PrintTable for (Option<&'_ str>, ContainerListItem) {
|
||||
Cell::new(self.0.unwrap_or("Unknown")),
|
||||
ports,
|
||||
Cell::new(networks.join(", ")),
|
||||
Cell::new(clamp_sha(&image)),
|
||||
Cell::new(clamp_sha(image)),
|
||||
];
|
||||
if !links {
|
||||
return res;
|
||||
|
||||
@@ -212,9 +212,15 @@ pub async fn handle(
|
||||
Execution::BatchDestroyStack(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::RunStackService(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::TestAlerter(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::SendAlert(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
Execution::ClearRepoCache(data) => {
|
||||
println!("{}: {data:?}", "Data".dimmed())
|
||||
}
|
||||
@@ -464,10 +470,18 @@ pub async fn handle(
|
||||
Execution::BatchDestroyStack(request) => {
|
||||
client.execute(request).await.map(ExecutionResult::Batch)
|
||||
}
|
||||
Execution::RunStackService(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::TestAlerter(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::SendAlert(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
.map(|u| ExecutionResult::Single(u.into())),
|
||||
Execution::ClearRepoCache(request) => client
|
||||
.execute(request)
|
||||
.await
|
||||
|
||||
@@ -233,7 +233,7 @@ async fn list_schedules(
|
||||
}
|
||||
|
||||
fn fix_tags<T>(
|
||||
resources: &mut Vec<ResourceListItem<T>>,
|
||||
resources: &mut [ResourceListItem<T>],
|
||||
tags: &HashMap<String, String>,
|
||||
) {
|
||||
resources.iter_mut().for_each(|resource| {
|
||||
|
||||
@@ -111,7 +111,7 @@ fn print_items<T: PrintTable + Serialize>(
|
||||
};
|
||||
table.load_preset(preset).set_header(
|
||||
T::header(links)
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|h| Cell::new(h).add_attribute(Attribute::Bold)),
|
||||
);
|
||||
for item in items {
|
||||
|
||||
@@ -17,6 +17,28 @@ pub async fn send_alert(
|
||||
"{level} | If you see this message, then Alerter **{name}** is **working**\n{link}"
|
||||
)
|
||||
}
|
||||
AlertData::ServerVersionMismatch {
|
||||
id,
|
||||
name,
|
||||
region,
|
||||
server_version,
|
||||
core_version,
|
||||
} => {
|
||||
let region = fmt_region(region);
|
||||
let link = resource_link(ResourceTargetVariant::Server, id);
|
||||
match alert.level {
|
||||
SeverityLevel::Ok => {
|
||||
format!(
|
||||
"{level} | **{name}** ({region}) | Server version now matches core version ✅\n{link}"
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
format!(
|
||||
"{level} | **{name}** ({region}) | Version mismatch detected ⚠️\nServer: **{server_version}** | Core: **{core_version}**\n{link}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertData::ServerUnreachable {
|
||||
id,
|
||||
name,
|
||||
@@ -207,6 +229,16 @@ pub async fn send_alert(
|
||||
"{level} | **{name}** ({resource_type}) | Scheduled run started 🕝\n{link}"
|
||||
)
|
||||
}
|
||||
AlertData::Custom { message, details } => {
|
||||
format!(
|
||||
"{level} | {message}{}",
|
||||
if details.is_empty() {
|
||||
format_args!("")
|
||||
} else {
|
||||
format_args!("\n{details}")
|
||||
}
|
||||
)
|
||||
}
|
||||
AlertData::None {} => Default::default(),
|
||||
};
|
||||
if !content.is_empty() {
|
||||
|
||||
+239
-3
@@ -48,8 +48,9 @@ pub async fn send_alerts(alerts: &[Alert]) {
|
||||
return;
|
||||
};
|
||||
|
||||
let handles =
|
||||
alerts.iter().map(|alert| send_alert(&alerters, alert));
|
||||
let handles = alerts
|
||||
.iter()
|
||||
.map(|alert| send_alert_to_alerters(&alerters, alert));
|
||||
|
||||
join_all(handles).await;
|
||||
}
|
||||
@@ -58,7 +59,7 @@ pub async fn send_alerts(alerts: &[Alert]) {
|
||||
}
|
||||
|
||||
#[instrument(level = "debug")]
|
||||
async fn send_alert(alerters: &[Alerter], alert: &Alert) {
|
||||
async fn send_alert_to_alerters(alerters: &[Alerter], alert: &Alert) {
|
||||
if alerters.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -250,3 +251,238 @@ fn resource_link(
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Standard message content format
|
||||
/// used by Ntfy, Pushover.
|
||||
fn standard_alert_content(alert: &Alert) -> String {
|
||||
let level = fmt_level(alert.level);
|
||||
match &alert.data {
|
||||
AlertData::Test { id, name } => {
|
||||
let link = resource_link(ResourceTargetVariant::Alerter, id);
|
||||
format!(
|
||||
"{level} | If you see this message, then Alerter {name} is working\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::ServerVersionMismatch {
|
||||
id,
|
||||
name,
|
||||
region,
|
||||
server_version,
|
||||
core_version,
|
||||
} => {
|
||||
let region = fmt_region(region);
|
||||
let link = resource_link(ResourceTargetVariant::Server, id);
|
||||
match alert.level {
|
||||
SeverityLevel::Ok => {
|
||||
format!(
|
||||
"{level} | {name} ({region}) | Server version now matches core version ✅\n{link}"
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
format!(
|
||||
"{level} | {name} ({region}) | Version mismatch detected ⚠️\nServer: {server_version} | Core: {core_version}\n{link}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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} | {name}{region} is now reachable\n{link}")
|
||||
}
|
||||
SeverityLevel::Critical => {
|
||||
let err = err
|
||||
.as_ref()
|
||||
.map(|e| format!("\nerror: {e:#?}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{level} | {name}{region} is unreachable ❌\n{link}{err}"
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
AlertData::ServerCpu {
|
||||
id,
|
||||
name,
|
||||
region,
|
||||
percentage,
|
||||
} => {
|
||||
let region = fmt_region(region);
|
||||
let link = resource_link(ResourceTargetVariant::Server, id);
|
||||
format!(
|
||||
"{level} | {name}{region} cpu usage at {percentage:.1}%\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} memory usage at {percentage:.1}%💾\n\nUsing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} disk usage at {percentage:.1}%💿\nmount point: {path:?}\nusing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::DeploymentImageUpdateAvailable {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Deployment, id);
|
||||
format!(
|
||||
"⬆ Deployment {name} has an update available\nserver: {server_name}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::DeploymentAutoUpdated {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Deployment, id);
|
||||
format!(
|
||||
"⬆ Deployment {name} was updated automatically\nserver: {server_name}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::StackImageUpdateAvailable {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
service,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Stack, id);
|
||||
format!(
|
||||
"⬆ Stack {name} has an update available\nserver: {server_name}\nservice: {service}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} was updated automatically ⏫\nserver: {server_name}\n{images_label}: {images_str}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::AwsBuilderTerminationFailed {
|
||||
instance_id,
|
||||
message,
|
||||
} => {
|
||||
format!(
|
||||
"{level} | Failed to terminate AWS builder instance\ninstance id: {instance_id}\n{message}",
|
||||
)
|
||||
}
|
||||
AlertData::ResourceSyncPendingUpdates { id, name } => {
|
||||
let link =
|
||||
resource_link(ResourceTargetVariant::ResourceSync, id);
|
||||
format!(
|
||||
"{level} | Pending resource sync updates on {name}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {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::Custom { message, details } => {
|
||||
format!(
|
||||
"{level} | {message}{}",
|
||||
if details.is_empty() {
|
||||
format_args!("")
|
||||
} else {
|
||||
format_args!("\n{details}")
|
||||
}
|
||||
)
|
||||
}
|
||||
AlertData::None {} => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-199
@@ -8,205 +8,7 @@ pub async fn send_alert(
|
||||
email: Option<&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 {name} is working\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} is now reachable\n{link}")
|
||||
}
|
||||
SeverityLevel::Critical => {
|
||||
let err = err
|
||||
.as_ref()
|
||||
.map(|e| format!("\nerror: {e:#?}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{level} | {name}{region} is unreachable ❌\n{link}{err}"
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
AlertData::ServerCpu {
|
||||
id,
|
||||
name,
|
||||
region,
|
||||
percentage,
|
||||
} => {
|
||||
let region = fmt_region(region);
|
||||
let link = resource_link(ResourceTargetVariant::Server, id);
|
||||
format!(
|
||||
"{level} | {name}{region} cpu usage at {percentage:.1}%\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} memory usage at {percentage:.1}%💾\n\nUsing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} disk usage at {percentage:.1}%💿\nmount point: {path:?}\nusing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::DeploymentImageUpdateAvailable {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Deployment, id);
|
||||
format!(
|
||||
"⬆ Deployment {name} has an update available\nserver: {server_name}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::DeploymentAutoUpdated {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Deployment, id);
|
||||
format!(
|
||||
"⬆ Deployment {name} was updated automatically\nserver: {server_name}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::StackImageUpdateAvailable {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
service,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Stack, id);
|
||||
format!(
|
||||
"⬆ Stack {name} has an update available\nserver: {server_name}\nservice: {service}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} was updated automatically ⏫\nserver: {server_name}\n{images_label}: {images_str}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::AwsBuilderTerminationFailed {
|
||||
instance_id,
|
||||
message,
|
||||
} => {
|
||||
format!(
|
||||
"{level} | Failed to terminate AWS builder instance\ninstance id: {instance_id}\n{message}",
|
||||
)
|
||||
}
|
||||
AlertData::ResourceSyncPendingUpdates { id, name } => {
|
||||
let link =
|
||||
resource_link(ResourceTargetVariant::ResourceSync, id);
|
||||
format!(
|
||||
"{level} | Pending resource sync updates on {name}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {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(),
|
||||
};
|
||||
|
||||
let content = standard_alert_content(alert);
|
||||
if !content.is_empty() {
|
||||
send_message(url, email, content).await?;
|
||||
}
|
||||
|
||||
@@ -7,205 +7,7 @@ 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 {name} is working\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} is now reachable\n{link}")
|
||||
}
|
||||
SeverityLevel::Critical => {
|
||||
let err = err
|
||||
.as_ref()
|
||||
.map(|e| format!("\nerror: {e:#?}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{level} | {name}{region} is unreachable ❌\n{link}{err}"
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
AlertData::ServerCpu {
|
||||
id,
|
||||
name,
|
||||
region,
|
||||
percentage,
|
||||
} => {
|
||||
let region = fmt_region(region);
|
||||
let link = resource_link(ResourceTargetVariant::Server, id);
|
||||
format!(
|
||||
"{level} | {name}{region} cpu usage at {percentage:.1}%\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} memory usage at {percentage:.1}%💾\n\nUsing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
|
||||
)
|
||||
}
|
||||
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} | {name}{region} disk usage at {percentage:.1}%💿\nmount point: {path:?}\nusing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::DeploymentImageUpdateAvailable {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Deployment, id);
|
||||
format!(
|
||||
"⬆ Deployment {name} has an update available\nserver: {server_name}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::DeploymentAutoUpdated {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Deployment, id);
|
||||
format!(
|
||||
"⬆ Deployment {name} was updated automatically\nserver: {server_name}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::StackImageUpdateAvailable {
|
||||
id,
|
||||
name,
|
||||
server_id: _server_id,
|
||||
server_name,
|
||||
service,
|
||||
image,
|
||||
} => {
|
||||
let link = resource_link(ResourceTargetVariant::Stack, id);
|
||||
format!(
|
||||
"⬆ Stack {name} has an update available\nserver: {server_name}\nservice: {service}\nimage: {image}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {name} was updated automatically ⏫\nserver: {server_name}\n{images_label}: {images_str}\n{link}",
|
||||
)
|
||||
}
|
||||
AlertData::AwsBuilderTerminationFailed {
|
||||
instance_id,
|
||||
message,
|
||||
} => {
|
||||
format!(
|
||||
"{level} | Failed to terminate AWS builder instance\ninstance id: {instance_id}\n{message}",
|
||||
)
|
||||
}
|
||||
AlertData::ResourceSyncPendingUpdates { id, name } => {
|
||||
let link =
|
||||
resource_link(ResourceTargetVariant::ResourceSync, id);
|
||||
format!(
|
||||
"{level} | Pending resource sync updates on {name}\n{link}",
|
||||
)
|
||||
}
|
||||
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 {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(),
|
||||
};
|
||||
|
||||
let content = standard_alert_content(alert);
|
||||
if !content.is_empty() {
|
||||
send_message(url, content).await?;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,35 @@ pub async fn send_alert(
|
||||
];
|
||||
(text, blocks.into())
|
||||
}
|
||||
AlertData::ServerVersionMismatch {
|
||||
id,
|
||||
name,
|
||||
region,
|
||||
server_version,
|
||||
core_version,
|
||||
} => {
|
||||
let region = fmt_region(region);
|
||||
let text = match alert.level {
|
||||
SeverityLevel::Ok => {
|
||||
format!(
|
||||
"{level} | {name} ({region}) | Server version now matches core version ✅"
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
format!(
|
||||
"{level} | {name} ({region}) | Version mismatch detected ⚠️\nServer: {server_version} | Core: {core_version}"
|
||||
)
|
||||
}
|
||||
};
|
||||
let blocks = vec![
|
||||
Block::header(text.clone()),
|
||||
Block::section(resource_link(
|
||||
ResourceTargetVariant::Server,
|
||||
id,
|
||||
)),
|
||||
];
|
||||
(text, blocks.into())
|
||||
}
|
||||
AlertData::ServerUnreachable {
|
||||
id,
|
||||
name,
|
||||
@@ -429,6 +458,12 @@ pub async fn send_alert(
|
||||
];
|
||||
(text, blocks.into())
|
||||
}
|
||||
AlertData::Custom { message, details } => {
|
||||
let text = format!("{level} | {message}");
|
||||
let blocks =
|
||||
vec![Block::header(text.clone()), Block::section(details)];
|
||||
(text, blocks.into())
|
||||
}
|
||||
AlertData::None {} => Default::default(),
|
||||
};
|
||||
if !text.is_empty() {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use formatting::format_serror;
|
||||
use futures::{TryStreamExt, stream::FuturesUnordered};
|
||||
use komodo_client::{
|
||||
api::execute::TestAlerter,
|
||||
api::execute::{SendAlert, TestAlerter},
|
||||
entities::{
|
||||
alert::{Alert, AlertData, SeverityLevel},
|
||||
alert::{Alert, AlertData, AlertDataVariant, SeverityLevel},
|
||||
alerter::Alerter,
|
||||
komodo_timestamp,
|
||||
permission::PermissionLevel,
|
||||
},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use serror::AddStatusCodeError;
|
||||
|
||||
use crate::{
|
||||
alert::send_alert_to_alerter, helpers::update::update_update,
|
||||
permission::get_check_permissions,
|
||||
permission::get_check_permissions, resource::list_full_for_user,
|
||||
};
|
||||
|
||||
use super::ExecuteArgs;
|
||||
@@ -71,3 +75,75 @@ impl Resolve<ExecuteArgs> for TestAlerter {
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<ExecuteArgs> for SendAlert {
|
||||
#[instrument(name = "SendAlert", skip(user, update), fields(user_id = user.id, update_id = update.id))]
|
||||
async fn resolve(
|
||||
self,
|
||||
ExecuteArgs { user, update }: &ExecuteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let alerters = list_full_for_user::<Alerter>(
|
||||
Default::default(),
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
&[],
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
a.config.enabled
|
||||
&& (self.alerters.is_empty()
|
||||
|| self.alerters.contains(&a.name)
|
||||
|| self.alerters.contains(&a.id))
|
||||
&& (a.config.alert_types.is_empty()
|
||||
|| a.config.alert_types.contains(&AlertDataVariant::Custom))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if alerters.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Could not find any valid alerters to send to, this required Execute permissions on the Alerter"
|
||||
).status_code(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
let mut update = update.clone();
|
||||
|
||||
let ts = komodo_timestamp();
|
||||
|
||||
let alert = Alert {
|
||||
id: Default::default(),
|
||||
ts,
|
||||
resolved: true,
|
||||
level: self.level,
|
||||
target: update.target.clone(),
|
||||
data: AlertData::Custom {
|
||||
message: self.message,
|
||||
details: self.details,
|
||||
},
|
||||
resolved_ts: Some(ts),
|
||||
};
|
||||
|
||||
update.push_simple_log(
|
||||
"Send alert",
|
||||
serde_json::to_string_pretty(&alert)
|
||||
.context("Failed to serialize alert to JSON")?,
|
||||
);
|
||||
|
||||
if let Err(e) = alerters
|
||||
.iter()
|
||||
.map(|alerter| send_alert_to_alerter(alerter, &alert))
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
{
|
||||
update.push_error_log("Send Error", format_serror(&e.into()));
|
||||
};
|
||||
|
||||
update.finalize();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use std::{future::IntoFuture, time::Duration};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
future::IntoFuture,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use database::mungos::{
|
||||
@@ -20,7 +24,7 @@ use komodo_client::{
|
||||
entities::{
|
||||
alert::{Alert, AlertData, SeverityLevel},
|
||||
all_logs_success,
|
||||
build::{Build, BuildConfig, ImageRegistryConfig},
|
||||
build::{Build, BuildConfig},
|
||||
builder::{Builder, BuilderConfig},
|
||||
deployment::DeploymentState,
|
||||
komodo_timestamp,
|
||||
@@ -133,8 +137,8 @@ impl Resolve<ExecuteArgs> for RunBuild {
|
||||
let git_token =
|
||||
build_git_token(&mut build, repo.as_mut()).await?;
|
||||
|
||||
let registry_token =
|
||||
validate_account_extract_registry_token(&build).await?;
|
||||
let registry_tokens =
|
||||
validate_account_extract_registry_tokens(&build).await?;
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
let cancel_clone = cancel.clone();
|
||||
@@ -284,7 +288,7 @@ impl Resolve<ExecuteArgs> for RunBuild {
|
||||
.request(api::build::Build {
|
||||
build: build.clone(),
|
||||
repo,
|
||||
registry_token,
|
||||
registry_tokens,
|
||||
replacers: secret_replacers.into_iter().collect(),
|
||||
// Push a commit hash tagged image
|
||||
additional_tags: if update.commit_hash.is_empty() {
|
||||
@@ -608,34 +612,48 @@ async fn handle_post_build_redeploy(build_id: &str) {
|
||||
/// This will make sure that a build with non-none image registry has an account attached,
|
||||
/// and will check the core config for a token matching requirements.
|
||||
/// Otherwise it is left to periphery.
|
||||
async fn validate_account_extract_registry_token(
|
||||
async fn validate_account_extract_registry_tokens(
|
||||
Build {
|
||||
config:
|
||||
BuildConfig {
|
||||
image_registry:
|
||||
ImageRegistryConfig {
|
||||
domain, account, ..
|
||||
},
|
||||
..
|
||||
},
|
||||
config: BuildConfig { image_registry, .. },
|
||||
..
|
||||
}: &Build,
|
||||
) -> serror::Result<Option<String>> {
|
||||
if domain.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if account.is_empty() {
|
||||
return Err(
|
||||
anyhow!(
|
||||
"Must attach account to use registry provider {domain}"
|
||||
)
|
||||
.into(),
|
||||
// Maps (domain, account) -> token
|
||||
) -> serror::Result<Vec<(String, String, String)>> {
|
||||
let mut res = HashMap::with_capacity(image_registry.capacity());
|
||||
|
||||
for (domain, account) in image_registry
|
||||
.iter()
|
||||
.map(|r| (r.domain.as_str(), r.account.as_str()))
|
||||
// This ensures uniqueness / prevents redundant logins
|
||||
.collect::<HashSet<_>>()
|
||||
{
|
||||
if domain.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if account.is_empty() {
|
||||
return Err(
|
||||
anyhow!(
|
||||
"Must attach account to use registry provider {domain}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let Some(registry_token) = registry_token(domain, account).await.with_context(
|
||||
|| format!("Failed to get registry token in call to db. Stopping run. | {domain} | {account}"),
|
||||
)? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
res.insert(
|
||||
(domain.to_string(), account.to_string()),
|
||||
registry_token,
|
||||
);
|
||||
}
|
||||
|
||||
let registry_token = registry_token(domain, account).await.with_context(
|
||||
|| format!("Failed to get registry token in call to db. Stopping run. | {domain} | {account}"),
|
||||
)?;
|
||||
|
||||
Ok(registry_token)
|
||||
Ok(
|
||||
res
|
||||
.into_iter()
|
||||
.map(|((domain, account), token)| (domain, account, token))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use komodo_client::{
|
||||
deployment::{
|
||||
Deployment, DeploymentImage, extract_registry_domain,
|
||||
},
|
||||
get_image_name, komodo_timestamp, optional_string,
|
||||
get_image_names, komodo_timestamp, optional_string,
|
||||
permission::PermissionLevel,
|
||||
server::Server,
|
||||
update::{Log, Update},
|
||||
@@ -115,8 +115,11 @@ impl Resolve<ExecuteArgs> for Deploy {
|
||||
let (version, registry_token) = match &deployment.config.image {
|
||||
DeploymentImage::Build { build_id, version } => {
|
||||
let build = resource::get::<Build>(build_id).await?;
|
||||
let image_name = get_image_name(&build)
|
||||
.context("failed to create image name")?;
|
||||
let image_names = get_image_names(&build);
|
||||
let image_name = image_names
|
||||
.first()
|
||||
.context("No image name could be created")
|
||||
.context("Failed to create image name")?;
|
||||
let version = if version.is_none() {
|
||||
build.config.version
|
||||
} else {
|
||||
@@ -133,21 +136,27 @@ impl Resolve<ExecuteArgs> for Deploy {
|
||||
deployment.config.image = DeploymentImage::Image {
|
||||
image: format!("{image_name}:{version_str}"),
|
||||
};
|
||||
if build.config.image_registry.domain.is_empty() {
|
||||
let first_registry = build
|
||||
.config
|
||||
.image_registry
|
||||
.first()
|
||||
.unwrap_or(ImageRegistryConfig::static_default());
|
||||
if first_registry.domain.is_empty() {
|
||||
(version, None)
|
||||
} else {
|
||||
let ImageRegistryConfig {
|
||||
domain, account, ..
|
||||
} = build.config.image_registry;
|
||||
} = first_registry;
|
||||
if deployment.config.image_registry_account.is_empty() {
|
||||
deployment.config.image_registry_account = account
|
||||
deployment.config.image_registry_account =
|
||||
account.to_string();
|
||||
}
|
||||
let token = if !deployment
|
||||
.config
|
||||
.image_registry_account
|
||||
.is_empty()
|
||||
{
|
||||
registry_token(&domain, &deployment.config.image_registry_account).await.with_context(
|
||||
registry_token(domain, &deployment.config.image_registry_account).await.with_context(
|
||||
|| format!("Failed to get git token in call to db. Stopping run. | {domain} | {}", deployment.config.image_registry_account),
|
||||
)?
|
||||
} else {
|
||||
@@ -240,8 +249,11 @@ pub async fn pull_deployment_inner(
|
||||
let (image, account, token) = match deployment.config.image {
|
||||
DeploymentImage::Build { build_id, version } => {
|
||||
let build = resource::get::<Build>(&build_id).await?;
|
||||
let image_name = get_image_name(&build)
|
||||
.context("failed to create image name")?;
|
||||
let image_names = get_image_names(&build);
|
||||
let image_name = image_names
|
||||
.first()
|
||||
.context("No image name could be created")
|
||||
.context("Failed to create image name")?;
|
||||
let version = if version.is_none() {
|
||||
build.config.version.to_string()
|
||||
} else {
|
||||
@@ -255,26 +267,31 @@ pub async fn pull_deployment_inner(
|
||||
};
|
||||
// replace image with corresponding build image.
|
||||
let image = format!("{image_name}:{version}");
|
||||
if build.config.image_registry.domain.is_empty() {
|
||||
let first_registry = build
|
||||
.config
|
||||
.image_registry
|
||||
.first()
|
||||
.unwrap_or(ImageRegistryConfig::static_default());
|
||||
if first_registry.domain.is_empty() {
|
||||
(image, None, None)
|
||||
} else {
|
||||
let ImageRegistryConfig {
|
||||
domain, account, ..
|
||||
} = build.config.image_registry;
|
||||
} = first_registry;
|
||||
let account =
|
||||
if deployment.config.image_registry_account.is_empty() {
|
||||
account
|
||||
} else {
|
||||
deployment.config.image_registry_account
|
||||
&deployment.config.image_registry_account
|
||||
};
|
||||
let token = if !account.is_empty() {
|
||||
registry_token(&domain, &account).await.with_context(
|
||||
registry_token(domain, account).await.with_context(
|
||||
|| format!("Failed to get git token in call to db. Stopping run. | {domain} | {account}"),
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(image, optional_string(&account), token)
|
||||
(image, optional_string(account), token)
|
||||
}
|
||||
}
|
||||
DeploymentImage::Image { image } => {
|
||||
|
||||
@@ -102,6 +102,7 @@ pub enum ExecuteRequest {
|
||||
UnpauseStack(UnpauseStack),
|
||||
DestroyStack(DestroyStack),
|
||||
BatchDestroyStack(BatchDestroyStack),
|
||||
RunStackService(RunStackService),
|
||||
|
||||
// ==== DEPLOYMENT ====
|
||||
Deploy(Deploy),
|
||||
@@ -139,6 +140,7 @@ pub enum ExecuteRequest {
|
||||
|
||||
// ==== ALERTER ====
|
||||
TestAlerter(TestAlerter),
|
||||
SendAlert(SendAlert),
|
||||
|
||||
// ==== SYNC ====
|
||||
RunSync(RunSync),
|
||||
@@ -219,24 +221,33 @@ pub fn inner_handler(
|
||||
));
|
||||
}
|
||||
|
||||
// Spawn a task for the execution which continues
|
||||
// running after this method returns.
|
||||
let handle =
|
||||
tokio::spawn(task(req_id, request, user, update.clone()));
|
||||
|
||||
// Spawns another task to monitor the first for failures,
|
||||
// and add the log to Update about it (which primary task can't do because it errored out)
|
||||
tokio::spawn({
|
||||
let update_id = update.id.clone();
|
||||
async move {
|
||||
let log = match handle.await {
|
||||
Ok(Err(e)) => {
|
||||
warn!("/execute request {req_id} task error: {e:#}",);
|
||||
Log::error("task error", format_serror(&e.into()))
|
||||
Log::error("Task Error", format_serror(&e.into()))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("/execute request {req_id} spawn error: {e:?}",);
|
||||
Log::error("spawn error", format!("{e:#?}"))
|
||||
Log::error("Spawn Error", format!("{e:#?}"))
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let res = async {
|
||||
// Nothing to do if update was never actually created,
|
||||
// which is the case when the id is empty.
|
||||
if update_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut update =
|
||||
find_one_by_id(&db_client().updates, &update_id)
|
||||
.await
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Context;
|
||||
use database::mungos::mongodb::bson::{doc, to_document};
|
||||
use formatting::format_serror;
|
||||
@@ -5,11 +7,15 @@ use interpolate::Interpolator;
|
||||
use komodo_client::{
|
||||
api::{execute::*, write::RefreshStackCache},
|
||||
entities::{
|
||||
FileContents,
|
||||
permission::PermissionLevel,
|
||||
repo::Repo,
|
||||
server::Server,
|
||||
stack::{Stack, StackInfo},
|
||||
stack::{
|
||||
Stack, StackFileRequires, StackInfo, StackRemoteFileContents,
|
||||
},
|
||||
update::{Log, Update},
|
||||
user::User,
|
||||
},
|
||||
};
|
||||
use periphery_client::api::compose::*;
|
||||
@@ -21,7 +27,9 @@ use crate::{
|
||||
periphery_client,
|
||||
query::{VariablesAndSecrets, get_variables_and_secrets},
|
||||
stack_git_token,
|
||||
update::{add_update_without_send, update_update},
|
||||
update::{
|
||||
add_update_without_send, init_execution_update, update_update,
|
||||
},
|
||||
},
|
||||
monitor::update_cache_for_server,
|
||||
permission::get_check_permissions,
|
||||
@@ -179,7 +187,15 @@ impl Resolve<ExecuteArgs> for DeployStack {
|
||||
) = if deployed {
|
||||
(
|
||||
Some(latest_services.clone()),
|
||||
Some(file_contents.clone()),
|
||||
Some(
|
||||
file_contents
|
||||
.iter()
|
||||
.map(|f| FileContents {
|
||||
path: f.path.clone(),
|
||||
contents: f.contents.clone(),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
compose_config,
|
||||
commit_hash.clone(),
|
||||
commit_message.clone(),
|
||||
@@ -289,62 +305,279 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
RefreshStackCache {
|
||||
stack: stack.id.clone(),
|
||||
}
|
||||
.resolve(&WriteArgs { user: user.clone() })
|
||||
.await?;
|
||||
|
||||
let stack = resource::get::<Stack>(&stack.id).await?;
|
||||
let changed = match (
|
||||
|
||||
let action = match (
|
||||
&stack.info.deployed_contents,
|
||||
&stack.info.remote_contents,
|
||||
) {
|
||||
(Some(deployed_contents), Some(latest_contents)) => {
|
||||
let changed = || {
|
||||
for latest in latest_contents {
|
||||
let Some(deployed) = deployed_contents
|
||||
.iter()
|
||||
.find(|c| c.path == latest.path)
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
if latest.contents != deployed.contents {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
changed()
|
||||
let services = stack
|
||||
.info
|
||||
.latest_services
|
||||
.iter()
|
||||
.map(|s| s.service_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
resolve_deploy_if_changed_action(
|
||||
deployed_contents,
|
||||
latest_contents,
|
||||
&services,
|
||||
)
|
||||
}
|
||||
(None, _) => true,
|
||||
_ => false,
|
||||
(None, _) => DeployIfChangedAction::FullDeploy,
|
||||
_ => DeployIfChangedAction::Services {
|
||||
deploy: Vec::new(),
|
||||
restart: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let mut update = update.clone();
|
||||
|
||||
if !changed {
|
||||
update.push_simple_log(
|
||||
"Diff compose files",
|
||||
String::from("Deploy cancelled after no changes detected."),
|
||||
);
|
||||
update.finalize();
|
||||
return Ok(update);
|
||||
}
|
||||
match action {
|
||||
// Existing path pre 1.19.1
|
||||
DeployIfChangedAction::FullDeploy => {
|
||||
// Don't actually send it here, let the handler send it after it can set action state.
|
||||
// This is usually done in crate::helpers::update::init_execution_update.
|
||||
update.id = add_update_without_send(&update).await?;
|
||||
|
||||
// Don't actually send it here, let the handler send it after it can set action state.
|
||||
// This is usually done in crate::helpers::update::init_execution_update.
|
||||
update.id = add_update_without_send(&update).await?;
|
||||
DeployStack {
|
||||
stack: stack.name,
|
||||
services: Vec::new(),
|
||||
stop_time: self.stop_time,
|
||||
}
|
||||
.resolve(&ExecuteArgs {
|
||||
user: user.clone(),
|
||||
update,
|
||||
})
|
||||
.await
|
||||
}
|
||||
DeployIfChangedAction::FullRestart => {
|
||||
// For git repo based stacks, need to do a
|
||||
// PullStack in order to ensure latest repo contents on the
|
||||
// host before restart.
|
||||
maybe_pull_stack(&stack, Some(&mut update)).await?;
|
||||
|
||||
DeployStack {
|
||||
stack: stack.name,
|
||||
services: Vec::new(),
|
||||
stop_time: self.stop_time,
|
||||
// The existing update is initialized to DeployStack,
|
||||
// but also has not been created on database.
|
||||
// Setup a new update here.
|
||||
let req = ExecuteRequest::RestartStack(RestartStack {
|
||||
stack: stack.name,
|
||||
services: Vec::new(),
|
||||
});
|
||||
let update = init_execution_update(&req, user).await?;
|
||||
let ExecuteRequest::RestartStack(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
req
|
||||
.resolve(&ExecuteArgs {
|
||||
user: user.clone(),
|
||||
update,
|
||||
})
|
||||
.await
|
||||
}
|
||||
DeployIfChangedAction::Services { deploy, restart } => {
|
||||
match (deploy.is_empty(), restart.is_empty()) {
|
||||
// Both empty, nothing to do
|
||||
(true, true) => {
|
||||
update.push_simple_log(
|
||||
"Diff compose files",
|
||||
String::from(
|
||||
"Deploy cancelled after no changes detected.",
|
||||
),
|
||||
);
|
||||
update.finalize();
|
||||
Ok(update)
|
||||
}
|
||||
// Only restart
|
||||
(true, false) => {
|
||||
// For git repo based stacks, need to do a
|
||||
// PullStack in order to ensure latest repo contents on the
|
||||
// host before restart. Only necessary if no "deploys" (deploy already pulls stack).
|
||||
maybe_pull_stack(&stack, Some(&mut update)).await?;
|
||||
restart_services(stack.name, restart, user).await
|
||||
}
|
||||
// Only deploy
|
||||
(false, true) => {
|
||||
deploy_services(stack.name, deploy, user).await
|
||||
}
|
||||
// Deploy then restart, returning non-db update with executed services.
|
||||
(false, false) => {
|
||||
update.push_simple_log(
|
||||
"Execute Deploys",
|
||||
format!("Deploying: {}", deploy.join(", "),),
|
||||
);
|
||||
let deploy_update =
|
||||
deploy_services(stack.name.clone(), deploy, user)
|
||||
.await?;
|
||||
if !deploy_update.success {
|
||||
update.push_error_log(
|
||||
"Execute Deploys",
|
||||
String::from("There was a failure in service deploy"),
|
||||
);
|
||||
update.finalize();
|
||||
return Ok(update);
|
||||
}
|
||||
|
||||
update.push_simple_log(
|
||||
"Execute Restarts",
|
||||
format!("Restarting: {}", restart.join(", "),),
|
||||
);
|
||||
let restart_update =
|
||||
restart_services(stack.name, restart, user).await?;
|
||||
if !restart_update.success {
|
||||
update.push_error_log(
|
||||
"Execute Restarts",
|
||||
String::from(
|
||||
"There was a failure in a service restart",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
update.finalize();
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn deploy_services(
|
||||
stack: String,
|
||||
services: Vec<String>,
|
||||
user: &User,
|
||||
) -> serror::Result<Update> {
|
||||
// The existing update is initialized to DeployStack,
|
||||
// but also has not been created on database.
|
||||
// Setup a new update here.
|
||||
let req = ExecuteRequest::DeployStack(DeployStack {
|
||||
stack,
|
||||
services,
|
||||
stop_time: None,
|
||||
});
|
||||
let update = init_execution_update(&req, user).await?;
|
||||
let ExecuteRequest::DeployStack(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
req
|
||||
.resolve(&ExecuteArgs {
|
||||
user: user.clone(),
|
||||
update,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restart_services(
|
||||
stack: String,
|
||||
services: Vec<String>,
|
||||
user: &User,
|
||||
) -> serror::Result<Update> {
|
||||
// The existing update is initialized to DeployStack,
|
||||
// but also has not been created on database.
|
||||
// Setup a new update here.
|
||||
let req =
|
||||
ExecuteRequest::RestartStack(RestartStack { stack, services });
|
||||
let update = init_execution_update(&req, user).await?;
|
||||
let ExecuteRequest::RestartStack(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
req
|
||||
.resolve(&ExecuteArgs {
|
||||
user: user.clone(),
|
||||
update,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
enum DeployIfChangedAction {
|
||||
/// Changes to any compose or env files
|
||||
/// always lead to this.
|
||||
FullDeploy,
|
||||
/// If the above is not met, then changes to
|
||||
/// any changed additional file with `requires = "Restart"`
|
||||
/// and empty services array will lead to this.
|
||||
FullRestart,
|
||||
/// If all changed additional files have specific services
|
||||
/// they depend on, collect the final necessary
|
||||
/// services to deploy / restart.
|
||||
/// If eg `deploy` is empty, no services will be redeployed, same for `restart`.
|
||||
/// If both are empty, nothing is to be done.
|
||||
Services {
|
||||
deploy: Vec<String>,
|
||||
restart: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_deploy_if_changed_action(
|
||||
deployed_contents: &[FileContents],
|
||||
latest_contents: &[StackRemoteFileContents],
|
||||
all_services: &[String],
|
||||
) -> DeployIfChangedAction {
|
||||
let mut full_restart = false;
|
||||
let mut deploy = HashSet::<String>::new();
|
||||
let mut restart = HashSet::<String>::new();
|
||||
|
||||
for latest in latest_contents {
|
||||
let Some(deployed) =
|
||||
deployed_contents.iter().find(|c| c.path == latest.path)
|
||||
else {
|
||||
// If file doesn't exist in deployed contents, do full
|
||||
// deploy to align this.
|
||||
return DeployIfChangedAction::FullDeploy;
|
||||
};
|
||||
// Ignore unchanged files
|
||||
if latest.contents == deployed.contents {
|
||||
continue;
|
||||
}
|
||||
match (latest.requires, latest.services.is_empty()) {
|
||||
(StackFileRequires::Redeploy, true) => {
|
||||
// File has requires = "Redeploy" at global level.
|
||||
// Can do early return here.
|
||||
return DeployIfChangedAction::FullDeploy;
|
||||
}
|
||||
(StackFileRequires::Redeploy, false) => {
|
||||
// Requires redeploy on specific services
|
||||
deploy.extend(latest.services.clone());
|
||||
}
|
||||
(StackFileRequires::Restart, true) => {
|
||||
// Services empty -> Full restart
|
||||
full_restart = true;
|
||||
}
|
||||
(StackFileRequires::Restart, false) => {
|
||||
restart.extend(latest.services.clone());
|
||||
}
|
||||
(StackFileRequires::None, _) => {
|
||||
// File can be ignored even with changes.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (full_restart, deploy.is_empty()) {
|
||||
// Full restart required with NO deploys needed -> Full Restart
|
||||
(true, true) => DeployIfChangedAction::FullRestart,
|
||||
// Full restart required WITH deploys needed -> Deploy those, restart all others
|
||||
(true, false) => DeployIfChangedAction::Services {
|
||||
restart: all_services
|
||||
.iter()
|
||||
// Only keep ones that don't need deploy
|
||||
.filter(|&s| !deploy.contains(s))
|
||||
.cloned()
|
||||
.collect(),
|
||||
deploy: deploy.into_iter().collect(),
|
||||
},
|
||||
// No full restart needed -> Deploy / restart as. pickedup.
|
||||
(false, _) => DeployIfChangedAction::Services {
|
||||
deploy: deploy.into_iter().collect(),
|
||||
restart: restart.into_iter().collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +604,31 @@ impl Resolve<ExecuteArgs> for BatchPullStack {
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_pull_stack(
|
||||
stack: &Stack,
|
||||
update: Option<&mut Update>,
|
||||
) -> anyhow::Result<()> {
|
||||
if stack.config.files_on_host
|
||||
|| (stack.config.repo.is_empty()
|
||||
&& stack.config.linked_repo.is_empty())
|
||||
{
|
||||
// Not repo based, no pull necessary
|
||||
return Ok(());
|
||||
}
|
||||
let server =
|
||||
resource::get::<Server>(&stack.config.server_id).await?;
|
||||
let repo = if stack.config.repo.is_empty()
|
||||
&& !stack.config.linked_repo.is_empty()
|
||||
{
|
||||
Some(resource::get::<Repo>(&stack.config.linked_repo).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
pull_stack_inner(stack.clone(), Vec::new(), &server, repo, update)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pull_stack_inner(
|
||||
mut stack: Stack,
|
||||
services: Vec<String>,
|
||||
@@ -630,3 +888,94 @@ impl Resolve<ExecuteArgs> for DestroyStack {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<ExecuteArgs> for RunStackService {
|
||||
#[instrument(name = "RunStackService", skip(user, update), fields(user_id = user.id, update_id = update.id))]
|
||||
async fn resolve(
|
||||
self,
|
||||
ExecuteArgs { user, update }: &ExecuteArgs,
|
||||
) -> serror::Result<Update> {
|
||||
let (mut stack, server) = get_stack_and_server(
|
||||
&self.stack,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut repo = if !stack.config.files_on_host
|
||||
&& !stack.config.linked_repo.is_empty()
|
||||
{
|
||||
crate::resource::get::<Repo>(&stack.config.linked_repo)
|
||||
.await?
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let action_state =
|
||||
action_states().stack.get_or_insert_default(&stack.id).await;
|
||||
|
||||
let _action_guard =
|
||||
action_state.update(|state| state.deploying = true)?;
|
||||
|
||||
let mut update = update.clone();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
let git_token =
|
||||
stack_git_token(&mut stack, repo.as_mut()).await?;
|
||||
|
||||
let registry_token = crate::helpers::registry_token(
|
||||
&stack.config.registry_provider,
|
||||
&stack.config.registry_account,
|
||||
).await.with_context(
|
||||
|| format!("Failed to get registry token in call to db. Stopping run. | {} | {}", stack.config.registry_provider, stack.config.registry_account),
|
||||
)?;
|
||||
|
||||
let secret_replacers = if !stack.config.skip_secret_interp {
|
||||
let VariablesAndSecrets { variables, secrets } =
|
||||
get_variables_and_secrets().await?;
|
||||
|
||||
let mut interpolator =
|
||||
Interpolator::new(Some(&variables), &secrets);
|
||||
|
||||
interpolator.interpolate_stack(&mut stack)?;
|
||||
if let Some(repo) = repo.as_mut()
|
||||
&& !repo.config.skip_secret_interp
|
||||
{
|
||||
interpolator.interpolate_repo(repo)?;
|
||||
}
|
||||
interpolator.push_logs(&mut update.logs);
|
||||
|
||||
interpolator.secret_replacers
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
|
||||
let log = periphery_client(&server)?
|
||||
.request(ComposeRun {
|
||||
stack,
|
||||
repo,
|
||||
git_token,
|
||||
registry_token,
|
||||
replacers: secret_replacers.into_iter().collect(),
|
||||
service: self.service,
|
||||
command: self.command,
|
||||
no_tty: self.no_tty,
|
||||
no_deps: self.no_deps,
|
||||
service_ports: self.service_ports,
|
||||
env: self.env,
|
||||
workdir: self.workdir,
|
||||
user: self.user,
|
||||
entrypoint: self.entrypoint,
|
||||
pull: self.pull,
|
||||
})
|
||||
.await?;
|
||||
|
||||
update.logs.push(log);
|
||||
update.finalize();
|
||||
update_update(update.clone()).await?;
|
||||
|
||||
Ok(update)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,24 @@ impl Resolve<ReadArgs> for GetServersSummary {
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let core_version = env!("CARGO_PKG_VERSION");
|
||||
let mut res = GetServersSummaryResponse::default();
|
||||
|
||||
for server in servers {
|
||||
res.total += 1;
|
||||
match server.info.state {
|
||||
ServerState::Ok => {
|
||||
res.healthy += 1;
|
||||
// Check for version mismatch
|
||||
let has_version_mismatch = !server.info.version.is_empty()
|
||||
&& server.info.version != "Unknown"
|
||||
&& server.info.version != core_version;
|
||||
|
||||
if has_version_mismatch {
|
||||
res.warning += 1;
|
||||
} else {
|
||||
res.healthy += 1;
|
||||
}
|
||||
}
|
||||
ServerState::NotOk => {
|
||||
res.unhealthy += 1;
|
||||
|
||||
@@ -429,7 +429,7 @@ impl Resolve<WriteArgs> for RefreshStackCache {
|
||||
let GetComposeContentsOnHostResponse { contents, errors } =
|
||||
match periphery_client(&server)?
|
||||
.request(GetComposeContentsOnHost {
|
||||
file_paths: stack.file_paths().to_vec(),
|
||||
file_paths: stack.all_file_dependencies(),
|
||||
name: stack.name.clone(),
|
||||
run_directory: stack.config.run_directory.clone(),
|
||||
})
|
||||
@@ -451,6 +451,10 @@ impl Resolve<WriteArgs> for RefreshStackCache {
|
||||
let mut services = Vec::new();
|
||||
|
||||
for contents in &contents {
|
||||
// Don't include additional files in service parsing
|
||||
if !stack.is_compose_file(&contents.path) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = extract_services_into_res(
|
||||
&project_name,
|
||||
&contents.contents,
|
||||
@@ -489,6 +493,10 @@ impl Resolve<WriteArgs> for RefreshStackCache {
|
||||
let mut services = Vec::new();
|
||||
|
||||
for contents in &remote_contents {
|
||||
// Don't include additional files in service parsing
|
||||
if !stack.is_compose_file(&contents.path) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = extract_services_into_res(
|
||||
&project_name,
|
||||
&contents.contents,
|
||||
|
||||
@@ -1101,6 +1101,23 @@ async fn execute_execution(
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::RunStackService(req) => {
|
||||
let req = ExecuteRequest::RunStackService(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
let ExecuteRequest::RunStackService(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
let update_id = update.id.clone();
|
||||
handle_resolve_result(
|
||||
req
|
||||
.resolve(&ExecuteArgs { user, update })
|
||||
.await
|
||||
.map_err(|e| e.error)
|
||||
.context("Failed at RunStackService"),
|
||||
&update_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::BatchDestroyStack(_) => {
|
||||
// All batch executions must be expanded in `execute_stage`
|
||||
return Err(anyhow!(
|
||||
@@ -1124,6 +1141,23 @@ async fn execute_execution(
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::SendAlert(req) => {
|
||||
let req = ExecuteRequest::SendAlert(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
let ExecuteRequest::SendAlert(req) = req else {
|
||||
unreachable!()
|
||||
};
|
||||
let update_id = update.id.clone();
|
||||
handle_resolve_result(
|
||||
req
|
||||
.resolve(&ExecuteArgs { user, update })
|
||||
.await
|
||||
.map_err(|e| e.error)
|
||||
.context("Failed at SendAlert"),
|
||||
&update_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Execution::ClearRepoCache(req) => {
|
||||
let req = ExecuteRequest::ClearRepoCache(req);
|
||||
let update = init_execution_update(&req, &user).await?;
|
||||
|
||||
@@ -492,6 +492,13 @@ pub async fn init_execution_update(
|
||||
return Ok(Default::default());
|
||||
}
|
||||
|
||||
ExecuteRequest::RunStackService(data) => (
|
||||
Operation::RunStackService,
|
||||
ResourceTarget::Stack(
|
||||
resource::get::<Stack>(&data.stack).await?.id,
|
||||
),
|
||||
),
|
||||
|
||||
// Alerter
|
||||
ExecuteRequest::TestAlerter(data) => (
|
||||
Operation::TestAlerter,
|
||||
@@ -499,6 +506,9 @@ pub async fn init_execution_update(
|
||||
resource::get::<Alerter>(&data.alerter).await?.id,
|
||||
),
|
||||
),
|
||||
ExecuteRequest::SendAlert(_) => {
|
||||
(Operation::SendAlert, ResourceTarget::system())
|
||||
}
|
||||
|
||||
// Maintenance
|
||||
ExecuteRequest::ClearRepoCache(_data) => {
|
||||
|
||||
@@ -178,6 +178,77 @@ pub async fn alert_servers(
|
||||
),
|
||||
}
|
||||
|
||||
// ===================
|
||||
// SERVER VERSION MISMATCH
|
||||
// ===================
|
||||
let core_version = env!("CARGO_PKG_VERSION");
|
||||
let has_version_mismatch = server_status.state == ServerState::Ok
|
||||
&& !server_status.version.is_empty()
|
||||
&& server_status.version != "Unknown"
|
||||
&& server_status.version != core_version;
|
||||
|
||||
let version_alert = server_alerts.as_ref().and_then(|alerts| {
|
||||
alerts.get(&AlertDataVariant::ServerVersionMismatch)
|
||||
});
|
||||
|
||||
match (has_version_mismatch, version_alert) {
|
||||
(true, None) => {
|
||||
// Only open version mismatch alert if not in maintenance and buffer is ready
|
||||
if !in_maintenance
|
||||
&& buffer.ready_to_open(
|
||||
server_status.id.clone(),
|
||||
AlertDataVariant::ServerVersionMismatch,
|
||||
)
|
||||
{
|
||||
let alert = Alert {
|
||||
id: Default::default(),
|
||||
ts,
|
||||
resolved: false,
|
||||
resolved_ts: None,
|
||||
level: SeverityLevel::Warning,
|
||||
target: ResourceTarget::Server(server_status.id.clone()),
|
||||
data: AlertData::ServerVersionMismatch {
|
||||
id: server_status.id.clone(),
|
||||
name: server.name.clone(),
|
||||
region: optional_string(&server.config.region),
|
||||
server_version: server_status.version.clone(),
|
||||
core_version: core_version.to_string(),
|
||||
},
|
||||
};
|
||||
// Use send_unreachable_alerts as a proxy for general server alerts
|
||||
alerts_to_open
|
||||
.push((alert, server.config.send_version_mismatch_alerts))
|
||||
}
|
||||
}
|
||||
(true, Some(alert)) => {
|
||||
// Update existing alert with current version info
|
||||
let mut alert = alert.clone();
|
||||
alert.data = AlertData::ServerVersionMismatch {
|
||||
id: server_status.id.clone(),
|
||||
name: server.name.clone(),
|
||||
region: optional_string(&server.config.region),
|
||||
server_version: server_status.version.clone(),
|
||||
core_version: core_version.to_string(),
|
||||
};
|
||||
// Don't send notification for updates
|
||||
alerts_to_update.push((alert, false));
|
||||
}
|
||||
(false, Some(alert)) => {
|
||||
// Version is now correct, close the alert
|
||||
alert_ids_to_close.push((
|
||||
alert.clone(),
|
||||
server.config.send_version_mismatch_alerts,
|
||||
));
|
||||
}
|
||||
(false, None) => {
|
||||
// Reset buffer state when no mismatch and no alert
|
||||
buffer.reset(
|
||||
server_status.id.clone(),
|
||||
AlertDataVariant::ServerVersionMismatch,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let Some(health) = &server_status.health else {
|
||||
continue;
|
||||
};
|
||||
|
||||
+23
-24
@@ -36,10 +36,10 @@ fn is_container_environment() -> bool {
|
||||
}
|
||||
|
||||
// Check cgroup for container runtime indicators
|
||||
if let Ok(content) = std::fs::read_to_string(CGROUP_FILE) {
|
||||
if content.contains("docker") || content.contains("containerd") {
|
||||
return true;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string(CGROUP_FILE)
|
||||
&& (content.contains("docker") || content.contains("containerd"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
@@ -142,7 +142,7 @@ async fn find_gateway(
|
||||
}
|
||||
|
||||
let ip_cidr = ip_cidr.ok_or_else(|| anyhow!(
|
||||
"Could not find IP address for interface '{}'. Ensure interface has a valid IPv4 address",
|
||||
"Could not find IP address for interface '{}'. Ensure interface has a valid IPv4 address",
|
||||
interface_name
|
||||
))?;
|
||||
|
||||
@@ -167,14 +167,13 @@ async fn find_gateway(
|
||||
if line.contains("via") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if let Some(via_idx) = parts.iter().position(|&x| x == "via")
|
||||
&& let Some(&gateway) = parts.get(via_idx + 1)
|
||||
{
|
||||
if let Some(&gateway) = parts.get(via_idx + 1) {
|
||||
trace!(
|
||||
"Found gateway {} for {} from routing table",
|
||||
gateway, interface_name
|
||||
);
|
||||
return Ok(gateway.to_string());
|
||||
}
|
||||
trace!(
|
||||
"Found gateway {} for {} from routing table",
|
||||
gateway, interface_name
|
||||
);
|
||||
return Ok(gateway.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,14 +205,14 @@ async fn find_gateway(
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(output) = route_test {
|
||||
if output.status.success() {
|
||||
trace!(
|
||||
"Gateway {} is reachable via {}",
|
||||
gateway, interface_name
|
||||
);
|
||||
return Ok(gateway.to_string());
|
||||
}
|
||||
if let Ok(output) = route_test
|
||||
&& output.status.success()
|
||||
{
|
||||
trace!(
|
||||
"Gateway {} is reachable via {}",
|
||||
gateway, interface_name
|
||||
);
|
||||
return Ok(gateway.to_string());
|
||||
}
|
||||
|
||||
// Fallback: assume .1 is gateway (Docker standard)
|
||||
@@ -266,10 +265,10 @@ async fn set_default_gateway(
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(output) = remove_default {
|
||||
if output.status.success() {
|
||||
trace!("Removed existing default routes");
|
||||
}
|
||||
if let Ok(output) = remove_default
|
||||
&& output.status.success()
|
||||
{
|
||||
trace!("Removed existing default routes");
|
||||
}
|
||||
|
||||
// Add new default route
|
||||
|
||||
@@ -116,9 +116,11 @@ impl super::KomodoResource for Build {
|
||||
git_provider,
|
||||
repo,
|
||||
branch,
|
||||
image_registry_domain: optional_string(
|
||||
build.config.image_registry.domain,
|
||||
),
|
||||
image_registry_domain: build
|
||||
.config
|
||||
.image_registry
|
||||
.first()
|
||||
.and_then(|r| optional_string(&r.domain)),
|
||||
built_hash: build.info.built_hash,
|
||||
latest_hash: build.info.latest_hash,
|
||||
state,
|
||||
|
||||
@@ -229,6 +229,12 @@ pub trait KomodoResource {
|
||||
pub async fn get<T: KomodoResource>(
|
||||
id_or_name: &str,
|
||||
) -> anyhow::Result<Resource<T::Config, T::Info>> {
|
||||
if id_or_name.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Cannot find {} with empty name / id",
|
||||
T::resource_type()
|
||||
));
|
||||
}
|
||||
T::coll()
|
||||
.find_one(id_or_name_filter(id_or_name))
|
||||
.await
|
||||
|
||||
@@ -5,6 +5,7 @@ use database::mungos::{
|
||||
find::find_collect,
|
||||
mongodb::{Collection, bson::doc, options::FindOneOptions},
|
||||
};
|
||||
use futures::{TryStreamExt, stream::FuturesUnordered};
|
||||
use komodo_client::{
|
||||
api::execute::Execution,
|
||||
entities::{
|
||||
@@ -709,6 +710,15 @@ async fn validate_config(
|
||||
.await?;
|
||||
params.stack = stack.id;
|
||||
}
|
||||
Execution::RunStackService(params) => {
|
||||
let stack = super::get_check_permissions::<Stack>(
|
||||
¶ms.stack,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?;
|
||||
params.stack = stack.id;
|
||||
}
|
||||
Execution::BatchDestroyStack(_params) => {
|
||||
if !user.admin {
|
||||
return Err(anyhow!(
|
||||
@@ -725,6 +735,24 @@ async fn validate_config(
|
||||
.await?;
|
||||
params.alerter = alerter.id;
|
||||
}
|
||||
Execution::SendAlert(params) => {
|
||||
params.alerters = params
|
||||
.alerters
|
||||
.iter()
|
||||
.map(async |alerter| {
|
||||
let id = super::get_check_permissions::<Alerter>(
|
||||
alerter,
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
)
|
||||
.await?
|
||||
.id;
|
||||
anyhow::Ok(id)
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
}
|
||||
Execution::ClearRepoCache(_params) => {
|
||||
if !user.admin {
|
||||
return Err(anyhow!(
|
||||
|
||||
@@ -82,6 +82,9 @@ impl super::KomodoResource for Server {
|
||||
send_cpu_alerts: server.config.send_cpu_alerts,
|
||||
send_mem_alerts: server.config.send_mem_alerts,
|
||||
send_disk_alerts: server.config.send_disk_alerts,
|
||||
send_version_mismatch_alerts: server
|
||||
.config
|
||||
.send_version_mismatch_alerts,
|
||||
terminals_disabled,
|
||||
container_exec_disabled,
|
||||
},
|
||||
|
||||
@@ -3,14 +3,16 @@ use std::{fs, path::PathBuf};
|
||||
use anyhow::Context;
|
||||
use formatting::format_serror;
|
||||
use komodo_client::entities::{
|
||||
FileContents, RepoExecutionArgs, repo::Repo, stack::Stack,
|
||||
FileContents, RepoExecutionArgs,
|
||||
repo::Repo,
|
||||
stack::{Stack, StackRemoteFileContents},
|
||||
update::Log,
|
||||
};
|
||||
|
||||
use crate::{config::core_config, helpers::git_token};
|
||||
|
||||
pub struct RemoteComposeContents {
|
||||
pub successful: Vec<FileContents>,
|
||||
pub successful: Vec<StackRemoteFileContents>,
|
||||
pub errored: Vec<FileContents>,
|
||||
pub hash: Option<String>,
|
||||
pub message: Option<String>,
|
||||
@@ -38,23 +40,25 @@ pub async fn get_repo_compose_contents(
|
||||
let mut successful = Vec::new();
|
||||
let mut errored = Vec::new();
|
||||
|
||||
for path in stack.file_paths() {
|
||||
let file_path = run_directory.join(path);
|
||||
for file in stack.all_file_dependencies() {
|
||||
let file_path = run_directory.join(&file.path);
|
||||
if !file_path.exists()
|
||||
&& let Some(missing_files) = &mut missing_files
|
||||
{
|
||||
missing_files.push(path.to_string());
|
||||
missing_files.push(file.path.clone());
|
||||
}
|
||||
// If file does not exist, will show up in err case so the log is handled
|
||||
match fs::read_to_string(&file_path).with_context(|| {
|
||||
format!("Failed to read file contents from {file_path:?}")
|
||||
}) {
|
||||
Ok(contents) => successful.push(FileContents {
|
||||
path: path.to_string(),
|
||||
Ok(contents) => successful.push(StackRemoteFileContents {
|
||||
path: file.path,
|
||||
contents,
|
||||
services: file.services,
|
||||
requires: file.requires,
|
||||
}),
|
||||
Err(e) => errored.push(FileContents {
|
||||
path: path.to_string(),
|
||||
path: file.path,
|
||||
contents: format_serror(&e.into()),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -285,8 +285,7 @@ async fn ensure_init_user_and_resources() {
|
||||
}
|
||||
.resolve(&AuthArgs::default())
|
||||
.await
|
||||
.expect("Failed to initialize default admin user.")
|
||||
.jwt;
|
||||
.expect("Failed to initialize default admin user.");
|
||||
db.users
|
||||
.find_one(doc! { "username": username })
|
||||
.await
|
||||
|
||||
@@ -9,12 +9,15 @@ use komodo_client::{
|
||||
read::ListBuildVersions,
|
||||
},
|
||||
entities::{
|
||||
FileContents, ResourceTarget,
|
||||
ResourceTarget,
|
||||
deployment::{
|
||||
Deployment, DeploymentConfig, DeploymentImage, DeploymentState,
|
||||
PartialDeploymentConfig,
|
||||
},
|
||||
stack::{PartialStackConfig, Stack, StackConfig, StackState},
|
||||
stack::{
|
||||
PartialStackConfig, Stack, StackConfig,
|
||||
StackRemoteFileContents, StackState,
|
||||
},
|
||||
sync::SyncDeployUpdate,
|
||||
toml::ResourceToml,
|
||||
update::Log,
|
||||
@@ -554,7 +557,13 @@ fn build_cache_for_stack<'a>(
|
||||
&original.info.remote_contents,
|
||||
) {
|
||||
(Some(deployed_contents), Some(remote_contents)) => {
|
||||
for FileContents { path, contents } in remote_contents {
|
||||
for StackRemoteFileContents {
|
||||
path,
|
||||
contents,
|
||||
services: _services,
|
||||
requires: _requires,
|
||||
} in remote_contents
|
||||
{
|
||||
if let Some(deployed) =
|
||||
deployed_contents.iter().find(|c| &c.path == path)
|
||||
{
|
||||
|
||||
@@ -661,6 +661,13 @@ impl ResourceSyncTrait for Procedure {
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::RunStackService(config) => {
|
||||
config.stack = resources
|
||||
.stacks
|
||||
.get(&config.stack)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::BatchDestroyStack(_config) => {}
|
||||
Execution::TestAlerter(config) => {
|
||||
config.alerter = resources
|
||||
@@ -669,6 +676,19 @@ impl ResourceSyncTrait for Procedure {
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Execution::SendAlert(config) => {
|
||||
config.alerters = config
|
||||
.alerters
|
||||
.iter()
|
||||
.map(|alerter| {
|
||||
resources
|
||||
.alerters
|
||||
.get(alerter)
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Execution::ClearRepoCache(_) => {}
|
||||
Execution::BackupCoreDatabase(_) => {}
|
||||
Execution::GlobalAutoUpdate(_) => {}
|
||||
|
||||
@@ -752,6 +752,13 @@ impl ToToml for Procedure {
|
||||
.map(|r| &r.name)
|
||||
.unwrap_or(&String::new()),
|
||||
),
|
||||
Execution::RunStackService(exec) => exec.stack.clone_from(
|
||||
all
|
||||
.stacks
|
||||
.get(&exec.stack)
|
||||
.map(|r| &r.name)
|
||||
.unwrap_or(&String::new()),
|
||||
),
|
||||
Execution::PauseStack(exec) => exec.stack.clone_from(
|
||||
all
|
||||
.stacks
|
||||
@@ -788,6 +795,17 @@ impl ToToml for Procedure {
|
||||
.map(|a| &a.name)
|
||||
.unwrap_or(&String::new()),
|
||||
),
|
||||
Execution::SendAlert(exec) => {
|
||||
exec.alerters.iter_mut().for_each(|a| {
|
||||
a.clone_from(
|
||||
all
|
||||
.alerters
|
||||
.get(a)
|
||||
.map(|a| &a.name)
|
||||
.unwrap_or(&String::new()),
|
||||
)
|
||||
})
|
||||
}
|
||||
Execution::None(_)
|
||||
| Execution::Sleep(_)
|
||||
| Execution::ClearRepoCache(_)
|
||||
|
||||
@@ -57,4 +57,5 @@ axum.workspace = true
|
||||
clap.workspace = true
|
||||
envy.workspace = true
|
||||
uuid.workspace = true
|
||||
rand.workspace = true
|
||||
rand.workspace = true
|
||||
shell-escape.workspace = true
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use command::{
|
||||
@@ -9,7 +12,7 @@ use interpolate::Interpolator;
|
||||
use komodo_client::entities::{
|
||||
EnvironmentVar, all_logs_success,
|
||||
build::{Build, BuildConfig},
|
||||
environment_vars_from_str, get_image_name, optional_string,
|
||||
environment_vars_from_str, get_image_names, optional_string,
|
||||
to_path_compatible_name,
|
||||
update::Log,
|
||||
};
|
||||
@@ -122,7 +125,7 @@ impl Resolve<super::Args> for build::Build {
|
||||
let build::Build {
|
||||
mut build,
|
||||
repo: linked_repo,
|
||||
registry_token,
|
||||
registry_tokens,
|
||||
additional_tags,
|
||||
mut replacers,
|
||||
} = self;
|
||||
@@ -169,25 +172,41 @@ impl Resolve<super::Args> for build::Build {
|
||||
return Err(anyhow!("Build must be files on host mode, have a repo attached, or have dockerfile contents set to build").into());
|
||||
}
|
||||
|
||||
let registry_tokens = registry_tokens
|
||||
.iter()
|
||||
.map(|(domain, account, token)| {
|
||||
((domain.as_str(), account.as_str()), token.as_str())
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
// Maybe docker login
|
||||
let should_push = match docker_login(
|
||||
&image_registry.domain,
|
||||
&image_registry.account,
|
||||
registry_token.as_deref(),
|
||||
)
|
||||
.await
|
||||
let mut should_push = false;
|
||||
for (domain, account) in image_registry
|
||||
.iter()
|
||||
.map(|r| (r.domain.as_str(), r.account.as_str()))
|
||||
// This ensures uniqueness / prevents redundant logins
|
||||
.collect::<HashSet<_>>()
|
||||
{
|
||||
Ok(should_push) => should_push,
|
||||
Err(e) => {
|
||||
logs.push(Log::error(
|
||||
"Docker Login",
|
||||
format_serror(
|
||||
&e.context("failed to login to docker registry").into(),
|
||||
),
|
||||
));
|
||||
return Ok(logs);
|
||||
}
|
||||
};
|
||||
match docker_login(
|
||||
domain,
|
||||
account,
|
||||
registry_tokens.get(&(domain, account)).copied(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(logged_in) if logged_in => should_push = true,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
logs.push(Log::error(
|
||||
"Docker Login",
|
||||
format_serror(
|
||||
&e.context("failed to login to docker registry").into(),
|
||||
),
|
||||
));
|
||||
return Ok(logs);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let build_path = if let Some(repo) = &linked_repo {
|
||||
periphery_config()
|
||||
@@ -245,8 +264,8 @@ impl Resolve<super::Args> for build::Build {
|
||||
}
|
||||
|
||||
// Get command parts
|
||||
let image_name =
|
||||
get_image_name(&build).context("failed to make image name")?;
|
||||
|
||||
let image_names = get_image_names(&build);
|
||||
|
||||
// Add VERSION to build args (if not already there)
|
||||
let mut build_args = environment_vars_from_str(build_args)
|
||||
@@ -267,10 +286,15 @@ impl Resolve<super::Args> for build::Build {
|
||||
let labels = parse_labels(
|
||||
&environment_vars_from_str(labels).context("Invalid labels")?,
|
||||
);
|
||||
|
||||
let extra_args = parse_extra_args(extra_args);
|
||||
|
||||
let buildx = if *use_buildx { " buildx" } else { "" };
|
||||
|
||||
let image_tags =
|
||||
image_tags(&image_name, image_tag, version, &additional_tags);
|
||||
image_tags(&image_names, image_tag, version, &additional_tags)
|
||||
.context("Failed to parse image tags into command")?;
|
||||
|
||||
let maybe_push = if should_push { " --push" } else { "" };
|
||||
|
||||
// Construct command
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use command::{
|
||||
run_komodo_command, run_komodo_command_with_sanitization,
|
||||
@@ -11,7 +9,7 @@ use komodo_client::entities::{
|
||||
FileContents, RepoExecutionResponse, all_logs_success,
|
||||
stack::{
|
||||
ComposeFile, ComposeProject, ComposeService,
|
||||
ComposeServiceDeploy, StackServiceNames,
|
||||
ComposeServiceDeploy, StackRemoteFileContents, StackServiceNames,
|
||||
},
|
||||
to_path_compatible_name,
|
||||
update::Log,
|
||||
@@ -19,11 +17,13 @@ use komodo_client::entities::{
|
||||
use periphery_client::api::compose::*;
|
||||
use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shell_escape::unix::escape;
|
||||
use std::{borrow::Cow, path::PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
compose::{
|
||||
docker_compose, pull_or_clone_stack,
|
||||
docker_compose, env_file_args, pull_or_clone_stack,
|
||||
up::{maybe_login_registry, validate_files},
|
||||
write::write_stack,
|
||||
},
|
||||
@@ -169,9 +169,11 @@ impl Resolve<super::Args> for GetComposeContentsOnHost {
|
||||
|
||||
let mut res = GetComposeContentsOnHostResponse::default();
|
||||
|
||||
for path in file_paths {
|
||||
let full_path =
|
||||
run_directory.join(&path).components().collect::<PathBuf>();
|
||||
for file in file_paths {
|
||||
let full_path = run_directory
|
||||
.join(&file.path)
|
||||
.components()
|
||||
.collect::<PathBuf>();
|
||||
match fs::read_to_string(&full_path).await.with_context(|| {
|
||||
format!(
|
||||
"Failed to read compose file contents at {full_path:?}"
|
||||
@@ -180,11 +182,16 @@ impl Resolve<super::Args> for GetComposeContentsOnHost {
|
||||
Ok(contents) => {
|
||||
// The path we store here has to be the same as incoming file path in the array,
|
||||
// in order for WriteComposeContentsToHost to write to the correct path.
|
||||
res.contents.push(FileContents { path, contents });
|
||||
res.contents.push(StackRemoteFileContents {
|
||||
path: file.path,
|
||||
contents,
|
||||
services: file.services,
|
||||
requires: file.requires,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
res.errors.push(FileContents {
|
||||
path,
|
||||
path: file.path,
|
||||
contents: format_serror(&e.into()),
|
||||
});
|
||||
}
|
||||
@@ -351,19 +358,19 @@ impl Resolve<super::Args> for ComposePull {
|
||||
)?;
|
||||
|
||||
let file_paths = stack
|
||||
.file_paths()
|
||||
.iter()
|
||||
.all_file_paths()
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
(
|
||||
path,
|
||||
// This will remove any intermediate uneeded '/./' in the path
|
||||
run_directory.join(path).components().collect::<PathBuf>(),
|
||||
run_directory.join(&path).components().collect::<PathBuf>(),
|
||||
path,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Validate files
|
||||
for (path, full_path) in &file_paths {
|
||||
for (full_path, path) in &file_paths {
|
||||
if !full_path.exists() {
|
||||
return Err(anyhow!("Missing compose file at {path}").into());
|
||||
}
|
||||
@@ -382,24 +389,12 @@ impl Resolve<super::Args> for ComposePull {
|
||||
format!(" {}", services.join(" "))
|
||||
};
|
||||
|
||||
let file_args = if stack.config.file_paths.is_empty() {
|
||||
String::from("compose.yaml")
|
||||
} else {
|
||||
stack.config.file_paths.join(" -f ")
|
||||
};
|
||||
let file_args = stack.compose_file_paths().join(" -f ");
|
||||
|
||||
let env_file = env_file_path
|
||||
.map(|path| format!(" --env-file {path}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let additional_env_files = stack
|
||||
.config
|
||||
.additional_env_files
|
||||
.iter()
|
||||
.fold(String::new(), |mut output, file| {
|
||||
let _ = write!(output, " --env-file {file}");
|
||||
output
|
||||
});
|
||||
let env_file_args = env_file_args(
|
||||
env_file_path,
|
||||
&stack.config.additional_env_files,
|
||||
)?;
|
||||
|
||||
let project_name = stack.project_name(false);
|
||||
|
||||
@@ -407,7 +402,7 @@ impl Resolve<super::Args> for ComposePull {
|
||||
"Compose Pull",
|
||||
run_directory.as_ref(),
|
||||
format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{additional_env_files}{env_file} pull{service_args}",
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} pull{service_args}",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -514,35 +509,23 @@ impl Resolve<super::Args> for ComposeUp {
|
||||
format!(" {}", services.join(" "))
|
||||
};
|
||||
|
||||
let file_args = if stack.config.file_paths.is_empty() {
|
||||
String::from("compose.yaml")
|
||||
} else {
|
||||
stack.config.file_paths.join(" -f ")
|
||||
};
|
||||
let file_args = stack.compose_file_paths().join(" -f ");
|
||||
|
||||
// This will be the last project name, which is the one that needs to be destroyed.
|
||||
// Might be different from the current project name, if user renames stack / changes to custom project name.
|
||||
let last_project_name = stack.project_name(false);
|
||||
let project_name = stack.project_name(true);
|
||||
|
||||
let env_file = env_file_path
|
||||
.map(|path| format!(" --env-file {path}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let additional_env_files = stack
|
||||
.config
|
||||
.additional_env_files
|
||||
.iter()
|
||||
.fold(String::new(), |mut output, file| {
|
||||
let _ = write!(output, " --env-file {file}");
|
||||
output
|
||||
});
|
||||
let env_file_args = env_file_args(
|
||||
env_file_path,
|
||||
&stack.config.additional_env_files,
|
||||
)?;
|
||||
|
||||
// Uses 'docker compose config' command to extract services (including image)
|
||||
// after performing interpolation
|
||||
{
|
||||
let command = format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{additional_env_files}{env_file} config",
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} config",
|
||||
);
|
||||
let Some(config_log) = run_komodo_command_with_sanitization(
|
||||
"Compose Config",
|
||||
@@ -607,7 +590,7 @@ impl Resolve<super::Args> for ComposeUp {
|
||||
let build_extra_args =
|
||||
parse_extra_args(&stack.config.build_extra_args);
|
||||
let command = format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{additional_env_files}{env_file} build{build_extra_args}{service_args}",
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} build{build_extra_args}{service_args}",
|
||||
);
|
||||
let Some(log) = run_komodo_command_with_sanitization(
|
||||
"Compose Build",
|
||||
@@ -631,7 +614,7 @@ impl Resolve<super::Args> for ComposeUp {
|
||||
// Pull images before destroying to minimize downtime.
|
||||
// If this fails, do not continue.
|
||||
let command = format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{additional_env_files}{env_file} pull{service_args}",
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} pull{service_args}",
|
||||
);
|
||||
let log = run_komodo_command(
|
||||
"Compose Pull",
|
||||
@@ -659,7 +642,7 @@ impl Resolve<super::Args> for ComposeUp {
|
||||
// Run compose up
|
||||
let extra_args = parse_extra_args(&stack.config.extra_args);
|
||||
let command = format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{additional_env_files}{env_file} up -d{extra_args}{service_args}",
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} up -d{extra_args}{service_args}",
|
||||
);
|
||||
|
||||
let Some(log) = run_komodo_command_with_sanitization(
|
||||
@@ -713,3 +696,146 @@ impl Resolve<super::Args> for ComposeExecution {
|
||||
Ok(log)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<super::Args> for ComposeRun {
|
||||
#[instrument(name = "ComposeRun", level = "debug", skip_all, fields(stack = &self.stack.name, service = &self.service))]
|
||||
async fn resolve(self, _: &super::Args) -> serror::Result<Log> {
|
||||
let ComposeRun {
|
||||
mut stack,
|
||||
repo,
|
||||
git_token,
|
||||
registry_token,
|
||||
mut replacers,
|
||||
service,
|
||||
command,
|
||||
no_tty,
|
||||
no_deps,
|
||||
service_ports,
|
||||
env,
|
||||
workdir,
|
||||
user,
|
||||
entrypoint,
|
||||
pull,
|
||||
} = self;
|
||||
|
||||
let mut interpolator =
|
||||
Interpolator::new(None, &periphery_config().secrets);
|
||||
interpolator
|
||||
.interpolate_stack(&mut stack)?
|
||||
.push_logs(&mut Vec::new());
|
||||
replacers.extend(interpolator.secret_replacers);
|
||||
|
||||
let mut res = ComposeRunResponse::default();
|
||||
let (run_directory, env_file_path) = match write_stack(
|
||||
&stack,
|
||||
repo.as_ref(),
|
||||
git_token,
|
||||
replacers.clone(),
|
||||
&mut res,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return Ok(Log::error(
|
||||
"Write Stack",
|
||||
format_serror(&e.into()),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let run_directory = run_directory.canonicalize().context(
|
||||
"Failed to validate run directory on host after stack write (canonicalize error)",
|
||||
)?;
|
||||
|
||||
maybe_login_registry(&stack, registry_token, &mut Vec::new())
|
||||
.await;
|
||||
|
||||
let docker_compose = docker_compose();
|
||||
|
||||
let file_args = if stack.config.file_paths.is_empty() {
|
||||
String::from("compose.yaml")
|
||||
} else {
|
||||
stack.config.file_paths.join(" -f ")
|
||||
};
|
||||
|
||||
let env_file_args = env_file_args(
|
||||
env_file_path,
|
||||
&stack.config.additional_env_files,
|
||||
)?;
|
||||
|
||||
let project_name = stack.project_name(true);
|
||||
|
||||
if pull.unwrap_or_default() {
|
||||
let pull_log = run_komodo_command(
|
||||
"Compose Pull",
|
||||
run_directory.as_ref(),
|
||||
format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} pull {service}",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if !pull_log.success {
|
||||
return Ok(pull_log);
|
||||
}
|
||||
}
|
||||
|
||||
let mut run_flags = String::from(" --rm");
|
||||
if no_tty.unwrap_or_default() {
|
||||
run_flags.push_str(" --no-tty");
|
||||
}
|
||||
if no_deps.unwrap_or_default() {
|
||||
run_flags.push_str(" --no-deps");
|
||||
}
|
||||
if service_ports.unwrap_or_default() {
|
||||
run_flags.push_str(" --service-ports");
|
||||
}
|
||||
if let Some(dir) = workdir.as_ref() {
|
||||
run_flags.push_str(&format!(" --workdir {dir}"));
|
||||
}
|
||||
if let Some(user) = user.as_ref() {
|
||||
run_flags.push_str(&format!(" --user {user}"));
|
||||
}
|
||||
if let Some(entrypoint) = entrypoint.as_ref() {
|
||||
run_flags.push_str(&format!(" --entrypoint {entrypoint}"));
|
||||
}
|
||||
if let Some(env) = env {
|
||||
for (k, v) in env {
|
||||
run_flags.push_str(&format!(" -e {}={} ", k, v));
|
||||
}
|
||||
}
|
||||
|
||||
let command_args = command
|
||||
.as_ref()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|argv| {
|
||||
let joined = argv
|
||||
.iter()
|
||||
.map(|s| escape(Cow::Borrowed(s)).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!(" {joined}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let command = format!(
|
||||
"{docker_compose} -p {project_name} -f {file_args}{env_file_args} run{run_flags} {service}{command_args}",
|
||||
);
|
||||
|
||||
let Some(log) = run_komodo_command_with_sanitization(
|
||||
"Compose Run",
|
||||
run_directory.as_path(),
|
||||
command,
|
||||
false,
|
||||
&replacers,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ pub enum PeripheryRequest {
|
||||
ComposePull(ComposePull),
|
||||
ComposeUp(ComposeUp),
|
||||
ComposeExecution(ComposeExecution),
|
||||
ComposeRun(ComposeRun),
|
||||
|
||||
// Container (Read)
|
||||
InspectContainer(InspectContainer),
|
||||
|
||||
+16
-10
@@ -53,25 +53,31 @@ pub async fn write_dockerfile(
|
||||
}
|
||||
|
||||
pub fn image_tags(
|
||||
image_name: &str,
|
||||
image_names: &[String],
|
||||
custom_tag: &str,
|
||||
version: &Version,
|
||||
additional: &[String],
|
||||
) -> String {
|
||||
) -> anyhow::Result<String> {
|
||||
let Version { major, minor, .. } = version;
|
||||
let custom_tag = if custom_tag.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{custom_tag}")
|
||||
};
|
||||
let additional = additional
|
||||
.iter()
|
||||
.map(|tag| format!(" -t {image_name}:{tag}{custom_tag}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
format!(
|
||||
" -t {image_name}:latest{custom_tag} -t {image_name}:{version}{custom_tag} -t {image_name}:{major}.{minor}{custom_tag} -t {image_name}:{major}{custom_tag}{additional}",
|
||||
)
|
||||
|
||||
let mut res = String::new();
|
||||
|
||||
for image_name in image_names {
|
||||
write!(
|
||||
&mut res,
|
||||
" -t {image_name}:latest{custom_tag} -t {image_name}:{version}{custom_tag} -t {image_name}:{major}.{minor}{custom_tag} -t {image_name}:{major}{custom_tag}"
|
||||
)?;
|
||||
for tag in additional {
|
||||
write!(&mut res, " -t {image_name}:{tag}{custom_tag}")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn parse_build_args(build_args: &[EnvironmentVar]) -> String {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{Context, anyhow};
|
||||
use command::run_komodo_command;
|
||||
use komodo_client::entities::{
|
||||
RepoExecutionArgs, repo::Repo, stack::Stack,
|
||||
@@ -24,6 +24,35 @@ pub fn docker_compose() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn env_file_args(
|
||||
env_file_path: Option<&str>,
|
||||
additional_env_files: &[String],
|
||||
) -> anyhow::Result<String> {
|
||||
let mut res = String::new();
|
||||
|
||||
for file in additional_env_files.iter().filter(|&path| {
|
||||
let Some(komodo_path) = env_file_path else {
|
||||
return true;
|
||||
};
|
||||
// Filter komodo env out of additional env file if its also in there.
|
||||
// It will be always be added last / have highest priority.
|
||||
path != komodo_path
|
||||
}) {
|
||||
write!(res, " --env-file {file}").with_context(|| {
|
||||
format!("Failed to write --env-file arg for {file}")
|
||||
})?;
|
||||
}
|
||||
|
||||
// Add this last, so it is applied on top
|
||||
if let Some(file) = env_file_path {
|
||||
write!(res, " --env-file {file}").with_context(|| {
|
||||
format!("Failed to write --env-file arg for {file}")
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn down(
|
||||
project: &str,
|
||||
services: &[String],
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Context, anyhow};
|
||||
use formatting::format_serror;
|
||||
use komodo_client::entities::{
|
||||
FileContents, stack::Stack, update::Log,
|
||||
FileContents,
|
||||
stack::{Stack, StackRemoteFileContents},
|
||||
update::Log,
|
||||
};
|
||||
use periphery_client::api::compose::ComposeUpResponse;
|
||||
use tokio::fs;
|
||||
@@ -16,20 +18,24 @@ pub async fn validate_files(
|
||||
res: &mut ComposeUpResponse,
|
||||
) {
|
||||
let file_paths = stack
|
||||
.file_paths()
|
||||
.iter()
|
||||
.map(|path| {
|
||||
.all_file_dependencies()
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
(
|
||||
path,
|
||||
// This will remove any intermediate uneeded '/./' in the path
|
||||
run_directory.join(path).components().collect::<PathBuf>(),
|
||||
run_directory
|
||||
.join(&file.path)
|
||||
.components()
|
||||
.collect::<PathBuf>(),
|
||||
file,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (path, full_path) in &file_paths {
|
||||
// First validate no missing files
|
||||
for (full_path, file) in &file_paths {
|
||||
if !full_path.exists() {
|
||||
res.missing_files.push(path.to_string());
|
||||
res.missing_files.push(file.path.clone());
|
||||
}
|
||||
}
|
||||
if !res.missing_files.is_empty() {
|
||||
@@ -37,21 +43,19 @@ pub async fn validate_files(
|
||||
"Validate Files",
|
||||
format_serror(
|
||||
&anyhow!(
|
||||
"Ensure the run_directory and file_paths are correct."
|
||||
"Ensure the run_directory and all file paths are correct."
|
||||
)
|
||||
.context("A compose file doesn't exist after writing stack.")
|
||||
.context("A file doesn't exist after writing stack.")
|
||||
.into(),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
for (path, full_path) in &file_paths {
|
||||
for (full_path, file) in file_paths {
|
||||
let file_contents =
|
||||
match fs::read_to_string(&full_path).await.with_context(|| {
|
||||
format!(
|
||||
"Failed to read compose file contents at {full_path:?}"
|
||||
)
|
||||
format!("Failed to read file contents at {full_path:?}")
|
||||
}) {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
@@ -61,15 +65,17 @@ pub async fn validate_files(
|
||||
.push(Log::error("Read Compose File", error.clone()));
|
||||
// This should only happen for repo stacks, ie remote error
|
||||
res.remote_errors.push(FileContents {
|
||||
path: path.to_string(),
|
||||
path: file.path,
|
||||
contents: error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
res.file_contents.push(FileContents {
|
||||
path: path.to_string(),
|
||||
res.file_contents.push(StackRemoteFileContents {
|
||||
path: file.path,
|
||||
contents: file_contents,
|
||||
services: file.services,
|
||||
requires: file.requires,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use komodo_client::entities::{
|
||||
stack::Stack, to_path_compatible_name, update::Log,
|
||||
};
|
||||
use periphery_client::api::{
|
||||
compose::{ComposePullResponse, ComposeUpResponse},
|
||||
compose::{
|
||||
ComposePullResponse, ComposeRunResponse, ComposeUpResponse,
|
||||
},
|
||||
git::{CloneRepo, PullOrCloneRepo},
|
||||
};
|
||||
use resolver_api::Resolve;
|
||||
@@ -43,6 +45,12 @@ impl WriteStackRes for &mut ComposePullResponse {
|
||||
}
|
||||
}
|
||||
|
||||
impl WriteStackRes for &mut ComposeRunResponse {
|
||||
fn logs(&mut self) -> &mut Vec<Log> {
|
||||
&mut self.logs
|
||||
}
|
||||
}
|
||||
|
||||
/// Either writes the stack file_contents to a file, or clones the repo.
|
||||
/// Asssumes all interpolation is already complete.
|
||||
/// Returns (run_directory, env_file_path, periphery_replacers)
|
||||
|
||||
@@ -60,7 +60,7 @@ pub struct BatchRunAction {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* actions
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
|
||||
@@ -4,7 +4,7 @@ use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::entities::update::Update;
|
||||
use crate::entities::{alert::SeverityLevel, update::Update};
|
||||
|
||||
use super::KomodoExecuteRequest;
|
||||
|
||||
@@ -27,3 +27,39 @@ pub struct TestAlerter {
|
||||
/// Name or id
|
||||
pub alerter: String,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// Send a custom alert message to configured Alerters. Response: [Update]
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Resolve,
|
||||
EmptyTraits,
|
||||
Parser,
|
||||
)]
|
||||
#[empty_traits(KomodoExecuteRequest)]
|
||||
#[response(Update)]
|
||||
#[error(serror::Error)]
|
||||
pub struct SendAlert {
|
||||
/// The alert level.
|
||||
#[serde(default)]
|
||||
#[clap(long, short = 'l', default_value_t = SeverityLevel::Ok)]
|
||||
pub level: SeverityLevel,
|
||||
/// The alert message. Required.
|
||||
pub message: String,
|
||||
/// The alert details. Optional.
|
||||
#[serde(default)]
|
||||
#[arg(long, short = 'd', default_value_t = String::new())]
|
||||
pub details: String,
|
||||
/// Specific alerter names or ids.
|
||||
/// If empty / not passed, sends to all configured alerters
|
||||
/// with the `Custom` alert type whitelisted / not blacklisted.
|
||||
#[serde(default)]
|
||||
#[arg(long, short = 'a')]
|
||||
pub alerters: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pub struct BatchRunBuild {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* builds
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
|
||||
@@ -62,7 +62,7 @@ pub struct BatchDeploy {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* deployments
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
@@ -270,7 +270,7 @@ pub struct BatchDestroyDeployment {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* deployments
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
|
||||
@@ -50,12 +50,12 @@ pub struct ClearRepoCache {}
|
||||
#[error(serror::Error)]
|
||||
pub struct BackupCoreDatabase {}
|
||||
|
||||
/// Trigger a global poll for image updateson Stacks and Deployments
|
||||
/// Trigger a global poll for image updates on Stacks and Deployments
|
||||
/// with `poll_for_updates` or `auto_update` enabled.
|
||||
/// Admin only. Response: [Update]
|
||||
///
|
||||
/// 1. `docker compose pull` any Stacks / Deployments with `poll_for_updates` or `auto_update` enabled. This will pick up any available updates.
|
||||
/// 2. Redeploy Stacks / Deployments that have updates found.
|
||||
/// 2. Redeploy Stacks / Deployments that have updates found and 'auto_update' enabled.
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Debug,
|
||||
|
||||
@@ -152,9 +152,12 @@ pub enum Execution {
|
||||
StopStack(StopStack),
|
||||
DestroyStack(DestroyStack),
|
||||
BatchDestroyStack(BatchDestroyStack),
|
||||
RunStackService(RunStackService),
|
||||
|
||||
// ALERTER
|
||||
TestAlerter(TestAlerter),
|
||||
#[clap(alias = "alert")]
|
||||
SendAlert(SendAlert),
|
||||
|
||||
// MAINTENANCE
|
||||
ClearRepoCache(ClearRepoCache),
|
||||
|
||||
@@ -48,7 +48,7 @@ pub struct BatchRunProcedure {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* procedures
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
|
||||
@@ -60,7 +60,7 @@ pub struct BatchCloneRepo {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* repos
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
@@ -118,7 +118,7 @@ pub struct BatchPullRepo {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* repos
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
@@ -180,7 +180,7 @@ pub struct BatchBuildRepo {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* repos
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::entities::update::Update;
|
||||
use anyhow::Context;
|
||||
use clap::ArgAction::SetTrue;
|
||||
use clap::Parser;
|
||||
use derive_empty_traits::EmptyTraits;
|
||||
use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::entities::update::Update;
|
||||
|
||||
use super::{BatchExecutionResponse, KomodoExecuteRequest};
|
||||
|
||||
/// Deploys the target stack. `docker compose up`. Response: [Update]
|
||||
@@ -57,7 +59,7 @@ pub struct BatchDeployStack {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* stacks
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
@@ -115,7 +117,7 @@ pub struct BatchDeployStackIfChanged {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* stacks
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
@@ -172,7 +174,7 @@ pub struct BatchPullStack {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* stacks
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
@@ -348,6 +350,61 @@ pub struct DestroyStack {
|
||||
|
||||
//
|
||||
|
||||
/// Runs a one-time command against a service using `docker compose run`. Response: [Update]
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Resolve,
|
||||
EmptyTraits,
|
||||
Parser,
|
||||
)]
|
||||
#[empty_traits(KomodoExecuteRequest)]
|
||||
#[response(Update)]
|
||||
#[error(serror::Error)]
|
||||
pub struct RunStackService {
|
||||
/// Id or name
|
||||
pub stack: String,
|
||||
/// Service to run
|
||||
pub service: String,
|
||||
/// Command and args to pass to the service container
|
||||
#[arg(trailing_var_arg = true, num_args = 1.., allow_hyphen_values = true)]
|
||||
pub command: Option<Vec<String>>,
|
||||
/// Do not allocate TTY
|
||||
#[arg(long = "no-tty", action = SetTrue)]
|
||||
pub no_tty: Option<bool>,
|
||||
/// Do not start linked services
|
||||
#[arg(long = "no-deps", action = SetTrue)]
|
||||
pub no_deps: Option<bool>,
|
||||
/// Map service ports to the host
|
||||
#[arg(long = "service-ports", action = SetTrue)]
|
||||
pub service_ports: Option<bool>,
|
||||
/// Extra environment variables for the run
|
||||
#[arg(long = "env", short = 'e', value_parser = env_parser)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
/// Working directory inside the container
|
||||
#[arg(long = "workdir")]
|
||||
pub workdir: Option<String>,
|
||||
/// User to run as inside the container
|
||||
#[arg(long = "user")]
|
||||
pub user: Option<String>,
|
||||
/// Override the default entrypoint
|
||||
#[arg(long = "entrypoint")]
|
||||
pub entrypoint: Option<String>,
|
||||
/// Pull the image before running
|
||||
#[arg(long = "pull", action = SetTrue)]
|
||||
pub pull: Option<bool>,
|
||||
}
|
||||
|
||||
fn env_parser(args: &str) -> anyhow::Result<HashMap<String, String>> {
|
||||
serde_qs::from_str(args).context("Failed to parse env")
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// Destroys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse].
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
@@ -368,7 +425,7 @@ pub struct BatchDestroyStack {
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///d
|
||||
/// Example:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// # match all foo-* stacks
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! the schemas given in [read], [mod@write], [execute], and so on.
|
||||
//!
|
||||
//! For example, this is an example body for [read::GetDeployment]:
|
||||
//! ```
|
||||
//! ```json
|
||||
//! {
|
||||
//! "type": "GetDeployment",
|
||||
//! "params": {
|
||||
@@ -56,7 +56,7 @@
|
||||
//!
|
||||
//! Request errors will be returned with a JSON body containing information about the error.
|
||||
//! They will have the following common format:
|
||||
//! ```
|
||||
//! ```json
|
||||
//! {
|
||||
//! "error": "top level error message",
|
||||
//! "trace": [
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct ListAlerts {
|
||||
/// Pass a custom mongo query to filter the alerts.
|
||||
///
|
||||
/// ## Example JSON
|
||||
/// ```
|
||||
/// ```json
|
||||
/// {
|
||||
/// "resolved": "false",
|
||||
/// "level": "CRITICAL",
|
||||
|
||||
@@ -623,6 +623,8 @@ pub struct GetServersSummaryResponse {
|
||||
pub total: I64,
|
||||
/// The number of healthy (`status: OK`) servers.
|
||||
pub healthy: I64,
|
||||
/// The number of servers with warnings (e.g., version mismatch).
|
||||
pub warning: I64,
|
||||
/// The number of unhealthy servers.
|
||||
pub unhealthy: I64,
|
||||
/// The number of disabled servers.
|
||||
|
||||
@@ -35,11 +35,10 @@ impl<'de> Visitor<'de> for ConversionVisitor {
|
||||
write!(formatter, "string or Vec<Conversion>")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
fn visit_string<E>(self, out: String) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
let out = v.to_string();
|
||||
if out.is_empty() || out.ends_with('\n') {
|
||||
Ok(out)
|
||||
} else {
|
||||
@@ -47,6 +46,13 @@ impl<'de> Visitor<'de> for ConversionVisitor {
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Self::visit_string(self, v.to_string())
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
@@ -78,6 +84,13 @@ impl<'de> Visitor<'de> for OptionConversionVisitor {
|
||||
write!(formatter, "null or string or Vec<Conversion>")
|
||||
}
|
||||
|
||||
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
ConversionVisitor.visit_string(v).map(Some)
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! # Item or Vec<Item> deserializer.
|
||||
//!
|
||||
//! Used to convert `item: T` (struct / map) -> `item: Vec<T>` (seq) in schemas with backward compatibility.
|
||||
//! Supports deserializing either a T as Vec<T> with length 1, or a seq as Vec<T> directly.
|
||||
|
||||
use serde::{
|
||||
Deserialize, Deserializer,
|
||||
de::{
|
||||
DeserializeOwned, IntoDeserializer, Visitor,
|
||||
value::{MapAccessDeserializer, SeqAccessDeserializer},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn item_or_vec_deserializer<'de, D, T>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
deserializer
|
||||
.deserialize_any(ItemOrVecVisitor::<T>(std::marker::PhantomData))
|
||||
}
|
||||
|
||||
pub fn option_item_or_vec_deserializer<'de, D, T>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Vec<T>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
deserializer.deserialize_any(OptionItemOrVecVisitor::<T>(
|
||||
std::marker::PhantomData,
|
||||
))
|
||||
}
|
||||
|
||||
struct ItemOrVecVisitor<T>(std::marker::PhantomData<T>);
|
||||
|
||||
impl<'de, T> Visitor<'de> for ItemOrVecVisitor<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
type Value = Vec<T>;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
write!(formatter, "Item or Vec<Item>")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
T::deserialize(
|
||||
MapAccessDeserializer::new(map).into_deserializer(),
|
||||
)
|
||||
.map(|r| vec![r])
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
{
|
||||
Vec::<T>::deserialize(
|
||||
SeqAccessDeserializer::new(seq).into_deserializer(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionItemOrVecVisitor<T>(std::marker::PhantomData<T>);
|
||||
|
||||
impl<'de, T> Visitor<'de> for OptionItemOrVecVisitor<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
type Value = Option<Vec<T>>;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
write!(formatter, "null or Item or Vec<Item>")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
ItemOrVecVisitor::<T>(std::marker::PhantomData)
|
||||
.visit_map(map)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
{
|
||||
ItemOrVecVisitor::<T>(std::marker::PhantomData)
|
||||
.visit_seq(seq)
|
||||
.map(Some)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod conversion;
|
||||
mod environment;
|
||||
mod file_contents;
|
||||
mod forgiving_vec;
|
||||
mod item_or_vec;
|
||||
mod labels;
|
||||
mod maybe_string_i64;
|
||||
mod permission;
|
||||
@@ -14,6 +15,7 @@ pub use conversion::*;
|
||||
pub use environment::*;
|
||||
pub use file_contents::*;
|
||||
pub use forgiving_vec::*;
|
||||
pub use item_or_vec::*;
|
||||
pub use labels::*;
|
||||
pub use maybe_string_i64::*;
|
||||
pub use string_list::*;
|
||||
|
||||
@@ -91,13 +91,13 @@ pub struct ActionConfig {
|
||||
/// 1. Regular CRON expression:
|
||||
///
|
||||
/// (second, minute, hour, day, month, day-of-week)
|
||||
/// ```
|
||||
/// ```text
|
||||
/// 0 0 0 1,15 * ?
|
||||
/// ```
|
||||
///
|
||||
/// 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
|
||||
///
|
||||
/// ```
|
||||
/// ```text
|
||||
/// at midnight on the 1st and 15th of the month
|
||||
/// ```
|
||||
#[serde(default)]
|
||||
|
||||
@@ -137,6 +137,20 @@ pub enum AlertData {
|
||||
total_gb: f64,
|
||||
},
|
||||
|
||||
/// A server has a version mismatch with the core.
|
||||
ServerVersionMismatch {
|
||||
/// The id of the server
|
||||
id: String,
|
||||
/// The name of the server
|
||||
name: String,
|
||||
/// The region of the server
|
||||
region: Option<String>,
|
||||
/// The actual server version
|
||||
server_version: String,
|
||||
/// The core version
|
||||
core_version: String,
|
||||
},
|
||||
|
||||
/// A container's state has changed unexpectedly.
|
||||
ContainerStateChange {
|
||||
/// The id of the deployment
|
||||
@@ -286,6 +300,16 @@ pub enum AlertData {
|
||||
/// The resource name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Custom header / body.
|
||||
/// Produced using `/execute/SendAlert`
|
||||
Custom {
|
||||
/// The alert message.
|
||||
message: String,
|
||||
/// Message details. May be empty string.
|
||||
#[serde(default)]
|
||||
details: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for AlertData {
|
||||
@@ -320,10 +344,29 @@ impl Default for AlertDataVariant {
|
||||
#[strum(serialize_all = "UPPERCASE")]
|
||||
pub enum SeverityLevel {
|
||||
/// No problem.
|
||||
///
|
||||
/// Aliases: ok, low, l
|
||||
#[default]
|
||||
#[strum(serialize = "ok", serialize = "low", serialize = "l")]
|
||||
Ok,
|
||||
/// Problem is imminent.
|
||||
///
|
||||
/// Aliases: warning, w, medium, m
|
||||
#[strum(
|
||||
serialize = "warning",
|
||||
serialize = "w",
|
||||
serialize = "medium",
|
||||
serialize = "m"
|
||||
)]
|
||||
Warning,
|
||||
/// Problem fully realized.
|
||||
///
|
||||
/// Aliases: critical, c, high, h
|
||||
#[strum(
|
||||
serialize = "critical",
|
||||
serialize = "c",
|
||||
serialize = "high",
|
||||
serialize = "h"
|
||||
)]
|
||||
Critical,
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use bson::{Document, doc};
|
||||
use derive_builder::Builder;
|
||||
use derive_default_builder::DefaultBuilder;
|
||||
use partial_derive2::Partial;
|
||||
use serde::{
|
||||
Deserialize, Deserializer, Serialize,
|
||||
de::{Visitor, value::MapAccessDeserializer},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::Display;
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::{
|
||||
deserializers::{
|
||||
env_vars_deserializer, labels_deserializer,
|
||||
option_env_vars_deserializer, option_labels_deserializer,
|
||||
env_vars_deserializer, item_or_vec_deserializer,
|
||||
labels_deserializer, option_env_vars_deserializer,
|
||||
option_item_or_vec_deserializer, option_labels_deserializer,
|
||||
option_string_list_deserializer, string_list_deserializer,
|
||||
},
|
||||
entities::I64,
|
||||
};
|
||||
|
||||
use super::{
|
||||
NoData, SystemCommand, Version,
|
||||
SystemCommand, Version,
|
||||
resource::{Resource, ResourceListItem, ResourceQuery},
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ pub struct BuildListItemInfo {
|
||||
/// Latest short commit hash, or null. Only for repo based stacks
|
||||
pub latest_hash: Option<String>,
|
||||
|
||||
/// The image registry domain
|
||||
/// The first listed image registry domain
|
||||
pub image_registry_domain: Option<String>,
|
||||
}
|
||||
|
||||
@@ -255,14 +255,15 @@ pub struct BuildConfig {
|
||||
#[partial_default(default_dockerfile_path())]
|
||||
pub dockerfile_path: String,
|
||||
|
||||
/// Configuration for the registry to push the built image to.
|
||||
#[serde(default, deserialize_with = "image_registry_deserializer")]
|
||||
/// Configuration for the registry/s to push the built image to.
|
||||
/// The first registry in this list will be used with attached Deployments.
|
||||
#[serde(default, deserialize_with = "item_or_vec_deserializer")]
|
||||
#[partial_attr(serde(
|
||||
default,
|
||||
deserialize_with = "option_image_registry_deserializer"
|
||||
deserialize_with = "option_item_or_vec_deserializer"
|
||||
))]
|
||||
#[builder(default)]
|
||||
pub image_registry: ImageRegistryConfig,
|
||||
pub image_registry: Vec<ImageRegistryConfig>,
|
||||
|
||||
/// Whether to skip secret interpolation in the build_args.
|
||||
#[serde(default)]
|
||||
@@ -422,125 +423,10 @@ pub struct ImageRegistryConfig {
|
||||
pub organization: String,
|
||||
}
|
||||
|
||||
pub fn image_registry_deserializer<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<ImageRegistryConfig, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_any(ImageRegistryVisitor)
|
||||
}
|
||||
|
||||
pub fn option_image_registry_deserializer<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<ImageRegistryConfig>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_any(OptionImageRegistryVisitor)
|
||||
}
|
||||
|
||||
struct ImageRegistryVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for ImageRegistryVisitor {
|
||||
type Value = ImageRegistryConfig;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"{{ \"domain\": string, \"account\": string, \"organization\": string }}"
|
||||
)
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
// Need to use Value intermediate to get something cloneable.
|
||||
let value = serde_json::Value::deserialize(
|
||||
MapAccessDeserializer::new(map),
|
||||
)?;
|
||||
// 1.14 and before: try to use ImageRegistry1_14 syntax
|
||||
if let Ok(config) =
|
||||
serde_json::from_value::<ImageRegistryLegacy1_14>(value.clone())
|
||||
{
|
||||
return Ok(config.into());
|
||||
}
|
||||
serde_json::from_value(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionImageRegistryVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for OptionImageRegistryVisitor {
|
||||
type Value = Option<ImageRegistryConfig>;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"null or {{ \"domain\": string, \"account\": string, \"organization\": string }}"
|
||||
)
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
ImageRegistryVisitor.visit_map(map).map(Some)
|
||||
}
|
||||
|
||||
fn visit_none<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the registry to push the built image to.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "params")]
|
||||
pub enum ImageRegistryLegacy1_14 {
|
||||
/// Don't push the image to any registry
|
||||
None(NoData),
|
||||
/// Push the image to a standard image registry (any domain)
|
||||
Standard(ImageRegistryConfig),
|
||||
}
|
||||
|
||||
impl Default for ImageRegistryLegacy1_14 {
|
||||
fn default() -> Self {
|
||||
Self::None(NoData {})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ImageRegistryLegacy1_14> for ImageRegistryConfig {
|
||||
fn from(value: ImageRegistryLegacy1_14) -> Self {
|
||||
match value {
|
||||
ImageRegistryLegacy1_14::None(_) => {
|
||||
ImageRegistryConfig::default()
|
||||
}
|
||||
ImageRegistryLegacy1_14::Standard(mut config) => {
|
||||
// This version had domain defaulted to docker.io
|
||||
if config.domain.is_empty() {
|
||||
config.domain = String::from("docker.io");
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
impl ImageRegistryConfig {
|
||||
pub fn static_default() -> &'static ImageRegistryConfig {
|
||||
static DEFAULT: OnceLock<ImageRegistryConfig> = OnceLock::new();
|
||||
DEFAULT.get_or_init(Default::default)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,14 @@ pub enum Command {
|
||||
#[clap(alias = "ls", alias = "resources")]
|
||||
List(list::List),
|
||||
|
||||
/// Run Komodo executions. (aliases: `x`, `run`, `deploy`, `dep`)
|
||||
#[clap(alias = "x", alias = "run", alias = "deploy", alias = "dep")]
|
||||
/// Run Komodo executions. (aliases: `x`, `run`, `deploy`, `dep`, `send`)
|
||||
#[clap(
|
||||
alias = "x",
|
||||
alias = "run",
|
||||
alias = "deploy",
|
||||
alias = "dep",
|
||||
alias = "send"
|
||||
)]
|
||||
Execute(Execute),
|
||||
|
||||
/// Update resource configuration. (alias: `set`)
|
||||
|
||||
@@ -70,7 +70,7 @@ pub struct Env {
|
||||
pub komodo_cli_backups_folder: Option<PathBuf>,
|
||||
/// Override `max_backups`
|
||||
pub komodo_cli_max_backups: Option<u16>,
|
||||
|
||||
|
||||
/// Override `database_target_uri`
|
||||
#[serde(alias = "komodo_cli_database_copy_uri")]
|
||||
pub komodo_cli_database_target_uri: Option<String>,
|
||||
|
||||
@@ -129,43 +129,52 @@ pub fn optional_string(string: impl Into<String>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_image_name(
|
||||
pub fn get_image_names(
|
||||
build::Build {
|
||||
name,
|
||||
config:
|
||||
build::BuildConfig {
|
||||
image_name,
|
||||
image_registry:
|
||||
ImageRegistryConfig {
|
||||
domain,
|
||||
account,
|
||||
organization,
|
||||
},
|
||||
image_registry,
|
||||
..
|
||||
},
|
||||
..
|
||||
}: &build::Build,
|
||||
) -> anyhow::Result<String> {
|
||||
) -> Vec<String> {
|
||||
let name = if image_name.is_empty() {
|
||||
name
|
||||
} else {
|
||||
image_name
|
||||
};
|
||||
let name = match (
|
||||
!domain.is_empty(),
|
||||
!organization.is_empty(),
|
||||
!account.is_empty(),
|
||||
) {
|
||||
// If organization and account provided, name under organization.
|
||||
(true, true, true) => {
|
||||
format!("{domain}/{organization}/{name}")
|
||||
}
|
||||
// Just domain / account provided
|
||||
(true, false, true) => format!("{domain}/{account}/{name}"),
|
||||
// Otherwise, just use name
|
||||
_ => name.to_string(),
|
||||
};
|
||||
Ok(name)
|
||||
// Local only
|
||||
if image_registry.is_empty() {
|
||||
return vec![name.to_string()];
|
||||
}
|
||||
image_registry
|
||||
.iter()
|
||||
.map(
|
||||
|ImageRegistryConfig {
|
||||
domain,
|
||||
account,
|
||||
organization,
|
||||
}| {
|
||||
match (
|
||||
!domain.is_empty(),
|
||||
!organization.is_empty(),
|
||||
!account.is_empty(),
|
||||
) {
|
||||
// If organization and account provided, name under organization.
|
||||
(true, true, true) => {
|
||||
format!("{domain}/{organization}/{name}")
|
||||
}
|
||||
// Just domain / account provided
|
||||
(true, false, true) => format!("{domain}/{account}/{name}"),
|
||||
// Otherwise, just use name (local only)
|
||||
_ => name.to_string(),
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn to_general_name(name: &str) -> String {
|
||||
@@ -396,7 +405,7 @@ pub struct LatestCommit {
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FileContents {
|
||||
/// The path of the file on the host
|
||||
/// The path to the file
|
||||
pub path: String,
|
||||
/// The contents of the file
|
||||
pub contents: String,
|
||||
@@ -1083,6 +1092,7 @@ pub enum Operation {
|
||||
UnpauseStack,
|
||||
StopStack,
|
||||
DestroyStack,
|
||||
RunStackService,
|
||||
|
||||
// stack (service)
|
||||
DeployStackService,
|
||||
@@ -1153,6 +1163,7 @@ pub enum Operation {
|
||||
RenameAlerter,
|
||||
DeleteAlerter,
|
||||
TestAlerter,
|
||||
SendAlert,
|
||||
|
||||
// sync
|
||||
CreateResourceSync,
|
||||
|
||||
@@ -91,13 +91,13 @@ pub struct ProcedureConfig {
|
||||
/// 1. Regular CRON expression:
|
||||
///
|
||||
/// (second, minute, hour, day, month, day-of-week)
|
||||
/// ```
|
||||
/// ```text
|
||||
/// 0 0 0 1,15 * ?
|
||||
/// ```
|
||||
///
|
||||
/// 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
|
||||
///
|
||||
/// ```
|
||||
/// ```text
|
||||
/// at midnight on the 1st and 15th of the month
|
||||
/// ```
|
||||
#[serde(default)]
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct ServerListItemInfo {
|
||||
pub send_mem_alerts: bool,
|
||||
/// Whether server is configured to send disk alerts.
|
||||
pub send_disk_alerts: bool,
|
||||
/// Whether server is configured to send version mismatch alerts.
|
||||
pub send_version_mismatch_alerts: bool,
|
||||
/// Whether terminals are disabled for this Server.
|
||||
pub terminals_disabled: bool,
|
||||
/// Whether container exec is disabled for this Server.
|
||||
@@ -84,7 +86,7 @@ pub struct ServerConfig {
|
||||
/// Whether a server is enabled.
|
||||
/// If a server is disabled,
|
||||
/// you won't be able to perform any actions on it or see deployment's status.
|
||||
/// default: true
|
||||
/// Default: false
|
||||
#[serde(default = "default_enabled")]
|
||||
#[builder(default = "default_enabled()")]
|
||||
#[partial_default(default_enabled())]
|
||||
@@ -161,6 +163,12 @@ pub struct ServerConfig {
|
||||
#[partial_default(default_send_alerts())]
|
||||
pub send_disk_alerts: bool,
|
||||
|
||||
/// Whether to send alerts about the servers version mismatch with core
|
||||
#[serde(default = "default_send_alerts")]
|
||||
#[builder(default = "default_send_alerts()")]
|
||||
#[partial_default(default_send_alerts())]
|
||||
pub send_version_mismatch_alerts: bool,
|
||||
|
||||
/// The percentage threshhold which triggers WARNING state for CPU.
|
||||
#[serde(default = "default_cpu_warning")]
|
||||
#[builder(default = "default_cpu_warning()")]
|
||||
@@ -272,6 +280,7 @@ impl Default for ServerConfig {
|
||||
send_cpu_alerts: default_send_alerts(),
|
||||
send_mem_alerts: default_send_alerts(),
|
||||
send_disk_alerts: default_send_alerts(),
|
||||
send_version_mismatch_alerts: default_send_alerts(),
|
||||
region: Default::default(),
|
||||
passkey: Default::default(),
|
||||
cpu_warning: default_cpu_warning(),
|
||||
|
||||
@@ -4,8 +4,12 @@ use anyhow::Context;
|
||||
use bson::{Document, doc};
|
||||
use derive_builder::Builder;
|
||||
use derive_default_builder::DefaultBuilder;
|
||||
use indexmap::IndexSet;
|
||||
use partial_derive2::Partial;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{
|
||||
Deserialize, Serialize,
|
||||
de::{IntoDeserializer, Visitor, value::MapAccessDeserializer},
|
||||
};
|
||||
use strum::Display;
|
||||
use typeshare::typeshare;
|
||||
|
||||
@@ -44,13 +48,56 @@ impl Stack {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_paths(&self) -> &[String] {
|
||||
pub fn compose_file_paths(&self) -> &[String] {
|
||||
if self.config.file_paths.is_empty() {
|
||||
default_stack_file_paths()
|
||||
} else {
|
||||
&self.config.file_paths
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_compose_file(&self, path: &str) -> bool {
|
||||
for compose_path in self.compose_file_paths() {
|
||||
if path.ends_with(compose_path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn all_file_paths(&self) -> Vec<String> {
|
||||
let mut res = self
|
||||
.compose_file_paths()
|
||||
.iter()
|
||||
.cloned()
|
||||
// Makes sure to dedup them, while maintaining ordering
|
||||
.collect::<IndexSet<_>>();
|
||||
res.extend(self.config.additional_env_files.clone());
|
||||
res.extend(
|
||||
self.config.config_files.iter().map(|f| f.path.clone()),
|
||||
);
|
||||
res.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn all_file_dependencies(&self) -> Vec<StackFileDependency> {
|
||||
let mut res = self
|
||||
.compose_file_paths()
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(StackFileDependency::full_redeploy)
|
||||
// Makes sure to dedup them, while maintaining ordering
|
||||
.collect::<IndexSet<_>>();
|
||||
res.extend(
|
||||
self
|
||||
.config
|
||||
.additional_env_files
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(StackFileDependency::full_redeploy),
|
||||
);
|
||||
res.extend(self.config.config_files.clone());
|
||||
res.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stack_file_paths() -> &'static [String] {
|
||||
@@ -159,7 +206,7 @@ pub enum StackState {
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct StackInfo {
|
||||
/// If any of the expected files are missing in the repo,
|
||||
/// If any of the expected compose / additional files are missing in the repo,
|
||||
/// they will be stored here.
|
||||
#[serde(default)]
|
||||
pub missing_files: Vec<String>,
|
||||
@@ -174,7 +221,7 @@ pub struct StackInfo {
|
||||
pub deployed_hash: Option<String>,
|
||||
/// Deployed commit message, or null. Only for repo based stacks
|
||||
pub deployed_message: Option<String>,
|
||||
/// The deployed compose file contents.
|
||||
/// The deployed compose / additional file contents.
|
||||
/// This is updated whenever Komodo successfully deploys the stack.
|
||||
pub deployed_contents: Option<Vec<FileContents>>,
|
||||
/// The deployed service names.
|
||||
@@ -188,10 +235,10 @@ pub struct StackInfo {
|
||||
#[serde(default)]
|
||||
pub latest_services: Vec<StackServiceNames>,
|
||||
|
||||
/// The remote compose file contents, whether on host or in repo.
|
||||
/// The remote compose / additional file contents, whether on host or in repo.
|
||||
/// This is updated whenever Komodo refreshes the stack cache.
|
||||
/// It will be empty if the file is defined directly in the stack config.
|
||||
pub remote_contents: Option<Vec<FileContents>>,
|
||||
pub remote_contents: Option<Vec<StackRemoteFileContents>>,
|
||||
/// If there was an error in getting the remote contents, it will be here.
|
||||
pub remote_errors: Option<Vec<FileContents>>,
|
||||
|
||||
@@ -386,6 +433,9 @@ pub struct StackConfig {
|
||||
|
||||
/// Add additional env files to attach with `--env-file`.
|
||||
/// Relative to the run directory root.
|
||||
///
|
||||
/// Note. It is already included as an `additional_file`.
|
||||
/// Don't add it again there.
|
||||
#[serde(default, deserialize_with = "string_list_deserializer")]
|
||||
#[partial_attr(serde(
|
||||
default,
|
||||
@@ -394,6 +444,18 @@ pub struct StackConfig {
|
||||
#[builder(default)]
|
||||
pub additional_env_files: Vec<String>,
|
||||
|
||||
/// Add additional config files either in repo or on host to track.
|
||||
/// Can add any files associated with the stack to enable editing them in the UI.
|
||||
/// Doing so will also include diffing these when deciding to deploy in `DeployStackIfChanged`.
|
||||
/// Relative to the run directory.
|
||||
///
|
||||
/// Note. If the config file is .env and should be included in compose command
|
||||
/// using `--env-file`, add it to `additional_env_files` instead.
|
||||
#[serde(default)]
|
||||
#[partial_attr(serde(default))]
|
||||
#[builder(default)]
|
||||
pub config_files: Vec<StackFileDependency>,
|
||||
|
||||
/// Whether to send StackStateChange alerts for this stack.
|
||||
#[serde(default = "default_send_alerts")]
|
||||
#[builder(default = "default_send_alerts()")]
|
||||
@@ -539,6 +601,7 @@ impl Default for StackConfig {
|
||||
environment: Default::default(),
|
||||
env_file_path: default_env_file_path(),
|
||||
additional_env_files: Default::default(),
|
||||
config_files: Default::default(),
|
||||
run_build: Default::default(),
|
||||
destroy_before_deploy: Default::default(),
|
||||
build_extra_args: Default::default(),
|
||||
@@ -638,6 +701,10 @@ pub struct StackQuerySpecifics {
|
||||
/// Only accepts Server id (not name).
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
/// Query only for Stacks with these linked repos.
|
||||
/// Only accepts Repo id (not name).
|
||||
#[serde(default)]
|
||||
pub linked_repos: Vec<String>,
|
||||
/// Filter syncs by their repo.
|
||||
#[serde(default)]
|
||||
pub repos: Vec<String>,
|
||||
@@ -652,6 +719,12 @@ impl super::resource::AddFilters for StackQuerySpecifics {
|
||||
filters
|
||||
.insert("config.server_id", doc! { "$in": &self.server_ids });
|
||||
}
|
||||
if !self.linked_repos.is_empty() {
|
||||
filters.insert(
|
||||
"config.linked_repo",
|
||||
doc! { "$in": &self.linked_repos },
|
||||
);
|
||||
}
|
||||
if !self.repos.is_empty() {
|
||||
filters.insert("config.repo", doc! { "$in": &self.repos });
|
||||
}
|
||||
@@ -683,3 +756,165 @@ pub struct ComposeServiceDeploy {
|
||||
)]
|
||||
pub replicas: Option<i64>,
|
||||
}
|
||||
|
||||
// PRE-1.19.1 BACKWARD COMPAT NOTE
|
||||
// This was split from general FileContents in 1.19.1,
|
||||
// and must maintain 2 way de/ser backward compatibility
|
||||
// with the mentioned struct.
|
||||
/// Same as [FileContents] with some extra
|
||||
/// info specific to Stacks.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct StackRemoteFileContents {
|
||||
/// The path to the file
|
||||
pub path: String,
|
||||
/// The contents of the file
|
||||
pub contents: String,
|
||||
/// The services depending on this file,
|
||||
/// or empty for global requirement (eg all compose files and env files).
|
||||
#[serde(default)]
|
||||
pub services: Vec<String>,
|
||||
/// Whether diff requires Redeploy / Restart / None
|
||||
#[serde(default)]
|
||||
pub requires: StackFileRequires,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
pub enum StackFileRequires {
|
||||
/// Diff requires service redeploy.
|
||||
#[serde(alias = "redeploy")]
|
||||
Redeploy,
|
||||
/// Diff requires service restart
|
||||
#[serde(alias = "restart")]
|
||||
Restart,
|
||||
/// Diff requires no action. Default.
|
||||
#[default]
|
||||
#[serde(alias = "none")]
|
||||
None,
|
||||
}
|
||||
|
||||
/// Configure additional file dependencies of the Stack.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
pub struct StackFileDependency {
|
||||
/// Specify the file
|
||||
pub path: String,
|
||||
/// Specify specific service/s
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<String>,
|
||||
/// Specify
|
||||
#[serde(default, skip_serializing_if = "is_none")]
|
||||
pub requires: StackFileRequires,
|
||||
}
|
||||
|
||||
impl StackFileDependency {
|
||||
pub fn full_redeploy(path: String) -> StackFileDependency {
|
||||
StackFileDependency {
|
||||
path,
|
||||
services: Vec::new(),
|
||||
requires: StackFileRequires::Redeploy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_none(requires: &StackFileRequires) -> bool {
|
||||
matches!(requires, StackFileRequires::None)
|
||||
}
|
||||
|
||||
/// Used with custom de/serializer for [StackFileDependency]
|
||||
#[derive(Deserialize)]
|
||||
struct __StackFileDependency {
|
||||
path: String,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "service",
|
||||
deserialize_with = "string_list_deserializer"
|
||||
)]
|
||||
services: Vec<String>,
|
||||
#[serde(default, alias = "req")]
|
||||
requires: StackFileRequires,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for StackFileDependency {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct StackFileDependencyVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for StackFileDependencyVisitor {
|
||||
type Value = StackFileDependency;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
write!(formatter, "string or StackFileDependency (object)")
|
||||
}
|
||||
|
||||
fn visit_string<E>(self, path: String) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(StackFileDependency {
|
||||
path,
|
||||
services: Vec::new(),
|
||||
requires: StackFileRequires::None,
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Self::visit_string(self, v.to_string())
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
__StackFileDependency::deserialize(
|
||||
MapAccessDeserializer::new(map).into_deserializer(),
|
||||
)
|
||||
.map(|v| StackFileDependency {
|
||||
path: v.path,
|
||||
services: v.services,
|
||||
requires: v.requires,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(StackFileDependencyVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
// // This one is nice for TOML, but annoying to use on frontend
|
||||
// impl Serialize for StackFileDependency {
|
||||
// fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
// where
|
||||
// S: serde::Serializer,
|
||||
// {
|
||||
// // Serialize to string in default case
|
||||
// if is_redeploy(&self.requires) && self.services.is_empty() {
|
||||
// return serializer.serialize_str(&self.path);
|
||||
// }
|
||||
// __StackFileDependency {
|
||||
// path: self.path.clone(),
|
||||
// services: self.services.clone(),
|
||||
// requires: self.requires,
|
||||
// }
|
||||
// .serialize(serializer)
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! - `KOMODO_API_SECRET`
|
||||
//!
|
||||
//! ## Client Example
|
||||
//! ```
|
||||
//! ```text
|
||||
//! dotenvy::dotenv().ok();
|
||||
//!
|
||||
//! let client = KomodoClient::new_from_env()?;
|
||||
@@ -109,7 +109,7 @@ impl KomodoClient {
|
||||
|
||||
/// Add a healthcheck in the initialization pipeline:
|
||||
///
|
||||
/// ```rust
|
||||
/// ```text
|
||||
/// let komodo = KomodoClient::new_from_env()?
|
||||
/// .with_healthcheck().await?;
|
||||
/// ```
|
||||
@@ -121,7 +121,7 @@ impl KomodoClient {
|
||||
|
||||
/// Add a healthcheck in the initialization pipeline:
|
||||
///
|
||||
/// ```rust
|
||||
/// ```text
|
||||
/// let komodo = KomodoClient::new_from_env()?
|
||||
/// .with_healthcheck().await?;
|
||||
/// ```
|
||||
|
||||
@@ -61,7 +61,7 @@ impl KomodoClient {
|
||||
/// and forwards the updates over a channel.
|
||||
/// Handles reconnection internally.
|
||||
///
|
||||
/// ```
|
||||
/// ```text
|
||||
/// let (mut rx, _) = komodo.subscribe_to_updates()?;
|
||||
/// loop {
|
||||
/// let update = match rx.recv().await {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "komodo_client",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.1",
|
||||
"description": "Komodo client package",
|
||||
"homepage": "https://komo.do",
|
||||
"main": "dist/lib.js",
|
||||
|
||||
@@ -405,9 +405,11 @@ export type ExecuteResponses = {
|
||||
PauseStackService: Types.Update;
|
||||
UnpauseStackService: Types.Update;
|
||||
DestroyStackService: Types.Update;
|
||||
|
||||
RunStackService: Types.Update;
|
||||
|
||||
// ==== ALERTER ====
|
||||
TestAlerter: Types.Update;
|
||||
SendAlert: Types.Update;
|
||||
|
||||
// ==== MAINTENANCE ====
|
||||
ClearRepoCache: Types.Update;
|
||||
|
||||
+179
-39
@@ -83,13 +83,13 @@ export interface ActionConfig {
|
||||
* 1. Regular CRON expression:
|
||||
*
|
||||
* (second, minute, hour, day, month, day-of-week)
|
||||
* ```
|
||||
* ```text
|
||||
* 0 0 0 1,15 * ?
|
||||
* ```
|
||||
*
|
||||
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
|
||||
*
|
||||
* ```
|
||||
* ```text
|
||||
* at midnight on the 1st and 15th of the month
|
||||
* ```
|
||||
*/
|
||||
@@ -386,6 +386,7 @@ export enum Operation {
|
||||
UnpauseStack = "UnpauseStack",
|
||||
StopStack = "StopStack",
|
||||
DestroyStack = "DestroyStack",
|
||||
RunStackService = "RunStackService",
|
||||
DeployStackService = "DeployStackService",
|
||||
PullStackService = "PullStackService",
|
||||
StartStackService = "StartStackService",
|
||||
@@ -440,6 +441,7 @@ export enum Operation {
|
||||
RenameAlerter = "RenameAlerter",
|
||||
DeleteAlerter = "DeleteAlerter",
|
||||
TestAlerter = "TestAlerter",
|
||||
SendAlert = "SendAlert",
|
||||
CreateResourceSync = "CreateResourceSync",
|
||||
UpdateResourceSync = "UpdateResourceSync",
|
||||
RenameResourceSync = "RenameResourceSync",
|
||||
@@ -643,8 +645,11 @@ export interface BuildConfig {
|
||||
build_path: string;
|
||||
/** The path of the dockerfile relative to the build path. */
|
||||
dockerfile_path: string;
|
||||
/** Configuration for the registry to push the built image to. */
|
||||
image_registry?: ImageRegistryConfig;
|
||||
/**
|
||||
* Configuration for the registry/s to push the built image to.
|
||||
* The first registry in this list will be used with attached Deployments.
|
||||
*/
|
||||
image_registry?: ImageRegistryConfig[];
|
||||
/** Whether to skip secret interpolation in the build_args. */
|
||||
skip_secret_interp?: boolean;
|
||||
/** Whether to use buildx to build (eg `docker buildx build ...`) */
|
||||
@@ -749,7 +754,7 @@ export interface BuildListItemInfo {
|
||||
built_hash?: string;
|
||||
/** Latest short commit hash, or null. Only for repo based stacks */
|
||||
latest_hash?: string;
|
||||
/** The image registry domain */
|
||||
/** The first listed image registry domain */
|
||||
image_registry_domain?: string;
|
||||
}
|
||||
|
||||
@@ -867,7 +872,9 @@ export type Execution =
|
||||
| { type: "StopStack", params: StopStack }
|
||||
| { type: "DestroyStack", params: DestroyStack }
|
||||
| { type: "BatchDestroyStack", params: BatchDestroyStack }
|
||||
| { type: "RunStackService", params: RunStackService }
|
||||
| { type: "TestAlerter", params: TestAlerter }
|
||||
| { type: "SendAlert", params: SendAlert }
|
||||
| { type: "ClearRepoCache", params: ClearRepoCache }
|
||||
| { type: "BackupCoreDatabase", params: BackupCoreDatabase }
|
||||
| { type: "GlobalAutoUpdate", params: GlobalAutoUpdate }
|
||||
@@ -905,13 +912,13 @@ export interface ProcedureConfig {
|
||||
* 1. Regular CRON expression:
|
||||
*
|
||||
* (second, minute, hour, day, month, day-of-week)
|
||||
* ```
|
||||
* ```text
|
||||
* 0 0 0 1,15 * ?
|
||||
* ```
|
||||
*
|
||||
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
|
||||
*
|
||||
* ```
|
||||
* ```text
|
||||
* at midnight on the 1st and 15th of the month
|
||||
* ```
|
||||
*/
|
||||
@@ -1354,11 +1361,23 @@ export type GetActionResponse = Action;
|
||||
|
||||
/** Severity level of problem. */
|
||||
export enum SeverityLevel {
|
||||
/** No problem. */
|
||||
/**
|
||||
* No problem.
|
||||
*
|
||||
* Aliases: ok, low, l
|
||||
*/
|
||||
Ok = "OK",
|
||||
/** Problem is imminent. */
|
||||
/**
|
||||
* Problem is imminent.
|
||||
*
|
||||
* Aliases: warning, w, medium, m
|
||||
*/
|
||||
Warning = "WARNING",
|
||||
/** Problem fully realized. */
|
||||
/**
|
||||
* Problem fully realized.
|
||||
*
|
||||
* Aliases: critical, c, high, h
|
||||
*/
|
||||
Critical = "CRITICAL",
|
||||
}
|
||||
|
||||
@@ -1426,6 +1445,19 @@ export type AlertData =
|
||||
used_gb: number;
|
||||
/** The total size of the disk in GB */
|
||||
total_gb: number;
|
||||
}}
|
||||
/** A server has a version mismatch with the core. */
|
||||
| { type: "ServerVersionMismatch", data: {
|
||||
/** The id of the server */
|
||||
id: string;
|
||||
/** The name of the server */
|
||||
name: string;
|
||||
/** The region of the server */
|
||||
region?: string;
|
||||
/** The actual server version */
|
||||
server_version: string;
|
||||
/** The core version */
|
||||
core_version: string;
|
||||
}}
|
||||
/** A container's state has changed unexpectedly. */
|
||||
| { type: "ContainerStateChange", data: {
|
||||
@@ -1563,6 +1595,16 @@ export type AlertData =
|
||||
id: string;
|
||||
/** The resource name */
|
||||
name: string;
|
||||
}}
|
||||
/**
|
||||
* Custom header / body.
|
||||
* Produced using `/execute/SendAlert`
|
||||
*/
|
||||
| { type: "Custom", data: {
|
||||
/** The alert message. */
|
||||
message: string;
|
||||
/** Message details. May be empty string. */
|
||||
details?: string;
|
||||
}};
|
||||
|
||||
/** Representation of an alert in the system. */
|
||||
@@ -1964,7 +2006,7 @@ export interface ServerConfig {
|
||||
* Whether a server is enabled.
|
||||
* If a server is disabled,
|
||||
* you won't be able to perform any actions on it or see deployment's status.
|
||||
* default: true
|
||||
* Default: false
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
@@ -2003,6 +2045,8 @@ export interface ServerConfig {
|
||||
send_mem_alerts: boolean;
|
||||
/** Whether to send alerts about the servers DISK status */
|
||||
send_disk_alerts: boolean;
|
||||
/** Whether to send alerts about the servers version mismatch with core */
|
||||
send_version_mismatch_alerts: boolean;
|
||||
/** The percentage threshhold which triggers WARNING state for CPU. */
|
||||
cpu_warning: number;
|
||||
/** The percentage threshhold which triggers CRITICAL state for CPU. */
|
||||
@@ -2038,6 +2082,25 @@ export type GetStackActionStateResponse = StackActionState;
|
||||
|
||||
export type GetStackLogResponse = Log;
|
||||
|
||||
export enum StackFileRequires {
|
||||
/** Diff requires service redeploy. */
|
||||
Redeploy = "Redeploy",
|
||||
/** Diff requires service restart */
|
||||
Restart = "Restart",
|
||||
/** Diff requires no action. Default. */
|
||||
None = "None",
|
||||
}
|
||||
|
||||
/** Configure additional file dependencies of the Stack. */
|
||||
export interface StackFileDependency {
|
||||
/** Specify the file */
|
||||
path: string;
|
||||
/** Specify specific service/s */
|
||||
services?: string[];
|
||||
/** Specify */
|
||||
requires?: StackFileRequires;
|
||||
}
|
||||
|
||||
/** The compose file configuration. */
|
||||
export interface StackConfig {
|
||||
/** The server to deploy the stack on. */
|
||||
@@ -2152,8 +2215,21 @@ export interface StackConfig {
|
||||
/**
|
||||
* Add additional env files to attach with `--env-file`.
|
||||
* Relative to the run directory root.
|
||||
*
|
||||
* Note. It is already included as an `additional_file`.
|
||||
* Don't add it again there.
|
||||
*/
|
||||
additional_env_files?: string[];
|
||||
/**
|
||||
* Add additional config files either in repo or on host to track.
|
||||
* Can add any files associated with the stack to enable editing them in the UI.
|
||||
* Doing so will also include diffing these when deciding to deploy in `DeployStackIfChanged`.
|
||||
* Relative to the run directory.
|
||||
*
|
||||
* Note. If the config file is .env and should be included in compose command
|
||||
* using `--env-file`, add it to `additional_env_files` instead.
|
||||
*/
|
||||
config_files?: StackFileDependency[];
|
||||
/** Whether to send StackStateChange alerts for this stack. */
|
||||
send_alerts: boolean;
|
||||
/** Used with `registry_account` to login to a registry before docker compose up. */
|
||||
@@ -2199,7 +2275,7 @@ export interface StackConfig {
|
||||
}
|
||||
|
||||
export interface FileContents {
|
||||
/** The path of the file on the host */
|
||||
/** The path to the file */
|
||||
path: string;
|
||||
/** The contents of the file */
|
||||
contents: string;
|
||||
@@ -2231,9 +2307,27 @@ export interface StackServiceNames {
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [FileContents] with some extra
|
||||
* info specific to Stacks.
|
||||
*/
|
||||
export interface StackRemoteFileContents {
|
||||
/** The path to the file */
|
||||
path: string;
|
||||
/** The contents of the file */
|
||||
contents: string;
|
||||
/**
|
||||
* The services depending on this file,
|
||||
* or empty for global requirement (eg all compose files and env files).
|
||||
*/
|
||||
services?: string[];
|
||||
/** Whether diff requires Redeploy / Restart / None */
|
||||
requires?: StackFileRequires;
|
||||
}
|
||||
|
||||
export interface StackInfo {
|
||||
/**
|
||||
* If any of the expected files are missing in the repo,
|
||||
* If any of the expected compose / additional files are missing in the repo,
|
||||
* they will be stored here.
|
||||
*/
|
||||
missing_files?: string[];
|
||||
@@ -2249,7 +2343,7 @@ export interface StackInfo {
|
||||
/** Deployed commit message, or null. Only for repo based stacks */
|
||||
deployed_message?: string;
|
||||
/**
|
||||
* The deployed compose file contents.
|
||||
* The deployed compose / additional file contents.
|
||||
* This is updated whenever Komodo successfully deploys the stack.
|
||||
*/
|
||||
deployed_contents?: FileContents[];
|
||||
@@ -2269,11 +2363,11 @@ export interface StackInfo {
|
||||
*/
|
||||
latest_services?: StackServiceNames[];
|
||||
/**
|
||||
* The remote compose file contents, whether on host or in repo.
|
||||
* The remote compose / additional file contents, whether on host or in repo.
|
||||
* This is updated whenever Komodo refreshes the stack cache.
|
||||
* It will be empty if the file is defined directly in the stack config.
|
||||
*/
|
||||
remote_contents?: FileContents[];
|
||||
remote_contents?: StackRemoteFileContents[];
|
||||
/** If there was an error in getting the remote contents, it will be here. */
|
||||
remote_errors?: FileContents[];
|
||||
/** Latest commit hash, or null */
|
||||
@@ -3760,6 +3854,8 @@ export interface ServerListItemInfo {
|
||||
send_mem_alerts: boolean;
|
||||
/** Whether server is configured to send disk alerts. */
|
||||
send_disk_alerts: boolean;
|
||||
/** Whether server is configured to send version mismatch alerts. */
|
||||
send_version_mismatch_alerts: boolean;
|
||||
/** Whether terminals are disabled for this Server. */
|
||||
terminals_disabled: boolean;
|
||||
/** Whether container exec is disabled for this Server. */
|
||||
@@ -3967,6 +4063,11 @@ export interface StackQuerySpecifics {
|
||||
* Only accepts Server id (not name).
|
||||
*/
|
||||
server_ids?: string[];
|
||||
/**
|
||||
* Query only for Stacks with these linked repos.
|
||||
* Only accepts Repo id (not name).
|
||||
*/
|
||||
linked_repos?: string[];
|
||||
/** Filter syncs by their repo. */
|
||||
repos?: string[];
|
||||
/** Query only for Stack with available image updates. */
|
||||
@@ -4107,7 +4208,7 @@ export interface AwsBuilderConfig {
|
||||
* timestamped database dumps, which can be restored using
|
||||
* the Komodo CLI.
|
||||
*
|
||||
* TODO: Link to docs
|
||||
* https://komo.do/docs/setup/backup
|
||||
*/
|
||||
export interface BackupCoreDatabase {
|
||||
}
|
||||
@@ -4119,7 +4220,7 @@ export interface BatchBuildRepo {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* repos
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4136,7 +4237,7 @@ export interface BatchCloneRepo {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* repos
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4153,7 +4254,7 @@ export interface BatchDeploy {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* deployments
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4170,7 +4271,7 @@ export interface BatchDeployStack {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4187,7 +4288,7 @@ export interface BatchDeployStackIfChanged {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4204,7 +4305,7 @@ export interface BatchDestroyDeployment {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* deployments
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4221,7 +4322,7 @@ export interface BatchDestroyStack {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
* d
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4243,7 +4344,7 @@ export interface BatchPullRepo {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* repos
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4260,7 +4361,7 @@ export interface BatchPullStack {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4277,7 +4378,7 @@ export interface BatchRunAction {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* actions
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4294,7 +4395,7 @@ export interface BatchRunBuild {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* builds
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4311,7 +4412,7 @@ export interface BatchRunProcedure {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* procedures
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -6243,6 +6344,8 @@ export interface GetServersSummaryResponse {
|
||||
total: I64;
|
||||
/** The number of healthy (`status: OK`) servers. */
|
||||
healthy: I64;
|
||||
/** The number of servers with warnings (e.g., version mismatch). */
|
||||
warning: I64;
|
||||
/** The number of unhealthy servers. */
|
||||
unhealthy: I64;
|
||||
/** The number of disabled servers. */
|
||||
@@ -6440,12 +6543,12 @@ export interface GetVersionResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a global poll for image updateson Stacks and Deployments
|
||||
* Trigger a global poll for image updates on Stacks and Deployments
|
||||
* with `poll_for_updates` or `auto_update` enabled.
|
||||
* Admin only. Response: [Update]
|
||||
*
|
||||
* 1. `docker compose pull` any Stacks / Deployments with `poll_for_updates` or `auto_update` enabled. This will pick up any available updates.
|
||||
* 2. Redeploy Stacks / Deployments that have updates found.
|
||||
* 2. Redeploy Stacks / Deployments that have updates found and 'auto_update' enabled.
|
||||
*/
|
||||
export interface GlobalAutoUpdate {
|
||||
}
|
||||
@@ -6528,7 +6631,7 @@ export interface ListAlerts {
|
||||
* Pass a custom mongo query to filter the alerts.
|
||||
*
|
||||
* ## Example JSON
|
||||
* ```
|
||||
* ```json
|
||||
* {
|
||||
* "resolved": "false",
|
||||
* "level": "CRITICAL",
|
||||
@@ -7585,6 +7688,32 @@ export interface RunProcedure {
|
||||
procedure: string;
|
||||
}
|
||||
|
||||
/** Runs a one-time command against a service using `docker compose run`. Response: [Update] */
|
||||
export interface RunStackService {
|
||||
/** Id or name */
|
||||
stack: string;
|
||||
/** Service to run */
|
||||
service: string;
|
||||
/** Command and args to pass to the service container */
|
||||
command?: string[];
|
||||
/** Do not allocate TTY */
|
||||
no_tty?: boolean;
|
||||
/** Do not start linked services */
|
||||
no_deps?: boolean;
|
||||
/** Map service ports to the host */
|
||||
service_ports?: boolean;
|
||||
/** Extra environment variables for the run */
|
||||
env?: Record<string, string>;
|
||||
/** Working directory inside the container */
|
||||
workdir?: string;
|
||||
/** User to run as inside the container */
|
||||
user?: string;
|
||||
/** Override the default entrypoint */
|
||||
entrypoint?: string;
|
||||
/** Pull the image before running */
|
||||
pull?: boolean;
|
||||
}
|
||||
|
||||
/** Runs the target resource sync. Response: [Update] */
|
||||
export interface RunSync {
|
||||
/** Id or name */
|
||||
@@ -7686,6 +7815,22 @@ export interface SearchStackLog {
|
||||
timestamps?: boolean;
|
||||
}
|
||||
|
||||
/** Send a custom alert message to configured Alerters. Response: [Update] */
|
||||
export interface SendAlert {
|
||||
/** The alert level. */
|
||||
level?: SeverityLevel;
|
||||
/** The alert message. Required. */
|
||||
message: string;
|
||||
/** The alert details. Optional. */
|
||||
details?: string;
|
||||
/**
|
||||
* Specific alerter names or ids.
|
||||
* If empty / not passed, sends to all configured alerters
|
||||
* with the `Custom` alert type whitelisted / not blacklisted.
|
||||
*/
|
||||
alerters?: string[];
|
||||
}
|
||||
|
||||
/** Configuration for a Komodo Server Builder. */
|
||||
export interface ServerBuilderConfig {
|
||||
/** The server id of the builder */
|
||||
@@ -8363,6 +8508,7 @@ export type ExecuteRequest =
|
||||
| { type: "UnpauseStack", params: UnpauseStack }
|
||||
| { type: "DestroyStack", params: DestroyStack }
|
||||
| { type: "BatchDestroyStack", params: BatchDestroyStack }
|
||||
| { type: "RunStackService", params: RunStackService }
|
||||
| { type: "Deploy", params: Deploy }
|
||||
| { type: "BatchDeploy", params: BatchDeploy }
|
||||
| { type: "PullDeployment", params: PullDeployment }
|
||||
@@ -8388,6 +8534,7 @@ export type ExecuteRequest =
|
||||
| { type: "RunAction", params: RunAction }
|
||||
| { type: "BatchRunAction", params: BatchRunAction }
|
||||
| { type: "TestAlerter", params: TestAlerter }
|
||||
| { type: "SendAlert", params: SendAlert }
|
||||
| { type: "RunSync", params: RunSync }
|
||||
| { type: "ClearRepoCache", params: ClearRepoCache }
|
||||
| { type: "BackupCoreDatabase", params: BackupCoreDatabase }
|
||||
@@ -8479,13 +8626,6 @@ export enum IanaTimezone {
|
||||
PacificKiritimati = "Pacific/Kiritimati",
|
||||
}
|
||||
|
||||
/** Configuration for the registry to push the built image to. */
|
||||
export type ImageRegistryLegacy1_14 =
|
||||
/** Don't push the image to any registry */
|
||||
| { type: "None", params: NoData }
|
||||
/** Push the image to a standard image registry (any domain) */
|
||||
| { type: "Standard", params: ImageRegistryConfig };
|
||||
|
||||
export type ReadRequest =
|
||||
| { type: "GetVersion", params: GetVersion }
|
||||
| { type: "GetCoreInfo", params: GetCoreInfo }
|
||||
|
||||
@@ -11,8 +11,10 @@ pub struct Build {
|
||||
pub build: komodo_client::entities::build::Build,
|
||||
/// Send the linked repo if it exists.
|
||||
pub repo: Option<Repo>,
|
||||
/// Override registry token with one sent from core.
|
||||
pub registry_token: Option<String>,
|
||||
/// Override registry tokens with ones sent from core.
|
||||
/// maps (domain, account) -> token.
|
||||
#[serde(default)]
|
||||
pub registry_tokens: Vec<(String, String, String)>,
|
||||
/// Propogate any secret replacers from core interpolation.
|
||||
#[serde(default)]
|
||||
pub replacers: Vec<(String, String)>,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use komodo_client::entities::{
|
||||
FileContents, RepoExecutionResponse, SearchCombinator,
|
||||
repo::Repo,
|
||||
stack::{ComposeProject, Stack, StackServiceNames},
|
||||
stack::{
|
||||
ComposeProject, Stack, StackFileDependency,
|
||||
StackRemoteFileContents, StackServiceNames,
|
||||
},
|
||||
update::Log,
|
||||
};
|
||||
use resolver_api::Resolve;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// List the compose project names that are on the host.
|
||||
/// List running `docker compose ls`
|
||||
@@ -28,12 +32,13 @@ pub struct GetComposeContentsOnHost {
|
||||
/// The name of the stack
|
||||
pub name: String,
|
||||
pub run_directory: String,
|
||||
pub file_paths: Vec<String>,
|
||||
/// Both compose files and env / additional files, all relative to run directory.
|
||||
pub file_paths: Vec<StackFileDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GetComposeContentsOnHostResponse {
|
||||
pub contents: Vec<FileContents>,
|
||||
pub contents: Vec<StackRemoteFileContents>,
|
||||
pub errors: Vec<FileContents>,
|
||||
}
|
||||
|
||||
@@ -91,7 +96,7 @@ pub struct GetComposeLogSearch {
|
||||
|
||||
//
|
||||
|
||||
/// Write the compose contents to the file on the host, for stacks using
|
||||
/// Write the compose / additional file contents to the file on the host, for stacks using
|
||||
/// `files_on_host`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Log)]
|
||||
@@ -204,7 +209,7 @@ pub struct ComposeUpResponse {
|
||||
#[serde(default)]
|
||||
pub services: Vec<StackServiceNames>,
|
||||
/// The deploy compose file contents if they could be acquired, or empty vec.
|
||||
pub file_contents: Vec<FileContents>,
|
||||
pub file_contents: Vec<StackRemoteFileContents>,
|
||||
/// The error in getting remote file contents at the path, or null
|
||||
pub remote_errors: Vec<FileContents>,
|
||||
/// The output of `docker compose config` at deploy time
|
||||
@@ -217,6 +222,14 @@ pub struct ComposeUpResponse {
|
||||
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ComposeRunResponse {
|
||||
/// Logs produced during stack write/prepare for the run
|
||||
pub logs: Vec<Log>,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// General compose command runner
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Log)]
|
||||
@@ -228,3 +241,53 @@ pub struct ComposeExecution {
|
||||
/// The command in `docker compose -p {project} {command}`
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// docker compose run one-time service execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(Log)]
|
||||
#[error(serror::Error)]
|
||||
pub struct ComposeRun {
|
||||
/// The stack to run a service for
|
||||
pub stack: Stack,
|
||||
/// The linked repo, if it exists.
|
||||
pub repo: Option<Repo>,
|
||||
/// If provided, use it to login in. Otherwise check periphery local registries.
|
||||
pub git_token: Option<String>,
|
||||
/// If provided, use it to login in. Otherwise check periphery local git providers.
|
||||
pub registry_token: Option<String>,
|
||||
/// Propogate any secret replacers from core interpolation.
|
||||
#[serde(default)]
|
||||
pub replacers: Vec<(String, String)>,
|
||||
|
||||
/// Service to run
|
||||
pub service: String,
|
||||
/// Command
|
||||
#[serde(default)]
|
||||
pub command: Option<Vec<String>>,
|
||||
/// Do not allocate TTY
|
||||
#[serde(default)]
|
||||
pub no_tty: Option<bool>,
|
||||
/// Do not start linked services
|
||||
#[serde(default)]
|
||||
pub no_deps: Option<bool>,
|
||||
/// Map service ports to the host
|
||||
#[serde(default)]
|
||||
pub service_ports: Option<bool>,
|
||||
/// Extra environment variables for the run
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
/// Working directory inside the container
|
||||
#[serde(default)]
|
||||
pub workdir: Option<String>,
|
||||
/// User to run as inside the container
|
||||
#[serde(default)]
|
||||
pub user: Option<String>,
|
||||
/// Override the default entrypoint
|
||||
#[serde(default)]
|
||||
pub entrypoint: Option<String>,
|
||||
/// Pull the image before running
|
||||
#[serde(default)]
|
||||
pub pull: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ impl PeripheryClient {
|
||||
/// sentinal value as the expected final line of the stream.
|
||||
///
|
||||
/// Example final line:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// __KOMODO_EXIT_CODE:0
|
||||
/// ```
|
||||
///
|
||||
@@ -104,7 +104,7 @@ impl PeripheryClient {
|
||||
/// sentinal value as the expected final line of the stream.
|
||||
///
|
||||
/// Example final line:
|
||||
/// ```
|
||||
/// ```text
|
||||
/// __KOMODO_EXIT_CODE:0
|
||||
/// ```
|
||||
///
|
||||
|
||||
@@ -30,7 +30,7 @@ If you do select select a network other than host, you can specify port bindings
|
||||
|
||||
In this case, you would access mongo from outside of the container on port `27018`.
|
||||
|
||||
Note that this is not the only affect of using a network other than `host`. For example, containers running on different networks can not communicate, and ones on the same network can not reach other containers on `localhost` even when they are running on the same system. This behavior can be a bit confusing if you are not familiar with it, and it can be bypassed entirely by just using `host` network.
|
||||
Note that this is not the only effect of using a network other than `host`. For example, containers running on different networks can not communicate, and ones on the same network can not reach other containers on `localhost` even when they are running on the same system. This behavior can be a bit confusing if you are not familiar with it, and it can be bypassed entirely by just using `host` network.
|
||||
|
||||
## Configuring restart behavior
|
||||
|
||||
@@ -81,4 +81,4 @@ Sometimes you need to override the default command in the image, or specify some
|
||||
docker run -d --name mongo-db mongo:6.0.3 --quiet
|
||||
```
|
||||
|
||||
In order to achieve this with Komodo, just pass `--quiet` to 'command'.
|
||||
In order to achieve this with Komodo, just pass `--quiet` to 'command'.
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"react-xtermjs": "1.0.10",
|
||||
"sanitize-html": "2.17.0",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7"
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"shell-quote": "1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.6",
|
||||
|
||||
+3
-1
@@ -220,7 +220,7 @@ export type WriteResponses = {
|
||||
DeleteResourceSync: Types.ResourceSync;
|
||||
UpdateResourceSync: Types.ResourceSync;
|
||||
RenameResourceSync: Types.Update;
|
||||
CommitSync: Types.ResourceSync;
|
||||
CommitSync: Types.Update;
|
||||
WriteSyncFileContents: Types.Update;
|
||||
RefreshResourceSyncPending: Types.ResourceSync;
|
||||
CreateSyncWebhook: Types.CreateSyncWebhookResponse;
|
||||
@@ -308,7 +308,9 @@ export type ExecuteResponses = {
|
||||
PauseStackService: Types.Update;
|
||||
UnpauseStackService: Types.Update;
|
||||
DestroyStackService: Types.Update;
|
||||
RunStackService: Types.Update;
|
||||
TestAlerter: Types.Update;
|
||||
SendAlert: Types.Update;
|
||||
ClearRepoCache: Types.Update;
|
||||
BackupCoreDatabase: Types.Update;
|
||||
GlobalAutoUpdate: Types.Update;
|
||||
|
||||
Vendored
+188
-44
@@ -71,13 +71,13 @@ export interface ActionConfig {
|
||||
* 1. Regular CRON expression:
|
||||
*
|
||||
* (second, minute, hour, day, month, day-of-week)
|
||||
* ```
|
||||
* ```text
|
||||
* 0 0 0 1,15 * ?
|
||||
* ```
|
||||
*
|
||||
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
|
||||
*
|
||||
* ```
|
||||
* ```text
|
||||
* at midnight on the 1st and 15th of the month
|
||||
* ```
|
||||
*/
|
||||
@@ -391,6 +391,7 @@ export declare enum Operation {
|
||||
UnpauseStack = "UnpauseStack",
|
||||
StopStack = "StopStack",
|
||||
DestroyStack = "DestroyStack",
|
||||
RunStackService = "RunStackService",
|
||||
DeployStackService = "DeployStackService",
|
||||
PullStackService = "PullStackService",
|
||||
StartStackService = "StartStackService",
|
||||
@@ -445,6 +446,7 @@ export declare enum Operation {
|
||||
RenameAlerter = "RenameAlerter",
|
||||
DeleteAlerter = "DeleteAlerter",
|
||||
TestAlerter = "TestAlerter",
|
||||
SendAlert = "SendAlert",
|
||||
CreateResourceSync = "CreateResourceSync",
|
||||
UpdateResourceSync = "UpdateResourceSync",
|
||||
RenameResourceSync = "RenameResourceSync",
|
||||
@@ -640,8 +642,11 @@ export interface BuildConfig {
|
||||
build_path: string;
|
||||
/** The path of the dockerfile relative to the build path. */
|
||||
dockerfile_path: string;
|
||||
/** Configuration for the registry to push the built image to. */
|
||||
image_registry?: ImageRegistryConfig;
|
||||
/**
|
||||
* Configuration for the registry/s to push the built image to.
|
||||
* The first registry in this list will be used with attached Deployments.
|
||||
*/
|
||||
image_registry?: ImageRegistryConfig[];
|
||||
/** Whether to skip secret interpolation in the build_args. */
|
||||
skip_secret_interp?: boolean;
|
||||
/** Whether to use buildx to build (eg `docker buildx build ...`) */
|
||||
@@ -742,7 +747,7 @@ export interface BuildListItemInfo {
|
||||
built_hash?: string;
|
||||
/** Latest short commit hash, or null. Only for repo based stacks */
|
||||
latest_hash?: string;
|
||||
/** The image registry domain */
|
||||
/** The first listed image registry domain */
|
||||
image_registry_domain?: string;
|
||||
}
|
||||
export type BuildListItem = ResourceListItem<BuildListItemInfo>;
|
||||
@@ -989,9 +994,15 @@ export type Execution =
|
||||
} | {
|
||||
type: "BatchDestroyStack";
|
||||
params: BatchDestroyStack;
|
||||
} | {
|
||||
type: "RunStackService";
|
||||
params: RunStackService;
|
||||
} | {
|
||||
type: "TestAlerter";
|
||||
params: TestAlerter;
|
||||
} | {
|
||||
type: "SendAlert";
|
||||
params: SendAlert;
|
||||
} | {
|
||||
type: "ClearRepoCache";
|
||||
params: ClearRepoCache;
|
||||
@@ -1035,13 +1046,13 @@ export interface ProcedureConfig {
|
||||
* 1. Regular CRON expression:
|
||||
*
|
||||
* (second, minute, hour, day, month, day-of-week)
|
||||
* ```
|
||||
* ```text
|
||||
* 0 0 0 1,15 * ?
|
||||
* ```
|
||||
*
|
||||
* 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron):
|
||||
*
|
||||
* ```
|
||||
* ```text
|
||||
* at midnight on the 1st and 15th of the month
|
||||
* ```
|
||||
*/
|
||||
@@ -1453,11 +1464,23 @@ export type GetActionActionStateResponse = ActionActionState;
|
||||
export type GetActionResponse = Action;
|
||||
/** Severity level of problem. */
|
||||
export declare enum SeverityLevel {
|
||||
/** No problem. */
|
||||
/**
|
||||
* No problem.
|
||||
*
|
||||
* Aliases: ok, low, l
|
||||
*/
|
||||
Ok = "OK",
|
||||
/** Problem is imminent. */
|
||||
/**
|
||||
* Problem is imminent.
|
||||
*
|
||||
* Aliases: warning, w, medium, m
|
||||
*/
|
||||
Warning = "WARNING",
|
||||
/** Problem fully realized. */
|
||||
/**
|
||||
* Problem fully realized.
|
||||
*
|
||||
* Aliases: critical, c, high, h
|
||||
*/
|
||||
Critical = "CRITICAL"
|
||||
}
|
||||
/** The variants of data related to the alert. */
|
||||
@@ -1542,6 +1565,22 @@ export type AlertData =
|
||||
total_gb: number;
|
||||
};
|
||||
}
|
||||
/** A server has a version mismatch with the core. */
|
||||
| {
|
||||
type: "ServerVersionMismatch";
|
||||
data: {
|
||||
/** The id of the server */
|
||||
id: string;
|
||||
/** The name of the server */
|
||||
name: string;
|
||||
/** The region of the server */
|
||||
region?: string;
|
||||
/** The actual server version */
|
||||
server_version: string;
|
||||
/** The core version */
|
||||
core_version: string;
|
||||
};
|
||||
}
|
||||
/** A container's state has changed unexpectedly. */
|
||||
| {
|
||||
type: "ContainerStateChange";
|
||||
@@ -1717,6 +1756,19 @@ export type AlertData =
|
||||
/** The resource name */
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Custom header / body.
|
||||
* Produced using `/execute/SendAlert`
|
||||
*/
|
||||
| {
|
||||
type: "Custom";
|
||||
data: {
|
||||
/** The alert message. */
|
||||
message: string;
|
||||
/** Message details. May be empty string. */
|
||||
details?: string;
|
||||
};
|
||||
};
|
||||
/** Representation of an alert in the system. */
|
||||
export interface Alert {
|
||||
@@ -2086,7 +2138,7 @@ export interface ServerConfig {
|
||||
* Whether a server is enabled.
|
||||
* If a server is disabled,
|
||||
* you won't be able to perform any actions on it or see deployment's status.
|
||||
* default: true
|
||||
* Default: false
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
@@ -2125,6 +2177,8 @@ export interface ServerConfig {
|
||||
send_mem_alerts: boolean;
|
||||
/** Whether to send alerts about the servers DISK status */
|
||||
send_disk_alerts: boolean;
|
||||
/** Whether to send alerts about the servers version mismatch with core */
|
||||
send_version_mismatch_alerts: boolean;
|
||||
/** The percentage threshhold which triggers WARNING state for CPU. */
|
||||
cpu_warning: number;
|
||||
/** The percentage threshhold which triggers CRITICAL state for CPU. */
|
||||
@@ -2154,6 +2208,23 @@ export interface StackActionState {
|
||||
}
|
||||
export type GetStackActionStateResponse = StackActionState;
|
||||
export type GetStackLogResponse = Log;
|
||||
export declare enum StackFileRequires {
|
||||
/** Diff requires service redeploy. */
|
||||
Redeploy = "Redeploy",
|
||||
/** Diff requires service restart */
|
||||
Restart = "Restart",
|
||||
/** Diff requires no action. Default. */
|
||||
None = "None"
|
||||
}
|
||||
/** Configure additional file dependencies of the Stack. */
|
||||
export interface StackFileDependency {
|
||||
/** Specify the file */
|
||||
path: string;
|
||||
/** Specify specific service/s */
|
||||
services?: string[];
|
||||
/** Specify */
|
||||
requires?: StackFileRequires;
|
||||
}
|
||||
/** The compose file configuration. */
|
||||
export interface StackConfig {
|
||||
/** The server to deploy the stack on. */
|
||||
@@ -2268,8 +2339,21 @@ export interface StackConfig {
|
||||
/**
|
||||
* Add additional env files to attach with `--env-file`.
|
||||
* Relative to the run directory root.
|
||||
*
|
||||
* Note. It is already included as an `additional_file`.
|
||||
* Don't add it again there.
|
||||
*/
|
||||
additional_env_files?: string[];
|
||||
/**
|
||||
* Add additional config files either in repo or on host to track.
|
||||
* Can add any files associated with the stack to enable editing them in the UI.
|
||||
* Doing so will also include diffing these when deciding to deploy in `DeployStackIfChanged`.
|
||||
* Relative to the run directory.
|
||||
*
|
||||
* Note. If the config file is .env and should be included in compose command
|
||||
* using `--env-file`, add it to `additional_env_files` instead.
|
||||
*/
|
||||
config_files?: StackFileDependency[];
|
||||
/** Whether to send StackStateChange alerts for this stack. */
|
||||
send_alerts: boolean;
|
||||
/** Used with `registry_account` to login to a registry before docker compose up. */
|
||||
@@ -2314,7 +2398,7 @@ export interface StackConfig {
|
||||
environment?: string;
|
||||
}
|
||||
export interface FileContents {
|
||||
/** The path of the file on the host */
|
||||
/** The path to the file */
|
||||
path: string;
|
||||
/** The contents of the file */
|
||||
contents: string;
|
||||
@@ -2344,9 +2428,26 @@ export interface StackServiceNames {
|
||||
/** The services image. */
|
||||
image?: string;
|
||||
}
|
||||
/**
|
||||
* Same as [FileContents] with some extra
|
||||
* info specific to Stacks.
|
||||
*/
|
||||
export interface StackRemoteFileContents {
|
||||
/** The path to the file */
|
||||
path: string;
|
||||
/** The contents of the file */
|
||||
contents: string;
|
||||
/**
|
||||
* The services depending on this file,
|
||||
* or empty for global requirement (eg all compose files and env files).
|
||||
*/
|
||||
services?: string[];
|
||||
/** Whether diff requires Redeploy / Restart / None */
|
||||
requires?: StackFileRequires;
|
||||
}
|
||||
export interface StackInfo {
|
||||
/**
|
||||
* If any of the expected files are missing in the repo,
|
||||
* If any of the expected compose / additional files are missing in the repo,
|
||||
* they will be stored here.
|
||||
*/
|
||||
missing_files?: string[];
|
||||
@@ -2362,7 +2463,7 @@ export interface StackInfo {
|
||||
/** Deployed commit message, or null. Only for repo based stacks */
|
||||
deployed_message?: string;
|
||||
/**
|
||||
* The deployed compose file contents.
|
||||
* The deployed compose / additional file contents.
|
||||
* This is updated whenever Komodo successfully deploys the stack.
|
||||
*/
|
||||
deployed_contents?: FileContents[];
|
||||
@@ -2382,11 +2483,11 @@ export interface StackInfo {
|
||||
*/
|
||||
latest_services?: StackServiceNames[];
|
||||
/**
|
||||
* The remote compose file contents, whether on host or in repo.
|
||||
* The remote compose / additional file contents, whether on host or in repo.
|
||||
* This is updated whenever Komodo refreshes the stack cache.
|
||||
* It will be empty if the file is defined directly in the stack config.
|
||||
*/
|
||||
remote_contents?: FileContents[];
|
||||
remote_contents?: StackRemoteFileContents[];
|
||||
/** If there was an error in getting the remote contents, it will be here. */
|
||||
remote_errors?: FileContents[];
|
||||
/** Latest commit hash, or null */
|
||||
@@ -3731,6 +3832,8 @@ export interface ServerListItemInfo {
|
||||
send_mem_alerts: boolean;
|
||||
/** Whether server is configured to send disk alerts. */
|
||||
send_disk_alerts: boolean;
|
||||
/** Whether server is configured to send version mismatch alerts. */
|
||||
send_version_mismatch_alerts: boolean;
|
||||
/** Whether terminals are disabled for this Server. */
|
||||
terminals_disabled: boolean;
|
||||
/** Whether container exec is disabled for this Server. */
|
||||
@@ -3903,6 +4006,11 @@ export interface StackQuerySpecifics {
|
||||
* Only accepts Server id (not name).
|
||||
*/
|
||||
server_ids?: string[];
|
||||
/**
|
||||
* Query only for Stacks with these linked repos.
|
||||
* Only accepts Repo id (not name).
|
||||
*/
|
||||
linked_repos?: string[];
|
||||
/** Filter syncs by their repo. */
|
||||
repos?: string[];
|
||||
/** Query only for Stack with available image updates. */
|
||||
@@ -4007,7 +4115,7 @@ export interface AwsBuilderConfig {
|
||||
* timestamped database dumps, which can be restored using
|
||||
* the Komodo CLI.
|
||||
*
|
||||
* TODO: Link to docs
|
||||
* https://komo.do/docs/setup/backup
|
||||
*/
|
||||
export interface BackupCoreDatabase {
|
||||
}
|
||||
@@ -4018,7 +4126,7 @@ export interface BatchBuildRepo {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* repos
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4034,7 +4142,7 @@ export interface BatchCloneRepo {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* repos
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4050,7 +4158,7 @@ export interface BatchDeploy {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* deployments
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4066,7 +4174,7 @@ export interface BatchDeployStack {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4082,7 +4190,7 @@ export interface BatchDeployStackIfChanged {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4098,7 +4206,7 @@ export interface BatchDestroyDeployment {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* deployments
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4114,7 +4222,7 @@ export interface BatchDestroyStack {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
* d
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4134,7 +4242,7 @@ export interface BatchPullRepo {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* repos
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4150,7 +4258,7 @@ export interface BatchPullStack {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4166,7 +4274,7 @@ export interface BatchRunAction {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* actions
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4182,7 +4290,7 @@ export interface BatchRunBuild {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* builds
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -4198,7 +4306,7 @@ export interface BatchRunProcedure {
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* ```text
|
||||
* # match all foo-* procedures
|
||||
* foo-*
|
||||
* # add some more
|
||||
@@ -5963,6 +6071,8 @@ export interface GetServersSummaryResponse {
|
||||
total: I64;
|
||||
/** The number of healthy (`status: OK`) servers. */
|
||||
healthy: I64;
|
||||
/** The number of servers with warnings (e.g., version mismatch). */
|
||||
warning: I64;
|
||||
/** The number of unhealthy servers. */
|
||||
unhealthy: I64;
|
||||
/** The number of disabled servers. */
|
||||
@@ -6139,12 +6249,12 @@ export interface GetVersionResponse {
|
||||
version: string;
|
||||
}
|
||||
/**
|
||||
* Trigger a global poll for image updateson Stacks and Deployments
|
||||
* Trigger a global poll for image updates on Stacks and Deployments
|
||||
* with `poll_for_updates` or `auto_update` enabled.
|
||||
* Admin only. Response: [Update]
|
||||
*
|
||||
* 1. `docker compose pull` any Stacks / Deployments with `poll_for_updates` or `auto_update` enabled. This will pick up any available updates.
|
||||
* 2. Redeploy Stacks / Deployments that have updates found.
|
||||
* 2. Redeploy Stacks / Deployments that have updates found and 'auto_update' enabled.
|
||||
*/
|
||||
export interface GlobalAutoUpdate {
|
||||
}
|
||||
@@ -6217,7 +6327,7 @@ export interface ListAlerts {
|
||||
* Pass a custom mongo query to filter the alerts.
|
||||
*
|
||||
* ## Example JSON
|
||||
* ```
|
||||
* ```json
|
||||
* {
|
||||
* "resolved": "false",
|
||||
* "level": "CRITICAL",
|
||||
@@ -7172,6 +7282,31 @@ export interface RunProcedure {
|
||||
/** Id or name */
|
||||
procedure: string;
|
||||
}
|
||||
/** Runs a one-time command against a service using `docker compose run`. Response: [Update] */
|
||||
export interface RunStackService {
|
||||
/** Id or name */
|
||||
stack: string;
|
||||
/** Service to run */
|
||||
service: string;
|
||||
/** Command and args to pass to the service container */
|
||||
command?: string[];
|
||||
/** Do not allocate TTY */
|
||||
no_tty?: boolean;
|
||||
/** Do not start linked services */
|
||||
no_deps?: boolean;
|
||||
/** Map service ports to the host */
|
||||
service_ports?: boolean;
|
||||
/** Extra environment variables for the run */
|
||||
env?: Record<string, string>;
|
||||
/** Working directory inside the container */
|
||||
workdir?: string;
|
||||
/** User to run as inside the container */
|
||||
user?: string;
|
||||
/** Override the default entrypoint */
|
||||
entrypoint?: string;
|
||||
/** Pull the image before running */
|
||||
pull?: boolean;
|
||||
}
|
||||
/** Runs the target resource sync. Response: [Update] */
|
||||
export interface RunSync {
|
||||
/** Id or name */
|
||||
@@ -7268,6 +7403,21 @@ export interface SearchStackLog {
|
||||
/** Enable `--timestamps` */
|
||||
timestamps?: boolean;
|
||||
}
|
||||
/** Send a custom alert message to configured Alerters. Response: [Update] */
|
||||
export interface SendAlert {
|
||||
/** The alert level. */
|
||||
level?: SeverityLevel;
|
||||
/** The alert message. Required. */
|
||||
message: string;
|
||||
/** The alert details. Optional. */
|
||||
details?: string;
|
||||
/**
|
||||
* Specific alerter names or ids.
|
||||
* If empty / not passed, sends to all configured alerters
|
||||
* with the `Custom` alert type whitelisted / not blacklisted.
|
||||
*/
|
||||
alerters?: string[];
|
||||
}
|
||||
/** Configuration for a Komodo Server Builder. */
|
||||
export interface ServerBuilderConfig {
|
||||
/** The server id of the builder */
|
||||
@@ -7967,6 +8117,9 @@ export type ExecuteRequest = {
|
||||
} | {
|
||||
type: "BatchDestroyStack";
|
||||
params: BatchDestroyStack;
|
||||
} | {
|
||||
type: "RunStackService";
|
||||
params: RunStackService;
|
||||
} | {
|
||||
type: "Deploy";
|
||||
params: Deploy;
|
||||
@@ -8042,6 +8195,9 @@ export type ExecuteRequest = {
|
||||
} | {
|
||||
type: "TestAlerter";
|
||||
params: TestAlerter;
|
||||
} | {
|
||||
type: "SendAlert";
|
||||
params: SendAlert;
|
||||
} | {
|
||||
type: "RunSync";
|
||||
params: RunSync;
|
||||
@@ -8140,18 +8296,6 @@ export declare enum IanaTimezone {
|
||||
/** UTC+14:00 */
|
||||
PacificKiritimati = "Pacific/Kiritimati"
|
||||
}
|
||||
/** Configuration for the registry to push the built image to. */
|
||||
export type ImageRegistryLegacy1_14 =
|
||||
/** Don't push the image to any registry */
|
||||
{
|
||||
type: "None";
|
||||
params: NoData;
|
||||
}
|
||||
/** Push the image to a standard image registry (any domain) */
|
||||
| {
|
||||
type: "Standard";
|
||||
params: ImageRegistryConfig;
|
||||
};
|
||||
export type ReadRequest = {
|
||||
type: "GetVersion";
|
||||
params: GetVersion;
|
||||
|
||||
@@ -105,6 +105,7 @@ export var Operation;
|
||||
Operation["UnpauseStack"] = "UnpauseStack";
|
||||
Operation["StopStack"] = "StopStack";
|
||||
Operation["DestroyStack"] = "DestroyStack";
|
||||
Operation["RunStackService"] = "RunStackService";
|
||||
Operation["DeployStackService"] = "DeployStackService";
|
||||
Operation["PullStackService"] = "PullStackService";
|
||||
Operation["StartStackService"] = "StartStackService";
|
||||
@@ -159,6 +160,7 @@ export var Operation;
|
||||
Operation["RenameAlerter"] = "RenameAlerter";
|
||||
Operation["DeleteAlerter"] = "DeleteAlerter";
|
||||
Operation["TestAlerter"] = "TestAlerter";
|
||||
Operation["SendAlert"] = "SendAlert";
|
||||
Operation["CreateResourceSync"] = "CreateResourceSync";
|
||||
Operation["UpdateResourceSync"] = "UpdateResourceSync";
|
||||
Operation["RenameResourceSync"] = "RenameResourceSync";
|
||||
@@ -248,13 +250,34 @@ export var DeploymentState;
|
||||
/** Severity level of problem. */
|
||||
export var SeverityLevel;
|
||||
(function (SeverityLevel) {
|
||||
/** No problem. */
|
||||
/**
|
||||
* No problem.
|
||||
*
|
||||
* Aliases: ok, low, l
|
||||
*/
|
||||
SeverityLevel["Ok"] = "OK";
|
||||
/** Problem is imminent. */
|
||||
/**
|
||||
* Problem is imminent.
|
||||
*
|
||||
* Aliases: warning, w, medium, m
|
||||
*/
|
||||
SeverityLevel["Warning"] = "WARNING";
|
||||
/** Problem fully realized. */
|
||||
/**
|
||||
* Problem fully realized.
|
||||
*
|
||||
* Aliases: critical, c, high, h
|
||||
*/
|
||||
SeverityLevel["Critical"] = "CRITICAL";
|
||||
})(SeverityLevel || (SeverityLevel = {}));
|
||||
export var StackFileRequires;
|
||||
(function (StackFileRequires) {
|
||||
/** Diff requires service redeploy. */
|
||||
StackFileRequires["Redeploy"] = "Redeploy";
|
||||
/** Diff requires service restart */
|
||||
StackFileRequires["Restart"] = "Restart";
|
||||
/** Diff requires no action. Default. */
|
||||
StackFileRequires["None"] = "None";
|
||||
})(StackFileRequires || (StackFileRequires = {}));
|
||||
export var Timelength;
|
||||
(function (Timelength) {
|
||||
/** `1-sec` */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WebhookIntegration,
|
||||
useWebhookIntegrations,
|
||||
useSettingsView,
|
||||
usePromptHotkeys,
|
||||
} from "@lib/hooks";
|
||||
import { Types } from "komodo_client";
|
||||
import {
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
MonacoLanguage,
|
||||
} from "@components/monaco";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "@ui/badge";
|
||||
|
||||
export const ConfigItem = ({
|
||||
label,
|
||||
@@ -200,236 +202,6 @@ export const ConfigSwitch = ({
|
||||
</ConfigItem>
|
||||
);
|
||||
|
||||
export const ProviderSelector = ({
|
||||
disabled,
|
||||
account_type,
|
||||
selected,
|
||||
onSelect,
|
||||
showCustom = true,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
account_type: "git" | "docker";
|
||||
selected: string | undefined;
|
||||
onSelect: (provider: string) => void;
|
||||
showCustom?: boolean;
|
||||
}) => {
|
||||
const [db_request, config_request]:
|
||||
| ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
| ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"] =
|
||||
account_type === "git"
|
||||
? ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
: ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"];
|
||||
const db_providers = useRead(db_request, {}).data;
|
||||
const config_providers = useRead(config_request, {}).data;
|
||||
const [customMode, setCustomMode] = useState(false);
|
||||
|
||||
if (customMode) {
|
||||
return (
|
||||
<Input
|
||||
placeholder="Input custom provider domain"
|
||||
value={selected}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
className="max-w-[75%] lg:max-w-[400px]"
|
||||
onBlur={() => setCustomMode(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setCustomMode(false);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const domains = new Set<string>();
|
||||
for (const provider of db_providers ?? []) {
|
||||
domains.add(provider.domain);
|
||||
}
|
||||
for (const provider of config_providers ?? []) {
|
||||
domains.add(provider.domain);
|
||||
}
|
||||
const providers = [...domains];
|
||||
providers.sort();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
if (value === "Custom") {
|
||||
onSelect("");
|
||||
setCustomMode(true);
|
||||
} else if (value === "None") {
|
||||
onSelect("");
|
||||
} else {
|
||||
onSelect(value);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-[200px] max-w-[50%]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder="Select Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers
|
||||
?.filter((provider) => provider)
|
||||
.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
{providers !== undefined &&
|
||||
selected &&
|
||||
!providers.includes(selected) && (
|
||||
<SelectItem value={selected}>{selected}</SelectItem>
|
||||
)}
|
||||
{showCustom && <SelectItem value="Custom">Custom</SelectItem>}
|
||||
{!showCustom && <SelectItem value="None">None</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProviderSelectorConfig = (params: {
|
||||
disabled: boolean;
|
||||
account_type: "git" | "docker";
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
https?: boolean;
|
||||
onHttpsSwitch?: () => void;
|
||||
description?: string;
|
||||
boldLabel?: boolean;
|
||||
}) => {
|
||||
const select =
|
||||
params.account_type === "git" ? "git provider" : "docker registry";
|
||||
const label =
|
||||
params.account_type === "git" ? "Git Provider" : "Image Registry";
|
||||
return (
|
||||
<ConfigItem
|
||||
label={label}
|
||||
description={params.description ?? `Select ${select} domain`}
|
||||
boldLabel={params.boldLabel}
|
||||
>
|
||||
{params.account_type === "git" ? (
|
||||
<div className="flex items-center gap-2 w-[75%]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={params.onHttpsSwitch}
|
||||
className="py-0 px-2"
|
||||
disabled={params.disabled}
|
||||
>
|
||||
{`http${params.https ? "s" : ""}://`}
|
||||
</Button>
|
||||
<ProviderSelector {...params} />
|
||||
</div>
|
||||
) : (
|
||||
<ProviderSelector {...params} />
|
||||
)}
|
||||
</ConfigItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountSelector = ({
|
||||
disabled,
|
||||
id,
|
||||
type,
|
||||
account_type,
|
||||
provider,
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = "Select Account",
|
||||
}: {
|
||||
disabled: boolean;
|
||||
type: "Server" | "Builder" | "None";
|
||||
id?: string;
|
||||
account_type: "git" | "docker";
|
||||
provider: string;
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
placeholder?: string;
|
||||
}) => {
|
||||
const [db_request, config_request]:
|
||||
| ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
| ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"] =
|
||||
account_type === "git"
|
||||
? ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
: ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"];
|
||||
const config_params =
|
||||
type === "None" ? {} : { target: id ? { type, id } : undefined };
|
||||
const db_accounts = useRead(db_request, {}).data?.filter(
|
||||
(account) => account.domain === provider
|
||||
);
|
||||
const config_providers = useRead(config_request, config_params).data?.filter(
|
||||
(_provider) => _provider.domain === provider
|
||||
);
|
||||
|
||||
const _accounts = new Set<string>();
|
||||
for (const account of db_accounts ?? []) {
|
||||
if (account.username) {
|
||||
_accounts.add(account.username);
|
||||
}
|
||||
}
|
||||
for (const provider of config_providers ?? []) {
|
||||
for (const account of provider.accounts ?? []) {
|
||||
_accounts.add(account.username);
|
||||
}
|
||||
}
|
||||
const accounts = [..._accounts];
|
||||
accounts.sort();
|
||||
return (
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
onSelect(value === "Empty" ? "" : value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-[200px] max-w-[50%]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"Empty"}>None</SelectItem>
|
||||
{accounts
|
||||
?.filter((account) => account)
|
||||
.map((account) => (
|
||||
<SelectItem key={account} value={account}>
|
||||
{account}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountSelectorConfig = (params: {
|
||||
disabled: boolean;
|
||||
id?: string;
|
||||
type: "Server" | "Builder" | "None";
|
||||
account_type: "git" | "docker";
|
||||
provider: string;
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
return (
|
||||
<ConfigItem
|
||||
label="Account"
|
||||
description={
|
||||
params.description ??
|
||||
"Select the account used to log in to the provider"
|
||||
}
|
||||
>
|
||||
<AccountSelector {...params} />
|
||||
</ConfigItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigList = <T extends { [key: string]: unknown }>(
|
||||
props: InputListProps<T> & {
|
||||
label?: string;
|
||||
@@ -452,9 +224,10 @@ export const ConfigList = <T extends { [key: string]: unknown }>(
|
||||
className="flex items-center gap-2 w-[200px]"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" />
|
||||
{(props.addLabel ?? "Add " + props.label?.endsWith("s"))
|
||||
? props.label?.slice(0, -1)
|
||||
: props.label}
|
||||
{props.addLabel ??
|
||||
("Add " + props.label?.endsWith("s")
|
||||
? props.label?.slice(0, -1)
|
||||
: props.label)}
|
||||
</Button>
|
||||
)}
|
||||
{props.values.length > 0 && <InputList {...props} />}
|
||||
@@ -533,14 +306,34 @@ export function ConfirmUpdate<T>({
|
||||
key_listener = false,
|
||||
}: ConfirmUpdateProps<T>) {
|
||||
const [open, set] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
set(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
set(false);
|
||||
};
|
||||
|
||||
// Keep the existing Ctrl+Enter behavior for backward compatibility
|
||||
useCtrlKeyListener("Enter", () => {
|
||||
if (!key_listener) return;
|
||||
if (open) {
|
||||
onConfirm();
|
||||
handleConfirm();
|
||||
} else {
|
||||
set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new prompt hotkeys for better UX
|
||||
usePromptHotkeys({
|
||||
onConfirm: handleConfirm,
|
||||
onCancel: handleCancel,
|
||||
enabled: open,
|
||||
confirmDisabled: disabled || loading,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={set}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -573,10 +366,7 @@ export function ConfirmUpdate<T>({
|
||||
<ConfirmButton
|
||||
title="Update"
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
await onConfirm();
|
||||
set(false);
|
||||
}}
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
/>
|
||||
</DialogFooter>
|
||||
@@ -788,92 +578,339 @@ export const AddExtraArgMenu = ({
|
||||
);
|
||||
};
|
||||
|
||||
const IMAGE_REGISTRY_DESCRIPTION = "Configure where the built image is pushed.";
|
||||
|
||||
export const ImageRegistryConfig = ({
|
||||
registry,
|
||||
setRegistry,
|
||||
disabled,
|
||||
resource_id,
|
||||
builder_id,
|
||||
onRemove,
|
||||
imageName,
|
||||
}: {
|
||||
registry: Types.ImageRegistryConfig | undefined;
|
||||
setRegistry: (registry: Types.ImageRegistryConfig) => void;
|
||||
disabled: boolean;
|
||||
// For builds, its builder id. For servers, its server id.
|
||||
resource_id?: string;
|
||||
builder_id: string | undefined;
|
||||
onRemove: () => void;
|
||||
imageName: string | undefined;
|
||||
}) => {
|
||||
// This is the only way to get organizations for now
|
||||
const config_provider = useRead("ListDockerRegistriesFromConfig", {
|
||||
target: resource_id ? { type: "Builder", id: resource_id } : undefined,
|
||||
target: builder_id ? { type: "Builder", id: builder_id } : undefined,
|
||||
}).data?.find((provider) => {
|
||||
return provider.domain === registry?.domain;
|
||||
});
|
||||
|
||||
const organizations = config_provider?.organizations ?? [];
|
||||
const namespace = registry?.organization || registry?.account;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigItem
|
||||
label="Image Registry"
|
||||
boldLabel
|
||||
description={IMAGE_REGISTRY_DESCRIPTION}
|
||||
>
|
||||
<div className="flex items-center justify-stretch gap-4">
|
||||
<ProviderSelector
|
||||
disabled={disabled}
|
||||
account_type="docker"
|
||||
selected={registry?.domain}
|
||||
onSelect={(domain) =>
|
||||
setRegistry({
|
||||
...registry,
|
||||
domain,
|
||||
})
|
||||
}
|
||||
showCustom={false}
|
||||
/>
|
||||
<ConfigItem
|
||||
label={
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<div className="text-muted-foreground">Pushes to:</div>{" "}
|
||||
{registry?.domain && registry.domain + " / "}
|
||||
{registry?.domain && (namespace ? namespace : "<namespace>") + " / "}
|
||||
{imageName}
|
||||
{!registry?.domain && <Badge variant="secondary">Local</Badge>}
|
||||
</div>
|
||||
</ConfigItem>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<ProviderSelector
|
||||
disabled={disabled}
|
||||
account_type="docker"
|
||||
selected={registry?.domain}
|
||||
onSelect={(domain) =>
|
||||
setRegistry({
|
||||
...registry,
|
||||
domain,
|
||||
})
|
||||
}
|
||||
showCustom={false}
|
||||
showLabel
|
||||
/>
|
||||
<AccountSelector
|
||||
id={builder_id}
|
||||
type="Builder"
|
||||
account_type="docker"
|
||||
provider={registry?.domain!}
|
||||
selected={registry?.account}
|
||||
onSelect={(account) =>
|
||||
setRegistry({
|
||||
...registry,
|
||||
account,
|
||||
})
|
||||
}
|
||||
disabled={!registry?.domain || disabled}
|
||||
showLabel
|
||||
/>
|
||||
<OrganizationSelector
|
||||
organizations={organizations}
|
||||
selected={registry?.organization!}
|
||||
set={(organization) =>
|
||||
setRegistry({
|
||||
...registry,
|
||||
organization,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
showLabel
|
||||
/>
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onRemove}
|
||||
className="px-3 py-1"
|
||||
>
|
||||
<MinusCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ConfigItem>
|
||||
);
|
||||
};
|
||||
|
||||
{registry?.domain && (
|
||||
<>
|
||||
<ConfigItem
|
||||
label="Account"
|
||||
description="Select the account used to authenticate against the registry."
|
||||
export const ProviderSelector = ({
|
||||
disabled,
|
||||
account_type,
|
||||
selected,
|
||||
onSelect,
|
||||
showCustom = true,
|
||||
showLabel,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
account_type: "git" | "docker";
|
||||
selected: string | undefined;
|
||||
onSelect: (provider: string) => void;
|
||||
showCustom?: boolean;
|
||||
showLabel?: boolean;
|
||||
}) => {
|
||||
const [db_request, config_request]:
|
||||
| ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
| ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"] =
|
||||
account_type === "git"
|
||||
? ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
: ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"];
|
||||
const db_providers = useRead(db_request, {}).data;
|
||||
const config_providers = useRead(config_request, {}).data;
|
||||
const [customMode, setCustomMode] = useState(false);
|
||||
|
||||
if (customMode) {
|
||||
return (
|
||||
<Input
|
||||
placeholder="Input custom provider domain"
|
||||
value={selected}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
className="max-w-[75%] lg:max-w-[400px]"
|
||||
onBlur={() => setCustomMode(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setCustomMode(false);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const domains = new Set<string>();
|
||||
for (const provider of db_providers ?? []) {
|
||||
domains.add(provider.domain);
|
||||
}
|
||||
for (const provider of config_providers ?? []) {
|
||||
domains.add(provider.domain);
|
||||
}
|
||||
const providers = [...domains];
|
||||
providers.sort();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
if (value === "Custom") {
|
||||
onSelect("");
|
||||
setCustomMode(true);
|
||||
} else if (value === "None") {
|
||||
onSelect("");
|
||||
} else {
|
||||
onSelect(value);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-[300px] md:max-w-[50%]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showLabel && selected && (
|
||||
<div className="text-xs text-muted-foreground">Domain:</div>
|
||||
)}
|
||||
<SelectValue placeholder="Select Provider" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers
|
||||
?.filter((provider) => provider)
|
||||
.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
{providers !== undefined &&
|
||||
selected &&
|
||||
!providers.includes(selected) && (
|
||||
<SelectItem value={selected}>{selected}</SelectItem>
|
||||
)}
|
||||
{showCustom && <SelectItem value="Custom">Custom</SelectItem>}
|
||||
{!showCustom && <SelectItem value="None">None</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProviderSelectorConfig = (params: {
|
||||
disabled: boolean;
|
||||
account_type: "git" | "docker";
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
https?: boolean;
|
||||
onHttpsSwitch?: () => void;
|
||||
description?: string;
|
||||
boldLabel?: boolean;
|
||||
}) => {
|
||||
const select =
|
||||
params.account_type === "git" ? "git provider" : "docker registry";
|
||||
const label =
|
||||
params.account_type === "git" ? "Git Provider" : "Image Registry";
|
||||
return (
|
||||
<ConfigItem
|
||||
label={label}
|
||||
description={params.description ?? `Select ${select} domain`}
|
||||
boldLabel={params.boldLabel}
|
||||
>
|
||||
{params.account_type === "git" ? (
|
||||
<div className="flex items-center gap-2 w-[75%]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={params.onHttpsSwitch}
|
||||
className="py-0 px-2"
|
||||
disabled={params.disabled}
|
||||
>
|
||||
<AccountSelector
|
||||
id={resource_id}
|
||||
type="Builder"
|
||||
account_type="docker"
|
||||
provider={registry.domain!}
|
||||
selected={registry.account}
|
||||
onSelect={(account) =>
|
||||
setRegistry({
|
||||
...registry,
|
||||
account,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ConfigItem>
|
||||
<ConfigItem
|
||||
label="Organization"
|
||||
description="Push the build under an organization / project namespace, rather than the account namespace."
|
||||
>
|
||||
<OrganizationSelector
|
||||
organizations={organizations}
|
||||
selected={registry?.organization!}
|
||||
set={(organization) =>
|
||||
setRegistry({
|
||||
...registry,
|
||||
organization,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ConfigItem>
|
||||
</>
|
||||
{`http${params.https ? "s" : ""}://`}
|
||||
</Button>
|
||||
<ProviderSelector {...params} />
|
||||
</div>
|
||||
) : (
|
||||
<ProviderSelector {...params} />
|
||||
)}
|
||||
</>
|
||||
</ConfigItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountSelector = ({
|
||||
disabled,
|
||||
id,
|
||||
type,
|
||||
account_type,
|
||||
provider,
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = "Select Account",
|
||||
showLabel,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
type: "Server" | "Builder" | "None";
|
||||
id?: string;
|
||||
account_type: "git" | "docker";
|
||||
provider: string;
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
placeholder?: string;
|
||||
showLabel?: boolean;
|
||||
}) => {
|
||||
const [db_request, config_request]:
|
||||
| ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
| ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"] =
|
||||
account_type === "git"
|
||||
? ["ListGitProviderAccounts", "ListGitProvidersFromConfig"]
|
||||
: ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"];
|
||||
const config_params =
|
||||
type === "None" ? {} : { target: id ? { type, id } : undefined };
|
||||
const db_accounts = useRead(db_request, {}).data?.filter(
|
||||
(account) => account.domain === provider
|
||||
);
|
||||
const config_providers = useRead(config_request, config_params).data?.filter(
|
||||
(_provider) => _provider.domain === provider
|
||||
);
|
||||
|
||||
const _accounts = new Set<string>();
|
||||
for (const account of db_accounts ?? []) {
|
||||
if (account.username) {
|
||||
_accounts.add(account.username);
|
||||
}
|
||||
}
|
||||
for (const provider of config_providers ?? []) {
|
||||
for (const account of provider.accounts ?? []) {
|
||||
_accounts.add(account.username);
|
||||
}
|
||||
}
|
||||
const accounts = [..._accounts];
|
||||
accounts.sort();
|
||||
return (
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
onSelect(value === "Empty" ? "" : value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-[300px] md:max-w-[50%]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
{showLabel && selected && (
|
||||
<div className="text-xs text-muted-foreground">Account:</div>
|
||||
)}
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"Empty"}>None</SelectItem>
|
||||
{accounts
|
||||
?.filter((account) => account)
|
||||
.map((account) => (
|
||||
<SelectItem key={account} value={account}>
|
||||
{account}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountSelectorConfig = (params: {
|
||||
disabled: boolean;
|
||||
id?: string;
|
||||
type: "Server" | "Builder" | "None";
|
||||
account_type: "git" | "docker";
|
||||
provider: string;
|
||||
selected: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
return (
|
||||
<ConfigItem
|
||||
label="Account"
|
||||
description={
|
||||
params.description ??
|
||||
"Select the account used to log in to the provider"
|
||||
}
|
||||
>
|
||||
<AccountSelector {...params} />
|
||||
</ConfigItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -882,11 +919,13 @@ const OrganizationSelector = ({
|
||||
selected,
|
||||
set,
|
||||
disabled,
|
||||
showLabel,
|
||||
}: {
|
||||
organizations: string[];
|
||||
selected: string;
|
||||
set: (org: string) => void;
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
}) => {
|
||||
const [customMode, setCustomMode] = useState(false);
|
||||
if (customMode) {
|
||||
@@ -929,10 +968,15 @@ const OrganizationSelector = ({
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full lg:w-[200px] max-w-[50%]"
|
||||
className="w-full lg:w-[300px] md:max-w-[50%]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder="Select Organization" />
|
||||
<div className="flex gap-2 items-center">
|
||||
{showLabel && selected && (
|
||||
<div className="text-xs text-muted-foreground">Org:</div>
|
||||
)}
|
||||
<SelectValue placeholder="Select Organization" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"Empty"}>None</SelectItem>
|
||||
|
||||
@@ -17,13 +17,39 @@ export type MonacoLanguage =
|
||||
| "fancy_toml"
|
||||
| "json"
|
||||
| "key_value"
|
||||
| "ini"
|
||||
| "string_list"
|
||||
| "shell"
|
||||
| "dockerfile"
|
||||
| "rust"
|
||||
| "javascript"
|
||||
| "typescript"
|
||||
| undefined;
|
||||
| "typescript";
|
||||
|
||||
const LANGUAGE_EXTENSIONS: Record<MonacoLanguage, string[]> = {
|
||||
yaml: [".yaml", ".yml"],
|
||||
toml: [".toml"],
|
||||
fancy_toml: [],
|
||||
json: [".json"],
|
||||
key_value: [".env", ".conf"],
|
||||
ini: [".ini"],
|
||||
string_list: [],
|
||||
shell: [".sh", ".bash", ".zsh"],
|
||||
dockerfile: ["Dockerfile"],
|
||||
rust: [".rs"],
|
||||
javascript: [".js", ".jsx", ".mjs", ".cjs"],
|
||||
typescript: [".ts", ".tsx"],
|
||||
};
|
||||
|
||||
export const language_from_path = (path: string) => {
|
||||
for (const [lang, extensions] of Object.entries(LANGUAGE_EXTENSIONS)) {
|
||||
for (const extension of extensions) {
|
||||
if (path.endsWith(extension)) {
|
||||
return lang as MonacoLanguage;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const MonacoEditor = ({
|
||||
value,
|
||||
@@ -35,7 +61,7 @@ export const MonacoEditor = ({
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onValueChange?: (value: string) => void;
|
||||
language: MonacoLanguage;
|
||||
language: MonacoLanguage | undefined;
|
||||
readOnly?: boolean;
|
||||
minHeight?: number;
|
||||
className?: string;
|
||||
@@ -167,7 +193,7 @@ export const MonacoDiffEditor = ({
|
||||
original: string | undefined;
|
||||
modified: string | undefined;
|
||||
onModifiedValueChange?: (value: string) => void;
|
||||
language: MonacoLanguage;
|
||||
language: MonacoLanguage | undefined;
|
||||
readOnly?: boolean;
|
||||
containerClassName?: string;
|
||||
hideUnchangedRegions?: boolean;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MinusCircle } from "lucide-react";
|
||||
|
||||
const ALERT_TYPES: Types.AlertData["type"][] = [
|
||||
// Server
|
||||
"ServerVersionMismatch",
|
||||
"ServerUnreachable",
|
||||
"ServerCpu",
|
||||
"ServerMem",
|
||||
@@ -26,6 +27,7 @@ const ALERT_TYPES: Types.AlertData["type"][] = [
|
||||
"ActionFailed",
|
||||
"ProcedureFailed",
|
||||
"AwsBuilderTerminationFailed",
|
||||
"Custom",
|
||||
];
|
||||
|
||||
export const AlertTypeConfig = ({
|
||||
@@ -38,7 +40,7 @@ export const AlertTypeConfig = ({
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const at = ALERT_TYPES.filter(
|
||||
(alert_type) => !alert_types.includes(alert_type)
|
||||
(alert_type) => !alert_types.includes(alert_type),
|
||||
);
|
||||
return (
|
||||
<ConfigItem
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Config, ConfigComponent } from "@components/config";
|
||||
import {
|
||||
AccountSelectorConfig,
|
||||
AddExtraArgMenu,
|
||||
ImageRegistryConfig,
|
||||
ConfigInput,
|
||||
ConfigItem,
|
||||
ConfigList,
|
||||
ImageRegistryConfig,
|
||||
InputList,
|
||||
ProviderSelectorConfig,
|
||||
SystemCommand,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
useWrite,
|
||||
} from "@lib/hooks";
|
||||
import { Types } from "komodo_client";
|
||||
import { Ban, CirclePlus } from "lucide-react";
|
||||
import { Ban, CirclePlus, PlusCircle } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { CopyWebhook, ResourceLink, ResourceSelector } from "../common";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@ui/select";
|
||||
import { LinkedRepoConfig } from "@components/config/linked_repo";
|
||||
import { Button } from "@ui/button";
|
||||
|
||||
type BuildMode = "UI Defined" | "Files On Server" | "Git Repo" | undefined;
|
||||
const BUILD_MODES: BuildMode[] = ["UI Defined", "Files On Server", "Git Repo"];
|
||||
@@ -226,18 +227,68 @@ export const BuildConfig = ({
|
||||
},
|
||||
};
|
||||
|
||||
const imageName = (update.image_name ?? config.image_name) || name;
|
||||
|
||||
const general_common: ConfigComponent<Types.BuildConfig>[] = [
|
||||
{
|
||||
label: "Registry",
|
||||
labelHidden: true,
|
||||
components: {
|
||||
image_registry: (registry, set) => (
|
||||
<ImageRegistryConfig
|
||||
registry={registry}
|
||||
setRegistry={(image_registry) => set({ image_registry })}
|
||||
resource_id={update.builder_id ?? config.builder_id}
|
||||
disabled={disabled}
|
||||
/>
|
||||
image_registry: (image_registries, set) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConfigItem
|
||||
label="Image Registry"
|
||||
boldLabel
|
||||
description="Configure where the built image is pushed."
|
||||
>
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
set({
|
||||
image_registry: [
|
||||
...(image_registries ?? []),
|
||||
{ domain: "", organization: "", account: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 w-[200px]"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" />
|
||||
Add Registry
|
||||
</Button>
|
||||
)}
|
||||
</ConfigItem>
|
||||
|
||||
{image_registries?.map((registry, index) => (
|
||||
<ImageRegistryConfig
|
||||
key={
|
||||
(registry.domain ?? "") +
|
||||
(registry.organization ?? "") +
|
||||
(registry.account ?? "") +
|
||||
index
|
||||
}
|
||||
registry={registry}
|
||||
imageName={imageName}
|
||||
setRegistry={(registry) =>
|
||||
set({
|
||||
image_registry:
|
||||
image_registries?.map((r, i) =>
|
||||
i === index ? registry : r
|
||||
) ?? [],
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
set({
|
||||
image_registry:
|
||||
image_registries?.filter((_, i) => i !== index) ?? [],
|
||||
})
|
||||
}
|
||||
builder_id={update.builder_id ?? config.builder_id}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,11 +27,13 @@ import {
|
||||
Plus,
|
||||
PlusCircle,
|
||||
SearchX,
|
||||
Settings2,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { TextUpdateMenuMonaco, TimezoneSelector } from "@components/util";
|
||||
import { Card } from "@ui/card";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { filterBySplit, text_to_env } from "@lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
|
||||
import { fmt_upper_camelcase } from "@lib/formatting";
|
||||
import {
|
||||
@@ -58,6 +60,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/dialog";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { quote as shellQuote, parse as shellParse } from "shell-quote";
|
||||
|
||||
type ExecutionType = Types.Execution["type"];
|
||||
|
||||
@@ -122,7 +134,7 @@ export const ProcedureConfig = ({ id }: { id: string }) => {
|
||||
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();
|
||||
@@ -165,7 +177,7 @@ export const ProcedureConfig = ({ id }: { id: string }) => {
|
||||
setStage={(stage) =>
|
||||
set({
|
||||
stages: stages.map((s, i) =>
|
||||
index === i ? stage : s
|
||||
index === i ? stage : s,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -545,7 +557,7 @@ const Stage = ({
|
||||
].params,
|
||||
},
|
||||
} as Types.EnabledExecution)
|
||||
: item
|
||||
: item,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -577,7 +589,7 @@ const Stage = ({
|
||||
...item,
|
||||
execution: { type, params },
|
||||
}
|
||||
: item
|
||||
: item,
|
||||
) as Types.EnabledExecution[],
|
||||
})
|
||||
}
|
||||
@@ -612,7 +624,7 @@ const Stage = ({
|
||||
setStage({
|
||||
...stage,
|
||||
executions: stage.executions!.filter(
|
||||
(_, i) => i !== index
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -639,7 +651,7 @@ const Stage = ({
|
||||
setStage({
|
||||
...stage,
|
||||
executions: stage.executions!.map((item, i) =>
|
||||
i === index ? { ...item, enabled: !enabled } : item
|
||||
i === index ? { ...item, enabled: !enabled } : item,
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -664,7 +676,7 @@ const ExecutionTypeSelector = ({
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const execution_types = Object.keys(TARGET_COMPONENTS).filter(
|
||||
(c) => !["None"].includes(c)
|
||||
(c) => !["None"].includes(c),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -745,14 +757,30 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
|
||||
},
|
||||
// Action
|
||||
RunAction: {
|
||||
params: { action: "" },
|
||||
params: { action: "", args: {} },
|
||||
Component: ({ params, setParams, disabled }) => (
|
||||
<ResourceSelector
|
||||
type="Action"
|
||||
selected={params.action}
|
||||
onSelect={(action) => setParams({ action })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<ResourceSelector
|
||||
type="Action"
|
||||
selected={params.action}
|
||||
onSelect={(action) => setParams({ action, args: params.args })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextUpdateMenuMonaco
|
||||
title="Action Arguments (JSON)"
|
||||
value={JSON.stringify(params.args ?? {}, undefined, 2)}
|
||||
onUpdate={(args) =>
|
||||
setParams({ action: params.action, args: JSON.parse(args) })
|
||||
}
|
||||
disabled={disabled}
|
||||
language="json"
|
||||
triggerChild={
|
||||
<Button variant="secondary" size="icon">
|
||||
<Settings2 className="w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
BatchRunAction: {
|
||||
@@ -1097,6 +1125,227 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
|
||||
/>
|
||||
),
|
||||
},
|
||||
RunStackService: {
|
||||
params: {
|
||||
stack: "",
|
||||
service: "",
|
||||
command: undefined,
|
||||
no_tty: undefined,
|
||||
no_deps: undefined,
|
||||
service_ports: undefined,
|
||||
env: undefined,
|
||||
workdir: undefined,
|
||||
user: undefined,
|
||||
entrypoint: undefined,
|
||||
pull: undefined,
|
||||
},
|
||||
Component: ({ params, setParams, disabled }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
// local mirrors to allow cancel without committing
|
||||
const [stack, setStack] = useState(params.stack ?? "");
|
||||
const [service, setService] = useState(params.service ?? "");
|
||||
const [commandText, setCommand] = useState(
|
||||
params.command && params.command.length
|
||||
? shellQuote(params.command)
|
||||
: ""
|
||||
);
|
||||
const [no_tty, setNoTty] = useState(!!params.no_tty);
|
||||
const [no_deps, setNoDeps] = useState(!!params.no_deps);
|
||||
const [service_ports, setServicePorts] = useState(!!params.service_ports);
|
||||
const [workdir, setWorkdir] = useState(params.workdir ?? "");
|
||||
const [user, setUser] = useState(params.user ?? "");
|
||||
const [entrypoint, setEntrypoint] = useState(params.entrypoint ?? "");
|
||||
const [pull, setPull] = useState(!!params.pull);
|
||||
const env_text = (
|
||||
params.env
|
||||
? Object.entries(params.env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n")
|
||||
: " # VARIABLE = value\n"
|
||||
) as string;
|
||||
const [envText, setEnvText] = useState(env_text);
|
||||
|
||||
useEffect(() => {
|
||||
setStack(params.stack ?? "");
|
||||
setService(params.service ?? "");
|
||||
setCommand(
|
||||
params.command && params.command.length
|
||||
? shellQuote(params.command)
|
||||
: ""
|
||||
);
|
||||
setNoTty(!!params.no_tty);
|
||||
setNoDeps(!!params.no_deps);
|
||||
setServicePorts(!!params.service_ports);
|
||||
setWorkdir(params.workdir ?? "");
|
||||
setUser(params.user ?? "");
|
||||
setEntrypoint(params.entrypoint ?? "");
|
||||
setPull(!!params.pull);
|
||||
setEnvText(
|
||||
params.env
|
||||
? Object.entries(params.env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n")
|
||||
: " # VARIABLE = value\n"
|
||||
);
|
||||
}, [params]);
|
||||
|
||||
const onConfirm = () => {
|
||||
const envArray = text_to_env(envText);
|
||||
const env = envArray.length
|
||||
? envArray.reduce<Record<string, string>>(
|
||||
(acc, { variable, value }) => {
|
||||
if (variable) acc[variable] = value;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
: undefined;
|
||||
const parsed = commandText.trim()
|
||||
? shellParse(commandText.trim()).map((tok) =>
|
||||
typeof tok === "string" ? tok : ((tok as any).op ?? String(tok))
|
||||
)
|
||||
: [];
|
||||
setParams({
|
||||
stack,
|
||||
service,
|
||||
command: parsed.length ? (parsed as string[]) : undefined,
|
||||
no_tty: no_tty ? true : undefined,
|
||||
no_deps: no_deps ? true : undefined,
|
||||
service_ports: service_ports ? true : undefined,
|
||||
workdir: workdir || undefined,
|
||||
user: user || undefined,
|
||||
entrypoint: entrypoint || undefined,
|
||||
pull: pull ? true : undefined,
|
||||
env,
|
||||
} as any);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" disabled={disabled}>
|
||||
Configure
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[800px] max-w-[95vw] max-h-[80vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run Stack Service</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground text-xs">Stack</div>
|
||||
<ResourceSelector
|
||||
type="Stack"
|
||||
selected={stack}
|
||||
onSelect={(id) => setStack(id)}
|
||||
disabled={disabled}
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground text-xs">Service</div>
|
||||
<Input
|
||||
placeholder="service name"
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground text-xs">Command</div>
|
||||
<Input
|
||||
value={commandText}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<Switch checked={no_tty} onCheckedChange={setNoTty} />
|
||||
<span className="text-sm">No TTY</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Switch checked={no_deps} onCheckedChange={setNoDeps} />
|
||||
<span className="text-sm">No Dependencies</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={service_ports}
|
||||
onCheckedChange={setServicePorts}
|
||||
/>
|
||||
<span className="text-sm">Service Ports</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Switch checked={pull} onCheckedChange={setPull} />
|
||||
<span className="text-sm">Pull Image</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Working Directory
|
||||
</div>
|
||||
<Input
|
||||
placeholder="/work/dir"
|
||||
value={workdir}
|
||||
onChange={(e) => setWorkdir(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground text-xs">User</div>
|
||||
<Input
|
||||
placeholder="uid:gid or user"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 md:col-span-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Entrypoint
|
||||
</div>
|
||||
<Input
|
||||
value={entrypoint}
|
||||
onChange={(e) => setEntrypoint(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Extra Environment Variables
|
||||
</div>
|
||||
<MonacoEditor
|
||||
value={envText}
|
||||
onValueChange={setEnvText}
|
||||
language="key_value"
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" /> Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Repo
|
||||
CloneRepo: {
|
||||
params: { repo: "" },
|
||||
@@ -1425,6 +1674,21 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
|
||||
Component: () => <></>,
|
||||
},
|
||||
|
||||
SendAlert: {
|
||||
params: { message: "" },
|
||||
Component: ({ params, setParams, disabled }) => (
|
||||
<TextUpdateMenuMonaco
|
||||
title="Alert message"
|
||||
value={params.message}
|
||||
placeholder="Configure custom alert message"
|
||||
onUpdate={(message) => setParams({ message })}
|
||||
disabled={disabled}
|
||||
language={undefined}
|
||||
fullWidth
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
Sleep: {
|
||||
params: { duration_ms: 0 },
|
||||
Component: ({ params, setParams, disabled }) => {
|
||||
|
||||
@@ -144,6 +144,17 @@ export const ServerConfig = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Version",
|
||||
labelHidden: true,
|
||||
components: {
|
||||
send_version_mismatch_alerts: {
|
||||
label: "Send Version Mismatch Alerts",
|
||||
description:
|
||||
"Send an alert if the Periphery version differs from the Core version.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "CPU",
|
||||
labelHidden: true,
|
||||
|
||||
@@ -47,13 +47,28 @@ export const useServer = (id?: string) =>
|
||||
export const useFullServer = (id: string) =>
|
||||
useRead("GetServer", { server: id }, { refetchInterval: 10_000 }).data;
|
||||
|
||||
// Helper function to check for version mismatch
|
||||
export const useVersionMismatch = (serverId?: string) => {
|
||||
const core_version = useRead("GetVersion", {}).data?.version;
|
||||
const server_version = useServer(serverId)?.info.version;
|
||||
|
||||
const unknown = !server_version || server_version === "Unknown";
|
||||
const mismatch = !!server_version && !!core_version && server_version !== core_version;
|
||||
|
||||
return { unknown, mismatch, hasVersionMismatch: mismatch && !unknown };
|
||||
};
|
||||
|
||||
const Icon = ({ id, size }: { id?: string; size: number }) => {
|
||||
const state = useServer(id)?.info.state;
|
||||
const { hasVersionMismatch } = useVersionMismatch(id);
|
||||
|
||||
return (
|
||||
<Server
|
||||
className={cn(
|
||||
`w-${size} h-${size}`,
|
||||
state && stroke_color_class_by_intention(server_state_intention(state))
|
||||
state && stroke_color_class_by_intention(
|
||||
server_state_intention(state, hasVersionMismatch)
|
||||
)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -205,8 +220,35 @@ const ConfigTabs = ({ id }: { id: string }) => {
|
||||
export const ServerVersion = ({ id }: { id: string }) => {
|
||||
const core_version = useRead("GetVersion", {}).data?.version;
|
||||
const version = useServer(id)?.info.version;
|
||||
const server_state = useServer(id)?.info.state;
|
||||
|
||||
const unknown = !version || version === "Unknown";
|
||||
const mismatch = !!version && !!core_version && version !== core_version;
|
||||
|
||||
// Don't show version for disabled servers
|
||||
if (server_state === Types.ServerState.Disabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<AlertCircle
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
stroke_color_class_by_intention("Unknown")
|
||||
)}
|
||||
/>
|
||||
Unknown
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div>
|
||||
Server is <span className="font-bold">disabled</span> - version unknown.
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -264,11 +306,16 @@ export const ServerComponents: RequiredResourceComponents = {
|
||||
),
|
||||
|
||||
Dashboard: () => {
|
||||
const summary = useRead("GetServersSummary", {}).data;
|
||||
const summary = useRead("GetServersSummary", {}, { refetchInterval: 15_000 }).data;
|
||||
return (
|
||||
<DashboardPieChart
|
||||
data={[
|
||||
{ title: "Healthy", intention: "Good", value: summary?.healthy ?? 0 },
|
||||
{
|
||||
title: "Warning",
|
||||
intention: "Warning",
|
||||
value: summary?.warning ?? 0,
|
||||
},
|
||||
{
|
||||
title: "Unhealthy",
|
||||
intention: "Critical",
|
||||
@@ -315,7 +362,21 @@ export const ServerComponents: RequiredResourceComponents = {
|
||||
|
||||
State: ({ id }) => {
|
||||
const state = useServer(id)?.info.state;
|
||||
return <StatusBadge text={state} intent={server_state_intention(state)} />;
|
||||
const { hasVersionMismatch } = useVersionMismatch(id);
|
||||
|
||||
// Show full version mismatch text
|
||||
const displayState = state === Types.ServerState.Ok && hasVersionMismatch
|
||||
? "Version Mismatch"
|
||||
: state === Types.ServerState.NotOk
|
||||
? "Not Ok"
|
||||
: state;
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
text={displayState}
|
||||
intent={server_state_intention(state, hasVersionMismatch)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
Status: {},
|
||||
@@ -531,19 +592,23 @@ export const ServerComponents: RequiredResourceComponents = {
|
||||
|
||||
ResourcePageHeader: ({ id }) => {
|
||||
const server = useServer(id);
|
||||
const { hasVersionMismatch } = useVersionMismatch(id);
|
||||
|
||||
// Determine display state for header (longer text is okay in header)
|
||||
const displayState = server?.info.state === Types.ServerState.Ok && hasVersionMismatch
|
||||
? "Version Mismatch"
|
||||
: server?.info.state === Types.ServerState.NotOk
|
||||
? "Not Ok"
|
||||
: server?.info.state;
|
||||
|
||||
return (
|
||||
<ResourcePageHeader
|
||||
intent={server_state_intention(server?.info.state)}
|
||||
intent={server_state_intention(server?.info.state, hasVersionMismatch)}
|
||||
icon={<Icon id={id} size={8} />}
|
||||
type="Server"
|
||||
id={id}
|
||||
resource={server}
|
||||
state={
|
||||
server?.info.state === Types.ServerState.NotOk
|
||||
? "Not Ok"
|
||||
: server?.info.state
|
||||
}
|
||||
state={displayState}
|
||||
status={server?.info.region}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,12 @@ import { cn } from "@lib/utils";
|
||||
import { Progress } from "@ui/progress";
|
||||
import { ServerState } from "komodo_client/dist/types";
|
||||
import { Cpu, Database, MemoryStick, LucideIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ServerStatsMiniProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface StatItemProps {
|
||||
@@ -22,7 +24,7 @@ const StatItem = ({ icon: Icon, label, percentage, type, isUnreachable, getTextC
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-3 h-3 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -41,12 +43,20 @@ const StatItem = ({ icon: Icon, label, percentage, type, isUnreachable, getTextC
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ServerStatsMini = ({ id, className }: ServerStatsMiniProps) => {
|
||||
export const ServerStatsMini = ({ id, className, enabled = true }: ServerStatsMiniProps) => {
|
||||
const calculatePercentage = (value: number) =>
|
||||
Number((value ?? 0).toFixed(2));
|
||||
|
||||
const server = useRead("ListServers", {}).data?.find((s) => s.id === id);
|
||||
const serverDetails = useRead("GetServer", { server: id }).data;
|
||||
const servers = useRead("ListServers", {}).data;
|
||||
const server = servers?.find((s) => s.id === id);
|
||||
|
||||
const isServerAvailable = server &&
|
||||
server.info.state !== ServerState.Disabled &&
|
||||
server.info.state !== ServerState.NotOk;
|
||||
|
||||
const serverDetails = useRead("GetServer", { server: id }, {
|
||||
enabled: enabled && isServerAvailable
|
||||
}).data;
|
||||
|
||||
const cpuWarning = serverDetails?.config?.cpu_warning ?? 75;
|
||||
const cpuCritical = serverDetails?.config?.cpu_critical ?? 90;
|
||||
@@ -63,42 +73,52 @@ export const ServerStatsMini = ({ id, className }: ServerStatsMiniProps) => {
|
||||
if (percentage >= warning) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
const stats = useRead(
|
||||
"GetSystemStats",
|
||||
{ server: id },
|
||||
{
|
||||
enabled: server ? server.info.state !== "Disabled" : false,
|
||||
refetchInterval: 10_000,
|
||||
enabled: enabled && isServerAvailable,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: 5_000,
|
||||
},
|
||||
).data;
|
||||
|
||||
if (!server || server.info.state === "Disabled") {
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cpuPercentage = stats ? calculatePercentage(stats.cpu_perc) : 0;
|
||||
const memoryPercentage = stats && stats.mem_total_gb > 0 ? calculatePercentage((stats.mem_used_gb / stats.mem_total_gb) * 100) : 0;
|
||||
const calculations = useMemo(() => {
|
||||
const cpuPercentage = stats ? calculatePercentage(stats.cpu_perc) : 0;
|
||||
const memoryPercentage = stats && stats.mem_total_gb > 0 ? calculatePercentage((stats.mem_used_gb / stats.mem_total_gb) * 100) : 0;
|
||||
|
||||
const diskUsed = stats ? stats.disks.reduce((acc, disk) => acc + disk.used_gb, 0) : 0;
|
||||
const diskTotal = stats ? stats.disks.reduce((acc, disk) => acc + disk.total_gb, 0) : 0;
|
||||
const diskPercentage = diskTotal > 0? calculatePercentage((diskUsed / diskTotal) * 100) : 0;
|
||||
const diskUsed = stats ? stats.disks.reduce((acc, disk) => acc + disk.used_gb, 0) : 0;
|
||||
const diskTotal = stats ? stats.disks.reduce((acc, disk) => acc + disk.total_gb, 0) : 0;
|
||||
const diskPercentage = diskTotal > 0 ? calculatePercentage((diskUsed / diskTotal) * 100) : 0;
|
||||
|
||||
const isUnreachable = !stats || server.info.state === ServerState.NotOk;
|
||||
const isDisabled = server.info.state === ServerState.Disabled;
|
||||
|
||||
const isUnreachable = !stats || server.info.state === ServerState.NotOk;
|
||||
const unreachableClass = isUnreachable ? "opacity-50" : "";
|
||||
return {
|
||||
cpuPercentage,
|
||||
memoryPercentage,
|
||||
diskPercentage,
|
||||
isUnreachable,
|
||||
isDisabled
|
||||
};
|
||||
}, [stats, server.info.state]);
|
||||
|
||||
const statItems = [
|
||||
const { cpuPercentage, memoryPercentage, diskPercentage, isUnreachable, isDisabled } = calculations;
|
||||
const overlayClass = (isUnreachable || isDisabled) ? "opacity-50" : "";
|
||||
|
||||
const statItems = useMemo(() => [
|
||||
{ icon: Cpu, label: "CPU", percentage: cpuPercentage, type: "cpu" as const },
|
||||
{ icon: MemoryStick, label: "Memory", percentage: memoryPercentage, type: "memory" as const },
|
||||
{ icon: Database, label: "Disk", percentage: diskPercentage, type: "disk" as const },
|
||||
];
|
||||
], [cpuPercentage, memoryPercentage, diskPercentage]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", unreachableClass, className)}>
|
||||
{isUnreachable && (
|
||||
<div className="text-xs text-muted-foreground italic text-center">
|
||||
Unreachable
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("relative flex flex-col gap-2", overlayClass, className)}>
|
||||
{statItems.map((item) => (
|
||||
<StatItem
|
||||
key={item.label}
|
||||
@@ -106,10 +126,20 @@ export const ServerStatsMini = ({ id, className }: ServerStatsMiniProps) => {
|
||||
label={item.label}
|
||||
percentage={item.percentage}
|
||||
type={item.type}
|
||||
isUnreachable={isUnreachable}
|
||||
isUnreachable={isUnreachable || isDisabled}
|
||||
getTextColor={getTextColor}
|
||||
/>
|
||||
))}
|
||||
{isDisabled && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-black/60 z-10">
|
||||
<span className="text-xs text-foreground font-bold italic text-center">Disabled</span>
|
||||
</div>
|
||||
)}
|
||||
{isUnreachable && !isDisabled && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-black/60 z-10">
|
||||
<span className="text-xs text-foreground font-bold italic text-center">Unreachable</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
useWebhookIntegrations,
|
||||
useWrite,
|
||||
} from "@lib/hooks";
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { CopyWebhook, ResourceLink, ResourceSelector } from "../common";
|
||||
import {
|
||||
Select,
|
||||
@@ -35,8 +35,30 @@ import { ConfirmButton, ShowHideButton } from "@components/util";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { text_color_class_by_intention } from "@lib/color";
|
||||
import { Ban, CirclePlus } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
ChevronsUpDown,
|
||||
CirclePlus,
|
||||
MinusCircle,
|
||||
PlusCircle,
|
||||
SearchX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { LinkedRepoConfig } from "@components/config/linked_repo";
|
||||
import { Button } from "@ui/button";
|
||||
import { Input } from "@ui/input";
|
||||
import { useStack } from ".";
|
||||
import { filterBySplit } from "@lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@ui/command";
|
||||
import { Checkbox } from "@ui/checkbox";
|
||||
|
||||
type StackMode = "UI Defined" | "Files On Server" | "Git Repo" | undefined;
|
||||
const STACK_MODES: StackMode[] = ["UI Defined", "Files On Server", "Git Repo"];
|
||||
@@ -199,51 +221,65 @@ export const StackConfig = ({
|
||||
},
|
||||
};
|
||||
|
||||
const auto_update = update.auto_update ?? config.auto_update ?? false;
|
||||
|
||||
const general_common: ConfigComponent<Types.StackConfig>[] = [
|
||||
{
|
||||
label: "Environment",
|
||||
description: "Pass these variables to the compose command",
|
||||
actions: (
|
||||
<ShowHideButton
|
||||
show={show.env}
|
||||
setShow={(env) => setShow({ ...show, env })}
|
||||
/>
|
||||
const environment: ConfigComponent<Types.StackConfig> = {
|
||||
label: "Environment",
|
||||
description: "Pass these variables to the compose command",
|
||||
actions: (
|
||||
<ShowHideButton
|
||||
show={show.env}
|
||||
setShow={(env) => setShow({ ...show, env })}
|
||||
/>
|
||||
),
|
||||
contentHidden: !show.env,
|
||||
components: {
|
||||
environment: (env, set) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SecretsSearch server={update.server_id ?? config.server_id} />
|
||||
<MonacoEditor
|
||||
value={env || " # VARIABLE = value\n"}
|
||||
onValueChange={(environment) => set({ environment })}
|
||||
language="key_value"
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
contentHidden: !show.env,
|
||||
components: {
|
||||
environment: (env, set) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SecretsSearch server={update.server_id ?? config.server_id} />
|
||||
<MonacoEditor
|
||||
value={env || " # VARIABLE = value\n"}
|
||||
onValueChange={(environment) => set({ environment })}
|
||||
language="key_value"
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
env_file_path: {
|
||||
description:
|
||||
"The path to write the file to, relative to the 'Run Directory'.",
|
||||
placeholder: ".env",
|
||||
},
|
||||
additional_env_files: (values, set) => (
|
||||
env_file_path: {
|
||||
description:
|
||||
"The path to write the file to, relative to the 'Run Directory'.",
|
||||
placeholder: ".env",
|
||||
},
|
||||
additional_env_files:
|
||||
(mode === "Files On Server" || mode === "Git Repo") &&
|
||||
((values, set) => (
|
||||
<ConfigList
|
||||
label="Additional Env Files"
|
||||
boldLabel
|
||||
addLabel="Add Env File"
|
||||
description="Add additional env files to pass with '--env-file', relative to the 'Run Directory'."
|
||||
description="Add additional env files to pass with '--env-file'. Relative to the 'Run Directory'."
|
||||
field="additional_env_files"
|
||||
values={values ?? []}
|
||||
set={set}
|
||||
disabled={disabled}
|
||||
placeholder="Input File Path"
|
||||
placeholder=".env"
|
||||
/>
|
||||
),
|
||||
},
|
||||
)),
|
||||
},
|
||||
};
|
||||
|
||||
const config_files: ConfigComponent<Types.StackConfig> = {
|
||||
label: "Config Files",
|
||||
description:
|
||||
"Add other config files to associate with the Stack, and edit in the UI. Relative to 'Run Directory'.",
|
||||
components: {
|
||||
config_files: (value, set) => (
|
||||
<ConfigFiles id={id} value={value} set={set} disabled={disabled} />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const auto_update = update.auto_update ?? config.auto_update ?? false;
|
||||
|
||||
const general_common: ConfigComponent<Types.StackConfig>[] = [
|
||||
{
|
||||
label: "Auto Update",
|
||||
components: {
|
||||
@@ -517,6 +553,8 @@ export const StackConfig = ({
|
||||
),
|
||||
},
|
||||
},
|
||||
environment,
|
||||
config_files,
|
||||
...general_common,
|
||||
],
|
||||
advanced,
|
||||
@@ -635,6 +673,8 @@ export const StackConfig = ({
|
||||
),
|
||||
},
|
||||
},
|
||||
environment,
|
||||
config_files,
|
||||
...general_common,
|
||||
{
|
||||
label: "Webhooks",
|
||||
@@ -848,6 +888,7 @@ export const StackConfig = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
environment,
|
||||
...general_common,
|
||||
],
|
||||
advanced,
|
||||
@@ -887,3 +928,222 @@ services:
|
||||
# volumes:
|
||||
# data:
|
||||
`;
|
||||
|
||||
const ConfigFiles = ({
|
||||
id,
|
||||
value,
|
||||
set,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
value: Types.StackFileDependency[] | undefined;
|
||||
set: (value: Partial<Types.StackConfig>) => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const values = value ?? [];
|
||||
return (
|
||||
<ConfigItem>
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
set({
|
||||
config_files: [
|
||||
...values,
|
||||
{
|
||||
path: "",
|
||||
services: [],
|
||||
requires: Types.StackFileRequires.None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 w-[200px]"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" />
|
||||
Add Additional File
|
||||
</Button>
|
||||
)}
|
||||
{values.length > 0 && (
|
||||
<div className="flex w-full">
|
||||
<div className="flex flex-col gap-4 w-fit">
|
||||
{values.map(({ path, services, requires }, i) => (
|
||||
<div className="w-full flex flex-wrap gap-4" key={i}>
|
||||
<Input
|
||||
placeholder="configs/config.yaml"
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
values[i] = { ...values[i], path: e.target.value };
|
||||
set({ config_files: [...values] });
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-[400px] max-w-full"
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
set({
|
||||
config_files: [...values.filter((_, idx) => idx !== i)],
|
||||
})
|
||||
}
|
||||
>
|
||||
<MinusCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ServicesSelector
|
||||
id={id}
|
||||
selected_services={services ?? []}
|
||||
set={(services) => {
|
||||
values[i] = { ...values[i], services };
|
||||
set({ config_files: [...values] });
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<RequiresSelector
|
||||
requires={requires ?? Types.StackFileRequires.Redeploy}
|
||||
set={(requires) => {
|
||||
values[i] = { ...values[i], requires };
|
||||
set({ config_files: [...values] });
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfigItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ServicesSelector = ({
|
||||
id,
|
||||
selected_services,
|
||||
set,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
selected_services: string[];
|
||||
set: (services: string[]) => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const services = useStack(id)?.info.services.map((s) => s.service) ?? [];
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = filterBySplit(services, search, (i) => i).sort();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex justify-between gap-2 w-fit max-w-[350px]"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-xs text-muted-foreground">Dependents:</div>
|
||||
{selected_services.length === 0
|
||||
? "All"
|
||||
: selected_services.join(", ")}
|
||||
</div>
|
||||
{!disabled && <ChevronsUpDown className="w-3 h-3" />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] max-h-[300px] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search services"
|
||||
className="h-9"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="flex justify-evenly items-center pt-3 pb-2">
|
||||
No services found
|
||||
<SearchX className="w-3 h-3" />
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{filtered.map((service) => (
|
||||
<CommandItem
|
||||
key={service}
|
||||
onSelect={() => {
|
||||
if (selected_services.includes(service)) {
|
||||
set(selected_services.filter((s) => s !== service));
|
||||
} else {
|
||||
set([...selected_services, service].sort());
|
||||
}
|
||||
// setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox checked={selected_services.includes(service)} />
|
||||
<div className="p-1">{service}</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{!search && selected_services.length > 0 && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
set([]);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
disabled={services.length === 0}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="px-1 py-0 h-fit"
|
||||
disabled={services.length === 0}
|
||||
>
|
||||
<X className="w-4" />
|
||||
</Button>
|
||||
<div className="p-1">Clear</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const RequiresSelector = ({
|
||||
requires,
|
||||
set,
|
||||
disabled,
|
||||
}: {
|
||||
requires: Types.StackFileRequires;
|
||||
set: (requires: Types.StackFileRequires) => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
value={requires}
|
||||
onValueChange={(requires: Types.StackFileRequires) => {
|
||||
set(requires);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[180px] flex gap-2 items-center"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">Requires:</div>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Types.StackFileRequires).map((requires) => (
|
||||
<SelectItem key={requires} value={requires}>
|
||||
{requires}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@ui/card";
|
||||
import { useFullStack, useStack } from ".";
|
||||
import { cn, updateLogToHtml } from "@lib/utils";
|
||||
import { MonacoEditor } from "@components/monaco";
|
||||
import { language_from_path, MonacoEditor } from "@components/monaco";
|
||||
import { usePermissions } from "@lib/hooks";
|
||||
import { ConfirmUpdate } from "@components/config/util";
|
||||
import { useLocalStorage, useWrite } from "@lib/hooks";
|
||||
@@ -261,7 +261,7 @@ export const StackInfo = ({
|
||||
<CardContent className="pr-8">
|
||||
<MonacoEditor
|
||||
value={edits[content.path] ?? content.contents}
|
||||
language="yaml"
|
||||
language={language_from_path(content.path)}
|
||||
readOnly={!canEdit}
|
||||
onValueChange={editFileCallback(content.path)}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SetStateAction,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
useContainerPortsMap,
|
||||
useRead,
|
||||
useTemplatesQueryBehavior,
|
||||
usePromptHotkeys,
|
||||
} from "@lib/hooks";
|
||||
import { Prune } from "./resources/server/actions";
|
||||
import { MonacoEditor, MonacoLanguage } from "./monaco";
|
||||
@@ -129,6 +131,7 @@ export const ActionButton = forwardRef<
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onBlur?: FocusEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
"data-confirm-button"?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -142,6 +145,7 @@ export const ActionButton = forwardRef<
|
||||
loading,
|
||||
onClick,
|
||||
onBlur,
|
||||
"data-confirm-button": dataConfirmButton,
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
@@ -156,6 +160,7 @@ export const ActionButton = forwardRef<
|
||||
onBlur={onBlur}
|
||||
disabled={disabled || loading}
|
||||
ref={ref}
|
||||
data-confirm-button={dataConfirmButton}
|
||||
>
|
||||
{title} {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon}
|
||||
</Button>
|
||||
@@ -201,7 +206,22 @@ export const ActionWithDialog = ({
|
||||
useRead("GetCoreInfo", {}).data?.disable_confirm_dialog ?? false;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Add prompt hotkeys for better UX when dialog is open
|
||||
usePromptHotkeys({
|
||||
onConfirm: () => {
|
||||
if (name === input && !disabled) {
|
||||
onClick && onClick();
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
onCancel: () => setOpen(false),
|
||||
enabled: open,
|
||||
confirmDisabled: disabled || name !== input,
|
||||
});
|
||||
|
||||
// If confirm dialogs are disabled and this isn't forced, use ConfirmButton directly
|
||||
if (!forceConfirmDialog && disable_confirm_dialog) {
|
||||
return (
|
||||
<ConfirmButton
|
||||
@@ -258,6 +278,7 @@ export const ActionWithDialog = ({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ConfirmButton
|
||||
ref={confirmButtonRef}
|
||||
title={title}
|
||||
icon={icon}
|
||||
disabled={disabled || name !== input}
|
||||
@@ -272,7 +293,27 @@ export const ActionWithDialog = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmButton = ({
|
||||
export const ConfirmButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
variant?:
|
||||
| "link"
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| null
|
||||
| undefined;
|
||||
size?: "default" | "sm" | "lg" | "icon" | null | undefined;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({
|
||||
variant,
|
||||
size,
|
||||
title,
|
||||
@@ -281,28 +322,12 @@ export const ConfirmButton = ({
|
||||
loading,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
variant?:
|
||||
| "link"
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| null
|
||||
| undefined;
|
||||
size?: "default" | "sm" | "lg" | "icon" | null | undefined;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const [confirmed, set] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
title={confirmed ? "Confirm" : title}
|
||||
@@ -323,9 +348,10 @@ export const ConfirmButton = ({
|
||||
onBlur={() => set(false)}
|
||||
loading={loading}
|
||||
className={className}
|
||||
data-confirm-button={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export const UserSettings = () => (
|
||||
<Link to="/settings">
|
||||
@@ -390,6 +416,7 @@ export const TextUpdateMenuMonaco = ({
|
||||
setOpen,
|
||||
triggerHidden,
|
||||
language,
|
||||
triggerChild,
|
||||
}: {
|
||||
title: string;
|
||||
titleRight?: ReactNode;
|
||||
@@ -404,6 +431,7 @@ export const TextUpdateMenuMonaco = ({
|
||||
setOpen?: (open: boolean) => void;
|
||||
triggerHidden?: boolean;
|
||||
language?: MonacoLanguage;
|
||||
triggerChild?: ReactNode;
|
||||
}) => {
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const [__open, __setOpen] = [open ?? _open, setOpen ?? _setOpen];
|
||||
@@ -417,23 +445,25 @@ export const TextUpdateMenuMonaco = ({
|
||||
return (
|
||||
<Dialog open={__open} onOpenChange={__setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Card
|
||||
className={cn(
|
||||
"px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer",
|
||||
fullWidth ? "w-full" : "w-fit",
|
||||
triggerHidden && "hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{triggerChild ?? (
|
||||
<Card
|
||||
className={cn(
|
||||
"text-sm text-nowrap overflow-hidden overflow-ellipsis",
|
||||
(!value || !!disabled) && "text-muted-foreground",
|
||||
triggerClassName
|
||||
"px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer",
|
||||
fullWidth ? "w-full" : "w-fit",
|
||||
triggerHidden && "hidden"
|
||||
)}
|
||||
>
|
||||
{value.split("\n")[0] || placeholder}
|
||||
</div>
|
||||
</Card>
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm text-nowrap overflow-hidden overflow-ellipsis",
|
||||
(!value || !!disabled) && "text-muted-foreground",
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
{value.split("\n")[0] || placeholder}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-[50vw]">
|
||||
{titleRight && (
|
||||
@@ -508,16 +538,41 @@ export const StatusBadge = ({
|
||||
const background = hex_color_by_intention(intent) + "25";
|
||||
|
||||
const _text = text === Types.ServerState.NotOk ? "Not Ok" : text;
|
||||
const displayText = snake_case_to_upper_space_case(_text).toUpperCase();
|
||||
|
||||
// Special handling for "VERSION MISMATCH" with flex layout for responsive design
|
||||
if (displayText === "VERSION MISMATCH") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs text-white rounded-md font-medium tracking-wide",
|
||||
"inline-flex flex-wrap items-center justify-center text-center",
|
||||
"leading-tight gap-x-1",
|
||||
"min-h-[1.5rem]", // Minimum height to match other badges, but can grow
|
||||
color
|
||||
)}
|
||||
style={{
|
||||
background,
|
||||
minWidth: "fit-content",
|
||||
maxWidth: "80px", // This controls when it wraps to two lines
|
||||
}}
|
||||
>
|
||||
<span>VERSION</span>
|
||||
<span>MISMATCH</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"px-2 py-1 w-fit text-xs text-white rounded-md font-medium tracking-wide",
|
||||
"h-6 flex items-center", // Fixed height and center content vertically
|
||||
color
|
||||
)}
|
||||
style={{ background }}
|
||||
>
|
||||
{snake_case_to_upper_space_case(_text).toUpperCase()}
|
||||
{displayText}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-10
@@ -15,7 +15,7 @@ export const hex_color_by_intention = (intention: ColorIntention) => {
|
||||
case "Neutral":
|
||||
return "#3B82F6";
|
||||
case "Warning":
|
||||
return "#F97316";
|
||||
return "#EAB308";
|
||||
case "Critical":
|
||||
return "#EF0044";
|
||||
case "Unknown":
|
||||
@@ -32,7 +32,7 @@ export const fill_color_class_by_intention = (intention: ColorIntention) => {
|
||||
case "Neutral":
|
||||
return "text-blue-400 dark:text-blue-700";
|
||||
case "Warning":
|
||||
return "text-orange-400 dark:text-orange-700";
|
||||
return "text-yellow-500 dark:text-yellow-400";
|
||||
case "Critical":
|
||||
return "text-red-400 dark:text-red-700";
|
||||
case "Unknown":
|
||||
@@ -49,7 +49,7 @@ export const stroke_color_class_by_intention = (intention: ColorIntention) => {
|
||||
case "Neutral":
|
||||
return "stroke-blue-600 dark:stroke-blue-500";
|
||||
case "Warning":
|
||||
return "stroke-orange-600 dark:stroke-orange-500";
|
||||
return "stroke-yellow-500 dark:stroke-yellow-400";
|
||||
case "Critical":
|
||||
return "stroke-red-600 dark:stroke-red-500";
|
||||
case "Unknown":
|
||||
@@ -66,7 +66,7 @@ export const bg_color_class_by_intention = (intention: ColorIntention) => {
|
||||
case "Neutral":
|
||||
return "bg-blue-400 dark:bg-blue-700";
|
||||
case "Warning":
|
||||
return "bg-orange-400 dark:bg-orange-500";
|
||||
return "bg-yellow-500 dark:bg-yellow-600";
|
||||
case "Critical":
|
||||
return "bg-red-400 dark:bg-red-700";
|
||||
case "Unknown":
|
||||
@@ -83,7 +83,7 @@ export const border_color_class_by_intention = (intention: ColorIntention) => {
|
||||
case "Neutral":
|
||||
return "border-blue-700 dark:border-blue-400";
|
||||
case "Warning":
|
||||
return "border-orange-700 dark:border-orange-400";
|
||||
return "border-yellow-600 dark:border-yellow-400";
|
||||
case "Critical":
|
||||
return "border-red-700 dark:border-red-400";
|
||||
case "Unknown":
|
||||
@@ -100,7 +100,7 @@ export const text_color_class_by_intention = (intention: ColorIntention) => {
|
||||
case "Neutral":
|
||||
return "text-blue-700 dark:text-blue-400";
|
||||
case "Warning":
|
||||
return "text-orange-700 dark:text-orange-400";
|
||||
return "text-yellow-600 dark:text-yellow-400";
|
||||
case "Critical":
|
||||
return "text-red-700 dark:text-red-400";
|
||||
case "Unknown":
|
||||
@@ -119,7 +119,7 @@ export const soft_text_color_class_by_intention = (
|
||||
case "Neutral":
|
||||
return "text-blue-700/60 dark:text-blue-400/60";
|
||||
case "Warning":
|
||||
return "text-orange-700/60 dark:text-orange-400/60";
|
||||
return "text-yellow-600/60 dark:text-yellow-400/60";
|
||||
case "Critical":
|
||||
return "text-red-700/60 dark:text-red-400/60";
|
||||
case "Unknown":
|
||||
@@ -130,11 +130,13 @@ export const soft_text_color_class_by_intention = (
|
||||
};
|
||||
|
||||
export const server_state_intention: (
|
||||
state?: Types.ServerState
|
||||
) => ColorIntention = (state) => {
|
||||
state?: Types.ServerState,
|
||||
hasVersionMismatch?: boolean
|
||||
) => ColorIntention = (state, hasVersionMismatch) => {
|
||||
switch (state) {
|
||||
case Types.ServerState.Ok:
|
||||
return "Good";
|
||||
// If there's a version mismatch and the server is "Ok", show warning instead
|
||||
return hasVersionMismatch ? "Warning" : "Good";
|
||||
case Types.ServerState.NotOk:
|
||||
return "Critical";
|
||||
case Types.ServerState.Disabled:
|
||||
|
||||
@@ -539,6 +539,83 @@ export const useCtrlKeyListener = (listenKey: string, onPress: () => void) => {
|
||||
});
|
||||
};
|
||||
|
||||
export interface PromptHotkeysConfig {
|
||||
/** Function to call when Enter is pressed (confirm action) */
|
||||
onConfirm?: () => void;
|
||||
/** Function to call when Escape is pressed (cancel/close action) */
|
||||
onCancel?: () => void;
|
||||
/** Whether the hotkeys are enabled. Defaults to true */
|
||||
enabled?: boolean;
|
||||
/** Whether to ignore hotkeys when inside input/textarea elements. Defaults to true */
|
||||
ignoreInputs?: boolean;
|
||||
/** Whether the confirm action is disabled (e.g., form validation failed) */
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides standard prompt/dialog hotkey behavior:
|
||||
* - Enter: Confirm/submit action
|
||||
* - Escape: Cancel/close action
|
||||
*/
|
||||
export const usePromptHotkeys = ({
|
||||
enabled = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
ignoreInputs = true,
|
||||
confirmDisabled = false,
|
||||
}: PromptHotkeysConfig) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const findConfirmButton = (): HTMLButtonElement | null => {
|
||||
const dialogContainers = document.querySelectorAll('[role="dialog"], [data-state="open"], .dialog-content');
|
||||
for (const container of dialogContainers) {
|
||||
const button = container.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
|
||||
if (button) return button;
|
||||
}
|
||||
|
||||
return document.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (ignoreInputs) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
if (onConfirm && !confirmDisabled) {
|
||||
e.preventDefault();
|
||||
const confirmButton = findConfirmButton();
|
||||
if (confirmButton) {
|
||||
confirmButton.click();
|
||||
} else {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (onCancel) {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, onConfirm, onCancel, ignoreInputs, confirmDisabled]);
|
||||
};
|
||||
|
||||
export type WebhookIntegration = "Github" | "Gitlab";
|
||||
export type WebhookIntegrations = {
|
||||
[key: string]: WebhookIntegration;
|
||||
|
||||
@@ -43,7 +43,6 @@ export default function Dashboard() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updatePreference(
|
||||
"showServerStats",
|
||||
@@ -170,7 +169,7 @@ const RecentCard = ({
|
||||
<Link
|
||||
to={`${usableResourcePath(type)}/${id}`}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md hover:bg-accent/25 hover:-translate-y-1 transition-all flex flex-col justify-between",
|
||||
"w-full px-3 py-2 border rounded-md hover:bg-accent/25 hover:-translate-y-1 transition-all duration-1000 linear flex flex-col justify-between",
|
||||
showServerStats ? "min-h-32" : "h-20",
|
||||
className,
|
||||
)}
|
||||
@@ -185,17 +184,22 @@ const RecentCard = ({
|
||||
{type === "Stack" && <StackUpdateAvailable id={id} small />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 w-full",
|
||||
showServerStats ? "mt-2 flex-1" : "mt-auto",
|
||||
"overflow-hidden w-full transition-opacity transition-all duration-1000 linear",
|
||||
showServerStats
|
||||
? "max-h-40 opacity-100 py-2"
|
||||
: "max-h-0 opacity-0 py-0"
|
||||
)}
|
||||
>
|
||||
{showServerStats && <ServerStatsMini id={id} />}
|
||||
<div className="flex gap-2 w-full py-1">
|
||||
<TagsWithBadge className="flex-row" tag_ids={tags} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<ServerStatsMini id={id} enabled={showServerStats} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 w-full py-1">
|
||||
<TagsWithBadge tag_ids={tags} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2705,6 +2705,11 @@ shebang-regex@^3.0.0:
|
||||
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
shell-quote@1.8.1:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
|
||||
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
|
||||
|
||||
signal-exit@^4.0.1:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
|
||||
|
||||
@@ -112,13 +112,12 @@ impl Client {
|
||||
password: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let UserConfig::Local { .. } = user.config else {
|
||||
return Err(
|
||||
anyhow!("User is not a 'Local' (username / password) user")
|
||||
.into(),
|
||||
);
|
||||
return Err(anyhow!(
|
||||
"User is not a 'Local' (username / password) user"
|
||||
));
|
||||
};
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password cannot be empty.").into());
|
||||
return Err(anyhow!("Password cannot be empty."));
|
||||
}
|
||||
let id = ObjectId::from_str(&user.id)
|
||||
.context("User id not valid ObjectId.")?;
|
||||
|
||||
Reference in New Issue
Block a user