diff --git a/Cargo.lock b/Cargo.lock index 0aa08d8..ec0e871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -44,7 +53,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -55,15 +64,65 @@ checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" dependencies = [ "anstyle", "once_cell", - "windows-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.22" @@ -79,6 +138,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -131,7 +202,44 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", ] [[package]] @@ -143,6 +251,64 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-mac" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -156,9 +322,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fern" version = "0.7.1" @@ -190,282 +362,1112 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "heck" -version = "0.5.0" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hops-cli" -version = "0.0.0-dev" -dependencies = [ - "clap", - "colored", - "fern", - "flate2", - "log", - "openssl-sys", - "serde", - "serde_json", - "serde_yaml", - "tar", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "indexmap" -version = "2.13.0" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "equivalent", - "hashbrown", + "foreign-types-shared", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "itoa" -version = "1.0.17" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "libc" -version = "0.2.182" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] -name = "libredox" -version = "0.1.12" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ - "bitflags", - "libc", - "redox_syscall", + "futures-core", + "futures-sink", ] [[package]] -name = "linux-raw-sys" -version = "0.11.0" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "log" -version = "0.4.29" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "adler2", - "simd-adler32", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] -name = "openssl-src" -version = "300.5.0+3.5.0" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" -dependencies = [ - "cc", -] +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "openssl-sys" -version = "0.9.112" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", ] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "generic-array" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "unicode-ident", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "quote" -version = "1.0.45" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "proc-macro2", + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", ] [[package]] -name = "redox_syscall" -version = "0.7.1" +name = "h2" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ - "bitflags", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "rustix" -version = "1.1.3" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "foldhash", ] [[package]] -name = "ryu" -version = "1.0.23" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "serde" -version = "1.0.228" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "serde_core" -version = "1.0.228" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "serde_derive" -version = "1.0.228" +name = "hmac" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" dependencies = [ - "proc-macro2", - "quote", - "syn", + "crypto-mac", + "digest", ] [[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +name = "hops-cli" +version = "0.0.0-dev" dependencies = [ - "itoa", - "memchr", - "serde", + "clap", + "colored", + "dialoguer", + "fern", + "flate2", + "log", + "openssl-sys", + "rusoto_core", + "rusoto_credential", + "rusoto_kms", + "rusoto_secretsmanager", + "rusoto_sts", + "serde", + "serde_json", + "serde_yaml", + "tar", + "tokio", + "uuid", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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 = "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", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer", + "digest", + "opaque-debug", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.5.0+3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rusoto_core" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db30db44ea73551326269adcf7a2169428a054f14faf9e1768f2163494f2fa2" +dependencies = [ + "async-trait", + "base64", + "bytes", + "crc32fast", + "futures", + "http", + "hyper", + "hyper-tls", + "lazy_static", + "log", + "rusoto_credential", + "rusoto_signature", + "rustc_version", + "serde", + "serde_json", + "tokio", + "xml-rs", +] + +[[package]] +name = "rusoto_credential" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee0a6c13db5aad6047b6a44ef023dbbc21a056b6dab5be3b79ce4283d5c02d05" +dependencies = [ + "async-trait", + "chrono", + "dirs-next", + "futures", + "hyper", + "serde", + "serde_json", + "shlex", + "tokio", + "zeroize", +] + +[[package]] +name = "rusoto_kms" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1fc19cfcfd9f6b2f96e36d5b0dddda9004d2cbfc2d17543e3b9f10cc38fce8" +dependencies = [ + "async-trait", + "bytes", + "futures", + "rusoto_core", + "serde", + "serde_json", +] + +[[package]] +name = "rusoto_secretsmanager" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3331ce698e92491f53bf3abad5bb2bc3f72e9c8794a89d0137de1bbb3f3e3b" +dependencies = [ + "async-trait", + "bytes", + "futures", + "rusoto_core", + "serde", + "serde_json", +] + +[[package]] +name = "rusoto_signature" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ae95491c8b4847931e291b151127eccd6ff8ca13f33603eb3d0035ecb05272" +dependencies = [ + "base64", + "bytes", + "chrono", + "digest", + "futures", + "hex", + "hmac", + "http", + "hyper", + "log", + "md-5", + "percent-encoding", + "pin-project-lite", + "rusoto_credential", + "rustc_version", + "serde", + "sha2", + "tokio", +] + +[[package]] +name = "rusoto_sts" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1643f49aa67cb7cb895ebac5a2ff3f991c6dbdc58ad98b28158cd5706aecd1d" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "rusoto_core", + "serde_urlencoded", + "xml-rs", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", "serde_core", "zmij", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] [[package]] -name = "simd-adler32" -version = "0.3.8" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "strsim" -version = "0.11.1" +name = "tokio" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] [[package]] -name = "syn" -version = "2.0.117" +name = "tokio-macros" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn", ] [[package]] -name = "tar" -version = "0.4.45" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "filetime", - "libc", - "xattr", + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -478,12 +1480,231 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -493,6 +1714,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -557,6 +1787,94 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "xattr" version = "1.6.1" @@ -567,6 +1885,18 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 023429e..68f3c11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,14 @@ tar = "0.4.44" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_yaml = "0.9.34" +dialoguer = "0.12.0" +rusoto_core = "0.48.0" +rusoto_credential = "0.48.0" +rusoto_kms = "0.48.0" +rusoto_secretsmanager = "0.48.0" +rusoto_sts = "0.48.0" +tokio = { version = "1.45.1", features = ["rt-multi-thread"] } +uuid = { version = "1.17.0", features = ["v4"] } [features] vendored = ["openssl-sys/vendored"] diff --git a/README.md b/README.md index e9d1753..bdf6f4f 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,114 @@ cargo build --features vendored hops --help hops local --help hops config --help +hops secrets --help hops validate --help hops xr --help ``` +## Command Areas + +`hops-cli` is organized into a few command groups: + +- `local` + - Manage a local Colima-based control plane, install providers, and bootstrap AWS or GitHub provider auth. +- `config` + - Build, install, reload, and uninstall Crossplane configuration packages against the connected cluster. +- `secrets` + - Initialize secrets config, encrypt and decrypt local secrets, and sync repo-managed secrets to AWS Secrets Manager or GitHub repository secrets. +- `validate` + - Generate configuration manifests from Upbound-format XRD projects for validation workflows. +- `xr` + - Observe existing XR-backed infrastructure and render adoption, management, or orphaning manifests. + +## Secrets + +`hops secrets init` sets up local secrets directories, `.sops.yaml`, and `.hops.yaml` so plaintext secrets can be encrypted locally and synced to AWS Secrets Manager or GitHub repository secrets. + +Typical layout: + +```text +secrets/ + aws/ + github/ + _shared/ +secrets-encrypted/ + aws/ + github/ +``` + +Typical config: + +```yaml +secrets: + plaintext_dir: secrets + encrypted_dir: secrets-encrypted + aws: + path: aws + region: us-east-2 + tags: + hops.ops.com.ai/secret: "true" + github: + owner: hops-ops + path: github + shared_secrets: + path: _shared + repos: + - repo-a + - repo-b +``` + +Encrypt and decrypt operate from the configured roots: + +```bash +hops secrets encrypt +hops secrets decrypt +``` + +AWS sync reads from `/`: + +```bash +hops secrets sync aws +``` + +AWS rules: + +- A `.json` file becomes one AWS Secrets Manager secret with the JSON object stored as-is. +- A directory containing plain files rolls up into one AWS secret. Each filename becomes a key in the JSON object. +- A `.env` file is parsed into key/value pairs and stored as one JSON secret. +- A directory containing a `.env` file merges those parsed key/value pairs into that directory's rolled-up JSON secret. +- Secret names are derived from the path relative to the AWS root. +- `--cleanup` only works when syncing the full configured AWS root. +- `hops.ops.com.ai/secret=true` is always applied to repo-managed AWS secrets. + +Examples: + +- `secrets/aws/app.json` -> AWS secret `app` +- `secrets/aws/github/token` and `secrets/aws/github/owner` -> AWS secret `github` +- `secrets/aws/slack/.env` with `WEBHOOK_URL=...` -> AWS secret `slack` + +GitHub sync reads from `/`: + +```bash +hops secrets sync github +``` + +GitHub rules: + +- Each GitHub secret remains a separate GitHub secret. There is no AWS-style roll-up into a single JSON secret. +- A raw file becomes one GitHub secret. +- A `.json` file becomes multiple GitHub secrets, one per top-level key. +- Repo-specific secrets come from repo-named paths like `secrets/github/repo-a/...` or `secrets/github/repo-a.json`. +- Shared GitHub secrets come from `secrets/github/_shared/...` and fan out to the repos listed in `secrets.github.shared_secrets.repos` or passed with `--repo`. +- If a shared secret and a repo-specific secret have the same final name, the repo-specific value wins for that repo. +- GitHub secret names are normalized by the CLI to a stable format before syncing. + +Examples: + +- `secrets/github/repo-a/NPM_TOKEN` -> GitHub secret `NPM_TOKEN` in `repo-a` +- `secrets/github/repo-a/actions.json` with `{"SLACK_WEBHOOK":"..."}` -> GitHub secret `SLACK_WEBHOOK` in `repo-a` +- `secrets/github/_shared/ORG_TOKEN` -> synced to every configured shared target repo + ## Create a Local Control Plane ```bash @@ -147,40 +251,6 @@ How it works: - Applies a GitHub `ProviderConfig` named `default` unless `--refresh` is used. - Supports overrides for namespace, Secret name, ProviderConfig name, provider name, and provider package. -## Quick Start - -```bash -# Build and load a Crossplane configuration package from an Upbound-format XRD project -hops config install --path /path/to/project - -# Install from a GitHub repo; interactive TTY runs ask whether to build from source -# or use a published version (non-interactive runs default to source build) -hops config install --repo hops-ops/helm-certmanager - -# Force reload from source (deletes existing ConfigurationRevision(s) first) -hops config install --repo hops-ops/helm-certmanager --reload - -# Apply a pinned remote package version directly (no clone/build) -hops config install --repo hops-ops/helm-certmanager --version v0.1.0 - -# Remove a configuration and prune orphaned package dependencies -hops config uninstall --repo hops-ops/helm-certmanager - -# Generate apis/*/configuration.yaml from upbound.yaml for validation -hops validate generate-configuration --path /path/to/project - -# Observe an existing XR into a manifest -hops xr observe --kind AutoEKSCluster --name pat-local --namespace default --aws-region us-east-2 - -# Render adoption patches for managed resources under an existing XR -hops xr adopt --kind AutoEKSCluster --name pat-local --namespace default - -# Convert an observed/adopted XR into a managed manifest -hops xr manage --kind AutoEKSCluster --name pat-local --namespace default - -# Render patches that remove Delete from management policies -hops xr orphan --kind AutoEKSCluster --name pat-local --namespace default -``` ## Config packages `config install` and `config uninstall` operate on the currently connected Kubernetes cluster. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 302e41b..6035fbb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod config; pub mod local; +pub mod secrets; pub mod validate; pub mod xr; diff --git a/src/commands/secrets/decrypt.rs b/src/commands/secrets/decrypt.rs new file mode 100644 index 0000000..16f488e --- /dev/null +++ b/src/commands/secrets/decrypt.rs @@ -0,0 +1,40 @@ +use super::{default_secret_paths, mirror_tree_with_sops}; +use clap::Args; +use std::error::Error; +use std::path::PathBuf; + +#[derive(Args, Debug)] +pub struct DecryptArgs { + /// Source directory containing encrypted secrets + #[arg(long, default_value = "secrets-encrypted")] + pub source: PathBuf, + + /// Destination directory for decrypted secrets + #[arg(long, default_value = "secrets")] + pub destination: PathBuf, + + /// Overwrite destination files if they already exist + #[arg(long)] + pub force: bool, +} + +pub fn run(args: &DecryptArgs) -> Result<(), Box> { + let (default_destination, default_source) = default_secret_paths()?; + let source = if args.source.as_os_str().is_empty() { + default_source + } else { + args.source.clone() + }; + let destination = if args.destination.as_os_str().is_empty() { + default_destination + } else { + args.destination.clone() + }; + + log::info!( + "Decrypting secrets from {} to {}", + source.display(), + destination.display() + ); + mirror_tree_with_sops(&source, &destination, "decrypt", args.force) +} diff --git a/src/commands/secrets/encrypt.rs b/src/commands/secrets/encrypt.rs new file mode 100644 index 0000000..6da66d5 --- /dev/null +++ b/src/commands/secrets/encrypt.rs @@ -0,0 +1,40 @@ +use super::{default_secret_paths, mirror_tree_with_sops}; +use clap::Args; +use std::error::Error; +use std::path::PathBuf; + +#[derive(Args, Debug)] +pub struct EncryptArgs { + /// Source directory containing plaintext secrets + #[arg(long, default_value = "secrets")] + pub source: PathBuf, + + /// Destination directory for encrypted secrets + #[arg(long, default_value = "secrets-encrypted")] + pub destination: PathBuf, + + /// Overwrite destination files if they already exist + #[arg(long)] + pub force: bool, +} + +pub fn run(args: &EncryptArgs) -> Result<(), Box> { + let (default_source, default_destination) = default_secret_paths()?; + let source = if args.source.as_os_str().is_empty() { + default_source + } else { + args.source.clone() + }; + let destination = if args.destination.as_os_str().is_empty() { + default_destination + } else { + args.destination.clone() + }; + + log::info!( + "Encrypting secrets from {} to {}", + source.display(), + destination.display() + ); + mirror_tree_with_sops(&source, &destination, "encrypt", args.force) +} diff --git a/src/commands/secrets/init.rs b/src/commands/secrets/init.rs new file mode 100644 index 0000000..0e1b990 --- /dev/null +++ b/src/commands/secrets/init.rs @@ -0,0 +1,920 @@ +use super::{ + configured_aws_settings, configured_github_settings, configured_secret_paths, load_config, + require_command, save_config, sort_value, CONFIG_FILE, SOPS_FILE, +}; +use crate::commands::local::{kubectl_apply_stdin, run_cmd_output}; +use clap::Args; +use dialoguer::{Input, Select}; +use serde_json::Value as JsonValue; +use serde_yaml::{Mapping, Value}; +use std::collections::HashMap; +use std::error::Error; +use std::fs; +use std::path::Path; +use std::thread; +use std::time::Duration; + +const KMS_KEY_RESOURCE: &str = "key.kms.aws.m.upbound.io"; +const DEFAULT_KMS_PROVIDER_CONFIG: &str = "default"; +const DEFAULT_KMS_NAMESPACE: &str = "default"; +const DEFAULT_KMS_WAIT_SECONDS: u64 = 300; + +#[derive(Args, Debug)] +pub struct InitArgs { + /// Existing KMS ARN to use directly (skip interactive prompt) + #[arg(long)] + pub kms_arn: Option, + + /// Create the KMS key via the connected control plane and wait for it to be ready + #[arg(long, conflicts_with = "kms_arn")] + pub create_kms: bool, + + /// Crossplane managed resource name to use when creating a KMS key + #[arg(long)] + pub kms_resource_name: Option, + + /// Crossplane ProviderConfig name for control plane KMS key creation + #[arg(long, default_value = DEFAULT_KMS_PROVIDER_CONFIG)] + pub kms_provider_config: String, + + /// Namespace for the control plane-managed KMS key and ProviderConfig + #[arg(long, default_value = DEFAULT_KMS_NAMESPACE)] + pub kms_namespace: String, + + /// Optional description to set on a control plane-managed KMS key + #[arg(long)] + pub kms_description: Option, + + /// AWS region for the control plane-managed KMS key + #[arg(long)] + pub kms_region: Option, + + /// Seconds to wait for a control plane-managed KMS key to become ready + #[arg(long, default_value_t = DEFAULT_KMS_WAIT_SECONDS)] + pub kms_wait_seconds: u64, + + /// Create example secret inputs under secrets/ + #[arg(long, conflicts_with = "no_examples")] + pub examples: bool, + + /// Skip creating example secret inputs + #[arg(long)] + pub no_examples: bool, + + /// Additional default tags to store for secrets sync in key=value form + #[arg(long, value_parser = parse_key_value)] + pub tags: Vec<(String, String)>, +} + +pub fn run(args: &InitArgs) -> Result<(), Box> { + log::info!("Initializing secrets configuration..."); + configure_secret_paths()?; + configure_target_paths()?; + update_gitignore()?; + ensure_secret_tags(args)?; + configure_kms(args)?; + maybe_create_examples(args)?; + log::info!("Secrets initialization complete"); + Ok(()) +} + +fn configure_kms(args: &InitArgs) -> Result<(), Box> { + if let Some(existing_kms) = existing_sops_kms_key()? { + return Err(format!( + "{} already exists and references this KMS key: {}. Continue using the existing file, or delete it before creating a new SOPS key configuration.", + SOPS_FILE, existing_kms + ) + .into()); + } + + if Path::new(SOPS_FILE).exists() { + return Err(format!( + "{} already exists. Continue using the existing file, or delete it before creating a new SOPS key configuration.", + SOPS_FILE + ) + .into()); + } + + if let Some(arn) = args.kms_arn.as_ref() { + write_sops_file(arn)?; + return Ok(()); + } + + if args.create_kms { + create_control_plane_kms_key(args)?; + return Ok(()); + } + + let arn = select_kms_configuration()?; + match arn { + KmsSelection::ExistingArn(arn) => write_sops_file(&arn)?, + KmsSelection::CreateViaControlPlane => create_control_plane_kms_key(args)?, + } + Ok(()) +} + +enum KmsSelection { + ExistingArn(String), + CreateViaControlPlane, +} + +fn select_kms_configuration() -> Result> { + let items = [ + "Create a new KMS key via the control plane", + "Use an existing KMS key ARN", + ]; + let selection = Select::new() + .with_prompt("Choose how to configure SOPS KMS") + .items(items) + .default(0) + .interact()?; + + match selection { + 0 => Ok(KmsSelection::CreateViaControlPlane), + 1 => Ok(KmsSelection::ExistingArn(prompt_for_kms_arn()?)), + _ => Err("invalid KMS selection".into()), + } +} + +fn prompt_for_kms_arn() -> Result> { + log::info!( + "Paste an existing KMS key ARN. You can find it in the AWS console under KMS > Customer managed keys, or with `aws kms list-keys`." + ); + let arn: String = Input::new().with_prompt("KMS key ARN").interact_text()?; + let arn = arn.trim().to_string(); + if arn.is_empty() { + return Err("KMS key ARN cannot be empty".into()); + } + Ok(arn) +} + +fn write_sops_file(kms_arn: &str) -> Result<(), Box> { + let path = Path::new(SOPS_FILE); + if path.exists() { + return Err(format!( + "{} already exists. Continue using the existing file, or delete it before creating a new SOPS key configuration.", + path.display() + ) + .into()); + } + + let contents = format!("creation_rules:\n - kms: \"{}\"\n", kms_arn); + fs::write(path, contents)?; + log::info!("Created {}", path.display()); + Ok(()) +} + +fn existing_sops_kms_key() -> Result, Box> { + let path = Path::new(SOPS_FILE); + if !path.exists() { + return Ok(None); + } + + let value: Value = serde_yaml::from_str(&fs::read_to_string(path)?)?; + let Some(rules) = value + .as_mapping() + .and_then(|root| root.get(vs("creation_rules"))) + .and_then(Value::as_sequence) + else { + return Ok(None); + }; + + for rule in rules { + if let Some(kms) = rule + .as_mapping() + .and_then(|entry| entry.get(vs("kms"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(Some(kms.to_string())); + } + } + + Ok(None) +} + +fn update_gitignore() -> Result<(), Box> { + let (plaintext_dir, _) = configured_secret_paths()?; + let path = Path::new(".gitignore"); + let mut lines = if path.exists() { + fs::read_to_string(path)? + .lines() + .map(str::to_string) + .collect::>() + } else { + Vec::new() + }; + + let plaintext_entry = normalize_gitignore_dir(&plaintext_dir); + for required in [plaintext_entry.as_str(), ".tmp/", ".DS_Store"] { + if !lines.iter().any(|line| line == required) { + lines.push(required.to_string()); + } + } + + fs::write(path, format!("{}\n", lines.join("\n")))?; + log::info!("Updated {}", path.display()); + Ok(()) +} + +fn ensure_secret_tags(args: &InitArgs) -> Result<(), Box> { + let mut config = load_config()?; + let mut tags = config.secrets.aws.tags.clone().unwrap_or_else(HashMap::new); + + for (key, value) in &args.tags { + tags.insert(key.clone(), value.clone()); + } + + if args.tags.is_empty() && prompt_yes_no("Add tags to secrets when syncing to AWS?", false)? { + loop { + let key: String = Input::new() + .with_prompt("Default tag key") + .allow_empty(true) + .interact_text()?; + + let key = key.trim().to_string(); + if key.is_empty() { + break; + } + + let value: String = Input::new() + .with_prompt(format!("Default value for tag '{}'", key)) + .interact_text()?; + tags.insert(key, value); + } + } else if args.tags.is_empty() { + log::info!( + "Skipping optional default sync tags. `hops secrets sync` will still apply hops.ops.com.ai/secret=true." + ); + } + + tags.insert("hops.ops.com.ai/secret".to_string(), "true".to_string()); + config.secrets.aws.tags = Some(tags); + save_config(&config)?; + log::info!("Saved default secret tags to {}", CONFIG_FILE); + Ok(()) +} + +fn configure_secret_paths() -> Result<(), Box> { + let mut config = load_config()?; + let (current_plaintext, current_encrypted) = configured_secret_paths()?; + + let plaintext: String = Input::new() + .with_prompt("Directory for unencrypted secrets") + .default(current_plaintext.display().to_string()) + .interact_text()?; + let encrypted: String = Input::new() + .with_prompt("Directory for encrypted secrets") + .default(current_encrypted.display().to_string()) + .interact_text()?; + + let plaintext = plaintext.trim().to_string(); + let encrypted = encrypted.trim().to_string(); + if plaintext.is_empty() || encrypted.is_empty() { + return Err("Secret directories cannot be empty".into()); + } + + config.secrets.plaintext_dir = Some(plaintext); + config.secrets.encrypted_dir = Some(encrypted); + save_config(&config)?; + log::info!("Saved secret directories to {}", CONFIG_FILE); + Ok(()) +} + +fn configure_target_paths() -> Result<(), Box> { + let mut config = load_config()?; + let aws = configured_aws_settings()?; + let github = configured_github_settings()?; + + let aws_path: String = Input::new() + .with_prompt("Subdirectory for AWS secrets") + .default(aws.path) + .interact_text()?; + let aws_region: String = Input::new() + .with_prompt("AWS region for Secrets Manager and KMS") + .default(aws.region) + .interact_text()?; + let github_path: String = Input::new() + .with_prompt("Subdirectory for GitHub secrets") + .default(github.path) + .interact_text()?; + let github_shared_path: String = Input::new() + .with_prompt("Subdirectory for shared GitHub secrets") + .default(github.shared_path) + .interact_text()?; + let github_owner: String = Input::new() + .with_prompt("GitHub owner or organization for repo secrets") + .allow_empty(true) + .default(github.owner.unwrap_or_default()) + .interact_text()?; + let github_repos: String = Input::new() + .with_prompt("Default GitHub repos for shared secrets (comma-separated, optional)") + .allow_empty(true) + .default(github.shared_repos.join(",")) + .interact_text()?; + + config.secrets.aws.path = Some(aws_path.trim().to_string()); + config.secrets.aws.region = Some(aws_region.trim().to_string()); + config.secrets.github.path = Some(github_path.trim().to_string()); + config.secrets.github.shared_secrets.path = Some(github_shared_path.trim().to_string()); + config.secrets.github.owner = non_empty(github_owner.trim()); + config.secrets.github.shared_secrets.repos = parse_csv(github_repos.trim()); + save_config(&config)?; + log::info!("Saved secrets target settings to {}", CONFIG_FILE); + Ok(()) +} + +fn maybe_create_examples(args: &InitArgs) -> Result<(), Box> { + let (plaintext_dir, _) = configured_secret_paths()?; + let aws = configured_aws_settings()?; + let github = configured_github_settings()?; + let should_create = if args.examples { + true + } else if args.no_examples { + false + } else { + prompt_yes_no( + &format!( + "Create example secret inputs under {}/?", + plaintext_dir.display() + ), + false, + )? + }; + + if !should_create { + return Ok(()); + } + + create_example_secret_inputs( + &plaintext_dir.join(&aws.path), + &plaintext_dir.join(&github.path), + &plaintext_dir.join(&github.path).join(&github.shared_path), + )?; + log::warn!( + "Example secrets are real sync inputs. Remove or replace them before running `hops secrets sync` in a real repo." + ); + log::info!("Secret input rollups:"); + log::info!( + " {}/examples/app.json -> AWS secret 'examples/app' (JSON object preserved as-is)", + plaintext_dir.join(&aws.path).display() + ); + log::info!( + " {}/examples/github/{{token,owner}} -> AWS secret 'examples/github' (directory rolls up into a JSON object keyed by filenames)", + plaintext_dir.join(&aws.path).display() + ); + log::info!( + " {}/sample-repo/NPM_TOKEN -> GitHub secret 'NPM_TOKEN' in repo 'sample-repo'", + plaintext_dir.join(&github.path).display() + ); + log::info!( + " GitHub secrets do not roll up into one JSON blob: each file is one secret, and JSON files expand into one secret per top-level key" + ); + log::info!( + " {}/ORG_TOKEN -> shared GitHub secret synced to every targeted repo unless overridden per repo", + plaintext_dir + .join(&github.path) + .join(&github.shared_path) + .display() + ); + log::info!( + " For GitHub shared secrets, target repos come from `secrets.github.shared_secrets.repos` or repeated `--repo` flags on `hops secrets sync github`" + ); + Ok(()) +} + +fn create_example_secret_inputs( + aws_root: &Path, + github_root: &Path, + github_shared_root: &Path, +) -> Result<(), Box> { + let json_example = aws_root.join("examples/app.json"); + let env_dir = aws_root.join("examples/github"); + let github_repo = github_root.join("sample-repo"); + + write_example_file( + &json_example, + "{\n \"DATABASE_URL\": \"postgres://app:change-me@example.internal:5432/app\",\n \"API_KEY\": \"replace-me\"\n}\n", + )?; + write_example_file(&env_dir.join("token"), "ghp_replace_me\n")?; + write_example_file(&env_dir.join("owner"), "hops-ops\n")?; + write_example_file(&github_repo.join("NPM_TOKEN"), "npm_replace_me\n")?; + write_example_file( + &github_repo.join("actions.json"), + "{\n \"SLACK_WEBHOOK\": \"https://hooks.slack.com/services/replace/me\"\n}\n", + )?; + write_example_file( + &github_shared_root.join("ORG_TOKEN"), + "org_shared_replace_me\n", + )?; + + Ok(()) +} + +fn write_example_file(path: &Path, contents: &str) -> Result<(), Box> { + if path.exists() { + log::info!( + "Leaving existing example file unchanged: {}", + path.display() + ); + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, contents)?; + log::info!("Created {}", path.display()); + Ok(()) +} + +fn create_control_plane_kms_key(args: &InitArgs) -> Result<(), Box> { + require_command("kubectl")?; + + let resource_name = args + .kms_resource_name + .clone() + .unwrap_or_else(default_kms_resource_name); + let description = args + .kms_description + .clone() + .unwrap_or_else(default_kms_description); + let region = resolve_kms_region(args)?; + + let manifest = build_kms_key_manifest( + &resource_name, + &args.kms_namespace, + &args.kms_provider_config, + ®ion, + &description, + None, + )?; + let rendered = render_yaml(&manifest)?; + + log::info!( + "Creating KMS key {} via control plane in region {}...", + resource_name, + region + ); + kubectl_apply_stdin(&rendered)?; + + log::info!("Waiting for {} to become ready...", resource_name); + let live = wait_for_kms_key_ready(&resource_name, &args.kms_namespace, args.kms_wait_seconds)?; + + let arn = live + .get("status") + .and_then(|status| status.get("atProvider")) + .and_then(|provider| provider.get("arn")) + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or("KMS key became ready but status.atProvider.arn is missing")?; + + write_sops_file(arn)?; + + let external_name = resolved_kms_external_name(&live) + .ok_or("KMS key became ready but external name could not be determined")?; + let gitops_manifest = build_kms_key_manifest( + &resource_name, + &args.kms_namespace, + &args.kms_provider_config, + ®ion, + &description, + Some(&external_name), + )?; + + println!("If you want to track this KMS key via GitOps, add the following:\n"); + print!("{}", render_yaml(&gitops_manifest)?); + + Ok(()) +} + +fn wait_for_kms_key_ready( + name: &str, + namespace: &str, + wait_seconds: u64, +) -> Result> { + let attempts = std::cmp::max(1, (wait_seconds + 4) / 5); + let mut last_summary = None; + + for i in 0..attempts { + let raw = run_cmd_output( + "kubectl", + &["get", KMS_KEY_RESOURCE, name, "-n", namespace, "-o", "json"], + )?; + let item: JsonValue = serde_json::from_str(&raw)?; + + if is_condition_true(&item, "Ready") + && item + .get("status") + .and_then(|status| status.get("atProvider")) + .and_then(|provider| provider.get("arn")) + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .is_some() + { + return Ok(item); + } + + last_summary = condition_summary(&item); + if i + 1 < attempts { + thread::sleep(Duration::from_secs(5)); + } + } + + let suffix = last_summary + .map(|summary| format!(" Last observed conditions: {summary}")) + .unwrap_or_default(); + Err(format!( + "Timed out waiting {} seconds for {} {} to become ready.{}", + wait_seconds, KMS_KEY_RESOURCE, name, suffix + ) + .into()) +} + +fn build_kms_key_manifest( + name: &str, + namespace: &str, + provider_config: &str, + region: &str, + description: &str, + external_name: Option<&str>, +) -> Result> { + let mut root = Mapping::new(); + root.insert(vs("apiVersion"), vs("kms.aws.m.upbound.io/v1beta1")); + root.insert(vs("kind"), vs("Key")); + + let mut metadata = Mapping::new(); + metadata.insert(vs("name"), vs(name)); + metadata.insert(vs("namespace"), vs(namespace)); + if let Some(external_name) = external_name { + let mut annotations = Mapping::new(); + annotations.insert(vs("crossplane.io/external-name"), vs(external_name)); + metadata.insert(vs("annotations"), Value::Mapping(annotations)); + } + root.insert(vs("metadata"), Value::Mapping(metadata)); + + let mut spec = Mapping::new(); + let mut for_provider = Mapping::new(); + for_provider.insert(vs("description"), vs(description)); + for_provider.insert(vs("enableKeyRotation"), Value::Bool(true)); + for_provider.insert(vs("region"), vs(region)); + spec.insert(vs("forProvider"), Value::Mapping(for_provider)); + + let mut provider_ref = Mapping::new(); + provider_ref.insert(vs("kind"), vs("ProviderConfig")); + provider_ref.insert(vs("name"), vs(provider_config)); + spec.insert(vs("providerConfigRef"), Value::Mapping(provider_ref)); + + root.insert(vs("spec"), Value::Mapping(spec)); + + let mut value = Value::Mapping(root); + sort_value(&mut value); + Ok(value) +} + +fn render_yaml(value: &Value) -> Result> { + let mut rendered = serde_yaml::to_string(value)?; + if rendered.starts_with("---\n") { + rendered = rendered.replacen("---\n", "", 1); + } + Ok(rendered) +} + +fn resolve_kms_region(args: &InitArgs) -> Result> { + if let Some(region) = args.kms_region.as_ref() { + let region = region.trim().to_string(); + if region.is_empty() { + return Err("KMS region cannot be empty".into()); + } + return Ok(region); + } + + let config = load_config()?; + let region = config + .secrets + .aws + .region + .clone() + .or_else(|| Some(configured_aws_settings().ok()?.region)) + .unwrap_or_default(); + if region.trim().is_empty() { + return Err( + "AWS region is not configured. Run `hops secrets init` again or pass --kms-region." + .into(), + ); + } + Ok(region) +} + +fn default_kms_resource_name() -> String { + let repo_name = std::env::current_dir() + .ok() + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| "hops".to_string()); + sanitize_k8s_name(&format!("{repo_name}-sops")) +} + +fn default_kms_description() -> String { + let repo_name = std::env::current_dir() + .ok() + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| "hops".to_string()); + format!("SOPS key for {}", repo_name) +} + +fn sanitize_k8s_name(input: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + + for ch in input.chars() { + let lower = ch.to_ascii_lowercase(); + let valid = lower.is_ascii_lowercase() || lower.is_ascii_digit(); + if valid { + out.push(lower); + prev_dash = false; + continue; + } + + if !out.is_empty() && !prev_dash { + out.push('-'); + prev_dash = true; + } + } + + let trimmed = out.trim_matches('-').to_string(); + if trimmed.is_empty() { + "sops".to_string() + } else { + trimmed + } +} + +fn is_condition_true(item: &JsonValue, condition_type: &str) -> bool { + item.get("status") + .and_then(|status| status.get("conditions")) + .and_then(JsonValue::as_array) + .map(|conditions| { + conditions.iter().any(|condition| { + condition.get("type").and_then(JsonValue::as_str) == Some(condition_type) + && condition.get("status").and_then(JsonValue::as_str) == Some("True") + }) + }) + .unwrap_or(false) +} + +fn condition_summary(item: &JsonValue) -> Option { + let conditions = item + .get("status") + .and_then(|status| status.get("conditions")) + .and_then(JsonValue::as_array)?; + + let parts = conditions + .iter() + .filter_map(|condition| { + let condition_type = condition.get("type").and_then(JsonValue::as_str)?; + let status = condition.get("status").and_then(JsonValue::as_str)?; + let reason = condition + .get("reason") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + let message = condition + .get("message") + .and_then(JsonValue::as_str) + .unwrap_or_default(); + + let mut summary = format!("{condition_type}={status}"); + if !reason.is_empty() { + summary.push('('); + summary.push_str(reason); + summary.push(')'); + } + if !message.is_empty() { + summary.push(':'); + summary.push(' '); + summary.push_str(message); + } + Some(summary) + }) + .collect::>(); + + if parts.is_empty() { + None + } else { + Some(parts.join("; ")) + } +} + +fn resolved_kms_external_name(item: &JsonValue) -> Option { + item.get("status") + .and_then(|status| status.get("atProvider")) + .and_then(|provider| provider.get("keyId")) + .and_then(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string) + .or_else(|| { + item.get("status") + .and_then(|status| status.get("atProvider")) + .and_then(|provider| provider.get("arn")) + .and_then(JsonValue::as_str) + .and_then(kms_key_id_from_arn) + }) +} + +fn kms_key_id_from_arn(arn: &str) -> Option { + arn.split(":key/").nth(1).map(ToString::to_string) +} + +fn parse_key_value(value: &str) -> Result<(String, String), String> { + let mut parts = value.splitn(2, '='); + let key = parts.next().ok_or("Empty key")?.trim(); + let value = parts.next().ok_or("Missing value after '='")?.trim(); + + if key.is_empty() { + return Err("Tag key cannot be empty".to_string()); + } + + Ok((key.to_string(), value.to_string())) +} + +fn vs(value: &str) -> Value { + Value::String(value.to_string()) +} + +fn prompt_yes_no(prompt: &str, default: bool) -> Result> { + let suffix = if default { "[Y/n]" } else { "[y/N]" }; + + loop { + let response: String = Input::new() + .with_prompt(format!("{prompt} {suffix}")) + .allow_empty(true) + .interact_text()?; + match parse_yes_no(&response, default) { + Some(value) => return Ok(value), + None => log::warn!("Please enter 'y' or 'n'."), + } + } +} + +fn normalize_gitignore_dir(path: &Path) -> String { + let value = path.display().to_string(); + if value.ends_with('/') { + value + } else { + format!("{value}/") + } +} + +fn parse_csv(value: &str) -> Option> { + let values = value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>(); + if values.is_empty() { + None + } else { + Some(values) + } +} + +fn non_empty(value: &str) -> Option { + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn parse_yes_no(input: &str, default: bool) -> Option { + let value = input.trim().to_ascii_lowercase(); + if value.is_empty() { + return Some(default); + } + if matches!(value.as_str(), "y" | "yes") { + return Some(true); + } + if matches!(value.as_str(), "n" | "no") { + return Some(false); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_k8s_name_normalizes_repo_name() { + assert_eq!(sanitize_k8s_name("Hops Ops_repo"), "hops-ops-repo"); + assert_eq!(sanitize_k8s_name("___"), "sops"); + } + + #[test] + fn build_kms_key_manifest_adds_external_name_when_present() { + let manifest = build_kms_key_manifest( + "hops-sops", + "default", + "default", + "us-east-2", + "SOPS key for hops", + Some("1234-5678"), + ) + .expect("manifest"); + let rendered = render_yaml(&manifest).expect("yaml"); + + assert!(rendered.contains("apiVersion: kms.aws.m.upbound.io/v1beta1")); + assert!(rendered.contains("kind: Key")); + assert!(rendered.contains("namespace: default")); + assert!(rendered.contains("crossplane.io/external-name: 1234-5678")); + assert!(rendered.contains("enableKeyRotation: true")); + assert!(rendered.contains("region: us-east-2")); + } + + #[test] + fn ready_condition_detection_matches_true_ready() { + let item: JsonValue = serde_json::from_str( + r#"{ + "status": { + "conditions": [ + {"type": "Synced", "status": "True"}, + {"type": "Ready", "status": "True"} + ] + } + }"#, + ) + .expect("json"); + + assert!(is_condition_true(&item, "Ready")); + assert!(!is_condition_true(&item, "AsyncOperation")); + } + + #[test] + fn resolved_kms_external_name_prefers_key_id_then_arn() { + let with_key_id: JsonValue = serde_json::from_str( + r#"{"status":{"atProvider":{"keyId":"abc-123","arn":"arn:aws:kms:us-east-2:123:key/ignored"}}}"#, + ) + .expect("json"); + assert_eq!( + resolved_kms_external_name(&with_key_id).as_deref(), + Some("abc-123") + ); + + let with_arn_only: JsonValue = serde_json::from_str( + r#"{"status":{"atProvider":{"arn":"arn:aws:kms:us-east-2:123:key/def-456"}}}"#, + ) + .expect("json"); + assert_eq!( + resolved_kms_external_name(&with_arn_only).as_deref(), + Some("def-456") + ); + } + + #[test] + fn example_secret_rollups_match_sync_rules() { + assert_eq!( + Path::new("secrets/examples/app.json") + .strip_prefix("secrets") + .expect("prefix") + .to_string_lossy() + .trim_start_matches('/') + .trim_end_matches(".json"), + "examples/app" + ); + assert_eq!( + Path::new("secrets/examples/github") + .strip_prefix("secrets") + .expect("prefix") + .to_string_lossy() + .trim_start_matches('/'), + "examples/github" + ); + assert_eq!( + Path::new("secrets/examples/slack-webhook-url") + .strip_prefix("secrets") + .expect("prefix") + .to_string_lossy() + .trim_start_matches('/'), + "examples/slack-webhook-url" + ); + } + + #[test] + fn parse_yes_no_accepts_expected_values() { + assert_eq!(parse_yes_no("y", false), Some(true)); + assert_eq!(parse_yes_no("yes", false), Some(true)); + assert_eq!(parse_yes_no("n", true), Some(false)); + assert_eq!(parse_yes_no("no", true), Some(false)); + assert_eq!(parse_yes_no("", true), Some(true)); + assert_eq!(parse_yes_no("", false), Some(false)); + assert_eq!(parse_yes_no("maybe", false), None); + } +} diff --git a/src/commands/secrets/list.rs b/src/commands/secrets/list.rs new file mode 100644 index 0000000..6b41a77 --- /dev/null +++ b/src/commands/secrets/list.rs @@ -0,0 +1,756 @@ +use super::{ + aws_clients, collect_local_secret_names, configured_aws_settings, configured_github_settings, + configured_secret_paths, require_command, run_command_output_string, +}; +use rusoto_secretsmanager::{ListSecretsRequest, SecretsManager, SecretsManagerClient}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::env; +use std::error::Error; +use std::fs; +use std::path::Path; + +pub fn run() -> Result<(), Box> { + let (plaintext_dir, _) = configured_secret_paths()?; + let aws_settings = configured_aws_settings()?; + let aws_root = plaintext_dir.join(&aws_settings.path); + let local_names = collect_local_secret_names(Path::new(&aws_root)); + let local_lookup: HashSet = local_names.iter().cloned().collect(); + + let mut expected_tags = BTreeMap::new(); + for (key, value) in aws_settings.tags { + expected_tags.insert(key, value); + } + expected_tags.insert("hops.ops.com.ai/secret".to_string(), "true".to_string()); + + let runtime = tokio::runtime::Runtime::new()?; + let (client, _) = aws_clients(&aws_settings.region)?; + let remote_secrets = fetch_remote_secrets(&runtime, &client)?; + + let mut remote_by_name = HashMap::new(); + let mut other_remote_rows = Vec::new(); + for secret in remote_secrets { + if secret.managed || local_lookup.contains(&secret.name) { + remote_by_name.insert(secret.name.clone(), secret); + continue; + } + + let status = if is_crossplane_managed(&secret) { + "managed by crossplane" + } else { + "-" + }; + let kms = secret + .kms_key_id + .clone() + .unwrap_or_else(|| "aws/secretsmanager".to_string()); + other_remote_rows.push(RemoteOnlyRow { + name: secret.name, + tags: format_tags(&secret.tags), + kms_key: shorten_kms_key(&kms), + status: status.to_string(), + }); + } + other_remote_rows.sort_by(|left, right| left.name.cmp(&right.name)); + + let mut names = BTreeSet::new(); + for name in local_names { + names.insert(name); + } + for name in remote_by_name.keys() { + names.insert(name.clone()); + } + + let mut rows = Vec::new(); + for name in names { + let local = local_lookup.contains(&name); + let remote = remote_by_name.get(&name); + let missing_tags = remote + .map(|secret| missing_expected_tags(secret, &expected_tags)) + .unwrap_or_default(); + let status = match (local, remote.is_some()) { + (true, true) if missing_tags.is_empty() => "ok", + (true, true) => "remote tags differ", + (true, false) => "missing remote secret", + (false, true) => "missing local secret", + (false, false) => "-", + }; + let remote_tags = remote + .map(|secret| format_tags(&secret.tags)) + .unwrap_or_else(|| "-".to_string()); + let expected_tags_display = format_expected_tags(&expected_tags); + + let kms = remote + .and_then(|secret| secret.kms_key_id.clone()) + .unwrap_or_else(|| "-".to_string()); + rows.push(SecretRow { + name, + local, + remote: remote.is_some(), + remote_tags, + expected_tags: expected_tags_display, + kms_key: shorten_kms_key(&kms), + status: status.to_string(), + }); + } + + println!("Managed secrets"); + print_secret_rows(&rows); + println!(); + println!("Other AWS secrets"); + print_remote_only_rows(&other_remote_rows); + println!(); + print_github_section()?; + + Ok(()) +} + +struct SecretRow { + name: String, + local: bool, + remote: bool, + remote_tags: String, + expected_tags: String, + kms_key: String, + status: String, +} + +struct RemoteOnlyRow { + name: String, + tags: String, + kms_key: String, + status: String, +} + +struct GithubSecretRow { + repo: String, + name: String, + local: bool, + remote: bool, + status: String, +} + +#[derive(Clone, Debug)] +struct RemoteSecret { + name: String, + tags: Vec<(String, String)>, + managed: bool, + kms_key_id: Option, +} + +fn fetch_remote_secrets( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, +) -> Result, Box> { + let mut next_token = None; + let mut results = Vec::new(); + + loop { + let response = runtime.block_on(client.list_secrets(ListSecretsRequest { + next_token: next_token.clone(), + ..Default::default() + }))?; + + if let Some(secret_list) = response.secret_list { + for entry in secret_list { + let Some(name) = entry.name else { + continue; + }; + + let mut tags = Vec::new(); + let mut managed = false; + for tag in entry.tags.unwrap_or_default() { + if let (Some(key), Some(value)) = (tag.key, tag.value) { + if key == "hops.ops.com.ai/secret" { + managed = true; + } + tags.push((key, value)); + } + } + + results.push(RemoteSecret { + name, + tags, + managed, + kms_key_id: entry.kms_key_id, + }); + } + } + + if let Some(token) = response.next_token { + next_token = Some(token); + } else { + break; + } + } + + Ok(results) +} + +fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +fn shorten_kms_key(value: &str) -> String { + if value == "-" { + return value.to_string(); + } + value.rsplit('/').next().unwrap_or(value).to_string() +} + +fn format_tags(tags: &[(String, String)]) -> String { + if tags.is_empty() { + return "-".to_string(); + } + + let mut sorted = tags.to_vec(); + sorted.sort(); + sorted + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("\n") +} + +fn format_expected_tags(tags: &BTreeMap) -> String { + if tags.is_empty() { + return "-".to_string(); + } + + tags.iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("\n") +} + +fn missing_expected_tags( + secret: &RemoteSecret, + expected_tags: &BTreeMap, +) -> Vec { + expected_tags + .iter() + .filter(|(key, value)| { + !secret + .tags + .iter() + .any(|(actual_key, actual_value)| actual_key == *key && actual_value == *value) + }) + .map(|(key, value)| format!("{key}={value}")) + .collect() +} + +fn is_crossplane_managed(secret: &RemoteSecret) -> bool { + secret.tags.iter().any(|(key, _)| key == "crossplane-kind") +} + +fn print_secret_rows(rows: &[SecretRow]) { + let mut name_width = "Name".len(); + let mut local_width = "Local".len(); + let mut remote_width = "Remote".len(); + let mut remote_tags_width = "Remote Tags".len(); + let mut expected_tags_width = "Expected Tags".len(); + let mut kms_key_width = "KMS Key".len(); + let mut status_width = "Status".len(); + + for row in rows { + name_width = name_width.max(row.name.len()); + local_width = local_width.max(yes_no(row.local).len()); + remote_width = remote_width.max(yes_no(row.remote).len()); + for line in lines_or_dash(&row.remote_tags) { + remote_tags_width = remote_tags_width.max(line.len()); + } + for line in lines_or_dash(&row.expected_tags) { + expected_tags_width = expected_tags_width.max(line.len()); + } + kms_key_width = kms_key_width.max(row.kms_key.len()); + for line in lines_or_dash(&row.status) { + status_width = status_width.max(line.len()); + } + } + + println!( + "{: Result<(), Box> { + println!("GitHub secrets"); + + let github_settings = configured_github_settings()?; + let (plaintext_dir, _) = configured_secret_paths()?; + let source_root = plaintext_dir.join(&github_settings.path); + if !source_root.exists() { + println!("(none)"); + return Ok(()); + } + + require_command("gh")?; + ensure_gh_auth_for_list()?; + + let owner = resolve_github_owner_for_list(github_settings.owner.as_deref())?; + let repos = resolve_github_repos_for_list(&source_root, &github_settings)?; + if repos.is_empty() { + println!("(none)"); + return Ok(()); + } + + let shared_root = source_root.join(&github_settings.shared_path); + let shared_secrets = collect_github_target_secrets_for_list(&shared_root)?; + + let mut rows = Vec::new(); + for repo in repos { + let local_secrets = + collect_github_repo_secret_names(&source_root, &shared_root, &shared_secrets, &repo)?; + let remote_secrets = fetch_github_repo_secret_names(&owner, &repo)?; + + let mut names = BTreeSet::new(); + for name in &local_secrets { + names.insert(name.clone()); + } + for name in &remote_secrets { + names.insert(name.clone()); + } + + for name in names { + let local = local_secrets.contains(&name); + let remote = remote_secrets.contains(&name); + let status = match (local, remote) { + (true, true) => "ok", + (true, false) => "missing remote secret", + (false, true) => "missing local secret", + (false, false) => "-", + }; + rows.push(GithubSecretRow { + repo: repo.clone(), + name, + local, + remote, + status: status.to_string(), + }); + } + } + + print_github_rows(&rows); + Ok(()) +} + +fn ensure_gh_auth_for_list() -> Result<(), Box> { + let token = run_command_output_string("gh", &["auth", "token"]).map_err(|err| { + format!( + "failed to read GitHub auth token: {}\nRun `gh auth login` first.", + err + ) + })?; + if token.trim().is_empty() { + return Err("`gh auth token` returned an empty token. Run `gh auth login`.".into()); + } + Ok(()) +} + +fn resolve_github_owner_for_list(configured_owner: Option<&str>) -> Result> { + let env_owner = env::var("GH_OWNER").ok(); + let env_github_owner = env::var("GITHUB_OWNER").ok(); + let owner = [ + configured_owner, + env_owner.as_deref(), + env_github_owner.as_deref(), + ] + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) + .map(str::to_string); + + match owner { + Some(owner) => Ok(owner), + None => Err( + "GitHub owner is not configured. Set secrets.github.owner or GH_OWNER/GITHUB_OWNER." + .into(), + ), + } +} + +fn resolve_github_repos_for_list( + source_root: &Path, + settings: &super::GithubSecretsRuntimeConfig, +) -> Result, Box> { + let mut repos = settings.shared_repos.clone(); + + for entry in fs::read_dir(source_root)? { + let path = entry?.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|value| value.to_str()) { + if name == settings.shared_path { + continue; + } + repos.push(name.to_string()); + } + } else if path.extension().and_then(|value| value.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) { + if stem == settings.shared_path { + continue; + } + repos.push(stem.to_string()); + } + } + } + + repos.sort(); + repos.dedup(); + Ok(repos) +} + +fn collect_github_repo_secret_names( + source_root: &Path, + shared_root: &Path, + shared_secrets: &[(String, String, String)], + repo: &str, +) -> Result, Box> { + let repo_dir = source_root.join(repo); + let repo_file = source_root.join(format!("{repo}.json")); + let mut names = shared_secrets + .iter() + .map(|(name, _, _)| name.clone()) + .collect::>(); + + if repo_dir.is_dir() { + for (name, _, _) in collect_github_target_secrets_for_list(&repo_dir)? { + names.insert(name); + } + } else if repo_file.is_file() { + for (name, _, _) in collect_github_target_secrets_for_list(&repo_file)? { + names.insert(name); + } + } else if !shared_root.exists() || shared_secrets.is_empty() { + return Ok(HashSet::new()); + } + + Ok(names) +} + +fn collect_github_target_secrets_for_list( + target: &Path, +) -> Result, Box> { + if !target.exists() { + return Ok(Vec::new()); + } + if target.is_file() { + return collect_github_file_secrets_for_list(target, target); + } + + let mut out = Vec::new(); + collect_github_dir_secrets_for_list(target, target, &mut out)?; + Ok(out) +} + +fn collect_github_dir_secrets_for_list( + root: &Path, + current: &Path, + out: &mut Vec<(String, String, String)>, +) -> Result<(), Box> { + for entry in fs::read_dir(current)? { + let path = entry?.path(); + if path.is_dir() { + collect_github_dir_secrets_for_list(root, &path, out)?; + } else if path.is_file() { + out.extend(collect_github_file_secrets_for_list(root, &path)?); + } + } + Ok(()) +} + +fn collect_github_file_secrets_for_list( + root: &Path, + path: &Path, +) -> Result, Box> { + if path.extension().and_then(|value| value.to_str()) == Some("json") { + let contents = fs::read_to_string(path)?; + let secrets = parse_github_secret_map_for_list(&contents, path)?; + return Ok(secrets + .into_iter() + .map(|(name, value)| (name, value, path.display().to_string())) + .collect()); + } + + let secret_name = github_secret_name_for_list(root, path)?; + let secret_value = fs::read_to_string(path)?.trim().to_string(); + Ok(vec![( + secret_name, + secret_value, + path.display().to_string(), + )]) +} + +fn parse_github_secret_map_for_list( + contents: &str, + path: &Path, +) -> Result, Box> { + let value: serde_json::Value = serde_json::from_str(contents) + .map_err(|err| format!("Failed parsing JSON in {}: {}", path.display(), err))?; + let object = value + .as_object() + .ok_or_else(|| format!("GitHub secret JSON must be an object: {}", path.display()))?; + + let mut secrets = Vec::new(); + for (key, value) in object { + let secret_name = normalize_github_secret_name_for_list(key); + let secret_value = value + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| value.to_string()); + secrets.push((secret_name, secret_value)); + } + Ok(secrets) +} + +fn github_secret_name_for_list(repo_root: &Path, path: &Path) -> Result> { + let relative = path.strip_prefix(repo_root)?; + let raw = relative + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("__"); + Ok(normalize_github_secret_name_for_list( + raw.trim_end_matches(".json"), + )) +} + +fn normalize_github_secret_name_for_list(value: &str) -> String { + let mut out = String::new(); + let mut prev_underscore = false; + for ch in value.chars() { + let mapped = if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + }; + if mapped == '_' { + if !prev_underscore { + out.push(mapped); + } + prev_underscore = true; + } else { + out.push(mapped); + prev_underscore = false; + } + } + out.trim_matches('_').to_string() +} + +#[derive(Deserialize)] +struct GithubSecretListEntry { + name: String, +} + +fn fetch_github_repo_secret_names( + owner: &str, + repo: &str, +) -> Result, Box> { + let output = run_command_output_string( + "gh", + &[ + "secret", + "list", + "--repo", + &format!("{owner}/{repo}"), + "--json", + "name", + ], + )?; + let entries: Vec = serde_json::from_str(&output)?; + Ok(entries.into_iter().map(|entry| entry.name).collect()) +} + +fn print_github_rows(rows: &[GithubSecretRow]) { + if rows.is_empty() { + println!("(none)"); + return; + } + + let mut repo_width = "Repo".len(); + let mut name_width = "Name".len(); + let mut local_width = "Local".len(); + let mut remote_width = "Remote".len(); + let mut status_width = "Status".len(); + + for row in rows { + repo_width = repo_width.max(row.repo.len()); + name_width = name_width.max(row.name.len()); + local_width = local_width.max(yes_no(row.local).len()); + remote_width = remote_width.max(yes_no(row.remote).len()); + status_width = status_width.max(row.status.len()); + } + + println!( + "{: Vec<&str> { + if value == "-" { + vec!["-"] + } else { + let lines: Vec<&str> = value.split('\n').collect(); + if lines.is_empty() { + vec![""] + } else { + lines + } + } +} diff --git a/src/commands/secrets/mod.rs b/src/commands/secrets/mod.rs new file mode 100644 index 0000000..4aaabb3 --- /dev/null +++ b/src/commands/secrets/mod.rs @@ -0,0 +1,541 @@ +mod decrypt; +mod encrypt; +mod init; +mod list; +mod sync; + +use clap::{Args, Subcommand}; +use rusoto_core::{HttpClient, Region}; +use rusoto_credential::StaticProvider; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use std::collections::HashMap; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const CONFIG_FILE: &str = ".hops.yaml"; +const SOPS_FILE: &str = ".sops.yaml"; +const SECRET_DIR: &str = "secrets"; +const ENCRYPTED_SECRET_DIR: &str = "secrets-encrypted"; +const DEFAULT_AWS_REGION: &str = "us-east-1"; +const DEFAULT_AWS_SECRET_SUBDIR: &str = "aws"; +const DEFAULT_GITHUB_SECRET_SUBDIR: &str = "github"; +const DEFAULT_GITHUB_SHARED_SUBDIR: &str = "_shared"; + +#[derive(Args, Debug)] +pub struct SecretsArgs { + #[command(subcommand)] + pub command: SecretsCommands, +} + +#[derive(Subcommand, Debug)] +pub enum SecretsCommands { + /// Initialize repo secrets configuration (gitignore, tags, SOPS KMS key) + Init(init::InitArgs), + /// Encrypt files from secrets/ into secrets-encrypted/ using sops + Encrypt(encrypt::EncryptArgs), + /// Decrypt files from secrets-encrypted/ into secrets/ using sops + Decrypt(decrypt::DecryptArgs), + /// List local and remote secrets + List, + /// Sync secrets to AWS Secrets Manager + Sync(sync::SyncArgs), +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct RepoConfig { + #[serde(default)] + secrets: SecretsConfig, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct SecretsConfig { + #[serde(default)] + aws: AwsSecretsConfig, + encrypted_dir: Option, + #[serde(default)] + github: GithubSecretsConfig, + plaintext_dir: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct AwsSecretsConfig { + path: Option, + region: Option, + tags: Option>, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct GithubSecretsConfig { + owner: Option, + path: Option, + #[serde(default)] + shared_secrets: GithubSharedSecretsConfig, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct GithubSharedSecretsConfig { + path: Option, + repos: Option>, +} + +#[derive(Debug, Deserialize)] +struct AwsExportCredentials { + #[serde(rename = "AccessKeyId")] + access_key_id: String, + #[serde(rename = "SecretAccessKey")] + secret_access_key: String, + #[serde(rename = "SessionToken")] + session_token: Option, +} + +pub fn run(args: &SecretsArgs) -> Result<(), Box> { + match &args.command { + SecretsCommands::Init(init_args) => init::run(init_args), + SecretsCommands::Encrypt(encrypt_args) => encrypt::run(encrypt_args), + SecretsCommands::Decrypt(decrypt_args) => decrypt::run(decrypt_args), + SecretsCommands::List => list::run(), + SecretsCommands::Sync(sync_args) => sync::run(sync_args), + } +} + +fn run_command_output(program: &str, args: &[&str]) -> Result, Box> { + log::debug!("Running: {} {}", program, args.join(" ")); + let output = Command::new(program).args(args).output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("{} exited with {}: {}", program, output.status, stderr).into()); + } + Ok(output.stdout) +} + +fn run_command_output_string(program: &str, args: &[&str]) -> Result> { + Ok(String::from_utf8(run_command_output(program, args)?)?) +} + +fn require_command(program: &str) -> Result<(), Box> { + let status = Command::new("sh") + .args(["-c", &format!("command -v {} >/dev/null 2>&1", program)]) + .status()?; + if status.success() { + Ok(()) + } else { + Err(format!("Required command not found in PATH: {}", program).into()) + } +} + +fn load_config() -> Result> { + let path = Path::new(CONFIG_FILE); + if !path.exists() { + return Ok(RepoConfig::default()); + } + let content = fs::read_to_string(path)?; + Ok(serde_yaml::from_str(&content)?) +} + +fn save_config(config: &RepoConfig) -> Result<(), Box> { + let mut value = serde_yaml::to_value(config)?; + sort_value(&mut value); + fs::write(CONFIG_FILE, serde_yaml::to_string(&value)?)?; + Ok(()) +} + +fn configured_aws_settings() -> Result> { + let config = load_config()?; + let tags = config + .secrets + .aws + .tags + .unwrap_or_default() + .into_iter() + .collect::>(); + let path = config + .secrets + .aws + .path + .unwrap_or_else(|| DEFAULT_AWS_SECRET_SUBDIR.to_string()); + let region = config + .secrets + .aws + .region + .unwrap_or_else(|| DEFAULT_AWS_REGION.to_string()); + Ok(AwsSecretsRuntimeConfig { path, region, tags }) +} + +fn configured_github_settings() -> Result> { + let config = load_config()?; + Ok(GithubSecretsRuntimeConfig { + owner: config.secrets.github.owner, + path: config + .secrets + .github + .path + .unwrap_or_else(|| DEFAULT_GITHUB_SECRET_SUBDIR.to_string()), + shared_path: config + .secrets + .github + .shared_secrets + .path + .unwrap_or_else(|| DEFAULT_GITHUB_SHARED_SUBDIR.to_string()), + shared_repos: config + .secrets + .github + .shared_secrets + .repos + .unwrap_or_default(), + }) +} + +fn configured_secret_paths() -> Result<(PathBuf, PathBuf), Box> { + let config = load_config()?; + let plaintext = config + .secrets + .plaintext_dir + .unwrap_or_else(|| SECRET_DIR.to_string()); + let encrypted = config + .secrets + .encrypted_dir + .unwrap_or_else(|| ENCRYPTED_SECRET_DIR.to_string()); + Ok((PathBuf::from(plaintext), PathBuf::from(encrypted))) +} + +#[derive(Debug, Clone)] +struct AwsSecretsRuntimeConfig { + path: String, + region: String, + tags: HashMap, +} + +#[derive(Debug, Clone)] +struct GithubSecretsRuntimeConfig { + owner: Option, + path: String, + shared_path: String, + shared_repos: Vec, +} + +fn selected_aws_profile() -> Option { + [ + std::env::var("AWS_PROFILE").ok(), + std::env::var("AWS_DEFAULT_PROFILE").ok(), + ] + .into_iter() + .flatten() + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn run_aws_export_credentials(profile: &str) -> Result { + run_command_output_string( + "aws", + &[ + "configure", + "export-credentials", + "--profile", + profile, + "--format", + "process", + ], + ) + .map_err(|err| err.to_string()) +} + +fn export_aws_credentials(profile: &str) -> Result> { + let output = run_aws_export_credentials(profile).map_err(|initial_err| { + format!( + "failed to export AWS credentials for profile '{}': {}\nIf this is an SSO profile, run `aws sso login --profile {}` first.", + profile, initial_err, profile + ) + })?; + + let credentials: AwsExportCredentials = serde_json::from_str(&output).map_err(|err| { + format!( + "failed to parse credential JSON for profile '{}': {}", + profile, err + ) + })?; + + if credentials.access_key_id.trim().is_empty() + || credentials.secret_access_key.trim().is_empty() + { + return Err(format!( + "AWS profile '{}' returned empty access key or secret key", + profile + ) + .into()); + } + + Ok(credentials) +} + +fn aws_clients( + region: &str, +) -> Result< + ( + rusoto_secretsmanager::SecretsManagerClient, + rusoto_sts::StsClient, + ), + Box, +> { + let region = region + .parse::() + .map_err(|_| format!("unsupported AWS region '{}'", region))?; + if let Some(profile) = selected_aws_profile() { + require_command("aws")?; + let credentials = export_aws_credentials(&profile)?; + let provider = StaticProvider::new( + credentials.access_key_id, + credentials.secret_access_key, + credentials.session_token, + None, + ); + + let secrets_client = rusoto_secretsmanager::SecretsManagerClient::new_with( + HttpClient::new()?, + provider.clone(), + region.clone(), + ); + let sts_client = rusoto_sts::StsClient::new_with(HttpClient::new()?, provider, region); + return Ok((secrets_client, sts_client)); + } + + Ok(( + rusoto_secretsmanager::SecretsManagerClient::new(region.clone()), + rusoto_sts::StsClient::new(region), + )) +} + +fn collect_local_secret_names(root: &Path) -> Vec { + if !root.exists() { + return Vec::new(); + } + + let mut results = Vec::new(); + walk_local_secret_names(root, root, &mut results); + results.sort(); + results +} + +fn walk_local_secret_names(root: &Path, current: &Path, results: &mut Vec) { + let metadata = match fs::metadata(current) { + Ok(metadata) => metadata, + Err(err) => { + log::warn!("Failed to inspect '{}': {}", current.display(), err); + return; + } + }; + + if metadata.is_dir() { + let entries = match fs::read_dir(current) { + Ok(entries) => entries, + Err(err) => { + log::warn!("Failed to read directory '{}': {}", current.display(), err); + return; + } + }; + + let mut has_env_files = false; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + walk_local_secret_names(root, &p, results); + } else if p.is_file() { + if p.extension().and_then(|e| e.to_str()) == Some("json") { + if let Some(name) = derive_secret_name(root, &p) { + results.push(name); + } + } else { + has_env_files = true; + } + } + } + + if has_env_files { + if let Some(name) = derive_secret_name(root, current) { + results.push(name); + } + } + return; + } + + if let Some(name) = derive_secret_name(root, current) { + results.push(name); + } +} + +fn derive_secret_name(root: &Path, file_path: &Path) -> Option { + let relative = file_path.strip_prefix(root).ok()?; + let mut components: Vec = relative + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect(); + + let last = components.last_mut()?; + if let Some(stripped) = last.strip_suffix(".json") { + *last = stripped.to_string(); + } + Some(components.join("/")) +} + +fn mirror_tree_with_sops( + source_root: &Path, + dest_root: &Path, + sops_mode: &str, + force: bool, +) -> Result<(), Box> { + require_command("sops")?; + + if !source_root.exists() { + return Err(format!("Source path does not exist: {}", source_root.display()).into()); + } + + let source_root = normalized_path(source_root)?; + let dest_root = normalized_path(dest_root)?; + if source_root == dest_root + || dest_root.starts_with(&source_root) + || source_root.starts_with(&dest_root) + { + return Err(format!( + "Source and destination paths must not overlap: {} and {}", + source_root.display(), + dest_root.display() + ) + .into()); + } + + fs::create_dir_all(&dest_root)?; + process_tree(&source_root, &source_root, &dest_root, sops_mode, force) +} + +fn normalized_path(path: &Path) -> Result> { + if path.exists() { + return Ok(path.canonicalize()?); + } + + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + + Ok(normalized) +} + +fn process_tree( + source_root: &Path, + current: &Path, + dest_root: &Path, + sops_mode: &str, + force: bool, +) -> Result<(), Box> { + if current.is_dir() { + for entry in fs::read_dir(current)? { + let entry = entry?; + process_tree(source_root, &entry.path(), dest_root, sops_mode, force)?; + } + return Ok(()); + } + + if !current.is_file() { + return Ok(()); + } + + let relative = current.strip_prefix(source_root)?; + let destination = dest_root.join(relative); + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + + let source = current + .to_str() + .ok_or_else(|| format!("Non-UTF8 path not supported: {}", current.display()))?; + let output = match sops_mode { + "encrypt" => run_command_output( + "sops", + &["--encrypt", "--input-type=raw", "--output-type=raw", source], + )?, + "decrypt" => run_command_output( + "sops", + &["--decrypt", "--input-type=raw", "--output-type=raw", source], + )?, + _ => return Err(format!("Unsupported sops mode: {}", sops_mode).into()), + }; + + fs::write(&destination, output)?; + log::info!("Wrote {}", destination.display()); + Ok(()) +} + +fn sort_value(value: &mut Value) { + if let Some(mapping) = value.as_mapping_mut() { + let mut entries: Vec<_> = mapping + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + entries.sort_by(|(left, _), (right, _)| { + left.as_str() + .unwrap_or("") + .cmp(right.as_str().unwrap_or("")) + }); + mapping.clear(); + for (key, mut value) in entries { + sort_value(&mut value); + mapping.insert(key, value); + } + return; + } + + if let Some(sequence) = value.as_sequence_mut() { + for entry in sequence { + sort_value(entry); + } + } +} + +fn default_secret_paths() -> Result<(PathBuf, PathBuf), Box> { + configured_secret_paths() +} + +#[cfg(test)] +mod tests { + use super::{derive_secret_name, sort_value}; + use serde_yaml::Value; + use std::path::Path; + + #[test] + fn derive_secret_name_trims_root_and_json() { + let name = derive_secret_name( + Path::new("secrets"), + Path::new("secrets/examples/example.json"), + ); + assert_eq!(name.as_deref(), Some("examples/example")); + } + + #[test] + fn derive_secret_name_env_dir() { + let name = derive_secret_name(Path::new("secrets"), Path::new("secrets/github")); + assert_eq!(name.as_deref(), Some("github")); + } + + #[test] + fn sort_value_orders_mapping_keys() { + let mut value: Value = serde_yaml::from_str("b: 2\na: 1\n").expect("yaml"); + sort_value(&mut value); + let rendered = serde_yaml::to_string(&value).expect("yaml"); + assert!(rendered.find("a: 1").unwrap() < rendered.find("b: 2").unwrap()); + } +} diff --git a/src/commands/secrets/sync.rs b/src/commands/secrets/sync.rs new file mode 100644 index 0000000..306c35c --- /dev/null +++ b/src/commands/secrets/sync.rs @@ -0,0 +1,1074 @@ +use super::{ + aws_clients, collect_local_secret_names, configured_aws_settings, configured_github_settings, + configured_secret_paths, derive_secret_name, require_command, run_command_output_string, +}; +use clap::{Args, Subcommand}; +use dialoguer::Confirm; +use rusoto_secretsmanager::{ + CreateSecretRequest, DeleteSecretRequest, Filter, GetSecretValueRequest, ListSecretsRequest, + PutSecretValueRequest, SecretsManager, SecretsManagerClient, Tag, TagResourceRequest, +}; +use rusoto_sts::{GetCallerIdentityRequest, Sts}; +use serde_json::Value as JsonValue; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::error::Error; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use uuid::Uuid; + +#[derive(Args, Debug)] +pub struct SyncArgs { + #[command(subcommand)] + pub target: SyncTarget, +} + +#[derive(Subcommand, Debug)] +pub enum SyncTarget { + /// Sync secrets to AWS Secrets Manager + Aws(AwsSyncArgs), + /// Sync secrets to GitHub repository secrets + Github(GithubSyncArgs), +} + +#[derive(Args, Debug)] +pub struct AwsSyncArgs { + /// Secret path to sync, either a directory or a single file + #[arg(long)] + pub secret_path: Option, + + /// Tags to apply in key=value form + #[arg(long, value_parser = parse_key_value)] + pub tags: Vec<(String, String)>, + + /// Skip confirmation prompts + #[arg(short, long)] + pub yes: bool, + + /// Check for remote repo-owned secrets that no longer exist locally and delete them + #[arg(long)] + pub cleanup: bool, + + /// Only update tags on existing remote secrets; skip value create/update + #[arg(long)] + pub tags_only: bool, +} + +#[derive(Args, Debug)] +pub struct GithubSyncArgs { + /// Secret path to sync. Defaults to / + #[arg(long)] + pub secret_path: Option, + + /// Override configured repositories. Repeat to target multiple repos. + #[arg(long = "repo")] + pub repos: Vec, + + /// Override configured GitHub owner or organization + #[arg(long)] + pub owner: Option, + + /// Skip confirmation prompts + #[arg(short, long)] + pub yes: bool, +} + +pub fn run(args: &SyncArgs) -> Result<(), Box> { + match &args.target { + SyncTarget::Aws(aws_args) => run_aws(aws_args), + SyncTarget::Github(github_args) => run_github(github_args), + } +} + +fn run_aws(args: &AwsSyncArgs) -> Result<(), Box> { + let runtime = tokio::runtime::Runtime::new()?; + let aws_settings = configured_aws_settings()?; + let (plaintext_dir, _) = configured_secret_paths()?; + let default_source = plaintext_dir.join(&aws_settings.path); + let naming_root = default_source.clone(); + let secret_source = args + .secret_path + .clone() + .map(PathBuf::from) + .unwrap_or(default_source); + fs::metadata(&secret_source)?; + + if args.cleanup + && normalized_absolute_path(&secret_source)? != normalized_absolute_path(&naming_root)? + { + return Err( + "--cleanup can only be used when syncing the full configured AWS secrets root".into(), + ); + } + + let (client, sts_client) = aws_clients(&aws_settings.region)?; + + let mut final_tags_map = if args.tags.is_empty() { + aws_settings.tags + } else { + let mut tags = HashMap::new(); + for (key, value) in &args.tags { + tags.insert(key.clone(), value.clone()); + } + tags + }; + final_tags_map.insert("hops.ops.com.ai/secret".to_string(), "true".to_string()); + let mut final_tags = final_tags_map.into_iter().collect::>(); + final_tags.sort(); + + confirm_target_account(&runtime, &sts_client, args.yes)?; + + let mut synced = 0usize; + process_aws_path( + &runtime, + &client, + &final_tags, + &naming_root, + &secret_source, + &mut synced, + args.yes, + args.tags_only, + ); + + if args.cleanup { + let local_names = collect_local_secret_names(&naming_root); + delete_missing_secrets(&runtime, &client, &local_names, args.yes); + } + + log::info!("AWS sync complete - {} secrets processed", synced); + Ok(()) +} + +fn normalized_absolute_path(path: &Path) -> Result> { + if path.exists() { + return Ok(path.canonicalize()?); + } + + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + + Ok(normalized) +} + +fn process_aws_path( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + tags: &[(String, String)], + root: &Path, + path: &Path, + synced: &mut usize, + yes: bool, + tags_only: bool, +) { + if path.is_dir() { + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(err) => { + log::warn!("Failed to read directory '{}': {}", path.display(), err); + return; + } + }; + + let mut subdirs = Vec::new(); + let mut json_files = Vec::new(); + let mut env_files = Vec::new(); + + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + subdirs.push(p); + } else if p.is_file() { + if p.extension().and_then(|e| e.to_str()) == Some("json") { + json_files.push(p); + } else { + env_files.push(p); + } + } + } + + for dir in subdirs { + process_aws_path(runtime, client, tags, root, &dir, synced, yes, tags_only); + } + + for file in &json_files { + let secret_string = match fs::read_to_string(file) { + Ok(contents) => contents, + Err(err) => { + log::error!("Failed reading {}: {}", file.display(), err); + continue; + } + }; + let Some(secret_name) = derive_secret_name(root, file) else { + log::warn!("Could not derive secret name for {}", file.display()); + continue; + }; + sync_aws_secret( + runtime, + client, + tags, + &secret_name, + &secret_string, + &file.display().to_string(), + synced, + yes, + tags_only, + ); + } + + if !env_files.is_empty() { + let mut map = serde_json::Map::new(); + for file in &env_files { + match fs::read_to_string(file) { + Ok(contents) => { + if is_dotenv_file(file) { + match parse_dotenv_secret_map(&contents) { + Ok(values) => map.extend(values), + Err(err) => { + log::error!( + "Failed parsing dotenv file {}: {}", + file.display(), + err + ); + } + } + } else { + let key = file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("value") + .to_string(); + map.insert(key, JsonValue::String(contents.trim().to_string())); + } + } + Err(err) => { + log::error!("Failed reading {}: {}", file.display(), err); + } + } + } + + if !map.is_empty() { + let secret_string = JsonValue::Object(map).to_string(); + let Some(secret_name) = derive_secret_name(root, path) else { + log::warn!("Could not derive secret name for {}", path.display()); + return; + }; + sync_aws_secret( + runtime, + client, + tags, + &secret_name, + &secret_string, + &path.display().to_string(), + synced, + yes, + tags_only, + ); + } + } + + return; + } + + if !path.is_file() { + return; + } + + let secret_string = match fs::read_to_string(path) { + Ok(contents) => { + if path.extension().and_then(|e| e.to_str()) == Some("json") { + contents + } else if is_dotenv_file(path) { + match parse_dotenv_secret_map(&contents) { + Ok(values) => JsonValue::Object(values).to_string(), + Err(err) => { + log::error!("Failed parsing dotenv file {}: {}", path.display(), err); + return; + } + } + } else { + let key = path.file_name().and_then(|n| n.to_str()).unwrap_or("value"); + serde_json::json!({ key: contents.trim() }).to_string() + } + } + Err(err) => { + log::error!("Failed reading {}: {}", path.display(), err); + return; + } + }; + let Some(secret_name) = derive_secret_name(root, path) else { + log::warn!("Could not derive secret name for {}", path.display()); + return; + }; + sync_aws_secret( + runtime, + client, + tags, + &secret_name, + &secret_string, + &path.display().to_string(), + synced, + yes, + tags_only, + ); +} + +fn sync_aws_secret( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + tags: &[(String, String)], + secret_name: &str, + secret_string: &str, + source_label: &str, + synced: &mut usize, + yes: bool, + tags_only: bool, +) { + let exists = remote_secret_exists(runtime, client, secret_name); + if tags_only { + if !exists { + log::info!( + "Skipping {} because it does not exist remotely", + secret_name + ); + return; + } + if !check_tags_need_update(runtime, client, secret_name, tags) { + log::info!("Secret {} tags already up to date", secret_name); + return; + } + if !yes && !confirm(&format!("Update tags for secret '{}'?", secret_name), true) { + return; + } + if let Err(err) = apply_tags(runtime, client, secret_name, tags) { + log::error!("Failed applying tags to {}: {}", secret_name, err); + return; + } + *synced += 1; + return; + } + + let value_unchanged = if exists { + get_remote_secret_string(runtime, client, secret_name) + .map(|value| value == secret_string) + .unwrap_or(false) + } else { + false + }; + let tags_need_update = exists && check_tags_need_update(runtime, client, secret_name, tags); + + if value_unchanged && !tags_need_update { + log::info!("Secret {} unchanged; skipping", secret_name); + return; + } + + let action = if !exists { + "create" + } else if value_unchanged { + "update tags for" + } else { + "update" + }; + if !yes + && !confirm( + &format!( + "{} secret '{}' from '{}'?", + action, secret_name, source_label + ), + true, + ) + { + return; + } + + let client_request_token = Uuid::new_v4().to_string(); + if !exists { + let request = CreateSecretRequest { + name: secret_name.to_string(), + secret_string: Some(secret_string.to_string()), + client_request_token: Some(client_request_token), + tags: Some( + tags.iter() + .map(|(key, value)| Tag { + key: Some(key.clone()), + value: Some(value.clone()), + }) + .collect(), + ), + ..Default::default() + }; + if let Err(err) = runtime.block_on(client.create_secret(request)) { + log::error!("Failed to create {}: {}", secret_name, err); + return; + } + } else if !value_unchanged { + let request = PutSecretValueRequest { + secret_id: secret_name.to_string(), + secret_string: Some(secret_string.to_string()), + client_request_token: Some(client_request_token), + ..Default::default() + }; + if let Err(err) = runtime.block_on(client.put_secret_value(request)) { + log::error!("Failed to update {}: {}", secret_name, err); + return; + } + } + + if let Err(err) = apply_tags(runtime, client, secret_name, tags) { + log::error!("Failed applying tags to {}: {}", secret_name, err); + return; + } + *synced += 1; +} + +fn run_github(args: &GithubSyncArgs) -> Result<(), Box> { + require_command("gh")?; + ensure_gh_auth()?; + + let github_settings = configured_github_settings()?; + let (plaintext_dir, _) = configured_secret_paths()?; + let default_source = plaintext_dir.join(&github_settings.path); + let source_root = args + .secret_path + .clone() + .map(PathBuf::from) + .unwrap_or(default_source); + fs::metadata(&source_root)?; + + let owner = resolve_github_owner(args.owner.as_deref(), github_settings.owner.as_deref())?; + let repos = resolve_github_repos(&source_root, &github_settings, &args.repos)?; + if repos.is_empty() { + return Err("No GitHub repos configured. Add secrets.github.shared_secrets.repos, pass --repo, or create repo directories under the GitHub secrets path.".into()); + } + + let shared_root = source_root.join(&github_settings.shared_path); + let shared_secrets = collect_github_target_secrets(&shared_root)?; + + let mut synced = 0usize; + for repo in repos { + sync_github_repo( + &owner, + &repo, + &source_root, + &shared_root, + &shared_secrets, + args.yes, + &mut synced, + )?; + } + + log::info!("GitHub sync complete - {} secrets processed", synced); + Ok(()) +} + +fn ensure_gh_auth() -> Result<(), Box> { + let token = run_command_output_string("gh", &["auth", "token"]).map_err(|err| { + format!( + "failed to read GitHub auth token: {}\nRun `gh auth login` first.", + err + ) + })?; + if token.trim().is_empty() { + return Err("`gh auth token` returned an empty token. Run `gh auth login`.".into()); + } + Ok(()) +} + +fn resolve_github_owner( + cli_owner: Option<&str>, + configured_owner: Option<&str>, +) -> Result> { + let env_owner = env::var("GH_OWNER").ok(); + let env_github_owner = env::var("GITHUB_OWNER").ok(); + let owner = [ + cli_owner, + configured_owner, + env_owner.as_deref(), + env_github_owner.as_deref(), + ] + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) + .map(str::to_string); + + match owner { + Some(owner) => Ok(owner), + None => Err("GitHub owner is not configured. Set secrets.github.owner, pass --owner, or set GH_OWNER/GITHUB_OWNER.".into()), + } +} + +fn resolve_github_repos( + source_root: &Path, + settings: &super::GithubSecretsRuntimeConfig, + cli_repos: &[String], +) -> Result, Box> { + if !cli_repos.is_empty() { + return Ok(cli_repos.to_vec()); + } + if !settings.shared_repos.is_empty() { + return Ok(settings.shared_repos.clone()); + } + + let mut repos = Vec::new(); + for entry in fs::read_dir(source_root)? { + let path = entry?.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|value| value.to_str()) { + if name == settings.shared_path { + continue; + } + repos.push(name.to_string()); + } + } else if path.extension().and_then(|value| value.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) { + if stem == settings.shared_path { + continue; + } + repos.push(stem.to_string()); + } + } + } + repos.sort(); + repos.dedup(); + Ok(repos) +} + +fn sync_github_repo( + owner: &str, + repo: &str, + source_root: &Path, + shared_root: &Path, + shared_secrets: &[(String, String, String)], + yes: bool, + synced: &mut usize, +) -> Result<(), Box> { + let repo_dir = source_root.join(repo); + let repo_file = source_root.join(format!("{repo}.json")); + let mut merged = std::collections::BTreeMap::::new(); + + for (secret_name, secret_value, source_label) in shared_secrets { + merged.insert( + secret_name.clone(), + (secret_value.clone(), source_label.clone()), + ); + } + + if repo_dir.is_dir() { + for (secret_name, secret_value, source_label) in collect_github_target_secrets(&repo_dir)? { + merged.insert(secret_name, (secret_value, source_label)); + } + } else if repo_file.is_file() { + for (secret_name, secret_value, source_label) in collect_github_target_secrets(&repo_file)? + { + merged.insert(secret_name, (secret_value, source_label)); + } + } else if shared_root.exists() && !shared_secrets.is_empty() { + log::info!( + "Applying only shared GitHub secrets to '{}/{}' (no repo-specific secrets found).", + owner, + repo + ); + } else { + log::warn!( + "No secret source found for GitHub repo '{}'. Expected '{}' or '{}'.", + repo, + repo_dir.display(), + repo_file.display() + ); + } + + for (secret_name, (secret_value, source_label)) in merged { + set_github_secret(owner, repo, &secret_name, &secret_value, &source_label, yes)?; + *synced += 1; + } + Ok(()) +} + +fn collect_github_target_secrets( + target: &Path, +) -> Result, Box> { + if !target.exists() { + return Ok(Vec::new()); + } + if target.is_file() { + return collect_github_file_secrets(target, target); + } + + let mut out = Vec::new(); + collect_github_dir_secrets(target, target, &mut out)?; + Ok(out) +} + +fn collect_github_dir_secrets( + root: &Path, + current: &Path, + out: &mut Vec<(String, String, String)>, +) -> Result<(), Box> { + for entry in fs::read_dir(current)? { + let path = entry?.path(); + if path.is_dir() { + collect_github_dir_secrets(root, &path, out)?; + } else if path.is_file() { + out.extend(collect_github_file_secrets(root, &path)?); + } + } + Ok(()) +} + +fn collect_github_file_secrets( + root: &Path, + path: &Path, +) -> Result, Box> { + if path.extension().and_then(|value| value.to_str()) == Some("json") { + let contents = fs::read_to_string(path)?; + let secrets = parse_github_secret_map(&contents, path)?; + return Ok(secrets + .into_iter() + .map(|(name, value)| (name, value, path.display().to_string())) + .collect()); + } + + let secret_name = github_secret_name(root, path)?; + let secret_value = fs::read_to_string(path)?.trim().to_string(); + Ok(vec![( + secret_name, + secret_value, + path.display().to_string(), + )]) +} + +fn parse_github_secret_map( + contents: &str, + path: &Path, +) -> Result, Box> { + let value: JsonValue = serde_json::from_str(contents) + .map_err(|err| format!("Failed parsing JSON in {}: {}", path.display(), err))?; + let object = value + .as_object() + .ok_or_else(|| format!("GitHub secret JSON must be an object: {}", path.display()))?; + + let mut secrets = Vec::new(); + for (key, value) in object { + let secret_name = normalize_github_secret_name(key); + let secret_value = value + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| value.to_string()); + secrets.push((secret_name, secret_value)); + } + Ok(secrets) +} + +fn github_secret_name(repo_root: &Path, path: &Path) -> Result> { + let relative = path.strip_prefix(repo_root)?; + let raw = relative + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("__"); + Ok(normalize_github_secret_name(raw.trim_end_matches(".json"))) +} + +fn normalize_github_secret_name(value: &str) -> String { + let mut out = String::new(); + let mut prev_underscore = false; + for ch in value.chars() { + let mapped = if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + }; + if mapped == '_' { + if !prev_underscore { + out.push(mapped); + } + prev_underscore = true; + } else { + out.push(mapped); + prev_underscore = false; + } + } + out.trim_matches('_').to_string() +} + +fn is_dotenv_file(path: &Path) -> bool { + path.file_name().and_then(|name| name.to_str()) == Some(".env") +} + +fn parse_dotenv_secret_map(contents: &str) -> Result, String> { + let mut map = serde_json::Map::new(); + + for (index, raw_line) in contents.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let line = line + .strip_prefix("export ") + .map(str::trim_start) + .unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + return Err(format!("invalid dotenv entry on line {}", index + 1)); + }; + + let key = key.trim(); + if key.is_empty() { + return Err(format!("empty dotenv key on line {}", index + 1)); + } + + let value = strip_matching_quotes(value.trim()); + map.insert(key.to_string(), JsonValue::String(value.to_string())); + } + + Ok(map) +} + +fn strip_matching_quotes(value: &str) -> &str { + if value.len() >= 2 { + let quoted = (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')); + if quoted { + return &value[1..value.len() - 1]; + } + } + value +} + +fn set_github_secret( + owner: &str, + repo: &str, + secret_name: &str, + secret_value: &str, + source_label: &str, + yes: bool, +) -> Result<(), Box> { + if !yes + && !confirm( + &format!( + "Set GitHub secret '{}' in '{}/{}' from '{}'?", + secret_name, owner, repo, source_label + ), + true, + ) + { + return Ok(()); + } + + let mut child = Command::new("gh") + .args([ + "secret", + "set", + secret_name, + "--repo", + &format!("{}/{}", owner, repo), + ]) + .stdin(Stdio::piped()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(secret_value.as_bytes())?; + } else { + return Err("failed to open stdin for `gh secret set`".into()); + } + let status = child.wait()?; + if !status.success() { + return Err(format!("gh secret set exited with {}", status).into()); + } + log::info!( + "Set GitHub secret '{}' in '{}/{}'", + secret_name, + owner, + repo + ); + Ok(()) +} + +fn remote_secret_exists( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + secret_name: &str, +) -> bool { + match runtime.block_on(client.get_secret_value(GetSecretValueRequest { + secret_id: secret_name.to_string(), + ..Default::default() + })) { + Ok(_) => true, + Err(err) => { + let text = err.to_string(); + !(text.contains("ResourceNotFoundException") + || text.contains("can't find the specified secret")) + && { + log::error!("Failed to inspect {}: {}", secret_name, text); + false + } + } + } +} + +fn get_remote_secret_string( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + secret_name: &str, +) -> Option { + runtime + .block_on(client.get_secret_value(GetSecretValueRequest { + secret_id: secret_name.to_string(), + ..Default::default() + })) + .ok() + .and_then(|response| response.secret_string) +} + +fn check_tags_need_update( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + secret_name: &str, + tags: &[(String, String)], +) -> bool { + let request = ListSecretsRequest { + filters: Some(vec![Filter { + key: Some("name".to_string()), + values: Some(vec![secret_name.to_string()]), + ..Default::default() + }]), + ..Default::default() + }; + + match runtime.block_on(client.list_secrets(request)) { + Ok(response) => { + let current_tags = response + .secret_list + .unwrap_or_default() + .into_iter() + .find(|secret| secret.name.as_deref() == Some(secret_name)) + .and_then(|secret| secret.tags) + .unwrap_or_default() + .into_iter() + .filter_map(|tag| match (tag.key, tag.value) { + (Some(key), Some(value)) => Some((key, value)), + _ => None, + }) + .collect::>(); + + tags.iter() + .any(|expected| !current_tags.iter().any(|actual| actual == expected)) + } + Err(err) => { + log::warn!("Failed checking tags for {}: {}", secret_name, err); + true + } + } +} + +fn apply_tags( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + secret_name: &str, + tags: &[(String, String)], +) -> Result<(), Box> { + let request = TagResourceRequest { + secret_id: secret_name.to_string(), + tags: tags + .iter() + .map(|(key, value)| Tag { + key: Some(key.clone()), + value: Some(value.clone()), + }) + .collect(), + }; + + runtime.block_on(client.tag_resource(request))?; + Ok(()) +} + +fn delete_missing_secrets( + runtime: &tokio::runtime::Runtime, + client: &SecretsManagerClient, + local_secrets: &[String], + yes: bool, +) { + let local_set = local_secrets.iter().cloned().collect::>(); + let mut next_token = None; + + loop { + let response = match runtime.block_on(client.list_secrets(ListSecretsRequest { + next_token: next_token.clone(), + filters: Some(vec![Filter { + key: Some("tag-key".to_string()), + values: Some(vec!["hops.ops.com.ai/secret".to_string()]), + ..Default::default() + }]), + ..Default::default() + })) { + Ok(response) => response, + Err(err) => { + log::error!("Failed listing secrets for cleanup: {}", err); + return; + } + }; + + for secret in response.secret_list.unwrap_or_default() { + if !has_managed_secret_tag(secret.tags.as_ref()) { + continue; + } + let Some(secret_name) = secret.name else { + continue; + }; + if local_set.contains(&secret_name) { + continue; + } + + let should_delete = yes + || confirm( + &format!( + "Delete remote secret '{}' because it no longer exists locally?", + secret_name + ), + false, + ); + if !should_delete { + continue; + } + + if let Err(err) = runtime.block_on(client.delete_secret(DeleteSecretRequest { + secret_id: secret_name.clone(), + force_delete_without_recovery: Some(true), + ..Default::default() + })) { + log::error!("Failed deleting {}: {}", secret_name, err); + } else { + log::info!("Deleted {}", secret_name); + } + } + + if let Some(token) = response.next_token { + next_token = Some(token); + } else { + break; + } + } +} + +fn has_managed_secret_tag(tags: Option<&Vec>) -> bool { + tags.into_iter().flatten().any(|tag| { + tag.key.as_deref() == Some("hops.ops.com.ai/secret") && tag.value.as_deref() == Some("true") + }) +} + +fn confirm_target_account( + runtime: &tokio::runtime::Runtime, + client: &rusoto_sts::StsClient, + yes: bool, +) -> Result<(), Box> { + let profile = env::var("AWS_PROFILE") + .or_else(|_| env::var("AWS_DEFAULT_PROFILE")) + .unwrap_or_else(|_| "default".to_string()); + let response = runtime + .block_on(client.get_caller_identity(GetCallerIdentityRequest::default())) + .map_err(|err| format!("Failed to determine AWS account: {}", err))?; + let account = response + .account + .ok_or("AWS account ID not available from STS GetCallerIdentity")?; + + if yes + || confirm( + &format!( + "Continue syncing secrets with AWS profile '{}' targeting account '{}'?", + profile, account + ), + true, + ) + { + Ok(()) + } else { + Err("Secrets sync cancelled".into()) + } +} + +fn confirm(prompt: &str, default: bool) -> bool { + Confirm::new() + .with_prompt(prompt) + .default(default) + .interact() + .unwrap_or(false) +} + +fn parse_key_value(value: &str) -> Result<(String, String), String> { + let mut parts = value.splitn(2, '='); + let key = parts.next().ok_or("Empty key")?; + let value = parts.next().ok_or("Missing value after '='")?; + Ok((key.to_string(), value.to_string())) +} + +#[cfg(test)] +mod tests { + use crate::commands::secrets::derive_secret_name; + use serde_json::{json, Value as JsonValue}; + use std::path::Path; + + use super::{normalize_github_secret_name, parse_dotenv_secret_map}; + + #[test] + fn derive_secret_name_from_json_path() { + assert_eq!( + derive_secret_name( + Path::new("secrets"), + Path::new("secrets/examples/example.json") + ) + .as_deref(), + Some("examples/example") + ); + } + + #[test] + fn derive_secret_name_from_env_dir() { + assert_eq!( + derive_secret_name(Path::new("secrets"), Path::new("secrets/github")).as_deref(), + Some("github") + ); + } + + #[test] + fn normalize_github_secret_name_uppercases_and_flattens() { + assert_eq!(normalize_github_secret_name("token"), "TOKEN"); + assert_eq!( + normalize_github_secret_name("actions/npm-token"), + "ACTIONS_NPM_TOKEN" + ); + assert_eq!( + normalize_github_secret_name("app__prod.database-url"), + "APP_PROD_DATABASE_URL" + ); + } + + #[test] + fn parse_dotenv_secret_map_reads_key_values() { + let parsed = parse_dotenv_secret_map("FOO=bar\nBAZ=qux\n").unwrap(); + assert_eq!( + JsonValue::Object(parsed), + json!({"FOO": "bar", "BAZ": "qux"}) + ); + } + + #[test] + fn parse_dotenv_secret_map_skips_comments_and_export() { + let parsed = parse_dotenv_secret_map("# comment\nexport FOO=\"bar\"\n\n").unwrap(); + assert_eq!(JsonValue::Object(parsed), json!({"FOO": "bar"})); + } +} diff --git a/src/main.rs b/src/main.rs index c189249..1498ea4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,8 @@ struct Args { enum Commands { /// Manage the local control plane environment Local(commands::local::LocalArgs), + /// Manage repo secrets with SOPS and AWS Secrets Manager + Secrets(commands::secrets::SecretsArgs), /// Manage Crossplane configuration packages in the connected cluster Config(commands::config::ConfigArgs), /// Manage validation helpers for Crossplane projects @@ -33,6 +35,9 @@ fn main() -> Result<(), Box> { Some(Commands::Local(local_args)) => { commands::local::run(local_args)?; } + Some(Commands::Secrets(secrets_args)) => { + commands::secrets::run(secrets_args)?; + } Some(Commands::Config(config_args)) => { commands::config::run(config_args)?; }