From e732da3b0513f37d5fefbd0803aa1bfc308c9571 Mon Sep 17 00:00:00 2001 From: Maxwell Becker <49575486+mbecker20@users.noreply.github.com> Date: Sun, 24 Aug 2025 12:51:04 -0700 Subject: [PATCH] 1.19.1 (#740) * start 1.19.1 * deploy 1.19.1-dev-1 * Global Auto Update rustdoc * support stack additional files * deploy 1.19.1-dev-2 * Fe support additional file language detection * fix tsc * Fix: Example code blocks got interpreted as rust code, leading to compilation errors (#743) * Enhanced Server Stats Dashboard with Performance Optimizations (#746) * Improve the layout of server mini stats in the dashboard. - Server stats and tags made siblings for clearer responsibilities - Changed margin to padding - Unreachable indicator made into an overlay of the stats * feat: optimize dashboard server stats with lazy loading and smart server availability checks - Add enabled prop to ServerStatsMini for conditional data fetching - Implement server availability check (only fetch stats for Ok servers, not NotOk/Disabled) - Prevent 500 errors by avoiding API calls to offline servers - Increase polling interval from 10s to 15s and add 5s stale time - Add useMemo for expensive calculations to reduce re-renders - Add conditional overlay rendering for unreachable servers - Only render stats when showServerStats preference is enabled * fix: show disabled servers with overlay instead of hiding component - Maintain consistent layout by showing disabled state overlay - Prevent UX inconsistency where disabled servers disappeared entirely * fix: show button height * feat: add enhance card animations * cleanup * gen types * deploy 1.19.1-dev-3 * add .ini * deploy 1.19.1-dev-4 * simple configure action args as JSON * server enabled actually defaults false * SendAlert via Action / CLI * fix clippy if let string * deploy 1.19.1-dev-5 * improve cli ergonomics * gen types and fix responses formatting * Add RunStackService API implementing `docker compose run` (#732) * Add RunStackService API implementing `docker compose run` * Add working Procedure configuration * Remove `km execute run` alias. Remove redundant ``#[serde(default)]` on `Option`. * Refactor command from `String` to `Vec` * Implement proper shell escaping * bump deps * Update configuration.md - fix typo: "affect" -> "effect" (#747) * clean up SendAlert doc * deploy 1.19.1-dev-6 * env file args won't double pass env file * deploy 1.19.1-dev-7 * Add Enter Key Support for Dialog Confirmations (#750) * start 1.19.1 * deploy 1.19.1-dev-1 * Implement usePromptHotkeys for enhanced dialog interactions and UX * Refactor usePromptHotkeys to enhance confirm button detection and improve UX * Remove forceConfirmDialog prop from ActionWithDialog and related logic for cleaner implementation * Add dialog descriptions to ConfirmUpdate and ActionWithDialog for better clarity and resolve warnings * fix * Restore forceConfirmDialog prop to ActionWithDialog for enhanced confirmation handling * cleanup * Remove conditional className logic from ConfirmButton --------- Co-authored-by: mbecker20 * Support complex file depency action resolution * get FE compile * deploy 1.19.1-dev-8 * implement additional file dependency configuration * deploy 1.19.1-dev-9 * UI default file dependency None * default additional file requires is None * deploy 1.19.1-dev-10 * rename additional_files => config_files for clarity * deploy 1.19.1-dev-11 * fix skip serializing if None * deploy 1.19.1-dev-12 * stack file dependency toml parsing aliases * fmt * Add: Server Version Mismatch Warnings & Alert System (#748) * start 1.19.1 * deploy 1.19.1-dev-1 * feat: implement version mismatch warnings in server UI - Replace orange warning colors with yellow for better visibility - Add version mismatch detection that shows warnings instead of OK status Implement responsive "VERSION MISMATCH" badge layout - Update server dashboard to include warning counts - Add backend version comparison logic for GetServersSummary * feat: add warning count to server summary and update backup documentation link * feat: add server version mismatch alert handling and update server summary invalidation logic * fix: correct version mismatch alert config and disabled server display - Use send_version_mismatch_alerts instead of send_unreachable_alerts - Show 'Unknown' instead of 'Disabled' for disabled server versions - Remove commented VersionAlert and Alerts UI components - Update version to 1.19.0 * cleanup * Update TypeScript types after merge * cleanup * cleanup * cleanup * Add "ServerVersionMismatch" to alert types * Adjust color classes for warning states and revert server update invalidation logic --------- Co-authored-by: mbecker20 * backend for build multi registry push support * deploy 1.19.1-dev-13 * build multi registry configuration * deploy 1.19.1-dev-14 * fix invalid tokens JSON * DeployStackIfChanged restarts also update stack.info.deployed_contents * update deployed services comments * deploy 1.19.1-dev-15 * Enhance server monitoring with load average data and new server monitoring table (#761) * add monitoring page * initial table * moving monitoring table to servers * add cpu load average * typeshare doesnt allow tuples * fix GetHistoricalServerStats * add loadAvg to the server monitoring table * improve styling * add load average chart * multiple colors for average loads chart * make load average chart line and non-stacked * cleanup * use server thresholds * cleanup * Change "Dependents:" to "Services:" in config file service dependency selector * deploy 1.19.1-dev-16 * 1.19.1 --------- Co-authored-by: mbecker20 Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com> Co-authored-by: Brian Bradley Co-authored-by: Ravi Wolter-Krishan Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com> --- Cargo.lock | 472 ++++++------- Cargo.toml | 19 +- bin/cli/src/command/container.rs | 2 +- bin/cli/src/command/execute.rs | 14 + bin/cli/src/command/list.rs | 2 +- bin/cli/src/command/mod.rs | 2 +- bin/core/src/alert/discord.rs | 32 + bin/core/src/alert/mod.rs | 242 ++++++- bin/core/src/alert/ntfy.rs | 200 +----- bin/core/src/alert/pushover.rs | 200 +----- bin/core/src/alert/slack.rs | 35 + bin/core/src/api/execute/alerter.rs | 82 ++- bin/core/src/api/execute/build.rs | 76 +- bin/core/src/api/execute/deployment.rs | 45 +- bin/core/src/api/execute/mod.rs | 15 +- bin/core/src/api/execute/stack.rs | 493 ++++++++++++- bin/core/src/api/read/server.rs | 14 +- bin/core/src/api/write/stack.rs | 10 +- bin/core/src/helpers/procedure.rs | 34 + bin/core/src/helpers/update.rs | 10 + bin/core/src/monitor/alert/server.rs | 71 ++ bin/core/src/monitor/record.rs | 1 + bin/core/src/network.rs | 47 +- bin/core/src/resource/build.rs | 8 +- bin/core/src/resource/mod.rs | 6 + bin/core/src/resource/procedure.rs | 28 + bin/core/src/resource/server.rs | 3 + bin/core/src/stack/remote.rs | 20 +- bin/core/src/startup.rs | 3 +- bin/core/src/sync/deploy.rs | 15 +- bin/core/src/sync/resources.rs | 20 + bin/core/src/sync/toml.rs | 18 + bin/periphery/Cargo.toml | 3 +- bin/periphery/src/api/build.rs | 70 +- bin/periphery/src/api/compose.rs | 232 +++++-- bin/periphery/src/api/mod.rs | 1 + bin/periphery/src/build.rs | 26 +- bin/periphery/src/compose/mod.rs | 33 +- bin/periphery/src/compose/up.rs | 40 +- bin/periphery/src/compose/write.rs | 10 +- bin/periphery/src/stats.rs | 9 +- client/core/rs/src/api/execute/action.rs | 2 +- client/core/rs/src/api/execute/alerter.rs | 38 +- client/core/rs/src/api/execute/build.rs | 2 +- client/core/rs/src/api/execute/deployment.rs | 4 +- client/core/rs/src/api/execute/maintenance.rs | 4 +- client/core/rs/src/api/execute/mod.rs | 3 + client/core/rs/src/api/execute/procedure.rs | 2 +- client/core/rs/src/api/execute/repo.rs | 6 +- client/core/rs/src/api/execute/stack.rs | 69 +- client/core/rs/src/api/mod.rs | 4 +- client/core/rs/src/api/read/alert.rs | 2 +- client/core/rs/src/api/read/server.rs | 2 + .../core/rs/src/deserializers/conversion.rs | 17 +- .../core/rs/src/deserializers/item_or_vec.rs | 104 +++ client/core/rs/src/deserializers/mod.rs | 2 + client/core/rs/src/entities/action.rs | 4 +- client/core/rs/src/entities/alert.rs | 43 ++ client/core/rs/src/entities/build.rs | 148 +--- .../rs/src/entities/config/cli/args/mod.rs | 10 +- client/core/rs/src/entities/config/cli/mod.rs | 2 +- client/core/rs/src/entities/mod.rs | 59 +- client/core/rs/src/entities/procedure.rs | 4 +- client/core/rs/src/entities/server.rs | 11 +- client/core/rs/src/entities/stack.rs | 247 ++++++- client/core/rs/src/entities/stats.rs | 17 + client/core/rs/src/lib.rs | 6 +- client/core/rs/src/ws.rs | 2 +- client/core/ts/package.json | 2 +- client/core/ts/src/responses.ts | 4 +- client/core/ts/src/types.ts | 231 +++++-- client/periphery/rs/src/api/build.rs | 6 +- client/periphery/rs/src/api/compose.rs | 73 +- client/periphery/rs/src/terminal.rs | 4 +- .../deploy-containers/configuration.md | 4 +- frontend/package.json | 3 +- frontend/public/client/responses.d.ts | 4 +- frontend/public/client/types.d.ts | 244 +++++-- frontend/public/client/types.js | 29 +- frontend/src/components/config/util.tsx | 652 ++++++++++-------- frontend/src/components/monaco.tsx | 34 +- .../resources/alerter/config/alert_types.tsx | 4 +- .../src/components/resources/build/config.tsx | 69 +- .../components/resources/procedure/config.tsx | 294 +++++++- .../components/resources/server/config.tsx | 11 + .../src/components/resources/server/index.tsx | 104 ++- .../resources/server/monitoring-table.tsx | 190 +++++ .../resources/server/stat-chart.tsx | 81 ++- .../resources/server/stats-mini.tsx | 76 +- .../src/components/resources/server/stats.tsx | 68 ++ .../src/components/resources/stack/config.tsx | 332 ++++++++- .../src/components/resources/stack/info.tsx | 4 +- frontend/src/components/util.tsx | 125 +++- frontend/src/lib/color.ts | 22 +- frontend/src/lib/hooks.ts | 77 +++ frontend/src/pages/home/dashboard.tsx | 20 +- frontend/src/pages/resources.tsx | 55 +- frontend/yarn.lock | 5 + lib/database/src/lib.rs | 9 +- 99 files changed, 4638 insertions(+), 1675 deletions(-) create mode 100644 client/core/rs/src/deserializers/item_or_vec.rs create mode 100644 frontend/src/components/resources/server/monitoring-table.tsx diff --git a/Cargo.lock b/Cargo.lock index 35bc7aabf..6ac3866e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,9 +56,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -86,22 +86,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -137,18 +137,18 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -220,9 +220,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08b5d4e069cbc868041a64bd68dc8cb39a0d79585cd6c5a24caa8c2d622121be" +checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" dependencies = [ "aws-lc-sys", "zeroize", @@ -267,9 +267,9 @@ dependencies = [ [[package]] name = "aws-sdk-ec2" -version = "1.159.0" +version = "1.160.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed753fc534bbb68760d5b2e3e46f81e5ccb9a96d8359b9ce793e123cb3073e8e" +checksum = "4300d66be2c4e275d3a037576dacce6efaf9af24bfbd2460a848ea39299c979c" dependencies = [ "aws-credential-types", "aws-runtime", @@ -334,9 +334,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.83.0" +version = "1.84.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5468593c47efc31fdbe6c902d1a5fde8d9c82f78a3f8ccfe907b1e9434748cb" +checksum = "91abcdbfb48c38a0419eb75e0eac772a4783a96750392680e4f3c25a8a0535b9" dependencies = [ "aws-credential-types", "aws-runtime", @@ -410,20 +410,20 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.0.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f108f1ca850f3feef3009bdcc977be201bca9a91058864d9de0684e64514bee0" +checksum = "4fdbad9bd9dbcc6c5e68c311a841b54b70def3ca3b674c42fbebb265980539f8" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.11", + "h2 0.4.12", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-rustls 0.24.2", "hyper-rustls 0.27.7", "hyper-util", @@ -433,6 +433,7 @@ dependencies = [ "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio", + "tokio-rustls 0.26.2", "tower", "tracing", ] @@ -467,9 +468,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.8.6" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e107ce0783019dbff59b3a244aa0c114e4a8c9d93498af9162608cd5474e796" +checksum = "a3d57c8b53a72d15c8e190475743acf34e4996685e346a3448dd54ef696fc6e0" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -491,9 +492,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.8.7" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75d52251ed4b9776a3e8487b2a01ac915f73b2da3af8fc1e77e0fce697a550d4" +checksum = "07f5e0fc8a6b3f2303f331b94504bbf754d85488f402d6f1dd7a6080f99afe56" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -570,7 +571,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "itoa", "matchit", @@ -644,7 +645,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -658,7 +659,7 @@ dependencies = [ "fs-err", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "rustls 0.23.31", @@ -726,9 +727,9 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bcrypt" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f" +checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" dependencies = [ "base64 0.22.1", "blowfish", @@ -743,7 +744,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "cexpr", "clang-sys", "itertools 0.12.1", @@ -756,7 +757,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.104", + "syn 2.0.106", "which", ] @@ -768,9 +769,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" [[package]] name = "bitvec" @@ -817,7 +818,7 @@ dependencies = [ "hex", "http 1.3.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -828,7 +829,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-util", "tower-service", @@ -904,7 +905,7 @@ dependencies = [ [[package]] name = "cache" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "tokio", @@ -912,9 +913,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.29" +version = "1.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" dependencies = [ "jobserver", "libc", @@ -932,9 +933,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -996,9 +997,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.43" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" dependencies = [ "clap_builder", "clap_derive", @@ -1006,9 +1007,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.43" +version = "4.5.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" dependencies = [ "anstream", "anstyle", @@ -1018,14 +1019,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1071,7 +1072,7 @@ dependencies = [ [[package]] name = "command" -version = "1.19.0" +version = "1.19.1" dependencies = [ "komodo_client", "run_command", @@ -1080,7 +1081,7 @@ dependencies = [ [[package]] name = "config" -version = "1.19.0" +version = "1.19.1" dependencies = [ "colored", "indexmap 2.10.0", @@ -1088,7 +1089,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "thiserror 2.0.14", + "thiserror 2.0.16", "toml", "wildcard", ] @@ -1192,7 +1193,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "crossterm_winapi", "parking_lot 0.12.4", "rustix", @@ -1238,9 +1239,9 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "4.2.0" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373b7c5dbd637569a2cca66e8d66b8c446a1e7bf064ea321d265d7b3dfe7c97e" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", "cpufeatures", @@ -1260,7 +1261,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1284,7 +1285,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1295,7 +1296,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1320,7 +1321,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "database" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "async-compression", @@ -1365,18 +1366,18 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "derive-where" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "510c292c8cf384b1a340b816a9a6cf2599eb8f566a44949024af88418000c50b" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1397,7 +1398,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1407,7 +1408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1427,7 +1428,7 @@ checksum = "9e520b61247a9470ec86a98baf2aebae5c6dd0f25f02b1c87cafe45b06c160e7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1438,7 +1439,7 @@ checksum = "12c90da6aa09bad94e4411461560183be70bb33bc30efb2ce941492f22ed6850" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1451,7 +1452,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1472,7 +1473,7 @@ checksum = "1bceb8b4ad480f8cf02ae4efb42c95add230544b4239d543cbd9f9141d838581" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1495,7 +1496,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1518,9 +1519,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -1614,12 +1615,12 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "environment" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "formatting", @@ -1629,9 +1630,9 @@ dependencies = [ [[package]] name = "environment_file" -version = "1.19.0" +version = "1.19.1" dependencies = [ - "thiserror 2.0.14", + "thiserror 2.0.16", ] [[package]] @@ -1677,9 +1678,9 @@ dependencies = [ [[package]] name = "fiat-crypto" -version = "0.3.0" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filedescriptor" @@ -1710,16 +1711,16 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "formatting" -version = "1.19.0" +version = "1.19.1" dependencies = [ "serror", ] @@ -1802,7 +1803,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1881,7 +1882,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "git" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "cache", @@ -1895,9 +1896,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "group" @@ -1931,9 +1932,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -1971,9 +1972,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "headers" @@ -2183,20 +2184,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "h2 0.4.11", + "futures-core", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2209,7 +2212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "tokio", @@ -2240,7 +2243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.3.1", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "rustls 0.23.31", "rustls-native-certs 0.8.1", @@ -2248,7 +2251,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tower-service", - "webpki-roots 1.0.1", + "webpki-roots 1.0.2", ] [[package]] @@ -2257,7 +2260,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "tokio", @@ -2266,9 +2269,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -2277,12 +2280,12 @@ dependencies = [ "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -2298,7 +2301,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "tokio", @@ -2423,9 +2426,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2460,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -2487,7 +2490,7 @@ dependencies = [ [[package]] name = "interpolate" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "komodo_client", @@ -2496,11 +2499,11 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "cfg-if", "libc", ] @@ -2618,7 +2621,7 @@ dependencies = [ [[package]] name = "komodo_cli" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "chrono", @@ -2643,7 +2646,7 @@ dependencies = [ [[package]] name = "komodo_client" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "async_timing_util", @@ -2666,7 +2669,7 @@ dependencies = [ "serde_qs", "serror", "strum 0.27.2", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-tungstenite 0.27.0", "tokio-util", @@ -2677,7 +2680,7 @@ dependencies = [ [[package]] name = "komodo_core" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "arc-swap", @@ -2747,7 +2750,7 @@ dependencies = [ [[package]] name = "komodo_periphery" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "arc-swap", @@ -2784,6 +2787,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "serror", + "shell-escape", "sysinfo", "tokio", "tokio-stream", @@ -2809,9 +2813,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" @@ -2820,7 +2824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -2868,7 +2872,7 @@ dependencies = [ [[package]] name = "logger" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "komodo_client", @@ -2905,7 +2909,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2919,7 +2923,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2930,7 +2934,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2941,7 +2945,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3017,7 +3021,7 @@ dependencies = [ "mongo_indexed_derive", "mongodb", "serde", - "thiserror 2.0.14", + "thiserror 2.0.16", ] [[package]] @@ -3028,7 +3032,7 @@ checksum = "cf8405357bf431a2e73d83057f2960e9349ab946389dd78af655832921434949" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3089,7 +3093,7 @@ dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3112,7 +3116,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3255,7 +3259,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] @@ -3369,7 +3373,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.14", + "thiserror 2.0.16", "tracing", ] @@ -3399,7 +3403,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror 2.0.14", + "thiserror 2.0.16", "tonic", "tracing", ] @@ -3435,7 +3439,7 @@ dependencies = [ "percent-encoding", "rand 0.9.2", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-stream", ] @@ -3538,7 +3542,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.13", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -3571,7 +3575,7 @@ checksum = "3a506f66d52e40b2385d7b9f776fd5243d6cff16ba79147f859aa4e27d2d27cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3604,13 +3608,13 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "periphery_client" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "komodo_client", @@ -3661,7 +3665,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3744,12 +3748,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3763,9 +3767,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -3790,7 +3794,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3807,7 +3811,7 @@ dependencies = [ "rustc-hash 2.1.1", "rustls 0.23.31", "socket2 0.5.10", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -3828,7 +3832,7 @@ dependencies = [ "rustls 0.23.31", "rustls-pki-types", "slab", - "thiserror 2.0.14", + "thiserror 2.0.16", "tinyvec", "tracing", "web-time", @@ -3939,11 +3943,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] @@ -3963,7 +3967,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4003,9 +4007,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", @@ -4013,11 +4017,11 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.11", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-rustls 0.27.7", "hyper-util", "js-sys", @@ -4045,7 +4049,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.1", + "webpki-roots 1.0.2", ] [[package]] @@ -4086,7 +4090,7 @@ dependencies = [ "futures", "getrandom 0.2.16", "http 1.3.1", - "hyper 1.6.0", + "hyper 1.7.0", "parking_lot 0.11.2", "reqwest", "reqwest-middleware", @@ -4136,12 +4140,12 @@ checksum = "e45ff368040e7f3787e97e6f3a97718ca08e0553587747bd4d0bf3ca2b899963" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "response" -version = "1.19.0" +version = "1.19.1" dependencies = [ "anyhow", "axum", @@ -4214,9 +4218,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -4255,7 +4259,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "errno", "libc", "linux-raw-sys", @@ -4311,7 +4315,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework 3.3.0", ] [[package]] @@ -4366,9 +4370,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -4434,7 +4438,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4473,7 +4477,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4482,11 +4486,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4545,7 +4549,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4556,14 +4560,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "indexmap 2.10.0", "itoa", @@ -4599,7 +4603,7 @@ checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ "percent-encoding", "serde", - "thiserror 2.0.14", + "thiserror 2.0.16", ] [[package]] @@ -4610,7 +4614,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4663,7 +4667,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4681,9 +4685,9 @@ dependencies = [ [[package]] name = "serial2" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1d08630509d69f90eff4afcd02c3bd974d979225cbd815ff5942351b14375" +checksum = "26e1e5956803a69ddd72ce2de337b577898801528749565def03515f82bad5bb" dependencies = [ "cfg-if", "libc", @@ -4743,6 +4747,12 @@ dependencies = [ "libc", ] +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + [[package]] name = "shell-words" version = "1.1.0" @@ -4757,9 +4767,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -4782,7 +4792,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.14", + "thiserror 2.0.16", "time", ] @@ -4794,9 +4804,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slack_client_rs" @@ -4810,7 +4820,7 @@ dependencies = [ "serde_derive", "serde_json", "strum 0.27.2", - "strum_macros 0.27.1", + "strum_macros 0.27.2", ] [[package]] @@ -4890,7 +4900,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.1", + "strum_macros 0.27.2", ] [[package]] @@ -4903,20 +4913,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4931,7 +4940,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91bb902c47385644774ba89f7e7982d3a32cf1da4e8870bf633cdece450eced1" dependencies = [ - "thiserror 2.0.14", + "thiserror 2.0.16", ] [[package]] @@ -4947,9 +4956,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -4973,7 +4982,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4996,7 +5005,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5034,11 +5043,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.14" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.14", + "thiserror-impl 2.0.16", ] [[package]] @@ -5049,18 +5058,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.14" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5124,9 +5133,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -5165,7 +5174,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5284,7 +5293,7 @@ dependencies = [ "ordered_hash_map", "serde", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.16", ] [[package]] @@ -5305,7 +5314,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -5346,7 +5355,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "bytes", "futures-core", "futures-util", @@ -5400,7 +5409,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5488,7 +5497,7 @@ dependencies = [ "log", "rand 0.9.2", "sha1", - "thiserror 2.0.14", + "thiserror 2.0.16", "utf-8", ] @@ -5507,7 +5516,7 @@ dependencies = [ "rustls 0.23.31", "rustls-pki-types", "sha1", - "thiserror 2.0.14", + "thiserror 2.0.16", "utf-8", ] @@ -5547,7 +5556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a615d6c2764852a2e88a4f16e9ce1ea49bb776b5872956309e170d63a042a34f" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5609,9 +5618,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "137a3c834eaf7139b73688502f3f1141a0337c5d8e4d9b536f9b8c796e26a7c4" dependencies = [ "form_urlencoded", "idna", @@ -5645,9 +5654,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -5726,7 +5735,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -5761,7 +5770,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5831,9 +5840,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] @@ -5862,7 +5871,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" dependencies = [ - "thiserror 2.0.14", + "thiserror 2.0.16", ] [[package]] @@ -5941,7 +5950,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5952,7 +5961,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6033,7 +6042,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -6069,10 +6078,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6261,7 +6271,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] @@ -6305,7 +6315,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -6326,7 +6336,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6346,7 +6356,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -6369,9 +6379,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -6386,5 +6396,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] diff --git a/Cargo.toml b/Cargo.toml index 829d2ada2..180378d08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "1.19.0" +version = "1.19.1" edition = "2024" authors = ["mbecker20 "] 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" \ No newline at end of file diff --git a/bin/cli/src/command/container.rs b/bin/cli/src/command/container.rs index af57dc2e6..76a818a75 100644 --- a/bin/cli/src/command/container.rs +++ b/bin/cli/src/command/container.rs @@ -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; diff --git a/bin/cli/src/command/execute.rs b/bin/cli/src/command/execute.rs index 7dab58ccb..eb763ddfe 100644 --- a/bin/cli/src/command/execute.rs +++ b/bin/cli/src/command/execute.rs @@ -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 diff --git a/bin/cli/src/command/list.rs b/bin/cli/src/command/list.rs index d097f61fb..994a41b61 100644 --- a/bin/cli/src/command/list.rs +++ b/bin/cli/src/command/list.rs @@ -233,7 +233,7 @@ async fn list_schedules( } fn fix_tags( - resources: &mut Vec>, + resources: &mut [ResourceListItem], tags: &HashMap, ) { resources.iter_mut().for_each(|resource| { diff --git a/bin/cli/src/command/mod.rs b/bin/cli/src/command/mod.rs index 282b0b65c..3d1ba24d9 100644 --- a/bin/cli/src/command/mod.rs +++ b/bin/cli/src/command/mod.rs @@ -111,7 +111,7 @@ fn print_items( }; 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 { diff --git a/bin/core/src/alert/discord.rs b/bin/core/src/alert/discord.rs index d59504577..45ce19c18 100644 --- a/bin/core/src/alert/discord.rs +++ b/bin/core/src/alert/discord.rs @@ -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() { diff --git a/bin/core/src/alert/mod.rs b/bin/core/src/alert/mod.rs index e0fe93a8e..caf9a0243 100644 --- a/bin/core/src/alert/mod.rs +++ b/bin/core/src/alert/mod.rs @@ -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(), + } +} diff --git a/bin/core/src/alert/ntfy.rs b/bin/core/src/alert/ntfy.rs index d09203ee5..023b64b1a 100644 --- a/bin/core/src/alert/ntfy.rs +++ b/bin/core/src/alert/ntfy.rs @@ -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?; } diff --git a/bin/core/src/alert/pushover.rs b/bin/core/src/alert/pushover.rs index 0ecec2d6c..9ac11c503 100644 --- a/bin/core/src/alert/pushover.rs +++ b/bin/core/src/alert/pushover.rs @@ -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?; } diff --git a/bin/core/src/alert/slack.rs b/bin/core/src/alert/slack.rs index 97c530a79..4fa43815c 100644 --- a/bin/core/src/alert/slack.rs +++ b/bin/core/src/alert/slack.rs @@ -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() { diff --git a/bin/core/src/api/execute/alerter.rs b/bin/core/src/api/execute/alerter.rs index 47239897c..1cf03c249 100644 --- a/bin/core/src/api/execute/alerter.rs +++ b/bin/core/src/api/execute/alerter.rs @@ -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 for TestAlerter { Ok(update) } } + +// + +impl Resolve 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 { + let alerters = list_full_for_user::( + 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::>(); + + 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::>() + .try_collect::>() + .await + { + update.push_error_log("Send Error", format_serror(&e.into())); + }; + + update.finalize(); + update_update(update.clone()).await?; + + Ok(update) + } +} diff --git a/bin/core/src/api/execute/build.rs b/bin/core/src/api/execute/build.rs index febe50b2c..c54a14cc0 100644 --- a/bin/core/src/api/execute/build.rs +++ b/bin/core/src/api/execute/build.rs @@ -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 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 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> { - 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> { + 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::>() + { + 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(), + ) } diff --git a/bin/core/src/api/execute/deployment.rs b/bin/core/src/api/execute/deployment.rs index 368c50ec0..da8231ac2 100644 --- a/bin/core/src/api/execute/deployment.rs +++ b/bin/core/src/api/execute/deployment.rs @@ -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 for Deploy { let (version, registry_token) = match &deployment.config.image { DeploymentImage::Build { build_id, version } => { let build = resource::get::(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 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_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 } => { diff --git a/bin/core/src/api/execute/mod.rs b/bin/core/src/api/execute/mod.rs index af544b45b..5c78b887a 100644 --- a/bin/core/src/api/execute/mod.rs +++ b/bin/core/src/api/execute/mod.rs @@ -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 diff --git a/bin/core/src/api/execute/stack.rs b/bin/core/src/api/execute/stack.rs index 0e1829d16..87700f9cd 100644 --- a/bin/core/src/api/execute/stack.rs +++ b/bin/core/src/api/execute/stack.rs @@ -1,15 +1,23 @@ +use std::{collections::HashSet, str::FromStr}; + use anyhow::Context; -use database::mungos::mongodb::bson::{doc, to_document}; +use database::mungos::mongodb::bson::{ + doc, oid::ObjectId, to_bson, to_document, +}; use formatting::format_serror; 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 +29,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 +189,15 @@ impl Resolve 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 +307,347 @@ impl Resolve for DeployStackIfChanged { PermissionLevel::Execute.into(), ) .await?; + RefreshStackCache { stack: stack.id.clone(), } .resolve(&WriteArgs { user: user.clone() }) .await?; + let stack = resource::get::(&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::>(); + 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, + let mut update = + restart_services(stack.name, Vec::new(), user).await?; + + if update.success { + // Need to update 'info.deployed_contents' with the + // latest contents so next check doesn't read the same diff. + update_deployed_contents_with_latest( + &stack.id, + stack.info.remote_contents, + &mut update, + ) + .await; + } + + Ok(update) + } + 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?; + + let mut update = + restart_services(stack.name, restart, user).await?; + + if update.success { + // Need to update 'info.deployed_contents' with the + // latest contents so next check doesn't read the same diff. + update_deployed_contents_with_latest( + &stack.id, + stack.info.remote_contents, + &mut update, + ) + .await; + } + + Ok(update) + } + // 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(", "),), + ); + // This already updates 'stack.info.deployed_services', + // restart doesn't require this again. + 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, + user: &User, +) -> serror::Result { + // 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, + user: &User, +) -> serror::Result { + // 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 +} + +/// This can safely be called in [DeployStackIfChanged] +/// when there are ONLY changes to config files requiring restart, +/// AFTER the restart has been successfully completed. +/// +/// In the case the if changed action is not FullDeploy, +/// the only file diff possible is to config files. +/// Also note either full or service deploy will already update 'deployed_contents' +/// making this method unnecessary in those cases. +/// +/// Changes to config files after restart is applied should +/// be taken as the deployed contents, otherwise next changed check +/// will restart service again for no reason. +async fn update_deployed_contents_with_latest( + id: &str, + contents: Option>, + update: &mut Update, +) { + let Some(contents) = contents else { + return; + }; + let contents = contents + .into_iter() + .map(|f| FileContents { + path: f.path, + contents: f.contents, + }) + .collect::>(); + if let Err(e) = (async { + let contents = to_bson(&contents) + .context("Failed to serialize contents to bson")?; + let id = + ObjectId::from_str(id).context("Id is not valid ObjectId")?; + db_client() + .stacks + .update_one( + doc! { "_id": id }, + doc! { "$set": { "info.deployed_contents": contents } }, + ) + .await + .context("Failed to update stack 'deployed_contents'")?; + anyhow::Ok(()) + }) + .await + { + update.push_error_log( + "Update content cache", + format_serror(&e.into()), + ); + update.finalize(); + let _ = update_update(update.clone()).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, + restart: Vec, + }, +} + +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::::new(); + let mut restart = HashSet::::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 +674,31 @@ impl Resolve 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::(&stack.config.server_id).await?; + let repo = if stack.config.repo.is_empty() + && !stack.config.linked_repo.is_empty() + { + Some(resource::get::(&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, @@ -630,3 +958,94 @@ impl Resolve for DestroyStack { .map_err(Into::into) } } + +impl Resolve 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 { + 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::(&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) + } +} diff --git a/bin/core/src/api/read/server.rs b/bin/core/src/api/read/server.rs index b7f756a3c..187ffffd0 100644 --- a/bin/core/src/api/read/server.rs +++ b/bin/core/src/api/read/server.rs @@ -71,12 +71,24 @@ impl Resolve 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; diff --git a/bin/core/src/api/write/stack.rs b/bin/core/src/api/write/stack.rs index 28e7b5e26..a1f5d7e21 100644 --- a/bin/core/src/api/write/stack.rs +++ b/bin/core/src/api/write/stack.rs @@ -429,7 +429,7 @@ impl Resolve 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 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 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, diff --git a/bin/core/src/helpers/procedure.rs b/bin/core/src/helpers/procedure.rs index 9eb8b5a35..4b61df739 100644 --- a/bin/core/src/helpers/procedure.rs +++ b/bin/core/src/helpers/procedure.rs @@ -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?; diff --git a/bin/core/src/helpers/update.rs b/bin/core/src/helpers/update.rs index 612391f68..cde85b8e6 100644 --- a/bin/core/src/helpers/update.rs +++ b/bin/core/src/helpers/update.rs @@ -492,6 +492,13 @@ pub async fn init_execution_update( return Ok(Default::default()); } + ExecuteRequest::RunStackService(data) => ( + Operation::RunStackService, + ResourceTarget::Stack( + resource::get::(&data.stack).await?.id, + ), + ), + // Alerter ExecuteRequest::TestAlerter(data) => ( Operation::TestAlerter, @@ -499,6 +506,9 @@ pub async fn init_execution_update( resource::get::(&data.alerter).await?.id, ), ), + ExecuteRequest::SendAlert(_) => { + (Operation::SendAlert, ResourceTarget::system()) + } // Maintenance ExecuteRequest::ClearRepoCache(_data) => { diff --git a/bin/core/src/monitor/alert/server.rs b/bin/core/src/monitor/alert/server.rs index 892a300c0..3717682b1 100644 --- a/bin/core/src/monitor/alert/server.rs +++ b/bin/core/src/monitor/alert/server.rs @@ -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; }; diff --git a/bin/core/src/monitor/record.rs b/bin/core/src/monitor/record.rs index d4660e4eb..53031eac0 100644 --- a/bin/core/src/monitor/record.rs +++ b/bin/core/src/monitor/record.rs @@ -21,6 +21,7 @@ pub async fn record_server_stats(ts: i64) { ts, sid: status.id.clone(), cpu_perc: stats.cpu_perc, + load_average: stats.load_average.clone(), mem_total_gb: stats.mem_total_gb, mem_used_gb: stats.mem_used_gb, disk_total_gb, diff --git a/bin/core/src/network.rs b/bin/core/src/network.rs index 5d38e566a..37a030cdb 100644 --- a/bin/core/src/network.rs +++ b/bin/core/src/network.rs @@ -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 diff --git a/bin/core/src/resource/build.rs b/bin/core/src/resource/build.rs index 86463b62f..1107f72bb 100644 --- a/bin/core/src/resource/build.rs +++ b/bin/core/src/resource/build.rs @@ -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, diff --git a/bin/core/src/resource/mod.rs b/bin/core/src/resource/mod.rs index 3fa90585c..6585cf899 100644 --- a/bin/core/src/resource/mod.rs +++ b/bin/core/src/resource/mod.rs @@ -229,6 +229,12 @@ pub trait KomodoResource { pub async fn get( id_or_name: &str, ) -> anyhow::Result> { + 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 diff --git a/bin/core/src/resource/procedure.rs b/bin/core/src/resource/procedure.rs index eaa5eb551..8a43a8d1b 100644 --- a/bin/core/src/resource/procedure.rs +++ b/bin/core/src/resource/procedure.rs @@ -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::( + ¶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, + user, + PermissionLevel::Execute.into(), + ) + .await? + .id; + anyhow::Ok(id) + }) + .collect::>() + .try_collect::>() + .await?; + } Execution::ClearRepoCache(_params) => { if !user.admin { return Err(anyhow!( diff --git a/bin/core/src/resource/server.rs b/bin/core/src/resource/server.rs index 7f4dfae55..fd30554c3 100644 --- a/bin/core/src/resource/server.rs +++ b/bin/core/src/resource/server.rs @@ -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, }, diff --git a/bin/core/src/stack/remote.rs b/bin/core/src/stack/remote.rs index a4fce3b89..a6c012f6f 100644 --- a/bin/core/src/stack/remote.rs +++ b/bin/core/src/stack/remote.rs @@ -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, + pub successful: Vec, pub errored: Vec, pub hash: Option, pub message: Option, @@ -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()), }), } diff --git a/bin/core/src/startup.rs b/bin/core/src/startup.rs index 5f301f79d..b3d5d940c 100644 --- a/bin/core/src/startup.rs +++ b/bin/core/src/startup.rs @@ -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 diff --git a/bin/core/src/sync/deploy.rs b/bin/core/src/sync/deploy.rs index ac3829d7a..b97c65e2b 100644 --- a/bin/core/src/sync/deploy.rs +++ b/bin/core/src/sync/deploy.rs @@ -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) { diff --git a/bin/core/src/sync/resources.rs b/bin/core/src/sync/resources.rs index 018d43413..80f972eee 100644 --- a/bin/core/src/sync/resources.rs +++ b/bin/core/src/sync/resources.rs @@ -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(_) => {} diff --git a/bin/core/src/sync/toml.rs b/bin/core/src/sync/toml.rs index c414a3e43..c0a08ef6a 100644 --- a/bin/core/src/sync/toml.rs +++ b/bin/core/src/sync/toml.rs @@ -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(_) diff --git a/bin/periphery/Cargo.toml b/bin/periphery/Cargo.toml index f62bdedaa..34402303f 100644 --- a/bin/periphery/Cargo.toml +++ b/bin/periphery/Cargo.toml @@ -57,4 +57,5 @@ axum.workspace = true clap.workspace = true envy.workspace = true uuid.workspace = true -rand.workspace = true \ No newline at end of file +rand.workspace = true +shell-escape.workspace = true \ No newline at end of file diff --git a/bin/periphery/src/api/build.rs b/bin/periphery/src/api/build.rs index b918afa81..ac18ab8f5 100644 --- a/bin/periphery/src/api/build.rs +++ b/bin/periphery/src/api/build.rs @@ -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 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 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::>(); + // 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::>() { - 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 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 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 diff --git a/bin/periphery/src/api/compose.rs b/bin/periphery/src/api/compose.rs index dba988e12..31dff4016 100644 --- a/bin/periphery/src/api/compose.rs +++ b/bin/periphery/src/api/compose.rs @@ -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 for GetComposeContentsOnHost { let mut res = GetComposeContentsOnHostResponse::default(); - for path in file_paths { - let full_path = - run_directory.join(&path).components().collect::(); + for file in file_paths { + let full_path = run_directory + .join(&file.path) + .components() + .collect::(); 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 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 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::(), + run_directory.join(&path).components().collect::(), + path, ) }) .collect::>(); // 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 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 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 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 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 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 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 for ComposeExecution { Ok(log) } } + +// + +impl Resolve 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 { + 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::>() + .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) + } +} diff --git a/bin/periphery/src/api/mod.rs b/bin/periphery/src/api/mod.rs index d6baade29..afe462912 100644 --- a/bin/periphery/src/api/mod.rs +++ b/bin/periphery/src/api/mod.rs @@ -85,6 +85,7 @@ pub enum PeripheryRequest { ComposePull(ComposePull), ComposeUp(ComposeUp), ComposeExecution(ComposeExecution), + ComposeRun(ComposeRun), // Container (Read) InspectContainer(InspectContainer), diff --git a/bin/periphery/src/build.rs b/bin/periphery/src/build.rs index 2f3e95dd3..0d1b05982 100644 --- a/bin/periphery/src/build.rs +++ b/bin/periphery/src/build.rs @@ -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 { 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::>() - .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 { diff --git a/bin/periphery/src/compose/mod.rs b/bin/periphery/src/compose/mod.rs index 9272696af..856fc8bc2 100644 --- a/bin/periphery/src/compose/mod.rs +++ b/bin/periphery/src/compose/mod.rs @@ -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 { + 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], diff --git a/bin/periphery/src/compose/up.rs b/bin/periphery/src/compose/up.rs index 1450ddd1c..34cdd427f 100644 --- a/bin/periphery/src/compose/up.rs +++ b/bin/periphery/src/compose/up.rs @@ -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::(), + run_directory + .join(&file.path) + .components() + .collect::(), + file, ) }) .collect::>(); - 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, }); } } diff --git a/bin/periphery/src/compose/write.rs b/bin/periphery/src/compose/write.rs index 9da106714..277ac793a 100644 --- a/bin/periphery/src/compose/write.rs +++ b/bin/periphery/src/compose/write.rs @@ -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 { + &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) diff --git a/bin/periphery/src/stats.rs b/bin/periphery/src/stats.rs index f130ed2b4..2f37813b3 100644 --- a/bin/periphery/src/stats.rs +++ b/bin/periphery/src/stats.rs @@ -2,7 +2,7 @@ use std::{cmp::Ordering, sync::OnceLock}; use async_timing_util::wait_until_timelength; use komodo_client::entities::stats::{ - SingleDiskUsage, SystemInformation, SystemProcess, SystemStats, + SingleDiskUsage, SystemInformation, SystemLoadAverage, SystemProcess, SystemStats, }; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; use tokio::sync::RwLock; @@ -95,8 +95,15 @@ impl StatsClient { network_egress_bytes += network.transmitted(); } + let load_avg = System::load_average(); + SystemStats { cpu_perc: self.system.global_cpu_usage(), + load_average: SystemLoadAverage { + one: load_avg.one, + five: load_avg.five, + fifteen: load_avg.fifteen, + }, mem_free_gb: self.system.free_memory() as f64 / BYTES_PER_GB, mem_used_gb: (total_mem - available_mem) as f64 / BYTES_PER_GB, mem_total_gb: total_mem as f64 / BYTES_PER_GB, diff --git a/client/core/rs/src/api/execute/action.rs b/client/core/rs/src/api/execute/action.rs index cee479cc7..1a6113b35 100644 --- a/client/core/rs/src/api/execute/action.rs +++ b/client/core/rs/src/api/execute/action.rs @@ -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 diff --git a/client/core/rs/src/api/execute/alerter.rs b/client/core/rs/src/api/execute/alerter.rs index 1a160babb..fbcda74ec 100644 --- a/client/core/rs/src/api/execute/alerter.rs +++ b/client/core/rs/src/api/execute/alerter.rs @@ -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, +} diff --git a/client/core/rs/src/api/execute/build.rs b/client/core/rs/src/api/execute/build.rs index 0efc0efb1..9adf37e9c 100644 --- a/client/core/rs/src/api/execute/build.rs +++ b/client/core/rs/src/api/execute/build.rs @@ -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 diff --git a/client/core/rs/src/api/execute/deployment.rs b/client/core/rs/src/api/execute/deployment.rs index 67ea89215..cbcbdf8f3 100644 --- a/client/core/rs/src/api/execute/deployment.rs +++ b/client/core/rs/src/api/execute/deployment.rs @@ -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 diff --git a/client/core/rs/src/api/execute/maintenance.rs b/client/core/rs/src/api/execute/maintenance.rs index b57dc936c..9fba2852a 100644 --- a/client/core/rs/src/api/execute/maintenance.rs +++ b/client/core/rs/src/api/execute/maintenance.rs @@ -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, diff --git a/client/core/rs/src/api/execute/mod.rs b/client/core/rs/src/api/execute/mod.rs index 6b9d7e44c..a1f6550d7 100644 --- a/client/core/rs/src/api/execute/mod.rs +++ b/client/core/rs/src/api/execute/mod.rs @@ -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), diff --git a/client/core/rs/src/api/execute/procedure.rs b/client/core/rs/src/api/execute/procedure.rs index 983d41427..cb22922b7 100644 --- a/client/core/rs/src/api/execute/procedure.rs +++ b/client/core/rs/src/api/execute/procedure.rs @@ -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 diff --git a/client/core/rs/src/api/execute/repo.rs b/client/core/rs/src/api/execute/repo.rs index 0e0fed797..c69304299 100644 --- a/client/core/rs/src/api/execute/repo.rs +++ b/client/core/rs/src/api/execute/repo.rs @@ -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 diff --git a/client/core/rs/src/api/execute/stack.rs b/client/core/rs/src/api/execute/stack.rs index b6e992241..98e0718ed 100644 --- a/client/core/rs/src/api/execute/stack.rs +++ b/client/core/rs/src/api/execute/stack.rs @@ -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>, + /// Do not allocate TTY + #[arg(long = "no-tty", action = SetTrue)] + pub no_tty: Option, + /// Do not start linked services + #[arg(long = "no-deps", action = SetTrue)] + pub no_deps: Option, + /// Map service ports to the host + #[arg(long = "service-ports", action = SetTrue)] + pub service_ports: Option, + /// Extra environment variables for the run + #[arg(long = "env", short = 'e', value_parser = env_parser)] + pub env: Option>, + /// Working directory inside the container + #[arg(long = "workdir")] + pub workdir: Option, + /// User to run as inside the container + #[arg(long = "user")] + pub user: Option, + /// Override the default entrypoint + #[arg(long = "entrypoint")] + pub entrypoint: Option, + /// Pull the image before running + #[arg(long = "pull", action = SetTrue)] + pub pull: Option, +} + +fn env_parser(args: &str) -> anyhow::Result> { + 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 diff --git a/client/core/rs/src/api/mod.rs b/client/core/rs/src/api/mod.rs index 1e2905de1..b52dc0d38 100644 --- a/client/core/rs/src/api/mod.rs +++ b/client/core/rs/src/api/mod.rs @@ -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": [ diff --git a/client/core/rs/src/api/read/alert.rs b/client/core/rs/src/api/read/alert.rs index 3a679b966..c4d7f31ba 100644 --- a/client/core/rs/src/api/read/alert.rs +++ b/client/core/rs/src/api/read/alert.rs @@ -20,7 +20,7 @@ pub struct ListAlerts { /// Pass a custom mongo query to filter the alerts. /// /// ## Example JSON - /// ``` + /// ```json /// { /// "resolved": "false", /// "level": "CRITICAL", diff --git a/client/core/rs/src/api/read/server.rs b/client/core/rs/src/api/read/server.rs index 501bfbe93..238c0b625 100644 --- a/client/core/rs/src/api/read/server.rs +++ b/client/core/rs/src/api/read/server.rs @@ -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. diff --git a/client/core/rs/src/deserializers/conversion.rs b/client/core/rs/src/deserializers/conversion.rs index 9e7665cbc..680a5a8c0 100644 --- a/client/core/rs/src/deserializers/conversion.rs +++ b/client/core/rs/src/deserializers/conversion.rs @@ -35,11 +35,10 @@ impl<'de> Visitor<'de> for ConversionVisitor { write!(formatter, "string or Vec") } - fn visit_str(self, v: &str) -> Result + fn visit_string(self, out: String) -> Result 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(self, v: &str) -> Result + where + E: serde::de::Error, + { + Self::visit_string(self, v.to_string()) + } + fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, @@ -78,6 +84,13 @@ impl<'de> Visitor<'de> for OptionConversionVisitor { write!(formatter, "null or string or Vec") } + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + ConversionVisitor.visit_string(v).map(Some) + } + fn visit_str(self, v: &str) -> Result where E: serde::de::Error, diff --git a/client/core/rs/src/deserializers/item_or_vec.rs b/client/core/rs/src/deserializers/item_or_vec.rs new file mode 100644 index 000000000..811fcfbf7 --- /dev/null +++ b/client/core/rs/src/deserializers/item_or_vec.rs @@ -0,0 +1,104 @@ +//! # Item or Vec deserializer. +//! +//! Used to convert `item: T` (struct / map) -> `item: Vec` (seq) in schemas with backward compatibility. +//! Supports deserializing either a T as Vec with length 1, or a seq as Vec directly. + +use serde::{ + Deserialize, Deserializer, + de::{ + DeserializeOwned, IntoDeserializer, Visitor, + value::{MapAccessDeserializer, SeqAccessDeserializer}, + }, +}; + +pub fn item_or_vec_deserializer<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + deserializer + .deserialize_any(ItemOrVecVisitor::(std::marker::PhantomData)) +} + +pub fn option_item_or_vec_deserializer<'de, D, T>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + deserializer.deserialize_any(OptionItemOrVecVisitor::( + std::marker::PhantomData, + )) +} + +struct ItemOrVecVisitor(std::marker::PhantomData); + +impl<'de, T> Visitor<'de> for ItemOrVecVisitor +where + T: Deserialize<'de>, +{ + type Value = Vec; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + write!(formatter, "Item or Vec") + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + T::deserialize( + MapAccessDeserializer::new(map).into_deserializer(), + ) + .map(|r| vec![r]) + } + + fn visit_seq(self, seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + Vec::::deserialize( + SeqAccessDeserializer::new(seq).into_deserializer(), + ) + } +} + +struct OptionItemOrVecVisitor(std::marker::PhantomData); + +impl<'de, T> Visitor<'de> for OptionItemOrVecVisitor +where + T: Deserialize<'de>, +{ + type Value = Option>; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + write!(formatter, "null or Item or Vec") + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + ItemOrVecVisitor::(std::marker::PhantomData) + .visit_map(map) + .map(Some) + } + + fn visit_seq(self, seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + ItemOrVecVisitor::(std::marker::PhantomData) + .visit_seq(seq) + .map(Some) + } +} diff --git a/client/core/rs/src/deserializers/mod.rs b/client/core/rs/src/deserializers/mod.rs index 65d4ece89..9fbe7c2e9 100644 --- a/client/core/rs/src/deserializers/mod.rs +++ b/client/core/rs/src/deserializers/mod.rs @@ -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::*; diff --git a/client/core/rs/src/entities/action.rs b/client/core/rs/src/entities/action.rs index 46d23b1f6..ff39fc632 100644 --- a/client/core/rs/src/entities/action.rs +++ b/client/core/rs/src/entities/action.rs @@ -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)] diff --git a/client/core/rs/src/entities/alert.rs b/client/core/rs/src/entities/alert.rs index 56939e648..0b8270afd 100644 --- a/client/core/rs/src/entities/alert.rs +++ b/client/core/rs/src/entities/alert.rs @@ -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, + /// 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, } diff --git a/client/core/rs/src/entities/build.rs b/client/core/rs/src/entities/build.rs index af7eb6a91..3807ee112 100644 --- a/client/core/rs/src/entities/build.rs +++ b/client/core/rs/src/entities/build.rs @@ -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, - /// The image registry domain + /// The first listed image registry domain pub image_registry_domain: Option, } @@ -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, /// 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 -where - D: Deserializer<'de>, -{ - deserializer.deserialize_any(ImageRegistryVisitor) -} - -pub fn option_image_registry_deserializer<'de, D>( - deserializer: D, -) -> Result, 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(self, map: A) -> Result - 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::(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; - - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { - write!( - formatter, - "null or {{ \"domain\": string, \"account\": string, \"organization\": string }}" - ) - } - - fn visit_map(self, map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - ImageRegistryVisitor.visit_map(map).map(Some) - } - - fn visit_none(self) -> Result - where - E: serde::de::Error, - { - Ok(None) - } - - fn visit_unit(self) -> Result - 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 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 = OnceLock::new(); + DEFAULT.get_or_init(Default::default) } } diff --git a/client/core/rs/src/entities/config/cli/args/mod.rs b/client/core/rs/src/entities/config/cli/args/mod.rs index 03b3bc000..99f42ac79 100644 --- a/client/core/rs/src/entities/config/cli/args/mod.rs +++ b/client/core/rs/src/entities/config/cli/args/mod.rs @@ -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`) diff --git a/client/core/rs/src/entities/config/cli/mod.rs b/client/core/rs/src/entities/config/cli/mod.rs index c4bbb095c..6fa2404da 100644 --- a/client/core/rs/src/entities/config/cli/mod.rs +++ b/client/core/rs/src/entities/config/cli/mod.rs @@ -70,7 +70,7 @@ pub struct Env { pub komodo_cli_backups_folder: Option, /// Override `max_backups` pub komodo_cli_max_backups: Option, - + /// Override `database_target_uri` #[serde(alias = "komodo_cli_database_copy_uri")] pub komodo_cli_database_target_uri: Option, diff --git a/client/core/rs/src/entities/mod.rs b/client/core/rs/src/entities/mod.rs index 7c69ee13c..aee27c79c 100644 --- a/client/core/rs/src/entities/mod.rs +++ b/client/core/rs/src/entities/mod.rs @@ -129,43 +129,52 @@ pub fn optional_string(string: impl Into) -> Option { } } -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 { +) -> Vec { 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, diff --git a/client/core/rs/src/entities/procedure.rs b/client/core/rs/src/entities/procedure.rs index 623663304..53de21135 100644 --- a/client/core/rs/src/entities/procedure.rs +++ b/client/core/rs/src/entities/procedure.rs @@ -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)] diff --git a/client/core/rs/src/entities/server.rs b/client/core/rs/src/entities/server.rs index c29539368..c6c50a032 100644 --- a/client/core/rs/src/entities/server.rs +++ b/client/core/rs/src/entities/server.rs @@ -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(), diff --git a/client/core/rs/src/entities/stack.rs b/client/core/rs/src/entities/stack.rs index 774613e0d..16b742f77 100644 --- a/client/core/rs/src/entities/stack.rs +++ b/client/core/rs/src/entities/stack.rs @@ -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 { + let mut res = self + .compose_file_paths() + .iter() + .cloned() + // Makes sure to dedup them, while maintaining ordering + .collect::>(); + 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 { + let mut res = self + .compose_file_paths() + .iter() + .cloned() + .map(StackFileDependency::full_redeploy) + // Makes sure to dedup them, while maintaining ordering + .collect::>(); + 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, @@ -174,7 +221,7 @@ pub struct StackInfo { pub deployed_hash: Option, /// Deployed commit message, or null. Only for repo based stacks pub deployed_message: Option, - /// The deployed compose file contents. + /// The deployed compose / additional file contents. /// This is updated whenever Komodo successfully deploys the stack. pub deployed_contents: Option>, /// The deployed service names. @@ -188,10 +235,10 @@ pub struct StackInfo { #[serde(default)] pub latest_services: Vec, - /// 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>, + pub remote_contents: Option>, /// If there was an error in getting the remote contents, it will be here. pub remote_errors: Option>, @@ -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, + /// 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, + /// 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, + /// Query only for Stacks with these linked repos. + /// Only accepts Repo id (not name). + #[serde(default)] + pub linked_repos: Vec, /// Filter syncs by their repo. #[serde(default)] pub repos: Vec, @@ -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, } + +// 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, + /// 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, + /// 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, + #[serde(default, alias = "req")] + requires: StackFileRequires, +} + +impl<'de> Deserialize<'de> for StackFileDependency { + fn deserialize(deserializer: D) -> Result + 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(self, path: String) -> Result + where + E: serde::de::Error, + { + Ok(StackFileDependency { + path, + services: Vec::new(), + requires: StackFileRequires::None, + }) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Self::visit_string(self, v.to_string()) + } + + fn visit_map(self, map: A) -> Result + 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(&self, serializer: S) -> Result +// 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) +// } +// } diff --git a/client/core/rs/src/entities/stats.rs b/client/core/rs/src/entities/stats.rs index 03250800d..95b385230 100644 --- a/client/core/rs/src/entities/stats.rs +++ b/client/core/rs/src/entities/stats.rs @@ -45,6 +45,9 @@ pub struct SystemStatsRecord { // basic stats /// Cpu usage percentage pub cpu_perc: f32, + /// Load average (1m, 5m, 15m) + #[serde(default)] + pub load_average: SystemLoadAverage, /// Memory used in GB pub mem_used_gb: f64, /// Total memory in GB @@ -72,6 +75,9 @@ pub struct SystemStatsRecord { pub struct SystemStats { /// Cpu usage percentage pub cpu_perc: f32, + /// Load average (1m, 5m, 15m) + #[serde(default)] + pub load_average: SystemLoadAverage, /// [1.15.9+] /// Free memory in GB. /// This is really the 'Free' memory, not the 'Available' memory. @@ -175,3 +181,14 @@ pub struct SystemProcess { /// Process disk write in KB/s pub disk_write_kb: f64, } + +#[typeshare] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +pub struct SystemLoadAverage { + /// 1m load average + pub one: f64, + /// 5m load average + pub five: f64, + /// 15m load average + pub fifteen: f64, +} \ No newline at end of file diff --git a/client/core/rs/src/lib.rs b/client/core/rs/src/lib.rs index 12f21c0fc..2f1f438ef 100644 --- a/client/core/rs/src/lib.rs +++ b/client/core/rs/src/lib.rs @@ -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?; /// ``` diff --git a/client/core/rs/src/ws.rs b/client/core/rs/src/ws.rs index 78b6527c8..b2cd68702 100644 --- a/client/core/rs/src/ws.rs +++ b/client/core/rs/src/ws.rs @@ -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 { diff --git a/client/core/ts/package.json b/client/core/ts/package.json index 369f5eea0..fc060fd99 100644 --- a/client/core/ts/package.json +++ b/client/core/ts/package.json @@ -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", diff --git a/client/core/ts/src/responses.ts b/client/core/ts/src/responses.ts index 23a5e01c0..d1686778e 100644 --- a/client/core/ts/src/responses.ts +++ b/client/core/ts/src/responses.ts @@ -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; diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index 0fd3952b6..b1c9364b0 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -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 */ @@ -2308,6 +2402,15 @@ export interface SystemInformation { export type GetSystemInformationResponse = SystemInformation; +export interface SystemLoadAverage { + /** 1m load average */ + one: number; + /** 5m load average */ + five: number; + /** 15m load average */ + fifteen: number; +} + /** Info for a single disk mounted on the system. */ export interface SingleDiskUsage { /** The mount point of the disk */ @@ -2369,6 +2472,8 @@ export enum Timelength { export interface SystemStats { /** Cpu usage percentage */ cpu_perc: number; + /** Load average (1m, 5m, 15m) */ + load_average?: SystemLoadAverage; /** * [1.15.9+] * Free memory in GB. @@ -3760,6 +3865,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 +4074,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 +4219,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 +4231,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 +4248,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 +4265,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 +4282,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 +4299,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 +4316,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 +4333,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 +4355,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 +4372,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 +4389,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 +4406,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 +4423,7 @@ export interface BatchRunProcedure { * Supports multiline and comma delineated combinations of the above. * * Example: - * ``` + * ```text * # match all foo-* procedures * foo-* * # add some more @@ -5996,6 +6108,8 @@ export interface SystemStatsRecord { sid: string; /** Cpu usage percentage */ cpu_perc: number; + /** Load average (1m, 5m, 15m) */ + load_average?: SystemLoadAverage; /** Memory used in GB */ mem_used_gb: number; /** Total memory in GB */ @@ -6243,6 +6357,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 +6556,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 +6644,7 @@ export interface ListAlerts { * Pass a custom mongo query to filter the alerts. * * ## Example JSON - * ``` + * ```json * { * "resolved": "false", * "level": "CRITICAL", @@ -7585,6 +7701,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; + /** 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 +7828,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 +8521,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 +8547,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 +8639,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 } diff --git a/client/periphery/rs/src/api/build.rs b/client/periphery/rs/src/api/build.rs index ecd6c36ac..33e62ff58 100644 --- a/client/periphery/rs/src/api/build.rs +++ b/client/periphery/rs/src/api/build.rs @@ -11,8 +11,10 @@ pub struct Build { pub build: komodo_client::entities::build::Build, /// Send the linked repo if it exists. pub repo: Option, - /// Override registry token with one sent from core. - pub registry_token: Option, + /// 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)>, diff --git a/client/periphery/rs/src/api/compose.rs b/client/periphery/rs/src/api/compose.rs index 192e41dd0..7a4c69a24 100644 --- a/client/periphery/rs/src/api/compose.rs +++ b/client/periphery/rs/src/api/compose.rs @@ -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, + /// Both compose files and env / additional files, all relative to run directory. + pub file_paths: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GetComposeContentsOnHostResponse { - pub contents: Vec, + pub contents: Vec, pub errors: Vec, } @@ -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, /// The deploy compose file contents if they could be acquired, or empty vec. - pub file_contents: Vec, + pub file_contents: Vec, /// The error in getting remote file contents at the path, or null pub remote_errors: Vec, /// 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, +} + +// + /// 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, + /// If provided, use it to login in. Otherwise check periphery local registries. + pub git_token: Option, + /// If provided, use it to login in. Otherwise check periphery local git providers. + pub registry_token: Option, + /// 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>, + /// Do not allocate TTY + #[serde(default)] + pub no_tty: Option, + /// Do not start linked services + #[serde(default)] + pub no_deps: Option, + /// Map service ports to the host + #[serde(default)] + pub service_ports: Option, + /// Extra environment variables for the run + #[serde(default)] + pub env: Option>, + /// Working directory inside the container + #[serde(default)] + pub workdir: Option, + /// User to run as inside the container + #[serde(default)] + pub user: Option, + /// Override the default entrypoint + #[serde(default)] + pub entrypoint: Option, + /// Pull the image before running + #[serde(default)] + pub pull: Option, +} diff --git a/client/periphery/rs/src/terminal.rs b/client/periphery/rs/src/terminal.rs index 8a926d324..926b3115c 100644 --- a/client/periphery/rs/src/terminal.rs +++ b/client/periphery/rs/src/terminal.rs @@ -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 /// ``` /// diff --git a/docsite/docs/resources/deploy-containers/configuration.md b/docsite/docs/resources/deploy-containers/configuration.md index e5125e396..b9acac8f3 100644 --- a/docsite/docs/resources/deploy-containers/configuration.md +++ b/docsite/docs/resources/deploy-containers/configuration.md @@ -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'. \ No newline at end of file +In order to achieve this with Komodo, just pass `--quiet` to 'command'. diff --git a/frontend/package.json b/frontend/package.json index 7fdebb044..59a80e580 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/client/responses.d.ts b/frontend/public/client/responses.d.ts index 5f9426fd0..d3507fb43 100644 --- a/frontend/public/client/responses.d.ts +++ b/frontend/public/client/responses.d.ts @@ -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; diff --git a/frontend/public/client/types.d.ts b/frontend/public/client/types.d.ts index 9f762c7f7..7bf6e6983 100644 --- a/frontend/public/client/types.d.ts +++ b/frontend/public/client/types.d.ts @@ -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; @@ -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 */ @@ -2416,6 +2517,14 @@ export interface SystemInformation { container_exec_disabled: boolean; } export type GetSystemInformationResponse = SystemInformation; +export interface SystemLoadAverage { + /** 1m load average */ + one: number; + /** 5m load average */ + five: number; + /** 15m load average */ + fifteen: number; +} /** Info for a single disk mounted on the system. */ export interface SingleDiskUsage { /** The mount point of the disk */ @@ -2475,6 +2584,8 @@ export declare enum Timelength { export interface SystemStats { /** Cpu usage percentage */ cpu_perc: number; + /** Load average (1m, 5m, 15m) */ + load_average?: SystemLoadAverage; /** * [1.15.9+] * Free memory in GB. @@ -3731,6 +3842,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 +4016,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 +4125,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 +4136,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 +4152,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 +4168,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 +4184,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 +4200,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 +4216,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 +4232,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 +4252,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 +4268,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 +4284,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 +4300,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 +4316,7 @@ export interface BatchRunProcedure { * Supports multiline and comma delineated combinations of the above. * * Example: - * ``` + * ```text * # match all foo-* procedures * foo-* * # add some more @@ -5744,6 +5862,8 @@ export interface SystemStatsRecord { sid: string; /** Cpu usage percentage */ cpu_perc: number; + /** Load average (1m, 5m, 15m) */ + load_average?: SystemLoadAverage; /** Memory used in GB */ mem_used_gb: number; /** Total memory in GB */ @@ -5963,6 +6083,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 +6261,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 +6339,7 @@ export interface ListAlerts { * Pass a custom mongo query to filter the alerts. * * ## Example JSON - * ``` + * ```json * { * "resolved": "false", * "level": "CRITICAL", @@ -7172,6 +7294,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; + /** 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 +7415,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 +8129,9 @@ export type ExecuteRequest = { } | { type: "BatchDestroyStack"; params: BatchDestroyStack; +} | { + type: "RunStackService"; + params: RunStackService; } | { type: "Deploy"; params: Deploy; @@ -8042,6 +8207,9 @@ export type ExecuteRequest = { } | { type: "TestAlerter"; params: TestAlerter; +} | { + type: "SendAlert"; + params: SendAlert; } | { type: "RunSync"; params: RunSync; @@ -8140,18 +8308,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; diff --git a/frontend/public/client/types.js b/frontend/public/client/types.js index 2df8d222a..aaf7f1622 100644 --- a/frontend/public/client/types.js +++ b/frontend/public/client/types.js @@ -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` */ diff --git a/frontend/src/components/config/util.tsx b/frontend/src/components/config/util.tsx index 0be51c1a6..f2246d1db 100644 --- a/frontend/src/components/config/util.tsx +++ b/frontend/src/components/config/util.tsx @@ -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 = ({ ); -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 ( - 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(); - 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 ( - - ); -}; - -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 ( - - {params.account_type === "git" ? ( -
- - -
- ) : ( - - )} -
- ); -}; - -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(); - 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 ( - - ); -}; - -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 ( - - - - ); -}; - export const ConfigList = ( props: InputListProps & { label?: string; @@ -452,9 +224,10 @@ export const ConfigList = ( className="flex items-center gap-2 w-[200px]" > - {(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)} )} {props.values.length > 0 && } @@ -533,14 +306,34 @@ export function ConfirmUpdate({ key_listener = false, }: ConfirmUpdateProps) { 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 ( @@ -573,10 +366,7 @@ export function ConfirmUpdate({ } - onClick={async () => { - await onConfirm(); - set(false); - }} + onClick={handleConfirm} loading={loading} /> @@ -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 ( - <> - -
- - setRegistry({ - ...registry, - domain, - }) - } - showCustom={false} - /> + +
Pushes to:
{" "} + {registry?.domain && registry.domain + " / "} + {registry?.domain && (namespace ? namespace : "") + " / "} + {imageName} + {!registry?.domain && Local}
-
+ } + > +
+ + setRegistry({ + ...registry, + domain, + }) + } + showCustom={false} + showLabel + /> + + setRegistry({ + ...registry, + account, + }) + } + disabled={!registry?.domain || disabled} + showLabel + /> + + setRegistry({ + ...registry, + organization, + }) + } + disabled={disabled} + showLabel + /> + {!disabled && ( + + )} +
+ + ); +}; - {registry?.domain && ( - <> - 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 ( + 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(); + 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 ( + + ); +}; + +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 ( + + {params.account_type === "git" ? ( +
+ + +
+ ) : ( + )} - +
+ ); +}; + +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(); + 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 ( + + ); +}; + +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 ( + + + ); }; @@ -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} > - +
+ {showLabel && selected && ( +
Org:
+ )} + +
None diff --git a/frontend/src/components/monaco.tsx b/frontend/src/components/monaco.tsx index f5bc59f76..ac5d839f4 100644 --- a/frontend/src/components/monaco.tsx +++ b/frontend/src/components/monaco.tsx @@ -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 = { + 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; diff --git a/frontend/src/components/resources/alerter/config/alert_types.tsx b/frontend/src/components/resources/alerter/config/alert_types.tsx index 00b87b85a..6e84756b3 100644 --- a/frontend/src/components/resources/alerter/config/alert_types.tsx +++ b/frontend/src/components/resources/alerter/config/alert_types.tsx @@ -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 ( [] = [ { label: "Registry", labelHidden: true, components: { - image_registry: (registry, set) => ( - set({ image_registry })} - resource_id={update.builder_id ?? config.builder_id} - disabled={disabled} - /> + image_registry: (image_registries, set) => ( +
+ + {!disabled && ( + + )} + + + {image_registries?.map((registry, index) => ( + + 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} + /> + ))} +
), }, }, diff --git a/frontend/src/components/resources/procedure/config.tsx b/frontend/src/components/resources/procedure/config.tsx index fc622d54c..c9a913b75 100644 --- a/frontend/src/components/resources/procedure/config.tsx +++ b/frontend/src/components/resources/procedure/config.tsx @@ -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>( `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 }) => ( - setParams({ action })} - disabled={disabled} - /> +
+ setParams({ action, args: params.args })} + disabled={disabled} + /> + + setParams({ action: params.action, args: JSON.parse(args) }) + } + disabled={disabled} + language="json" + triggerChild={ + + } + /> +
), }, 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>( + (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 ( + + + + + + + Run Stack Service + +
+
+
+
Stack
+ setStack(id)} + disabled={disabled} + align="start" + /> +
+
+
Service
+ setService(e.target.value)} + disabled={disabled} + /> +
+
+ +
+
Command
+ setCommand(e.target.value)} + disabled={disabled} + /> +
+ +
+ + + + +
+ +
+
+
+ Working Directory +
+ setWorkdir(e.target.value)} + disabled={disabled} + /> +
+
+
User
+ setUser(e.target.value)} + disabled={disabled} + /> +
+
+
+ Entrypoint +
+ setEntrypoint(e.target.value)} + disabled={disabled} + /> +
+
+ +
+
+ Extra Environment Variables +
+ +
+
+ + + +
+
+ ); + }, + }, // Repo CloneRepo: { params: { repo: "" }, @@ -1425,6 +1674,21 @@ const TARGET_COMPONENTS: ExecutionConfigs = { Component: () => <>, }, + SendAlert: { + params: { message: "" }, + Component: ({ params, setParams, disabled }) => ( + setParams({ message })} + disabled={disabled} + language={undefined} + fullWidth + /> + ), + }, + Sleep: { params: { duration_ms: 0 }, Component: ({ params, setParams, disabled }) => { diff --git a/frontend/src/components/resources/server/config.tsx b/frontend/src/components/resources/server/config.tsx index 74a010646..2b53d7c5d 100644 --- a/frontend/src/components/resources/server/config.tsx +++ b/frontend/src/components/resources/server/config.tsx @@ -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, diff --git a/frontend/src/components/resources/server/index.tsx b/frontend/src/components/resources/server/index.tsx index d442befed..184e31ea7 100644 --- a/frontend/src/components/resources/server/index.tsx +++ b/frontend/src/components/resources/server/index.tsx @@ -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 ( ); @@ -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 ( + + +
+ + Unknown +
+
+ +
+ Server is disabled - version unknown. +
+
+
+ ); + } + return ( @@ -264,11 +306,16 @@ export const ServerComponents: RequiredResourceComponents = { ), Dashboard: () => { - const summary = useRead("GetServersSummary", {}).data; + const summary = useRead("GetServersSummary", {}, { refetchInterval: 15_000 }).data; return ( { const state = useServer(id)?.info.state; - return ; + 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 ( + + ); }, Status: {}, @@ -340,6 +401,27 @@ export const ServerComponents: RequiredResourceComponents = { ); }, + LoadAvg: ({ id }) => { + const server = useServer(id); + const stats = useRead( + "GetSystemStats", + { server: id }, + { + enabled: server ? server.info.state !== "Disabled" : false, + refetchInterval: 5000, + } + ).data; + + if (!stats?.load_average) return null; + const one = stats.load_average?.one; + + return ( +
+ + {one.toFixed(2)} +
+ ); + }, Mem: ({ id }) => { const server = useServer(id); const stats = useRead( @@ -531,19 +613,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 ( } type="Server" id={id} resource={server} - state={ - server?.info.state === Types.ServerState.NotOk - ? "Not Ok" - : server?.info.state - } + state={displayState} status={server?.info.region} /> ); diff --git a/frontend/src/components/resources/server/monitoring-table.tsx b/frontend/src/components/resources/server/monitoring-table.tsx new file mode 100644 index 000000000..f2e5aef8d --- /dev/null +++ b/frontend/src/components/resources/server/monitoring-table.tsx @@ -0,0 +1,190 @@ +import { ResourceLink } from "@components/resources/common"; +import { ServerComponents } from "@components/resources/server"; +import { DataTable, SortableHeader } from "@ui/data-table"; +import { useRead } from "@lib/hooks"; +import { useMemo } from "react"; + +export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => { + const servers = useRead("ListServers", {}).data; + const searchSplit = useMemo( + () => search.toLowerCase().split(" ").filter((t) => t), + [search] + ); + const filtered = useMemo( + () => + servers?.filter((s) => + searchSplit.length === 0 + ? true + : searchSplit.every((t) => s.name.toLowerCase().includes(t)) + ) ?? [], + [servers, searchSplit] + ); + return ( +
+ + tableKey="servers-monitoring-v1" + data={filtered} + columns={[ + { + accessorKey: "name", + size: 250, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + header: "CPU", + size: 200, + cell: ({ row }) => , + }, + { + header: "Memory", + size: 200, + cell: ({ row }) => , + }, + { + header: "Disk", + size: 200, + cell: ({ row }) => , + }, + { + header: "Load Avg", + size: 160, + cell: ({ row }) => , + }, + { + header: "Net", + size: 100, + cell: ({ row }) => , + }, + { + header: "Agent", + size: 160, + cell: ({ row }) => , + }, + ]} + /> +
+ ); +}; + +const useStats = (id: string) => + useRead("GetSystemStats", { server: id }, { refetchInterval: 10_000 }).data; + +const useServerThresholds = (id: string) => { + const config = useRead("GetServer", { server: id }).data?.config as any; + return { + cpuWarning: config?.cpu_warning ?? 75, + cpuCritical: config?.cpu_critical ?? 90, + memWarning: config?.mem_warning ?? 75, + memCritical: config?.mem_critical ?? 90, + diskWarning: config?.disk_warning ?? 75, + diskCritical: config?.disk_critical ?? 90, + }; +}; + +const Bar = ({ valuePerc, intent }: { valuePerc?: number; intent: "Good" | "Warning" | "Critical" }) => { + const w = Math.max(0, Math.min(100, valuePerc ?? 0)) / 100; + const color = intent === "Good" ? "bg-green-500" : intent === "Warning" ? "bg-orange-500" : "bg-red-500"; + return ( + + + + ); +}; + +const CpuCell = ({ id }: { id: string }) => { + const stats = useStats(id); + const cpu = stats?.cpu_perc ?? 0; + const { cpuWarning: warning, cpuCritical: critical } = useServerThresholds(id); + const intent: "Good" | "Warning" | "Critical" = + cpu < warning ? "Good" : cpu < critical ? "Warning" : "Critical"; + return ( +
+ {cpu.toFixed(2)}% + +
+ ); +}; + +const MemCell = ({ id }: { id: string }) => { + const stats = useStats(id); + const used = stats?.mem_used_gb ?? 0; + const total = stats?.mem_total_gb ?? 0; + const perc = total > 0 ? (used / total) * 100 : 0; + const { memWarning: warning, memCritical: critical } = useServerThresholds(id); + const intent: "Good" | "Warning" | "Critical" = + perc < warning ? "Good" : perc < critical ? "Warning" : "Critical"; + return ( +
+ {perc.toFixed(1)}% + +
+ ); +}; + +const DiskCell = ({ id }: { id: string }) => { + const stats = useStats(id); + const used = stats?.disks?.reduce((acc, d) => acc + (d.used_gb || 0), 0) ?? 0; + const total = stats?.disks?.reduce((acc, d) => acc + (d.total_gb || 0), 0) ?? 0; + const perc = total > 0 ? (used / total) * 100 : 0; + const { diskWarning: warning, diskCritical: critical } = useServerThresholds(id); + const intent: "Good" | "Warning" | "Critical" = + perc < warning ? "Good" : perc < critical ? "Warning" : "Critical"; + return ( +
+ {perc.toFixed(1)}% + +
+ ); +}; + +const formatRate = (bytes?: number) => { + const b = bytes ?? 0; + const kb = 1024; + const mb = kb * 1024; + const gb = mb * 1024; + if (b >= gb) return `${(b / gb).toFixed(2)} GB/s`; + if (b >= mb) return `${(b / mb).toFixed(2)} MB/s`; + if (b >= kb) return `${(b / kb).toFixed(2)} KB/s`; + return `${b.toFixed(0)} B/s`; +}; + +const NetCell = ({ id }: { id: string }) => { + const stats = useStats(id); + const ingress = stats?.network_ingress_bytes ?? 0; + const egress = stats?.network_egress_bytes ?? 0; + return ( + + {formatRate(ingress + egress)} + + ); +}; + +const LoadAvgCell = ({ id }: { id: string }) => { + const stats = useStats(id); + const one = stats?.load_average?.one; + const five = stats?.load_average?.five; + const fifteen = stats?.load_average?.fifteen; + if (one === undefined || five === undefined || fifteen === undefined) { + return ( +
+ N/A +
+ ); + } + return ( +
+ {one.toFixed(2)} + {five.toFixed(2)} + {fifteen.toFixed(2)} +
+ ); +}; + + diff --git a/frontend/src/components/resources/server/stat-chart.tsx b/frontend/src/components/resources/server/stat-chart.tsx index 1060b5bfd..68b4c8135 100644 --- a/frontend/src/components/resources/server/stat-chart.tsx +++ b/frontend/src/components/resources/server/stat-chart.tsx @@ -9,7 +9,7 @@ import { convertTsMsToLocalUnixTsInMs } from "@lib/utils"; import { useTheme } from "@ui/theme"; import { fmt_utc_date } from "@lib/formatting"; -type StatType = "Cpu" | "Memory" | "Disk" | "Network Ingress" | "Network Egress"; +type StatType = "Cpu" | "Memory" | "Disk" | "Network Ingress" | "Network Egress" | "Load Average"; type StatDatapoint = { date: number; value: number }; @@ -29,18 +29,34 @@ export const StatChart = ({ granularity, }); - const stats = useMemo( - () => - data?.stats - .map((stat) => { - return { - date: convertTsMsToLocalUnixTsInMs(stat.ts), - value: getStat(stat, type), - }; - }) - .reverse(), - [data] - ); + const seriesData = useMemo(() => { + if (!data?.stats) return [] as { label: string; data: StatDatapoint[] }[]; + const records = [...data.stats].reverse(); + if (type === "Load Average") { + const one = records.map((s) => ({ + date: convertTsMsToLocalUnixTsInMs(s.ts), + value: (s.load_average?.one ?? 0), + })); + const five = records.map((s) => ({ + date: convertTsMsToLocalUnixTsInMs(s.ts), + value: (s.load_average?.five ?? 0), + })); + const fifteen = records.map((s) => ({ + date: convertTsMsToLocalUnixTsInMs(s.ts), + value: (s.load_average?.fifteen ?? 0), + })); + return [ + { label: "1m", data: one }, + { label: "5m", data: five }, + { label: "15m", data: fifteen }, + ]; + } + const single = records.map((stat) => ({ + date: convertTsMsToLocalUnixTsInMs(stat.ts), + value: getStat(stat, type), + })); + return [{ label: type, data: single }]; + }, [data, type]); return (
@@ -49,10 +65,9 @@ export const StatChart = ({
- ) : ( - stats && - stats.length > 0 && - )} + ) : seriesData.length > 0 ? ( + s.data)} seriesData={seriesData} /> + ) : null}
); }; @@ -64,9 +79,11 @@ const BYTES_PER_KB = 1024.0; export const InnerStatChart = ({ type, stats, + seriesData, }: { type: StatType; stats: StatDatapoint[] | undefined; + seriesData?: { label: string; data: StatDatapoint[] }[]; }) => { const { theme: _theme } = useTheme(); const theme = @@ -99,7 +116,8 @@ export const InnerStatChart = ({ }, []); // Determine the dynamic scaling for network-related types - const maxStatValue = Math.max(...(stats?.map((d) => d.value) ?? [0])); + const allValues = (seriesData ?? [{ data: stats ?? [] }]).flatMap((s) => s.data.map((d) => d.value)); + const maxStatValue = Math.max(...(allValues.length ? allValues : [0])); const { unit, maxUnitValue } = useMemo(() => { if (type === "Network Ingress" || type === "Network Egress") { @@ -113,6 +131,10 @@ export const InnerStatChart = ({ return { unit: "TB", maxUnitValue: BYTES_PER_GB * 1024 }; // Larger scale for high values } } + if (type === "Load Average") { + // Leave unitless; set max slightly above observed + return { unit: "", maxUnitValue: maxStatValue === 0 ? 1 : maxStatValue * 1.2 }; + } return { unit: "", maxUnitValue: 100 }; // Default for CPU, memory, disk }, [type, maxStatValue]); @@ -120,7 +142,8 @@ export const InnerStatChart = ({ (): AxisOptions[] => [ { getValue: (datum) => datum.value, - elementType: "area", + elementType: type === "Load Average" ? "line" : "area", + stacked: type !== "Load Average", min: 0, max: maxUnitValue, formatters: { @@ -128,7 +151,9 @@ export const InnerStatChart = ({
{(type === "Network Ingress" || type === "Network Egress") && unit ? `${(value ?? 0) / (maxUnitValue / 1024)} ${unit}` - : `${value?.toFixed(2)}%`} + : type === "Load Average" + ? `${(value ?? 0).toFixed(2)}` + : `${value?.toFixed(2)}%`}
), }, @@ -139,15 +164,17 @@ export const InnerStatChart = ({ return (