From 51cc028200f4cec5649e0ce8c5d8bbdcf91da5e6 Mon Sep 17 00:00:00 2001 From: Markus Schirp Date: Mon, 12 Jan 2026 21:49:31 +0000 Subject: [PATCH] Add greenhell lambda support --- Cargo.lock | 370 +++++++++++++++++++++++++++++----- Cargo.toml | 10 + greenhell/Cargo.toml | 14 ++ greenhell/src/aws.rs | 19 ++ greenhell/src/lib.rs | 4 + greenhell/src/main.rs | 146 +++++++++++++- greenhell/src/secrets.rs | 87 ++++++++ greenhell/src/stack.rs | 195 ++++++++++++++++++ greenhell/src/webhook.rs | 215 ++++++++++++++++++++ stack-deploy/Cargo.toml | 2 + stack-deploy/src/cli.rs | 11 + stack-deploy/src/lib.rs | 1 + stack-deploy/src/logs.rs | 2 + stack-deploy/src/logs/cli.rs | 46 +++++ stack-deploy/src/logs/tail.rs | 90 +++++++++ 15 files changed, 1154 insertions(+), 58 deletions(-) create mode 100644 greenhell/src/aws.rs create mode 100644 greenhell/src/secrets.rs create mode 100644 greenhell/src/stack.rs create mode 100644 greenhell/src/webhook.rs create mode 100644 stack-deploy/src/logs.rs create mode 100644 stack-deploy/src/logs/cli.rs create mode 100644 stack-deploy/src/logs/tail.rs diff --git a/Cargo.lock b/Cargo.lock index a7efc5d7..a3639c6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,8 @@ checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" dependencies = [ "aws-credential-types", "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", "aws-smithy-http", @@ -202,11 +204,14 @@ dependencies = [ "aws-types", "bytes", "fastrand", + "hex", "http 1.4.0", + "ring", "time", "tokio", "tracing", "url", + "zeroize", ] [[package]] @@ -292,6 +297,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-cloudwatchlogs" +version = "1.115.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2becbc454e9480856a012b98b65a274d0f658dc26353503e91a4885e0dcb674f" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-lambda" version = "1.113.0" @@ -318,9 +347,9 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.120.0" +version = "1.119.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06673901e961f20fa8d7da907da48f7ad6c1b383e3726c22bd418900f015abe1" +checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" dependencies = [ "aws-credential-types", "aws-runtime", @@ -330,7 +359,6 @@ dependencies = [ "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-json", - "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -374,6 +402,52 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-sso" +version = "1.92.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d63bd2bdeeb49aa3f9b00c15e18583503b778b2e792fc06284d54e7d5b6566" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.94.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532d93574bf731f311bafb761366f9ece345a0416dbcc273d81d6d1a1205239b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sts" version = "1.96.0" @@ -434,9 +508,9 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.63.13" +version = "0.63.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23374b9170cbbcc6f5df8dc5ebb9b6c5c28a3c8f599f0e8b8b10eb6f4a5c6e74" +checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" dependencies = [ "aws-smithy-http", "aws-smithy-types", @@ -485,6 +559,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-smithy-http-client" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + [[package]] name = "aws-smithy-json" version = "0.61.9" @@ -521,6 +625,7 @@ checksum = "bb5b6167fcdf47399024e81ac08e795180c576a20e4d4ce67949f9a88ae37dc1" dependencies = [ "aws-smithy-async", "aws-smithy-http", + "aws-smithy-http-client", "aws-smithy-observability", "aws-smithy-runtime-api", "aws-smithy-types", @@ -851,9 +956,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -866,14 +971,15 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc-fast" -version = "1.9.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" dependencies = [ "crc", "digest", + "rand 0.9.2", + "regex", "rustversion", - "spin 0.10.0", ] [[package]] @@ -1022,7 +1128,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1093,7 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1160,7 +1266,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin", ] [[package]] @@ -1169,6 +1275,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1313,21 +1425,35 @@ name = "greenhell" version = "0.0.1" dependencies = [ "async-stream", + "aws-config", + "aws-sdk-cloudformation", + "aws-sdk-cloudwatchlogs", + "aws-sdk-s3", + "aws-sdk-secretsmanager", + "base64", "chrono", "clap", "cmd-proc", "env_logger", "futures-util", "git-proc", + "hex", + "hmac", "http 1.4.0", "itertools", "log", "mhttp", + "mlambda", "nom 8.0.0", "nom-language", + "rcgen", "reqwest", "serde", "serde_json", + "sha2", + "stack-deploy", + "stratosphere", + "strum", "thiserror 2.0.18", "tokio", "tower", @@ -1335,6 +1461,55 @@ dependencies = [ "url", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1343,7 +1518,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1352,7 +1527,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -1461,6 +1636,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.3.0" @@ -1477,6 +1658,30 @@ dependencies = [ "serde", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1487,6 +1692,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -1498,6 +1704,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1505,12 +1726,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.36", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -1527,12 +1749,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -1671,7 +1893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1803,7 +2025,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -1894,11 +2116,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -2406,8 +2628,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.36", + "socket2 0.6.1", "thiserror 2.0.18", "tokio", "tracing", @@ -2427,7 +2649,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2445,9 +2667,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.1", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2633,15 +2855,15 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -2649,7 +2871,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -2727,7 +2949,19 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", ] [[package]] @@ -2740,7 +2974,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -2778,14 +3012,14 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.9", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2794,6 +3028,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -2842,6 +3086,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -3006,6 +3260,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -3025,12 +3289,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" - [[package]] name = "spki" version = "0.7.3" @@ -3069,13 +3327,13 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", "percent-encoding", - "rustls", + "rustls 0.23.36", "serde", "serde_json", "sha2", @@ -3235,6 +3493,7 @@ version = "0.0.1" dependencies = [ "aws-config", "aws-sdk-cloudformation", + "aws-sdk-cloudwatchlogs", "aws-sdk-lambda", "aws-sdk-s3", "aws-sdk-secretsmanager", @@ -3251,6 +3510,7 @@ dependencies = [ "sha2", "stratosphere", "strum", + "thiserror 2.0.18", "tokio", "url", "uuid", @@ -3491,7 +3751,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -3507,13 +3767,23 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.36", "tokio", ] @@ -3950,7 +4220,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 80d2cf30..7dc0ad57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,12 @@ rust-version = "1.92" [workspace.dependencies] async-stream = "0.3" +aws-config = { version = "1", default-features = false } +base64 = "0.22" +aws-sdk-cloudformation = { version = "1", default-features = false } +aws-sdk-cloudwatchlogs = { version = "1", default-features = false } +aws-sdk-s3 = { version = "1", default-features = false } +aws-sdk-secretsmanager = { version = "1", default-features = false } chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive"] } cmd-proc = { version = "0.1.0", path = "cmd-proc" } @@ -33,11 +39,13 @@ dirs = "6" env_logger = "0.11" git-proc = { version = "0.0.1", path = "git-proc" } hex = "0.4.3" +hmac = "0.12" http = "1.3" indoc = "2" itertools = "0.14" log = "0.4" mhttp = { path = "mhttp" } +mlambda = { path = "mlambda" } mmigration = { path = "mmigration" } nom = "8" nom-language = "0.1" @@ -49,6 +57,7 @@ prettyplease = "0.2" proc-macro2 = "1" quote = "1" rand = "0.9" +rcgen = "0.13" regex-lite = "0.1.6" reqwest = { version = "0.13", features = ["json", "query", "rustls"], default-features = false } semver = "1" @@ -56,6 +65,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision", "indexmap"] } serde_path_to_error = "0.1" sha2 = "0.10" +stack-deploy = { version = "0.0.1", path = "./stack-deploy" } sqlx = { version = "0.9.0-alpha.1", git = "https://github.com/mbj/sqlx", rev = "f795fe994a6973ebe872c8c619706a4244c65d54", features = ["postgres", "runtime-tokio", "tls-rustls"] } stratosphere = { version = "0.0.4", path = "./stratosphere" } stratosphere-core = { version = "0.0.4", path = "./stratosphere-core" } diff --git a/greenhell/Cargo.toml b/greenhell/Cargo.toml index 24f4e2f7..bd61d699 100644 --- a/greenhell/Cargo.toml +++ b/greenhell/Cargo.toml @@ -16,21 +16,35 @@ workspace = true [dependencies] async-stream.workspace = true +aws-config = { workspace = true, features = ["behavior-version-latest", "rustls", "rt-tokio", "sso"] } +base64.workspace = true +aws-sdk-cloudformation = { workspace = true, features = ["behavior-version-latest", "rustls"] } +aws-sdk-cloudwatchlogs = { workspace = true, features = ["behavior-version-latest", "rustls"] } +aws-sdk-s3 = { workspace = true, features = ["behavior-version-latest", "rustls"] } +aws-sdk-secretsmanager = { workspace = true, features = ["behavior-version-latest", "rustls"] } chrono.workspace = true clap.workspace = true cmd-proc.workspace = true env_logger.workspace = true git-proc.workspace = true futures-util.workspace = true +hex.workspace = true +hmac.workspace = true http.workspace = true itertools.workspace = true log.workspace = true mhttp.workspace = true +mlambda.workspace = true nom.workspace = true nom-language.workspace = true +rcgen.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true +stack-deploy.workspace = true +stratosphere = { workspace = true, features = ["aws_iam", "aws_lambda", "aws_logs", "aws_s3", "aws_secretsmanager"] } +strum.workspace = true thiserror.workspace = true tokio.workspace = true tower.workspace = true diff --git a/greenhell/src/aws.rs b/greenhell/src/aws.rs new file mode 100644 index 00000000..d534524d --- /dev/null +++ b/greenhell/src/aws.rs @@ -0,0 +1,19 @@ +pub struct Clients { + pub cloudformation: aws_sdk_cloudformation::Client, + pub cloudwatchlogs: aws_sdk_cloudwatchlogs::Client, + pub s3: aws_sdk_s3::Client, + pub secretsmanager: aws_sdk_secretsmanager::Client, +} + +impl Clients { + pub async fn load() -> Self { + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + + Self { + cloudformation: aws_sdk_cloudformation::Client::new(&config), + cloudwatchlogs: aws_sdk_cloudwatchlogs::Client::new(&config), + s3: aws_sdk_s3::Client::new(&config), + secretsmanager: aws_sdk_secretsmanager::Client::new(&config), + } + } +} diff --git a/greenhell/src/lib.rs b/greenhell/src/lib.rs index 022f0cc3..60bcccd1 100644 --- a/greenhell/src/lib.rs +++ b/greenhell/src/lib.rs @@ -1,7 +1,11 @@ +pub mod aws; pub mod cli; pub mod cli_token; pub mod evaluate; pub mod events; pub mod github; pub mod parse; +pub mod secrets; +pub mod stack; pub mod watch; +pub mod webhook; diff --git a/greenhell/src/main.rs b/greenhell/src/main.rs index ad136335..6c105c11 100644 --- a/greenhell/src/main.rs +++ b/greenhell/src/main.rs @@ -100,6 +100,27 @@ enum Command { #[clap(long)] dry_run: bool, }, + /// Manage secrets + Secrets { + #[clap(subcommand)] + command: stack_deploy::secrets::cli::Command, + }, + /// Manage CloudFormation stack + Stack { + #[clap(subcommand)] + command: stack_deploy::cli::Command, + }, + /// Lambda deployment + Lambda { + #[clap(subcommand)] + command: stack_deploy::lambda::deploy::cli::Command, + }, + /// Stream Lambda logs + Logs { + /// Filter pattern for log events + #[arg(long)] + filter: Option, + }, } /// Target for evaluation: either a branch or a pull request number. @@ -360,22 +381,131 @@ impl App { } } } + Command::Secrets { command } => { + let aws = greenhell::aws::Clients::load().await; + command.run(&aws.cloudformation, &aws.secretsmanager).await; + } + Command::Stack { command } => { + let aws = greenhell::aws::Clients::load().await; + let registry = greenhell::stack::registry(); + + let config = stack_deploy::cli::Config { + cloudformation: &aws.cloudformation, + cloudwatchlogs: &aws.cloudwatchlogs, + registry: ®istry, + template_uploader: None, + }; + + command.run(&config).await; + } + Command::Lambda { command } => { + let target = stack_deploy::lambda::deploy::Target { + binary_name: stack_deploy::lambda::deploy::BinaryName("greenhell".into()), + build_target: stack_deploy::lambda::deploy::BuildTarget( + "x86_64-unknown-linux-musl".into(), + ), + build_type: stack_deploy::lambda::deploy::BuildType::Release, + extra_files: std::collections::BTreeMap::new(), + }; + + match command { + stack_deploy::lambda::deploy::cli::Command::Build => { + target.build(); + } + _ => { + let aws = greenhell::aws::Clients::load().await; + let registry = greenhell::stack::registry(); + + let config = stack_deploy::lambda::deploy::cli::Config { + cloudformation: &aws.cloudformation, + parameter_key: stack_deploy::types::ParameterKey::from("LambdaS3Key"), + registry, + s3: &aws.s3, + s3_bucket_source: + stack_deploy::lambda::deploy::S3BucketSource::StackOutput { + stack_name: stack_deploy::types::StackName::from( + greenhell::stack::artifacts::STACK_NAME, + ), + output_key: stack_deploy::types::OutputKey::from( + "LambdaBucketName", + ), + }, + target, + template_uploader: None, + }; + + command.run(&config).await; + } + } + } + Command::Logs { filter } => { + let aws = greenhell::aws::Clients::load().await; + + let log_group_arn = stack_deploy::stack::read_stack_output( + &aws.cloudformation, + &stack_deploy::types::StackName::from(greenhell::stack::webhook::STACK_NAME), + &stack_deploy::types::OutputKey::from( + greenhell::stack::webhook::LOG_GROUP_ARN_OUTPUT, + ), + ) + .await; + + let config = stack_deploy::logs::tail::Config { + client: &aws.cloudwatchlogs, + log_group_arn: &log_group_arn, + log_stream_names: vec![], + filter_pattern: filter.clone(), + }; + + if let Err(error) = stack_deploy::logs::tail::run(&config).await { + log::error!("{error}"); + } + } } Ok(()) } } -#[tokio::main(flavor = "current_thread")] -async fn main() { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - - let app = ::parse(); - if let Err(error) = app.run().await { - log::error!("{error}"); - std::process::exit(1); +fn main() { + let binary_name = std::env::args() + .next() + .and_then(|path| path.rsplit('/').next().map(String::from)) + .unwrap(); + + let mut builder = + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")); + + if binary_name == "bootstrap" { + builder.format_timestamp(None).init(); + run_webhook(); + } else { + builder.init(); + run_cli(); } } +fn run_webhook() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(greenhell::webhook::run()); +} + +fn run_cli() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let app = ::parse(); + if let Err(error) = app.run().await { + log::error!("{error}"); + std::process::exit(1); + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/greenhell/src/secrets.rs b/greenhell/src/secrets.rs new file mode 100644 index 00000000..1861ceca --- /dev/null +++ b/greenhell/src/secrets.rs @@ -0,0 +1,87 @@ +#[derive(Clone, Debug, Eq, PartialEq, strum::Display, strum::EnumIter, strum::EnumString)] +pub enum Secret { + GitHubApp, +} + +impl stack_deploy::secrets::SecretType for Secret { + fn to_arn_output_key(&self) -> stack_deploy::types::OutputKey { + match self { + Self::GitHubApp => "GitHubAppSecretArn".into(), + } + } + + fn to_env_variable_name(&self) -> &str { + match self { + Self::GitHubApp => "GITHUB_APP_SECRET_ARN", + } + } + + fn validate(&self, input: &str) -> Result<(), String> { + match self { + Self::GitHubApp => { + serde_json::from_str::(input).map_err(|error| error.to_string())?; + Ok(()) + } + } + } +} + +use std::num::NonZeroU64; + +#[derive(Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct GitHubApp { + pub app_id: NonZeroU64, + pub private_key: PrivateKey, + pub webhook_secret: WebhookSecret, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct PrivateKey(String); + +impl PrivateKey { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> serde::Deserialize<'de> for PrivateKey { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + rcgen::KeyPair::from_pem(&value).map_err(|error| { + serde::de::Error::custom(format!("invalid private key PEM: {error}")) + })?; + Ok(Self(value)) + } +} + +impl serde::Serialize for PrivateKey { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct WebhookSecret(String); + +impl WebhookSecret { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> serde::Deserialize<'de> for WebhookSecret { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.is_empty() { + return Err(serde::de::Error::custom("webhook_secret must be non-empty")); + } + Ok(Self(value)) + } +} + +impl serde::Serialize for WebhookSecret { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} diff --git a/greenhell/src/stack.rs b/greenhell/src/stack.rs new file mode 100644 index 00000000..f9c292a9 --- /dev/null +++ b/greenhell/src/stack.rs @@ -0,0 +1,195 @@ +use crate::secrets::Secret; +use stack_deploy::instance_spec::{InstanceSpec, Registry}; +use stack_deploy::secrets::SecretType; +use stack_deploy::types::{ParameterMap, StackName, Template, TemplateName}; +use stratosphere::services::aws::{iam, lambda, logs, s3, secretsmanager}; + +pub mod artifacts { + use super::*; + use stratosphere::template::OutputExport; + use stratosphere::value::ExpString; + + pub const STACK_NAME: &str = "greenhell-artifacts"; + pub const LAMBDA_BUCKET_EXPORT: &str = "greenhell-artifacts-lambda-bucket"; + pub const GITHUB_APP_SECRET_EXPORT: &str = "greenhell-artifacts-github-app-secret-arn"; + + pub fn template() -> stratosphere::Template<'static> { + stratosphere::Template::build(|template| { + let lambda_bucket = template.resource("LambdaBucket", s3::Bucket! {}); + + template.output( + "LambdaBucketName", + stratosphere::template::Output { + description: "Name of the Lambda artifacts bucket".into(), + value: ExpString::from(&lambda_bucket), + export: Some(OutputExport { + name: LAMBDA_BUCKET_EXPORT.into(), + value: None, + }), + }, + ); + + let github_app_secret = template.resource( + "GitHubAppSecret", + secretsmanager::Secret! { + description: "GitHub App credentials for greenhell", + }, + ); + + template.output( + "GitHubAppSecretArn", + stratosphere::template::Output { + description: "ARN of the GitHub App secret".into(), + value: ExpString::from(&github_app_secret), + export: Some(OutputExport { + name: GITHUB_APP_SECRET_EXPORT.into(), + value: None, + }), + }, + ); + }) + } + + pub fn instance_spec() -> InstanceSpec { + InstanceSpec { + capabilities: Default::default(), + stack_name: StackName::from(STACK_NAME), + parameter_map: ParameterMap::default(), + template: Template::Stratosphere { + name: TemplateName::from(STACK_NAME), + template: template(), + }, + } + } +} + +pub mod webhook { + use super::*; + + pub const STACK_NAME: &str = "greenhell-webhook"; + pub const LOG_GROUP_ARN_OUTPUT: &str = "LogGroupArn"; + const FUNCTION_NAME: &str = "greenhell-webhook"; + + pub fn template() -> stratosphere::Template<'static> { + stratosphere::Template::build(|template| { + let lambda_s3_key = template.parameter( + "LambdaS3Key", + stratosphere::Parameter! { + description: "S3 object key for Lambda deployment package", + r#type: stratosphere::template::ParameterType::String, + }, + ); + + let lambda_bucket = stratosphere::fn_import_value!(artifacts::LAMBDA_BUCKET_EXPORT); + let github_app_secret_arn = + stratosphere::fn_import_value!(artifacts::GITHUB_APP_SECRET_EXPORT); + + let _log_group = template.resource( + "LambdaLogGroup", + logs::LogGroup! { + log_group_name: stratosphere::lambda::log_group_name(FUNCTION_NAME), + retention_in_days: 30, + }, + ); + + template.output( + LOG_GROUP_ARN_OUTPUT, + stratosphere::Output! { + description: "ARN of the webhook Lambda log group", + value: stratosphere::logs::log_group_arn(stratosphere::lambda::log_group_name(FUNCTION_NAME)), + }, + ); + + let lambda_role = template.resource( + "LambdaRole", + iam::Role! { + assume_role_policy_document: serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + }] + }), + policies: vec![ + iam::role::Policy! { + policy_name: "write-logs", + policy_document: serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": stratosphere::lambda::log_group_streams_arn(FUNCTION_NAME) + }] + }), + }, + iam::role::Policy! { + policy_name: "read-github-app-secret", + policy_document: serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": github_app_secret_arn + }] + }), + }, + ], + }, + ); + + let lambda_function = template.resource( + "WebhookFunction", + lambda::Function! { + function_name: "greenhell-webhook", + runtime: "provided.al2023", + handler: "bootstrap", + role: stratosphere::fn_get_att_arn!(&lambda_role), + code: lambda::function::Code! { + s3_bucket: lambda_bucket.clone(), + s3_key: lambda_s3_key.clone(), + }, + environment: lambda::function::Environment! { + variables: std::collections::BTreeMap::from([ + ( + Secret::GitHubApp.to_env_variable_name().to_string(), + github_app_secret_arn.clone(), + ), + ]), + }, + }, + ); + + template.output( + "WebhookFunctionArn", + stratosphere::Output! { + description: "ARN of the webhook Lambda function", + value: stratosphere::fn_get_att_arn!(&lambda_function), + }, + ); + }) + } + + pub fn instance_spec() -> InstanceSpec { + InstanceSpec { + capabilities: [aws_sdk_cloudformation::types::Capability::CapabilityIam].into(), + stack_name: StackName::from(STACK_NAME), + parameter_map: ParameterMap::default(), + template: Template::Stratosphere { + name: TemplateName::from(STACK_NAME), + template: template(), + }, + } + } +} + +pub fn registry() -> Registry { + Registry(vec![artifacts::instance_spec(), webhook::instance_spec()]) +} diff --git a/greenhell/src/webhook.rs b/greenhell/src/webhook.rs new file mode 100644 index 00000000..95888de8 --- /dev/null +++ b/greenhell/src/webhook.rs @@ -0,0 +1,215 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::secrets::{GitHubApp, WebhookSecret}; + +type HmacSha256 = Hmac; + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub body: Option, + pub headers: std::collections::BTreeMap, + pub is_base64_encoded: bool, +} + +impl Request { + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + let name_lower = name.to_lowercase(); + self.headers + .iter() + .find(|(key, _)| key.to_lowercase() == name_lower) + .map(|(_, value)| value.as_str()) + } + + #[must_use] + pub fn body_bytes(&self) -> Option> { + self.body.as_ref().map(|body| { + if self.is_base64_encoded { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, body) + .unwrap_or_else(|_| body.as_bytes().to_vec()) + } else { + body.as_bytes().to_vec() + } + }) + } +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Response { + pub status_code: u16, + pub body: String, + pub headers: std::collections::BTreeMap, +} + +impl Response { + fn json(status_code: http::StatusCode, body: impl serde::Serialize) -> Self { + Self { + status_code: status_code.as_u16(), + body: serde_json::to_string(&body).unwrap(), + headers: std::collections::BTreeMap::from([( + "Content-Type".to_string(), + "application/json".to_string(), + )]), + } + } + + fn error(status_code: http::StatusCode, message: &str) -> Self { + Self::json(status_code, serde_json::json!({ "error": message })) + } + + fn success(message: &str) -> Self { + Self::json( + http::StatusCode::OK, + serde_json::json!({ "status": message }), + ) + } +} + +#[derive(Debug)] +pub enum SignatureError { + InvalidFormat, + InvalidHex, + Mismatch, +} + +pub fn verify_signature( + secret: &WebhookSecret, + body: &[u8], + signature: &str, +) -> Result<(), SignatureError> { + let signature = signature + .strip_prefix("sha256=") + .ok_or(SignatureError::InvalidFormat)?; + + let signature_bytes = hex::decode(signature).map_err(|_| SignatureError::InvalidHex)?; + + let mut mac = + HmacSha256::new_from_slice(secret.as_str().as_bytes()).expect("HMAC accepts any key size"); + mac.update(body); + + mac.verify_slice(&signature_bytes) + .map_err(|_| SignatureError::Mismatch) +} + +pub async fn run() { + let client = mlambda::runtime::Client::load(); + + loop { + let event = client.read_next_event::().await; + let response = match &event.body { + Ok(body) => { + log::info!( + "Received event: {}", + serde_json::to_string_pretty(body).unwrap() + ); + Response::json(http::StatusCode::OK, body) + } + Err(error) => { + log::error!("Failed to decode event: {}", error.message()); + Response::error(http::StatusCode::BAD_REQUEST, &error.message()) + } + }; + client.send_response(&event.request_id, response).await; + } +} + +async fn handle_event( + event: &mlambda::runtime::Event>, + github_app: &GitHubApp, +) -> Response { + let request = match &event.body { + Ok(request) => request, + Err(error) => { + log::error!( + "Failed to decode event body: {} at {}", + error.message(), + error.path() + ); + return Response::error( + http::StatusCode::BAD_REQUEST, + &format!("Invalid request: {}", error.message()), + ); + } + }; + + let signature = match request.header("X-Hub-Signature-256") { + Some(signature) => signature, + None => { + return Response::error(http::StatusCode::UNAUTHORIZED, "Missing signature"); + } + }; + + let body = match request.body_bytes() { + Some(bytes) => bytes, + None => { + return Response::error(http::StatusCode::BAD_REQUEST, "Missing body"); + } + }; + + if let Err(error) = verify_signature(&github_app.webhook_secret, &body, signature) { + log::warn!("Signature verification failed: {error:?}"); + return Response::error(http::StatusCode::UNAUTHORIZED, "Invalid signature"); + } + + let event_type = match request.header("X-GitHub-Event") { + Some(event_type) => event_type, + None => { + return Response::error(http::StatusCode::BAD_REQUEST, "Missing event type"); + } + }; + + log::info!("Received GitHub event: {event_type}"); + + match event_type { + "ping" => Response::success("pong"), + "check_suite" | "check_run" | "status" => { + // TODO: Trigger evaluation + Response::success("accepted") + } + _ => { + log::info!("Ignoring event type: {event_type}"); + Response::success("ignored") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_signature_valid() { + let secret = serde_json::from_str::(r#""test-secret""#).unwrap(); + let body = b"test body"; + + // Pre-computed: echo -n "test body" | openssl dgst -sha256 -hmac "test-secret" + let signature = "sha256=8e3a8e233c37c30eb0d85adfc8c4e534f2e9d6e2a4b4e8f5e6d7c8b9a0b1c2d3"; + + // This will fail because the signature is fake, but tests the parsing + let result = verify_signature(&secret, body, signature); + assert!(matches!(result, Err(SignatureError::Mismatch))); + } + + #[test] + fn verify_signature_missing_prefix() { + let secret = serde_json::from_str::(r#""test-secret""#).unwrap(); + let body = b"test body"; + let signature = "invalid"; + + let result = verify_signature(&secret, body, signature); + assert!(matches!(result, Err(SignatureError::InvalidFormat))); + } + + #[test] + fn verify_signature_invalid_hex() { + let secret = serde_json::from_str::(r#""test-secret""#).unwrap(); + let body = b"test body"; + let signature = "sha256=not-hex"; + + let result = verify_signature(&secret, body, signature); + assert!(matches!(result, Err(SignatureError::InvalidHex))); + } +} diff --git a/stack-deploy/Cargo.toml b/stack-deploy/Cargo.toml index d69043fa..92d91d6e 100644 --- a/stack-deploy/Cargo.toml +++ b/stack-deploy/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] aws-config = { version = "1", default-features = false } aws-sdk-cloudformation = { version = "1", default-features = false } +aws-sdk-cloudwatchlogs = { version = "1", default-features = false } aws-sdk-lambda = { version = "1", default-features = false } aws-sdk-s3 = { version = "1", default-features = false } aws-sdk-secretsmanager = { version = "1", default-features = false } @@ -25,6 +26,7 @@ serde_json.workspace = true sha2.workspace = true stratosphere.workspace = true strum.workspace = true +thiserror.workspace = true tokio.workspace = true url.workspace = true uuid = { version = "1.17.0", features = ["v4"] } diff --git a/stack-deploy/src/cli.rs b/stack-deploy/src/cli.rs index e3a3da08..5022359d 100644 --- a/stack-deploy/src/cli.rs +++ b/stack-deploy/src/cli.rs @@ -14,6 +14,7 @@ impl App { pub struct Config<'a> { pub cloudformation: &'a aws_sdk_cloudformation::client::Client, + pub cloudwatchlogs: &'a aws_sdk_cloudwatchlogs::Client, pub registry: &'a Registry, pub template_uploader: Option<&'a TemplateUploader<'a>>, } @@ -21,12 +22,22 @@ pub struct Config<'a> { #[derive(Clone, Debug, Eq, PartialEq, clap::Parser)] pub enum Command { Instance(Box), + Logs { + #[clap(subcommand)] + command: crate::logs::cli::Command, + }, } impl Command { pub async fn run(&self, config: &Config<'_>) { match self { Self::Instance(command) => command.run(config).await, + Self::Logs { command } => { + let logs_config = crate::logs::cli::Config { + client: config.cloudwatchlogs, + }; + command.run(&logs_config).await + } } } } diff --git a/stack-deploy/src/lib.rs b/stack-deploy/src/lib.rs index 119d290a..45d3b80c 100644 --- a/stack-deploy/src/lib.rs +++ b/stack-deploy/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod events; pub mod instance_spec; pub mod lambda; +pub mod logs; pub mod secrets; pub mod stack; pub mod types; diff --git a/stack-deploy/src/logs.rs b/stack-deploy/src/logs.rs new file mode 100644 index 00000000..def5642d --- /dev/null +++ b/stack-deploy/src/logs.rs @@ -0,0 +1,2 @@ +pub mod cli; +pub mod tail; diff --git a/stack-deploy/src/logs/cli.rs b/stack-deploy/src/logs/cli.rs new file mode 100644 index 00000000..99897437 --- /dev/null +++ b/stack-deploy/src/logs/cli.rs @@ -0,0 +1,46 @@ +use super::tail; + +#[derive(Clone, Debug, Eq, PartialEq, clap::Parser)] +pub enum Command { + /// Stream logs in real-time using CloudWatch Live Tail + Tail { + /// Log group ARN + #[arg(long)] + log_group_arn: String, + + /// Filter to specific log stream names + #[arg(long = "stream")] + log_stream_names: Vec, + + /// Filter pattern for log events + #[arg(long)] + filter: Option, + }, +} + +pub struct Config<'a> { + pub client: &'a aws_sdk_cloudwatchlogs::Client, +} + +impl Command { + pub async fn run(&self, config: &Config<'_>) { + match self { + Self::Tail { + log_group_arn, + log_stream_names, + filter, + } => { + let tail_config = tail::Config { + client: config.client, + log_group_arn, + log_stream_names: log_stream_names.clone(), + filter_pattern: filter.clone(), + }; + + if let Err(error) = tail::run(&tail_config).await { + log::error!("{error}"); + } + } + } + } +} diff --git a/stack-deploy/src/logs/tail.rs b/stack-deploy/src/logs/tail.rs new file mode 100644 index 00000000..5c2ac7cc --- /dev/null +++ b/stack-deploy/src/logs/tail.rs @@ -0,0 +1,90 @@ +use aws_sdk_cloudwatchlogs::types::StartLiveTailResponseStream; + +pub struct Config<'a> { + pub client: &'a aws_sdk_cloudwatchlogs::Client, + pub log_group_arn: &'a str, + pub log_stream_names: Vec, + pub filter_pattern: Option, +} + +pub async fn run(config: &Config<'_>) -> Result<(), Error> { + let mut request = config + .client + .start_live_tail() + .log_group_identifiers(config.log_group_arn); + + if !config.log_stream_names.is_empty() { + request = request.set_log_stream_names(Some(config.log_stream_names.clone())); + } + + if let Some(ref pattern) = config.filter_pattern { + request = request.log_event_filter_pattern(pattern); + } + + let response = request.send().await.map_err(Error::StartLiveTail)?; + + let mut stream = response.response_stream; + + loop { + match stream.recv().await { + Ok(Some(event)) => handle_event(event)?, + Ok(None) => { + log::info!("Stream ended"); + break; + } + Err(error) => { + return Err(Error::Stream(error.into())); + } + } + } + + Ok(()) +} + +fn handle_event(event: StartLiveTailResponseStream) -> Result<(), Error> { + match event { + StartLiveTailResponseStream::SessionStart(start) => { + log::info!( + "Live tail session started: {}", + start.session_id.as_deref().unwrap_or("unknown") + ); + } + StartLiveTailResponseStream::SessionUpdate(update) => { + if let Some(results) = update.session_results { + for result in results { + let timestamp = result + .timestamp + .map(|t| { + chrono::DateTime::from_timestamp_millis(t) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string()) + .unwrap_or_else(|| t.to_string()) + }) + .unwrap_or_default(); + + let stream = result.log_stream_name.as_deref().unwrap_or("unknown"); + let message = result.message.as_deref().unwrap_or(""); + + println!("{timestamp} [{stream}] {message}"); + } + } + } + _ => { + log::warn!("Received unknown event type"); + } + } + + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to start live tail: {0:#?}")] + StartLiveTail( + #[source] + aws_sdk_cloudwatchlogs::error::SdkError< + aws_sdk_cloudwatchlogs::operation::start_live_tail::StartLiveTailError, + >, + ), + #[error("Stream error: {0:#?}")] + Stream(#[source] Box), +}