diff --git a/Cargo.lock b/Cargo.lock index 76e5f2a..45b22c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "doctest-file" version = "1.0.0" @@ -1079,8 +1090,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1090,9 +1103,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1246,6 +1261,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1267,13 +1283,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1281,6 +1300,87 @@ dependencies = [ "tracing", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1293,6 +1393,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indenter" version = "0.3.4" @@ -1348,6 +1469,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1579,6 +1716,7 @@ dependencies = [ "kube", "predicates", "ratatui", + "reqwest", "serde", "serde_json", "skim", @@ -1636,6 +1774,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -1666,6 +1810,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -2092,12 +2242,30 @@ dependencies = [ "winreg", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -2147,6 +2315,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -2171,6 +2394,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" @@ -2182,12 +2415,31 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.0" @@ -2363,6 +2615,44 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -2409,6 +2699,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2464,6 +2760,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2605,6 +2902,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2785,6 +3094,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2851,6 +3166,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "tempfile" @@ -3004,6 +3333,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -3135,8 +3474,10 @@ dependencies = [ "base64", "bitflags 2.11.0", "bytes", + "futures-util", "http", "http-body", + "iri-string", "mime", "pin-project-lite", "tower", @@ -3311,6 +3652,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -3426,6 +3785,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.111" @@ -3492,6 +3865,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3874,6 +4276,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -3894,12 +4325,66 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 9fe3255..fdab492 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ anyhow = "1" # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # CLI args + shell completions + man page generation clap = { version = "4", features = ["derive"] } diff --git a/README.md b/README.md index 5b1d3e3..7316e81 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - **Live watch** — resources appear and update in real time as the cluster changes; deleted resources show `[DELETED]` - **Unhealthy-first ordering** — `CrashLoopBackOff`, `Error`, `ImagePullBackOff` pods surface to the top automatically - **Color-coded status** — red for critical, yellow for warning, green for healthy, dimmed for deleted +- **Optional OpenCost column** — show per-pod / per-controller cost inline, with namespace or cluster aggregate in the header - **Multi-select bulk actions** — `tab` to select multiple resources, then describe/delete/restart them all at once - **Multi-cluster support** — watch all kubeconfig contexts simultaneously with `--all-contexts`, or switch contexts interactively with `ctrl-x` - **Context persistence** — last-used context is remembered across sessions @@ -202,10 +203,34 @@ kf --kubeconfig ~/alt.yaml --context staging # use an alternate kubeconfig --- +## Optional Cost Awareness + +Create `~/.config/kuberift/config.toml`: + +```toml +[cost] +enabled = true +opencost_endpoint = "" # optional manual override; empty = auto-discover in cluster +display_period = "daily" # hourly | daily | monthly +highlight_threshold = 10.0 # highlight resources above $/day +``` + +When cost mode is enabled, `kf` will: + +- detect `opencost` / `kubecost` services and query `allocation/compute` +- map pod allocations onto pods and controller-backed resources +- render a cost column without changing existing actions or output format +- disable cost for the current session if the endpoint is missing, slow, or unreachable + +The namespace filter (`-n production`) also shows the namespace aggregate in the header. + +--- + ## Config & State | File | Purpose | |------|---------| +| `~/.config/kuberift/config.toml` | Optional settings (`[cost]`) for OpenCost integration | | `~/.config/kuberift/last_context` | Last-used context, restored on next launch | | `$XDG_RUNTIME_DIR//preview-mode` | Preview mode state (0=describe, 1=yaml, 2=logs) | | `$XDG_RUNTIME_DIR//preview-toggle` | Shell script installed at startup for ctrl-p | diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..665c530 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,70 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum CostDisplayPeriod { + Hourly, + #[default] + Daily, + Monthly, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CostConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub opencost_endpoint: String, + #[serde(default)] + pub display_period: CostDisplayPeriod, + #[serde(default = "default_highlight_threshold")] + pub highlight_threshold: f64, +} + +impl Default for CostConfig { + fn default() -> Self { + Self { + enabled: false, + opencost_endpoint: String::new(), + display_period: CostDisplayPeriod::Daily, + highlight_threshold: default_highlight_threshold(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub cost: CostConfig, +} + +fn default_highlight_threshold() -> f64 { + 10.0 +} + +pub fn config_file_path() -> Option { + dirs::config_dir().map(|dir| dir.join("kuberift").join("config.toml")) +} + +pub fn load_config() -> AppConfig { + config_file_path() + .as_deref() + .and_then(load_config_from_path) + .unwrap_or_default() +} + +pub fn load_config_from_path(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + Some(parse_config(&raw, path)) +} + +pub fn parse_config(raw: &str, source: &Path) -> AppConfig { + toml::from_str(raw).unwrap_or_else(|err| { + eprintln!( + "[kuberift] warning: cannot parse config '{}': {err}", + source.display() + ); + AppConfig::default() + }) +} diff --git a/src/cost.rs b/src/cost.rs new file mode 100644 index 0000000..1c1e7c0 --- /dev/null +++ b/src/cost.rs @@ -0,0 +1,679 @@ +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; +use std::{collections::HashMap, process::Command, sync::Arc, time::Duration}; + +use crate::{ + config::{CostConfig, CostDisplayPeriod}, + items::{K8sItem, ResourceKind}, +}; + +const OPENCOST_QUERY_TIMEOUT_MS: u64 = 1500; +const OPENCOST_WINDOW: &str = "1d"; +const OPENCOST_AGGREGATE: &str = "pod"; + +#[derive(Debug, Clone)] +pub struct CostSnapshot { + period: CostDisplayPeriod, + highlight_threshold_daily: f64, + item_daily_costs: HashMap, + namespace_daily_costs: HashMap, + cluster_daily_cost: f64, +} + +impl CostSnapshot { + pub fn apply(&self, item: &mut K8sItem) { + let Some(daily_cost) = self.lookup_daily_cost(item.kind(), item.namespace(), item.name()) + else { + item.clear_cost(); + return; + }; + + item.set_cost( + self.render_cost(daily_cost), + daily_cost >= self.highlight_threshold_daily, + ); + } + + pub fn header_label(&self, namespace: Option<&str>) -> Option { + match namespace { + Some(ns) => self + .namespace_daily_costs + .get(ns) + .copied() + .map(|daily_cost| format!("ns-cost:{}", self.render_cost(daily_cost))), + None if self.cluster_daily_cost > 0.0 => Some(format!( + "cluster-cost:{}", + self.render_cost(self.cluster_daily_cost) + )), + None => None, + } + } + + fn lookup_daily_cost(&self, kind: ResourceKind, namespace: &str, name: &str) -> Option { + if kind == ResourceKind::Namespace { + return self.namespace_daily_costs.get(name).copied(); + } + + self.item_daily_costs + .get(&ResourceRef::new(kind, namespace, name)) + .copied() + } + + fn render_cost(&self, daily_cost: f64) -> String { + match self.period { + CostDisplayPeriod::Hourly => format!("${:.2}/h", daily_cost / 24.0), + CostDisplayPeriod::Daily => format!("${:.2}/d", daily_cost), + CostDisplayPeriod::Monthly => format!("${:.2}/mo", daily_cost * 30.0), + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct ResourceRef { + kind: ResourceKind, + namespace: String, + name: String, +} + +impl ResourceRef { + fn new(kind: ResourceKind, namespace: impl Into, name: impl Into) -> Self { + Self { + kind, + namespace: namespace.into(), + name: name.into(), + } + } +} + +#[derive(Debug, Clone)] +struct ProxyTarget { + namespace: String, + service: String, + port_name: Option, + port: i64, +} + +impl ProxyTarget { + fn raw_path(&self) -> String { + let svc_port = self.port_name.as_deref().map_or_else( + || format!("{}:{}", self.service, self.port), + |name| format!("{}:{}", self.service, name), + ); + + format!( + "/api/v1/namespaces/{}/services/{svc_port}/proxy/allocation/compute?window={OPENCOST_WINDOW}&aggregate={OPENCOST_AGGREGATE}", + self.namespace, + ) + } +} + +#[derive(Debug, Clone)] +struct PodOwnerIndex { + controllers: HashMap<(String, String), ResourceRef>, +} + +impl PodOwnerIndex { + fn controller_for(&self, namespace: &str, pod: &str) -> Option<&ResourceRef> { + self.controllers + .get(&(namespace.to_string(), pod.to_string())) + } +} + +#[derive(Debug, Deserialize)] +struct ServiceList { + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct ServiceItem { + metadata: Metadata, + spec: Option, +} + +#[derive(Debug, Deserialize)] +struct ServiceSpec { + ports: Option>, +} + +#[derive(Debug, Deserialize)] +struct ServicePort { + name: Option, + port: i64, +} + +#[derive(Debug, Deserialize)] +struct Metadata { + name: Option, + namespace: Option, + #[serde(rename = "ownerReferences")] + owner_references: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +struct OwnerReference { + kind: String, + name: String, + controller: Option, +} + +#[derive(Debug, Deserialize)] +struct PodList { + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct PodItem { + metadata: Metadata, +} + +#[derive(Debug, Deserialize)] +struct JobList { + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct JobItem { + metadata: Metadata, +} + +#[derive(Debug, Deserialize)] +struct AllocationResponse { + data: Vec>, +} + +#[derive(Debug, Deserialize)] +struct AllocationEntry { + name: Option, + #[serde(rename = "totalCost")] + total_cost: Option, + properties: Option, +} + +#[derive(Debug, Deserialize)] +struct AllocationProperties { + namespace: Option, + pod: Option, + controller: Option, + #[serde(rename = "controllerKind")] + controller_kind: Option, +} + +pub async fn prefetch_cost_snapshot( + context: &str, + kubeconfig: Option<&str>, + cost_config: &CostConfig, +) -> Option> { + if !cost_config.enabled { + return None; + } + + match tokio::time::timeout( + Duration::from_millis(OPENCOST_QUERY_TIMEOUT_MS), + fetch_snapshot(context, kubeconfig, cost_config), + ) + .await + { + Ok(Ok(snapshot)) => Some(Arc::new(snapshot)), + Ok(Err(err)) => { + eprintln!("[kuberift] warning: OpenCost disabled for this session: {err}"); + None + } + Err(_) => { + eprintln!( + "[kuberift] warning: OpenCost query timed out; disabling cost for this session" + ); + None + } + } +} + +async fn fetch_snapshot( + context: &str, + kubeconfig: Option<&str>, + cost_config: &CostConfig, +) -> Result { + let allocations_json = if cost_config.opencost_endpoint.trim().is_empty() { + let services_json = + run_kubectl_json(context, kubeconfig, ["get", "svc", "-A", "-o", "json"]).await?; + let proxy_target = detect_proxy_target(&services_json) + .ok_or_else(|| anyhow!("OpenCost service not found"))?; + run_kubectl_json( + context, + kubeconfig, + ["get", "--raw", &proxy_target.raw_path()], + ) + .await? + } else { + fetch_direct_allocation_json(&cost_config.opencost_endpoint).await? + }; + + let pods_json_fut = run_kubectl_json(context, kubeconfig, ["get", "pods", "-A", "-o", "json"]); + let jobs_json_fut = run_kubectl_json(context, kubeconfig, ["get", "jobs", "-A", "-o", "json"]); + let (pods_json, jobs_json) = tokio::join!(pods_json_fut, jobs_json_fut); + + build_snapshot_from_inputs( + &allocations_json, + pods_json.ok().as_deref(), + jobs_json.ok().as_deref(), + cost_config.display_period, + cost_config.highlight_threshold, + ) +} + +async fn fetch_direct_allocation_json(endpoint: &str) -> Result { + let base = endpoint.trim_end_matches('/'); + let url = if base.contains("/allocation/compute") { + base.to_string() + } else { + format!("{base}/allocation/compute?window={OPENCOST_WINDOW}&aggregate={OPENCOST_AGGREGATE}") + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(OPENCOST_QUERY_TIMEOUT_MS)) + .build() + .context("failed to build HTTP client")?; + + client + .get(url) + .send() + .await + .context("failed to reach configured OpenCost endpoint")? + .error_for_status() + .context("OpenCost endpoint returned an error status")? + .text() + .await + .context("failed to read OpenCost response body") +} + +async fn run_kubectl_json(context: &str, kubeconfig: Option<&str>, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let context = context.to_string(); + let kubeconfig = kubeconfig.map(str::to_string); + let args_vec: Vec = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect(); + + tokio::task::spawn_blocking(move || { + let mut cmd = Command::new("kubectl"); + if let Some(path) = kubeconfig.as_deref() { + cmd.arg("--kubeconfig").arg(path); + } + if !context.is_empty() { + cmd.arg("--context").arg(&context); + } + cmd.args(&args_vec); + + let output = cmd.output().context("failed to execute kubectl")?; + if !output.status.success() { + return Err(anyhow!( + "{}", + String::from_utf8_lossy(&output.stderr).trim().to_string() + )); + } + + String::from_utf8(output.stdout).context("kubectl output was not valid UTF-8") + }) + .await + .context("kubectl task join failed")? +} + +fn detect_proxy_target(raw: &str) -> Option { + let services: ServiceList = serde_json::from_str(raw).ok()?; + let candidates = [("opencost", "opencost"), ("opencost", ""), ("kubecost", "")]; + + for (name_match, namespace_match) in candidates { + for svc in &services.items { + let name = svc.metadata.name.as_deref()?; + let namespace = svc.metadata.namespace.as_deref()?; + if name == "kubernetes" { + continue; + } + if (!namespace_match.is_empty() && namespace != namespace_match) + || !name.contains(name_match) + { + continue; + } + + let port = pick_service_port(svc.spec.as_ref()?.ports.as_ref()?)?; + return Some(ProxyTarget { + namespace: namespace.to_string(), + service: name.to_string(), + port_name: port.name.clone(), + port: port.port, + }); + } + } + + None +} + +fn pick_service_port<'a>(ports: &'a [ServicePort]) -> Option<&'a ServicePort> { + ports + .iter() + .find(|port| { + port.name + .as_deref() + .is_some_and(|name| name.contains("http")) + }) + .or_else(|| ports.iter().find(|port| port.port == 9003)) + .or_else(|| ports.first()) +} + +fn build_pod_owner_index(pods_raw: Option<&str>, jobs_raw: Option<&str>) -> Result { + let mut job_to_cronjob: HashMap<(String, String), String> = HashMap::new(); + + if let Some(raw) = jobs_raw { + let jobs: JobList = serde_json::from_str(raw).context("failed to parse jobs JSON")?; + for job in jobs.items { + let namespace = job.metadata.namespace.unwrap_or_default(); + let Some(owner) = controller_owner(job.metadata.owner_references.as_deref()) else { + continue; + }; + if owner.kind == "CronJob" { + job_to_cronjob.insert( + (namespace, job.metadata.name.unwrap_or_default()), + owner.name, + ); + } + } + } + + let mut controllers = HashMap::new(); + if let Some(raw) = pods_raw { + let pods: PodList = serde_json::from_str(raw).context("failed to parse pods JSON")?; + for pod in pods.items { + let namespace = pod.metadata.namespace.unwrap_or_default(); + let pod_name = pod.metadata.name.unwrap_or_default(); + let Some(owner) = controller_owner(pod.metadata.owner_references.as_deref()) else { + continue; + }; + let Some(target) = resource_ref_for_owner(&namespace, &owner, &job_to_cronjob) else { + continue; + }; + controllers.insert((namespace, pod_name), target); + } + } + + Ok(PodOwnerIndex { controllers }) +} + +fn controller_owner(owners: Option<&[OwnerReference]>) -> Option { + owners? + .iter() + .find(|owner| owner.controller.unwrap_or(false)) + .cloned() +} + +fn resource_ref_for_owner( + namespace: &str, + owner: &OwnerReference, + job_to_cronjob: &HashMap<(String, String), String>, +) -> Option { + match owner.kind.as_str() { + "ReplicaSet" => infer_deployment_name(&owner.name) + .map(|deployment| ResourceRef::new(ResourceKind::Deployment, namespace, deployment)), + "Deployment" => Some(ResourceRef::new( + ResourceKind::Deployment, + namespace, + &owner.name, + )), + "StatefulSet" => Some(ResourceRef::new( + ResourceKind::StatefulSet, + namespace, + &owner.name, + )), + "DaemonSet" => Some(ResourceRef::new( + ResourceKind::DaemonSet, + namespace, + &owner.name, + )), + "Job" => job_to_cronjob + .get(&(namespace.to_string(), owner.name.clone())) + .map(|cronjob| ResourceRef::new(ResourceKind::CronJob, namespace, cronjob)) + .or_else(|| Some(ResourceRef::new(ResourceKind::Job, namespace, &owner.name))), + "CronJob" => Some(ResourceRef::new( + ResourceKind::CronJob, + namespace, + &owner.name, + )), + _ => None, + } +} + +fn infer_deployment_name(replica_set_name: &str) -> Option { + let (deployment, hash) = replica_set_name.rsplit_once('-')?; + if hash.len() >= 6 && hash.chars().all(|ch| ch.is_ascii_alphanumeric()) { + Some(deployment.to_string()) + } else { + None + } +} + +pub fn build_snapshot_from_inputs( + allocations_raw: &str, + pods_raw: Option<&str>, + jobs_raw: Option<&str>, + period: CostDisplayPeriod, + highlight_threshold: f64, +) -> Result { + let response: AllocationResponse = + serde_json::from_str(allocations_raw).context("failed to parse OpenCost response")?; + let pod_owner_index = build_pod_owner_index(pods_raw, jobs_raw)?; + + let mut item_daily_costs = HashMap::new(); + let mut namespace_daily_costs = HashMap::new(); + let mut cluster_daily_cost = 0.0; + + for bucket in response.data { + for (key, entry) in bucket { + let Some(daily_cost) = entry.total_cost else { + continue; + }; + if daily_cost <= 0.0 { + continue; + } + + let Some(namespace) = allocation_namespace(&key, &entry) else { + continue; + }; + if namespace == "__idle__" { + continue; + } + + let Some(pod_name) = allocation_pod_name(&key, &entry) else { + continue; + }; + + cluster_daily_cost += daily_cost; + *namespace_daily_costs + .entry(namespace.clone()) + .or_insert(0.0) += daily_cost; + *item_daily_costs + .entry(ResourceRef::new(ResourceKind::Pod, &namespace, &pod_name)) + .or_insert(0.0) += daily_cost; + + if let Some(controller) = + allocation_controller_ref(&namespace, &entry, &pod_owner_index, &pod_name) + { + *item_daily_costs.entry(controller).or_insert(0.0) += daily_cost; + } + } + } + + Ok(CostSnapshot { + period, + highlight_threshold_daily: highlight_threshold, + item_daily_costs, + namespace_daily_costs, + cluster_daily_cost, + }) +} + +fn allocation_namespace(key: &str, entry: &AllocationEntry) -> Option { + entry + .properties + .as_ref() + .and_then(|props| props.namespace.clone()) + .or_else(|| key.split('/').nth_back(1).map(ToOwned::to_owned)) +} + +fn allocation_pod_name(key: &str, entry: &AllocationEntry) -> Option { + entry + .properties + .as_ref() + .and_then(|props| props.pod.clone()) + .or_else(|| entry.name.clone()) + .or_else(|| key.rsplit('/').next().map(ToOwned::to_owned)) + .filter(|name| name != "__idle__") +} + +fn allocation_controller_ref( + namespace: &str, + entry: &AllocationEntry, + pod_owner_index: &PodOwnerIndex, + pod_name: &str, +) -> Option { + if let Some(props) = entry.properties.as_ref() { + if let (Some(controller), Some(kind)) = (&props.controller, &props.controller_kind) { + let kind = match kind.as_str() { + "deployment" | "Deployment" => ResourceKind::Deployment, + "statefulset" | "StatefulSet" => ResourceKind::StatefulSet, + "daemonset" | "DaemonSet" => ResourceKind::DaemonSet, + "job" | "Job" => ResourceKind::Job, + "cronjob" | "CronJob" => ResourceKind::CronJob, + _ => return None, + }; + + return Some(ResourceRef::new(kind, namespace, controller)); + } + } + + pod_owner_index.controller_for(namespace, pod_name).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_proxy_target_prefers_named_http_port() { + let services = r#" + { + "items": [ + { + "metadata": { "name": "opencost", "namespace": "opencost" }, + "spec": { + "ports": [ + { "name": "http", "port": 9003 }, + { "name": "metrics", "port": 9004 } + ] + } + } + ] + }"#; + + let target = detect_proxy_target(services).expect("expected target"); + assert_eq!(target.namespace, "opencost"); + assert_eq!(target.service, "opencost"); + assert_eq!(target.port_name.as_deref(), Some("http")); + assert_eq!( + target.raw_path(), + "/api/v1/namespaces/opencost/services/opencost:http/proxy/allocation/compute?window=1d&aggregate=pod" + ); + } + + #[test] + fn build_snapshot_maps_pod_deployment_and_namespace_totals() { + let allocations = r#" + { + "data": [ + { + "cluster-one/default/api-7d9f8b6c5-xk2lp": { + "name": "api-7d9f8b6c5-xk2lp", + "properties": { + "namespace": "default", + "pod": "api-7d9f8b6c5-xk2lp" + }, + "totalCost": 2.5 + }, + "cluster-one/default/api-7d9f8b6c5-jm4rn": { + "name": "api-7d9f8b6c5-jm4rn", + "properties": { + "namespace": "default", + "pod": "api-7d9f8b6c5-jm4rn" + }, + "totalCost": 3.5 + }, + "__idle__": { + "name": "__idle__", + "properties": { + "namespace": "__idle__" + }, + "totalCost": 99.0 + } + } + ] + }"#; + let pods = r#" + { + "items": [ + { + "metadata": { + "name": "api-7d9f8b6c5-xk2lp", + "namespace": "default", + "ownerReferences": [ + { "kind": "ReplicaSet", "name": "api-7d9f8b6c5", "controller": true } + ] + } + }, + { + "metadata": { + "name": "api-7d9f8b6c5-jm4rn", + "namespace": "default", + "ownerReferences": [ + { "kind": "ReplicaSet", "name": "api-7d9f8b6c5", "controller": true } + ] + } + } + ] + }"#; + + let snapshot = build_snapshot_from_inputs( + allocations, + Some(pods), + None, + CostDisplayPeriod::Monthly, + 4.0, + ) + .expect("snapshot"); + + let mut pod = K8sItem::new( + ResourceKind::Pod, + "default", + "api-7d9f8b6c5-xk2lp", + "Running", + "1d", + "", + ); + snapshot.apply(&mut pod); + assert_eq!(pod.cost_label(), Some("$75.00/mo")); + + let mut deployment = + K8sItem::new(ResourceKind::Deployment, "default", "api", "2/2", "1d", ""); + snapshot.apply(&mut deployment); + assert_eq!(deployment.cost_label(), Some("$180.00/mo")); + + let mut namespace = + K8sItem::new(ResourceKind::Namespace, "", "default", "Active", "1d", ""); + snapshot.apply(&mut namespace); + assert_eq!(namespace.cost_label(), Some("$180.00/mo")); + assert_eq!( + snapshot.header_label(Some("default")).as_deref(), + Some("ns-cost:$180.00/mo") + ); + } +} diff --git a/src/items.rs b/src/items.rs index 07f9a72..28a9092 100644 --- a/src/items.rs +++ b/src/items.rs @@ -172,6 +172,8 @@ pub struct K8sItem { age: String, /// The cluster context this resource belongs to (empty in single-cluster mode). context: String, + cost_label: String, + cost_highlighted: bool, } impl K8sItem { @@ -190,6 +192,8 @@ impl K8sItem { status: status.into(), age: age.into(), context: context.into(), + cost_label: String::new(), + cost_highlighted: false, } } @@ -209,6 +213,20 @@ impl K8sItem { &self.context } + pub fn set_cost(&mut self, label: impl Into, highlighted: bool) { + self.cost_label = label.into(); + self.cost_highlighted = highlighted; + } + + pub fn clear_cost(&mut self) { + self.cost_label.clear(); + self.cost_highlighted = false; + } + + pub fn cost_label(&self) -> Option<&str> { + (!self.cost_label.is_empty()).then_some(self.cost_label.as_str()) + } + /// Color the status string based on health — delegates to `StatusHealth`. pub fn status_color(&self) -> Color { StatusHealth::classify(&self.status).color() @@ -265,13 +283,14 @@ impl SkimItem for K8sItem { }; let name_truncated = truncate_name(&self.name, 31); Cow::Owned(format!( - "{:<8} {}{}{} {} {}", + "{:<8} {}{}{} {} {} {}", self.kind.as_str(), ctx_prefix, ns_prefix, name_truncated, self.status, self.age, + self.cost_label, )) } @@ -316,6 +335,16 @@ impl SkimItem for K8sItem { self.age.clone(), Style::default().fg(Color::DarkGray), )); + let cost_col = self.cost_label().map_or_else( + || " ".to_string(), + |label| format!(" {:>10}", label), + ); + let cost_color = if self.cost_highlighted { + Color::LightRed + } else { + Color::LightGreen + }; + spans.push(Span::styled(cost_col, Style::default().fg(cost_color))); Line::from(spans) } diff --git a/src/k8s/resources.rs b/src/k8s/resources.rs index 2a5d96f..713bf3c 100644 --- a/src/k8s/resources.rs +++ b/src/k8s/resources.rs @@ -31,6 +31,7 @@ use std::{ }; use tokio::sync::Notify; +use crate::cost::CostSnapshot; use crate::items::{K8sItem, ResourceKind}; /// All resource kinds to watch when no filter is given. @@ -65,6 +66,7 @@ pub async fn watch_resources( context: &str, namespace: Option<&str>, label_selector: Option<&str>, + cost_snapshot: Option>, ) -> Result<()> { let total_watchers = kinds.len(); let global_init: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -77,12 +79,18 @@ pub async fn watch_resources( let global_init = global_init.clone(); let tx_coord = tx.clone(); let all_init_done = all_init_done.clone(); + let cost_snapshot = cost_snapshot.clone(); tokio::spawn(async move { tokio::select! { () = all_init_done.notified() => {} () = tokio::time::sleep(Duration::from_secs(8)) => {} } let mut buf = global_init.lock().unwrap(); + if let Some(snapshot) = cost_snapshot.as_deref() { + for item in buf.iter_mut() { + snapshot.apply(item); + } + } buf.sort_by_key(|item| std::cmp::Reverse(status_priority(item.status()))); let sorted: Vec> = buf .drain(..) @@ -106,6 +114,7 @@ pub async fn watch_resources( let gi = global_init.clone(); let dc = done_count.clone(); let aid = all_init_done.clone(); + let cost_state = cost_snapshot.clone(); tasks.push(tokio::spawn(async move { let result = match k { @@ -122,6 +131,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -138,6 +148,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -154,6 +165,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -170,6 +182,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -186,6 +199,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -202,6 +216,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -218,6 +233,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -234,6 +250,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -251,6 +268,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -267,6 +285,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -284,6 +303,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -300,6 +320,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -316,6 +337,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -332,6 +354,7 @@ pub async fn watch_resources( dc, total_watchers, aid, + cost_state, ) .await } @@ -379,6 +402,7 @@ async fn watch_typed( done_count: Arc, total_watchers: usize, all_init_done: Arc, + cost_snapshot: Option>, ) -> Result<()> where T: Resource + DeserializeOwned + Clone + Send + Sync + Debug + 'static, @@ -412,7 +436,14 @@ where // ── Existing object during initial list ─────────────────────────── Ok(watcher::Event::InitApply(r)) => { - let item = make_item(&r, kind, &status_fn, false, &context); + let item = make_item( + &r, + kind, + &status_fn, + false, + &context, + cost_snapshot.as_deref(), + ); if in_init { init_batch.push(item); } else { @@ -461,7 +492,14 @@ where // ── Live add / update ───────────────────────────────────────────── Ok(watcher::Event::Apply(r)) => { - let item = make_item(&r, kind, &status_fn, false, &context); + let item = make_item( + &r, + kind, + &status_fn, + false, + &context, + cost_snapshot.as_deref(), + ); if tx .send(vec![Arc::new(item) as Arc]) .is_err() @@ -472,7 +510,14 @@ where // ── Live deletion ───────────────────────────────────────────────── Ok(watcher::Event::Delete(r)) => { - let item = make_item(&r, kind, &status_fn, true, &context); + let item = make_item( + &r, + kind, + &status_fn, + true, + &context, + cost_snapshot.as_deref(), + ); if tx .send(vec![Arc::new(item) as Arc]) .is_err() @@ -499,6 +544,7 @@ fn make_item( status_fn: &impl Fn(&T) -> String, deleted: bool, context: &str, + cost_snapshot: Option<&CostSnapshot>, ) -> K8sItem where T: Resource, @@ -511,7 +557,11 @@ where status_fn(r) }; let age = resource_age(r.meta()); - K8sItem::new(kind, ns, name, status, age, context) + let mut item = K8sItem::new(kind, ns, name, status, age, context); + if let Some(snapshot) = cost_snapshot { + snapshot.apply(&mut item); + } + item } // ─── Status priority (lower = shown first) ─────────────────────────────────── diff --git a/src/lib.rs b/src/lib.rs index d6097a4..810a3b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,5 +13,7 @@ pub mod actions; pub mod cli; +pub mod config; +pub mod cost; pub mod items; pub mod k8s; diff --git a/src/main.rs b/src/main.rs index 695c33b..ac1f97c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,8 @@ use kuberift::actions::{ action_rollout_restart, action_yaml, install_preview_toggle, preview_toggle_path, runtime_dir, }; use kuberift::cli::Args; +use kuberift::config::{load_config, AppConfig}; +use kuberift::cost::prefetch_cost_snapshot; use kuberift::items::{K8sItem, ResourceKind}; #[allow(unused_imports)] use kuberift::k8s::{ @@ -65,6 +67,7 @@ async fn main() -> Result<()> { install_preview_toggle(); let kinds: Vec = args.resource_filter().unwrap_or_else(|| ALL_KINDS.to_vec()); + let config = load_config(); let kind_label = if kinds.len() == 1 { kinds[0].as_str().to_string() @@ -73,19 +76,20 @@ async fn main() -> Result<()> { }; if args.all_contexts { - run_all_contexts(&args, &kinds, &kind_label) + run_all_contexts(&args, &kinds, &kind_label, &config).await } else { - run_single_context(&args, &kinds, &kind_label, args.read_only) + run_single_context(&args, &kinds, &kind_label, args.read_only, &config).await } } // ─── Single-cluster mode (with ctrl-x context switching) ───────────────────── -fn run_single_context( +async fn run_single_context( args: &Args, kinds: &[ResourceKind], kind_label: &str, read_only: bool, + config: &AppConfig, ) -> Result<()> { let mut active_ctx = args .context @@ -97,6 +101,7 @@ fn run_single_context( let label_selector = args.label.as_deref(); loop { + let cost_snapshot = prefetch_cost_snapshot(&active_ctx, kubeconfig, &config.cost).await; let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded(); let ctx_for_watcher = active_ctx.clone(); @@ -105,6 +110,7 @@ fn run_single_context( let kubeconfig_owned = kubeconfig.map(str::to_string); let namespace_owned = namespace.map(str::to_string); let label_owned = label_selector.map(str::to_string); + let cost_snapshot_owned = cost_snapshot.clone(); tokio::spawn(async move { match build_client_for_context(&ctx_for_watcher, kubeconfig_owned.as_deref()).await { Ok(client) => { @@ -115,6 +121,7 @@ fn run_single_context( "", namespace_owned.as_deref(), label_owned.as_deref(), + cost_snapshot_owned, ) .await { @@ -132,7 +139,17 @@ fn run_single_context( drop(tx); - let options = build_skim_options(&active_ctx, kind_label, true, read_only, namespace)?; + let cost_header = cost_snapshot + .as_ref() + .and_then(|snapshot| snapshot.header_label(namespace)); + let options = build_skim_options( + &active_ctx, + kind_label, + true, + read_only, + namespace, + cost_header.as_deref(), + )?; let output = Skim::run_with(options, Some(rx)).map_err(|e| anyhow::anyhow!("{e}"))?; if output.is_abort { @@ -159,7 +176,12 @@ fn run_single_context( // ─── Multi-cluster mode (--all-contexts) ───────────────────────────────────── -fn run_all_contexts(args: &Args, kinds: &[ResourceKind], kind_label: &str) -> Result<()> { +async fn run_all_contexts( + args: &Args, + kinds: &[ResourceKind], + kind_label: &str, + config: &AppConfig, +) -> Result<()> { let contexts = list_contexts(); if contexts.is_empty() { eprintln!("[kuberift] No contexts found in kubeconfig."); @@ -178,10 +200,17 @@ fn run_all_contexts(args: &Args, kinds: &[ResourceKind], kind_label: &str) -> Re let kubeconfig_owned = kubeconfig.map(str::to_string); let namespace_owned = namespace.map(str::to_string); let label_owned = label_selector.map(str::to_string); + let cost_config = config.cost.clone(); tokio::spawn(async move { match build_client_for_context(&ctx_clone, kubeconfig_owned.as_deref()).await { Ok(client) => { + let cost_snapshot = prefetch_cost_snapshot( + &ctx_clone, + kubeconfig_owned.as_deref(), + &cost_config, + ) + .await; if let Err(e) = watch_resources( client, tx_clone, @@ -189,6 +218,7 @@ fn run_all_contexts(args: &Args, kinds: &[ResourceKind], kind_label: &str) -> Re &ctx_clone, namespace_owned.as_deref(), label_owned.as_deref(), + cost_snapshot, ) .await { @@ -205,7 +235,14 @@ fn run_all_contexts(args: &Args, kinds: &[ResourceKind], kind_label: &str) -> Re drop(tx); let ctx_label = "all-contexts"; - let options = build_skim_options(ctx_label, kind_label, false, args.read_only, namespace)?; + let options = build_skim_options( + ctx_label, + kind_label, + false, + args.read_only, + namespace, + None, + )?; let output = Skim::run_with(options, Some(rx)).map_err(|e| anyhow::anyhow!("{e}"))?; if output.is_abort { @@ -272,6 +309,7 @@ fn build_skim_options( show_ctx_switch: bool, read_only: bool, namespace: Option<&str>, + cost_header: Option<&str>, ) -> Result { let ctx_hint = if show_ctx_switch { " ctrl-x switch-ctx" @@ -280,6 +318,9 @@ fn build_skim_options( }; let ro_hint = if read_only { " [READ-ONLY]" } else { "" }; let ns_hint = namespace.map(|n| format!(" ns:{n}")).unwrap_or_default(); + let cost_hint = cost_header + .map(|cost| format!(" {cost}")) + .unwrap_or_default(); Ok(SkimOptionsBuilder::default() .multi(true) @@ -287,7 +328,7 @@ fn build_skim_options( .preview_window("right:50%") .height("60%") .header(format!( - "KubeRift ctx:{ctx_label}{ns_hint} res:{kind_label}{ro_hint}\n\ + "KubeRift ctx:{ctx_label}{ns_hint}{cost_hint} res:{kind_label}{ro_hint}\n\ select describe ctrl-l logs ctrl-e exec \ ctrl-d delete ctrl-f forward ctrl-r restart ctrl-y yaml \ ctrl-p cycle-preview{ctx_hint}", diff --git a/tests/config_test.rs b/tests/config_test.rs new file mode 100644 index 0000000..875edee --- /dev/null +++ b/tests/config_test.rs @@ -0,0 +1,30 @@ +use std::path::Path; + +use kuberift::config::{parse_config, CostDisplayPeriod}; + +#[test] +fn parse_config_uses_defaults_when_cost_section_missing() { + let cfg = parse_config("", Path::new("config.toml")); + assert!(!cfg.cost.enabled); + assert_eq!(cfg.cost.display_period, CostDisplayPeriod::Daily); + assert_eq!(cfg.cost.highlight_threshold, 10.0); +} + +#[test] +fn parse_config_reads_cost_section() { + let cfg = parse_config( + r#" + [cost] + enabled = true + opencost_endpoint = "http://127.0.0.1:9003" + display_period = "monthly" + highlight_threshold = 24.5 + "#, + Path::new("config.toml"), + ); + + assert!(cfg.cost.enabled); + assert_eq!(cfg.cost.opencost_endpoint, "http://127.0.0.1:9003"); + assert_eq!(cfg.cost.display_period, CostDisplayPeriod::Monthly); + assert_eq!(cfg.cost.highlight_threshold, 24.5); +} diff --git a/tests/items_test.rs b/tests/items_test.rs index 54c26f9..3391360 100644 --- a/tests/items_test.rs +++ b/tests/items_test.rs @@ -269,6 +269,16 @@ fn k8s_item_empty_namespace_and_context() { assert_eq!(item.context(), ""); } +#[test] +fn k8s_item_cost_round_trip() { + let mut item = K8sItem::new(ResourceKind::Deployment, "default", "api", "2/2", "1d", ""); + assert_eq!(item.cost_label(), None); + item.set_cost("$12.00/d", true); + assert_eq!(item.cost_label(), Some("$12.00/d")); + item.clear_cost(); + assert_eq!(item.cost_label(), None); +} + // ── K8sItem::status_color ───────────────────────────────────────────────────── #[test]