From 74c13fb055d66e416439614dda035e48425727af Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Tue, 3 Feb 2026 16:02:34 -0800 Subject: [PATCH 1/7] Container support and S3 support for static storage * Added Containerfile and compose.yml allowing for rfd-api and all other necessary binaries to be built into a container image. The compose.yml can be used to stand-up a local stack which includes the database and meilisearch. * Added log_filter support to both the rfd-api and rfd-processor configuration files. This works the same as overriding RUST_LOG but allows for configuration via file instead of environment variable. * StaticStorage is now a trait. There are two implementations for the trait. Each implementation have corresponding configuration sections. Multiple static storage locations can be configured and processor will upload static storage to all configured static storage backends. ** GCS This is the original GCS implementation. ** S3 This is a new implementation using the S3 API. * PDF storage is now optional. Disable this feature by not configuring it in the configuration toml. --- .gitignore | 2 +- Cargo.lock | 1091 +++++++++++++++-- Cargo.toml | 2 + Containerfile | 78 ++ SETUP.md | 39 + compose.yml | 131 ++ rfd-api/src/config.rs | 1 + rfd-api/src/main.rs | 7 +- rfd-api/src/server.rs | 2 +- rfd-installer/src/lib.rs | 1 + rfd-model/diesel.toml | 6 +- rfd-processor/Cargo.toml | 5 + rfd-processor/config.example.toml | 20 +- rfd-processor/src/context.rs | 380 ++++-- rfd-processor/src/main.rs | 21 +- .../src/updater/copy_images_to_storage.rs | 28 +- rfd-processor/src/updater/update_pdfs.rs | 24 +- 17 files changed, 1607 insertions(+), 231 deletions(-) create mode 100644 Containerfile create mode 100644 compose.yml diff --git a/.gitignore b/.gitignore index 0704a0eb..7156a1fc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ node_modules .rustup /config/ local_data -.env \ No newline at end of file +.env diff --git a/Cargo.lock b/Cargo.lock index c6a6af89..ba3cc583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,7 +91,7 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "async-bb8-diesel" version = "0.3.0" -source = "git+https://github.com/oxidecomputer/async-bb8-diesel#18baf49aca5f72bf441951b11669c6c3c3affe70" +source = "git+https://github.com/oxidecomputer/async-bb8-diesel#f049570ff89080b2385c637af4c0e614b0a124a4" dependencies = [ "async-trait", "bb8", @@ -157,6 +157,453 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.122.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94c2ca0cba97e8e279eb6c0b2d0aa10db5959000e602ab2b7c02de6b85d4c19b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.93.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcb38bb33fc0a11f1ffc3e3e85669e0a11a37690b86f77e75306d8f369146a0" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ada8ffbea7bd1be1f53df1dadb0f8fdb04badb13185b3321b929d1ee3caad09" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6443ccadc777095d5ed13e21f5c364878c9f5bad4e35187a6cdbd863b0afcad" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256 0.11.1", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcf418858f9f3edd228acb8759d77394fed7531cce78d02bdda499025368439" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b9c7354a3b13c66f60fe4616d6d1969c9fd36b1b5333a5dfb3ee716b33c588" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base16ct" version = "0.2.0" @@ -169,6 +616,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -225,13 +682,23 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "camino" version = "1.2.2" @@ -243,11 +710,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.54" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -279,9 +748,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", "clap_derive", @@ -289,9 +758,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ "anstream", "anstyle", @@ -301,9 +770,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -317,6 +786,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -423,6 +901,33 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin 0.10.0", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -432,6 +937,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -451,7 +965,19 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] [[package]] name = "crypto-bigint" @@ -557,6 +1083,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe7ed1d93f4553003e20b629abe9085e1e81b1429520f897f8f8860bc6dfc21" +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -716,9 +1252,9 @@ checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" [[package]] name = "dropshot" -version = "0.16.6" +version = "0.16.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d0df98c06659ab85a454f32dc36ca5dbc6500bd2a58f25ede4dc1f1d478904e" +checksum = "d69fd85c8dfc67252d02f260595f6b62b5abceb1b88b4b9722369d27936e5fa4" dependencies = [ "async-stream", "async-trait", @@ -733,7 +1269,7 @@ dependencies = [ "hostname 0.4.2", "http 1.4.0", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "indexmap 2.13.0", "multer", @@ -767,7 +1303,7 @@ dependencies = [ [[package]] name = "dropshot-authorization-header" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/v-api#5ea039de839c4a88943961e8bbfb298781671755" +source = "git+https://github.com/oxidecomputer/v-api#21d760b210429e23df76a1f6bf691c94b3de5488" dependencies = [ "async-trait", "base64", @@ -786,7 +1322,7 @@ dependencies = [ "hex", "hmac", "http 1.4.0", - "hyper", + "hyper 1.8.1", "schemars 0.8.22", "serde", "serde_json", @@ -797,9 +1333,9 @@ dependencies = [ [[package]] name = "dropshot_endpoint" -version = "0.16.6" +version = "0.16.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e53aef8838e0e341485590738ab180a6dceff3565ffcb198d5f365fea650378" +checksum = "67d106478e4a4782556981d028a667f41c4845cdaa6e2d3a9f58c5d15e725401" dependencies = [ "heck", "proc-macro2", @@ -824,24 +1360,42 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve 0.12.3", + "rfc6979 0.3.1", + "signature 1.6.4", +] + [[package]] name = "ecdsa" version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -859,8 +1413,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] @@ -886,23 +1440,43 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct 0.1.1", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest", + "ff 0.12.1", + "generic-array", + "group 0.12.1", + "pkcs8 0.9.0", + "rand_core 0.6.4", + "sec1 0.3.0", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "hkdf", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -958,6 +1532,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.13.1" @@ -976,9 +1560,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fnv" @@ -1013,6 +1597,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -1156,7 +1746,7 @@ dependencies = [ "chrono", "http 1.4.0", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "mime", "percent-encoding", @@ -1176,8 +1766,8 @@ checksum = "cef55117f2b1d40e56f2fd26161a2e93c9d7899be9389a9d4aa59308168e880c" dependencies = [ "chrono", "google-apis-common", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "mime", "serde", @@ -1196,8 +1786,8 @@ checksum = "30742c5730cad187ffb8922f1a91766830676ec6b714489fcdb89e9b9385fd7c" dependencies = [ "chrono", "google-apis-common", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "mime", "serde", @@ -1216,8 +1806,8 @@ checksum = "0d454a68994bbdae724ec6a3129a23ec2a86ef29f8ae9e92bb3a3578c193284c" dependencies = [ "chrono", "google-apis-common", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "mime", "serde", @@ -1251,17 +1841,47 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff 0.12.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1410,6 +2030,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1429,7 +2060,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1445,6 +2076,30 @@ 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 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1455,9 +2110,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", + "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1468,6 +2123,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1475,7 +2145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", "rustls 0.23.36", "rustls-native-certs", @@ -1488,23 +2158,22 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "system-configuration", "tokio", "tower-layer", @@ -1515,9 +2184,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1737,6 +2406,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1775,16 +2454,16 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.2.0" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", "ed25519-dalek", "getrandom 0.2.17", "hmac", "js-sys", - "p256", + "p256 0.13.2", "p384", "pem", "rand 0.8.5", @@ -1792,7 +2471,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "signature", + "signature 2.2.0", "simple_asn1", ] @@ -1802,7 +2481,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -1857,6 +2536,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1996,9 +2684,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.14.0" +version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12ea9ac0a51fb5112607099560fdf0f90366ab088a2a9e6e8ae176794e9806aa" +checksum = "b479616bb6f0779fb0f3964246beda02d4b01144e1b0d5519616e012ccc2a245" dependencies = [ "memo-map", "self_cell", @@ -2055,7 +2743,7 @@ dependencies = [ "httparse", "memchr", "mime", - "spin", + "spin 0.9.8", "version_check", ] @@ -2274,20 +2962,37 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa 0.14.8", + "elliptic-curve 0.12.3", + "sha2", +] + [[package]] name = "p256" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "primeorder", "sha2", ] @@ -2298,8 +3003,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "primeorder", "sha2", ] @@ -2480,9 +3185,19 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", ] [[package]] @@ -2491,8 +3206,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] @@ -2503,9 +3218,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" @@ -2574,7 +3289,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", ] [[package]] @@ -2680,7 +3395,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -2717,7 +3432,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -2878,9 +3593,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2890,20 +3605,26 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "regress" @@ -2925,12 +3646,12 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "h2", + "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", @@ -2996,7 +3717,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "http 1.4.0", - "hyper", + "hyper 1.8.1", "parking_lot 0.11.2", "reqwest", "reqwest-middleware", @@ -3049,6 +3770,17 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -3078,8 +3810,8 @@ dependencies = [ "dropshot-verified-body", "hex", "http 1.4.0", - "hyper", - "jsonwebtoken 10.2.0", + "hyper 1.8.1", + "jsonwebtoken 10.3.0", "meilisearch-sdk", "minijinja", "mockall", @@ -3136,7 +3868,7 @@ dependencies = [ "dirs 6.0.0", "futures", "itertools", - "jsonwebtoken 10.2.0", + "jsonwebtoken 10.3.0", "oauth2", "owo-colors", "progenitor-client", @@ -3217,6 +3949,8 @@ name = "rfd-processor" version = "0.12.3" dependencies = [ "async-trait", + "aws-config", + "aws-sdk-s3", "base64", "chrono", "config", @@ -3230,6 +3964,7 @@ dependencies = [ "md-5", "meilisearch-sdk", "mime_guess", + "mockall", "newtype-uuid", "octorust", "parse-rfd", @@ -3307,11 +4042,11 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sha2", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -3367,6 +4102,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.22.4" @@ -3387,6 +4134,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -3426,6 +4174,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.102.8" @@ -3443,6 +4201,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3517,9 +4276,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -3545,22 +4304,46 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct 0.1.1", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -3745,7 +4528,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -3824,6 +4607,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "2.2.0" @@ -3854,9 +4647,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slog" @@ -3926,6 +4719,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[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.2" @@ -3942,6 +4745,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spinning_top" version = "0.3.0" @@ -3951,6 +4760,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -3958,7 +4777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -4035,9 +4854,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -4231,7 +5050,7 @@ dependencies = [ "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4247,6 +5066,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.25.0" @@ -4399,7 +5228,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -4646,6 +5475,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4673,7 +5508,7 @@ dependencies = [ [[package]] name = "v-api" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/v-api#5ea039de839c4a88943961e8bbfb298781671755" +source = "git+https://github.com/oxidecomputer/v-api#21d760b210429e23df76a1f6bf691c94b3de5488" dependencies = [ "async-trait", "base64", @@ -4688,8 +5523,8 @@ dependencies = [ "hex", "http 1.4.0", "http-body-util", - "hyper", - "jsonwebtoken 10.2.0", + "hyper 1.8.1", + "jsonwebtoken 10.3.0", "newtype-uuid", "oauth2", "partial-struct", @@ -4718,7 +5553,7 @@ dependencies = [ [[package]] name = "v-api-installer" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/v-api#5ea039de839c4a88943961e8bbfb298781671755" +source = "git+https://github.com/oxidecomputer/v-api#21d760b210429e23df76a1f6bf691c94b3de5488" dependencies = [ "diesel", "diesel_migrations", @@ -4727,7 +5562,7 @@ dependencies = [ [[package]] name = "v-api-permission-derive" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/v-api#5ea039de839c4a88943961e8bbfb298781671755" +source = "git+https://github.com/oxidecomputer/v-api#21d760b210429e23df76a1f6bf691c94b3de5488" dependencies = [ "heck", "proc-macro2", @@ -4738,7 +5573,7 @@ dependencies = [ [[package]] name = "v-model" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/v-api#5ea039de839c4a88943961e8bbfb298781671755" +source = "git+https://github.com/oxidecomputer/v-api#21d760b210429e23df76a1f6bf691c94b3de5488" dependencies = [ "async-bb8-diesel", "async-trait", @@ -4773,6 +5608,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "waitgroup" version = "0.1.2" @@ -4925,9 +5766,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -5210,6 +6051,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "xtask" version = "0.0.0" @@ -5280,8 +6127,8 @@ dependencies = [ "base64", "http 1.4.0", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "log", "percent-encoding", @@ -5297,18 +6144,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.34" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.34" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" dependencies = [ "proc-macro2", "quote", @@ -5377,6 +6224,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.17" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml index b74c6c61..538a7872 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ resolver = "2" anyhow = "1.0.100" async-bb8-diesel = { git = "https://github.com/oxidecomputer/async-bb8-diesel", version = "0.3" } async-trait = "0.1.89" +aws-config = "1.8.13" +aws-sdk-s3 = "1.99.0" base64 = "0.22" bb8 = "0.9" chrono = "0.4.43" diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..3c0dcfaf --- /dev/null +++ b/Containerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1 +# Build stage +FROM docker.io/rust:1-trixie AS builder + +ARG DIESEL_VER=v2.3.5 + +WORKDIR /app + +# Install build dependencies for diesel/postgres +RUN apt-get update && apt-get install -y \ + libpq-dev \ + pkg-config + + +# Copy workspace files +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ + +# Copy all workspace members +COPY parse-rfd ./parse-rfd +COPY rfd-api ./rfd-api +COPY rfd-cli ./rfd-cli +COPY rfd-data ./rfd-data +COPY rfd-github ./rfd-github +COPY rfd-installer ./rfd-installer +COPY rfd-model ./rfd-model +COPY rfd-processor ./rfd-processor +COPY rfd-sdk ./rfd-sdk +COPY trace-request ./trace-request +COPY xtask ./xtask + +ENV CARGO_HOME=/data/cargo + +# Build all target binaries in release mode +RUN cargo build --release \ + --package rfd-api \ + --package rfd-processor \ + --package rfd-cli \ + --package rfd-installer + +# Download diesel tool for migrations +WORKDIR /tmp +RUN curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VER}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz" \ + && curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VER}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256" \ + && sha256sum diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256 && tar --strip-components=1 -xJvf diesel_cli-x86_64-unknown-linux-gnu.tar.xz + +# Runtime stage +FROM docker.io/debian:trixie-slim + +# Install runtime dependencies and tini +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libpq5 \ + tini \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --user-group rfd \ + && mkdir /home/rfd/db + +# Copy binaries from builder +COPY --from=builder /app/target/release/rfd-api /usr/local/bin/ +COPY --from=builder /app/target/release/rfd-processor /usr/local/bin/ +COPY --from=builder /app/target/release/rfd-cli /usr/local/bin/ +COPY --from=builder /app/target/release/rfd-installer /usr/local/bin/ + +# Database migrations for diesel +COPY --from=builder /tmp/diesel /usr/local/bin/ +# COPY --from=builder /app/rfd-model/diesel.toml /home/rfd/db/ +COPY --from=builder /app/rfd-model/migrations/ /home/rfd/db/migrations/ + +# Create non-root user +USER rfd +WORKDIR /home/rfd + +# Use tini as entrypoint for proper signal handling +ENTRYPOINT ["/usr/bin/tini", "--"] + +# Default to running rfd-api, can be overridden with CMD +CMD ["rfd-api"] diff --git a/SETUP.md b/SETUP.md index 13a3119b..2416b351 100644 --- a/SETUP.md +++ b/SETUP.md @@ -107,3 +107,42 @@ cargo run -p rfd-cli --features local-dev The processor has multiple jobs that are able to be run, and configuration is only required for jobs that are going to be run. The `actions` key defines the jobs that should be run. By default all jobs are disabled. In this this mode the processor will only construct a database of RFDs. + +##### Static Asset Storage + +The processor can copy images and other static assets extracted from RFDs to cloud storage. Both +Google Cloud Storage (GCS) and Amazon S3 (or S3-compatible services) are supported. Multiple +storage backends can be configured simultaneously, and assets will be pushed to all of them. + +To enable this feature, add `CopyImagesToStorage` to the `actions` list and configure at least one +storage backend. + +**Google Cloud Storage (GCS)** + +```toml +[[gcs_storage]] +bucket = "your-bucket-name" +``` + +GCS uses GCP Application Default Credentials for authentication. Configure credentials using one of: +- `GOOGLE_APPLICATION_CREDENTIALS` environment variable pointing to a service account key file +- Instance metadata (when running on GCP Compute Engine, GKE, etc.) +- `gcloud auth application-default login` (for local development) + +**Amazon S3** + +```toml +[[s3_storage]] +bucket = "your-bucket-name" +region = "us-west-2" +# Optional: custom endpoint for S3-compatible services +# endpoint = "https://s3.example.com" +``` + +S3 uses the AWS SDK default credential chain for authentication: +- Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optionally `AWS_SESSION_TOKEN`) +- Shared credentials file (`~/.aws/credentials`) +- IAM role (when running on AWS EC2, ECS, Lambda, etc.) + +The optional `endpoint` field allows using S3-compatible services such as MinIO, Backblaze B2, or +Cloudflare R2. diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..136ff31c --- /dev/null +++ b/compose.yml @@ -0,0 +1,131 @@ +services: + # This requires changes to rfd-site which will be proposed via PR. + # rfd-site: + # build: + # context: https://github.com/oxidecomputer/rfd-site.git + # dockerfile: Containerfile + # image: localhost/rfd-site-test:latest + # networks: + # - rfd + # ports: + # - "3000:3000" + # environment: + # SESSION_SECRET: ${SESSION_SECRET} + # RFD_API_BACKEND_URL: http://rfd-api:8080 + # RFD_API_FRONTEND_URL: http://localhost:8080 + # RFD_API_CLIENT_ID: ${RFD_API_CLIENT_ID} + # RFD_API_CLIENT_SECRET: ${RFD_API_CLIENT_SECRET} + # RFD_API_GITHUB_CALLBACK_URL: http://localhost:3000/auth/github/callback + # AUTH_PROVIDERS: github + # STORAGE_PROVIDER: s3 + # S3_BUCKET: ${S3_BUCKET} + # AWS_REGION: ${AWS_REGION} + # AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + # AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + # AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN} + # TELEMETRY_DISABLE: true + # GITHUB_HOST: ${GITHUB_HOST} + # GITHUB_REPO_CLIENT_ID: ${GITHUB_REPO_CLIENT_ID} + # GITHUB_REPO_CLIENT_SECRET: ${GITHUB_REPO_CLIENT_SECRET} + + rfd-migration: + build: + dockerfile: Containerfile + image: localhost/rfd-api-test:latest + networks: + - rfd + depends_on: + postgres: + condition: service_healthy + entrypoint: [] + working_dir: /home/rfd/db + environment: + - DATABASE_URL=postgres://rfd:rfd@postgres/rfd + command: /usr/local/bin/rfd-installer && diesel migration run && echo "Migrations Complete!" + + rfd-api: + image: localhost/rfd-api-test:latest + # Uses default CMD: rfd-api + ports: + - "8080:8080" + environment: + # - RFD_API_CONFIG=/config/rfd-api.toml + - DATABASE_URL=postgres://rfd:rfd@postgres/rfd + command: + - /usr/local/bin/rfd-api + - /config/rfd-api.toml + volumes: + - ./config:/config:ro + networks: + - rfd + depends_on: + postgres: + condition: service_started + meilisearch: + condition: service_started + rfd-migration: + condition: service_completed_successfully + + rfd-processor: + image: localhost/rfd-api-test:latest + + # Override CMD to run rfd-processor instead + environment: + # - RFD_API_CONFIG=/config/rfd-api.toml + - DATABASE_URL=postgres://rfd:rfd@postgres/rfd + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} + command: + - /usr/local/bin/rfd-processor + - /config/rfd-processor.toml + volumes: + - ./config:/config:ro + networks: + - rfd + depends_on: + postgres: + condition: service_started + meilisearch: + condition: service_started + rfd-migration: + condition: service_completed_successfully + + postgres: + image: docker.io/postgres:14-trixie + environment: + POSTGRES_USER: rfd + POSTGRES_PASSWORD: rfd + POSTGRES_DB: rfd + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - rfd + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rfd"] + start_period: 5s + start_interval: 1s + retries: 10 + interval: 5s + + meilisearch: + image: docker.io/getmeili/meilisearch:v1.11 + environment: + MEILI_ENV: development + MEILI_MASTER_KEY: development-master-key + volumes: + - meilisearch_data:/meili_data + ports: + - "7700:7700" + networks: + - rfd + +networks: + rfd: + driver: bridge + +volumes: + postgres_data: + meilisearch_data: diff --git a/rfd-api/src/config.rs b/rfd-api/src/config.rs index 064ec64f..1179f41e 100644 --- a/rfd-api/src/config.rs +++ b/rfd-api/src/config.rs @@ -15,6 +15,7 @@ use crate::server::SpecConfig; #[derive(Debug, Deserialize)] pub struct AppConfig { pub log_format: ServerLogFormat, + pub log_filter: Option, pub log_directory: Option, pub initial_mappers: Option, pub public_url: String, diff --git a/rfd-api/src/main.rs b/rfd-api/src/main.rs index da4fd5f8..731934d4 100644 --- a/rfd-api/src/main.rs +++ b/rfd-api/src/main.rs @@ -55,10 +55,15 @@ async fn main() -> anyhow::Result<()> { NonBlocking::new(std::io::stdout()) }; + let env_filter = match config.log_filter { + Some(ref filter) => EnvFilter::new(filter), + None => EnvFilter::from_default_env(), + }; + let subscriber = tracing_subscriber::fmt() .with_file(false) .with_line_number(false) - .with_env_filter(EnvFilter::from_default_env()) + .with_env_filter(env_filter) .with_writer(writer); match config.log_format { diff --git a/rfd-api/src/server.rs b/rfd-api/src/server.rs index 70f19e04..61ae1f17 100644 --- a/rfd-api/src/server.rs +++ b/rfd-api/src/server.rs @@ -56,7 +56,7 @@ pub fn server( // Construct a shim to pipe dropshot logs into the global tracing logger let dropshot_logger = { - let level_drain = slog::LevelFilter(TracingSlogDrain, slog::Level::Debug).fuse(); + let level_drain = slog::LevelFilter(TracingSlogDrain, slog::Level::Trace).fuse(); let async_drain = slog_async::Async::new(level_drain).build().fuse(); slog::Logger::root(async_drain, slog::o!()) }; diff --git a/rfd-installer/src/lib.rs b/rfd-installer/src/lib.rs index e968fa6e..18c2a899 100644 --- a/rfd-installer/src/lib.rs +++ b/rfd-installer/src/lib.rs @@ -11,6 +11,7 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../rfd-model/migrations"); pub fn run_migrations(url: &str, v_only: bool) { + // These are safe to run multiple times. v_api_installer::run_migrations(url); if !v_only { diff --git a/rfd-model/diesel.toml b/rfd-model/diesel.toml index 2f2493e1..863549e3 100644 --- a/rfd-model/diesel.toml +++ b/rfd-model/diesel.toml @@ -1,9 +1,9 @@ # For documentation on how to configure this file, # see https://diesel.rs/guides/configuring-diesel-cli -[print_schema] -file = "src/schema.rs" -patch_file = "diesel-schema.patch" +# [print_schema] +# file = "src/schema.rs" +# patch_file = "diesel-schema.patch" [print_schema.filter] except_tables = [ diff --git a/rfd-processor/Cargo.toml b/rfd-processor/Cargo.toml index c853f700..178b9fb0 100644 --- a/rfd-processor/Cargo.toml +++ b/rfd-processor/Cargo.toml @@ -5,8 +5,13 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dev-dependencies] +mockall = { workspace = true } + [dependencies] async-trait = { workspace = true } +aws-config = { workspace = true } +aws-sdk-s3 = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } config = { workspace = true } diff --git a/rfd-processor/config.example.toml b/rfd-processor/config.example.toml index f33fbfed..385c7f38 100644 --- a/rfd-processor/config.example.toml +++ b/rfd-processor/config.example.toml @@ -69,10 +69,24 @@ path = "" # Branch to use as the default branch of the repository default_branch = "" -# Bucket to push static assets pulled from RFDs to (currently only GCP Storage buckets are supported) -[[static_storage]] -# Name of the bucket +# Static asset storage for images extracted from RFDs. Both GCS and S3 are supported. +# Multiple storage backends can be configured, and assets will be pushed to all of them. + +# Google Cloud Storage (GCS) +# Requires GCP Application Default Credentials to be configured +[[gcs_storage]] +# Name of the GCS bucket +bucket = "" + +# Amazon S3 (or S3-compatible storage) +# Uses AWS SDK default credential chain +[[s3_storage]] +# Name of the S3 bucket bucket = "" +# AWS region (e.g., "us-west-2") +region = "" +# Optional: Custom endpoint URL for S3-compatible services (e.g., MinIO, Backblaze B2) +# endpoint = "https://s3.example.com" # Location to store generated PDFs (currently on Google Drive Shared Drives are supported) [pdf_storage] diff --git a/rfd-processor/src/context.rs b/rfd-processor/src/context.rs index 7c5324b1..fbb6ce36 100644 --- a/rfd-processor/src/context.rs +++ b/rfd-processor/src/context.rs @@ -34,9 +34,22 @@ use crate::{ search::{RfdSearchIndex, SearchError}, updater::{BoxedAction, RfdUpdateMode, RfdUpdaterError}, util::{gdrive_client, GDriveError}, - AppConfig, GitHubAuthConfig, PdfStorageConfig, SearchConfig, StaticStorageConfig, + AppConfig, GcsStorageConfig, GitHubAuthConfig, PdfStorageConfig, S3StorageConfig, SearchConfig, }; +pub type StaticStorageError = Box; + +#[async_trait] +pub trait StaticStorage: Send + Sync { + async fn put( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result<(), StaticStorageError>; + fn name(&self) -> &str; +} + pub struct Database { pub storage: PostgresStore, } @@ -80,8 +93,8 @@ pub struct Context { pub db: Database, pub github: GitHubCtx, pub actions: Vec, - pub assets: StaticAssetStorageCtx, - pub pdf: PdfStorageCtx, + pub static_storage: Vec>, + pub pdf: Option, pub search: SearchCtx, } @@ -161,7 +174,7 @@ impl Context { .iter() .map(|action| action.as_str().try_into()) .collect::, RfdUpdaterError>>()?, - assets: StaticAssetStorageCtx::new(&config.static_storage).await?, + static_storage: build_static_storage(&config.gcs_storage, &config.s3_storage).await?, pdf: PdfStorageCtx::new(&config.pdf_storage).await?, search: SearchCtx::new(&config.search_storage)?, }) @@ -186,69 +199,150 @@ pub struct GitHubCtx { pub repository: GitHubRfdRepo, } -pub struct StaticAssetStorageCtx { - pub client: Storage>, - pub locations: Vec, +async fn build_static_storage( + gcs_entries: &[GcsStorageConfig], + s3_entries: &[S3StorageConfig], +) -> Result>, ContextError> { + let mut storage: Vec> = Vec::new(); + + // Build GCS storage instances + for entry in gcs_entries { + let client = build_gcs_client().await?; + storage.push(Box::new(GcsStorage { + client, + bucket: entry.bucket.clone(), + })); + } + + // Build S3 storage instances + for entry in s3_entries { + let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(aws_config::Region::new(entry.region.clone())); + + if let Some(endpoint) = &entry.endpoint { + config_loader = config_loader.endpoint_url(endpoint); + } + + let sdk_config = config_loader.load().await; + let client = aws_sdk_s3::Client::new(&sdk_config); + + storage.push(Box::new(S3Storage { + client, + bucket: entry.bucket.clone(), + })); + } + + Ok(storage) } -impl StaticAssetStorageCtx { - pub async fn new(entries: &[StaticStorageConfig]) -> Result { - let opts = yup_oauth2::ApplicationDefaultCredentialsFlowOpts::default(); - let gcp_auth = match yup_oauth2::ApplicationDefaultCredentialsAuthenticator::builder(opts) - .await - { - yup_oauth2::authenticator::ApplicationDefaultCredentialsTypes::ServiceAccount(auth) => { - tracing::debug!("Service account based credentials"); - - auth.build().await.map_err(|err| { - tracing::error!( - ?err, - "Failed to construct Cloud Storage credentials from service account" - ); - ContextError::FailedToFindGcpCredentials(err) - })? - } - yup_oauth2::authenticator::ApplicationDefaultCredentialsTypes::InstanceMetadata( - auth, - ) => { - tracing::debug!("Create instance based credentials"); - - auth.build().await.map_err(|err| { - tracing::error!( - ?err, - "Failed to construct Cloud Storage credentials from instance metadata" - ); - ContextError::FailedToFindGcpCredentials(err) - })? - } - }; +async fn build_gcs_client() -> Result>, ContextError> { + let opts = yup_oauth2::ApplicationDefaultCredentialsFlowOpts::default(); + let gcp_auth = match yup_oauth2::ApplicationDefaultCredentialsAuthenticator::builder(opts).await + { + yup_oauth2::authenticator::ApplicationDefaultCredentialsTypes::ServiceAccount(auth) => { + tracing::debug!("Service account based credentials"); + + auth.build().await.map_err(|err| { + tracing::error!( + ?err, + "Failed to construct Cloud Storage credentials from service account" + ); + ContextError::FailedToFindGcpCredentials(err) + })? + } + yup_oauth2::authenticator::ApplicationDefaultCredentialsTypes::InstanceMetadata(auth) => { + tracing::debug!("Create instance based credentials"); + + auth.build().await.map_err(|err| { + tracing::error!( + ?err, + "Failed to construct Cloud Storage credentials from instance metadata" + ); + ContextError::FailedToFindGcpCredentials(err) + })? + } + }; + + Ok(Storage::new( + Client::builder(TokioExecutor::new()).build( + HttpsConnectorBuilder::new() + .with_native_roots() + .unwrap() + .https_only() + .enable_http2() + .build(), + ), + gcp_auth, + )) +} - let storage = Storage::new( - Client::builder(TokioExecutor::new()).build( - HttpsConnectorBuilder::new() - .with_native_roots() - .unwrap() - .https_only() - .enable_http2() - .build(), - ), - gcp_auth, - ); +pub struct GcsStorage { + client: Storage>, + bucket: String, +} - Ok(Self { - client: storage, - locations: entries - .iter() - .map(|e| StaticAssetLocation { - bucket: e.bucket.to_string(), - }) - .collect(), - }) +#[async_trait] +impl StaticStorage for GcsStorage { + async fn put( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result<(), StaticStorageError> { + use google_storage1::api::Object; + + let mime_type: mime_guess::Mime = content_type + .parse() + .unwrap_or(mime_guess::mime::APPLICATION_OCTET_STREAM); + let cursor = std::io::Cursor::new(data); + + self.client + .objects() + .insert(Object::default(), &self.bucket) + .name(key) + .upload(cursor, mime_type) + .await?; + + Ok(()) + } + + fn name(&self) -> &str { + &self.bucket } } -pub struct StaticAssetLocation { - pub bucket: String, +pub struct S3Storage { + client: aws_sdk_s3::Client, + bucket: String, +} + +#[async_trait] +impl StaticStorage for S3Storage { + async fn put( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result<(), StaticStorageError> { + use aws_sdk_s3::primitives::ByteStream; + + let body = ByteStream::from(data); + + self.client + .put_object() + .bucket(&self.bucket) + .key(key) + .content_type(content_type) + .body(body) + .send() + .await?; + + Ok(()) + } + + fn name(&self) -> &str { + &self.bucket + } } pub type GDriveClient = DriveHub>; @@ -259,19 +353,16 @@ pub struct PdfStorageCtx { } impl PdfStorageCtx { - pub async fn new(config: &Option) -> Result { - Ok(Self { - // A client is only needed if files are going to be written - client: gdrive_client().await?, - locations: config - .as_ref() - .map(|config| { - vec![PdfStorageLocation { - folder_id: config.folder.clone(), - }] - }) - .unwrap_or_default(), - }) + pub async fn new(config: &Option) -> Result, GDriveError> { + match config { + Some(config) => Ok(Some(Self { + client: gdrive_client().await?, + locations: vec![PdfStorageLocation { + folder_id: config.folder.clone(), + }], + })), + None => Ok(None), + } } } @@ -324,7 +415,7 @@ impl PdfStorage for PdfStorageCtx { } } .tap_ok(|_| { - tracing::info!("Sucessfully uploaded PDF"); + tracing::info!("Successfully uploaded PDF"); }) .tap_err(|err| { tracing::error!(?err, "Failed to upload PDF"); @@ -364,3 +455,144 @@ impl SearchCtx { }) } } + +#[cfg(test)] +pub mod test { + use super::*; + use mockall::mock; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + mock! { + pub StaticStorage {} + + #[async_trait] + impl StaticStorage for StaticStorage { + async fn put( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result<(), StaticStorageError>; + fn name(&self) -> &str; + } + } + + /// In-memory storage implementation for testing + pub struct InMemoryStorage { + name: String, + objects: Arc>>, + } + + #[derive(Clone)] + pub struct StoredObject { + pub data: Vec, + pub content_type: String, + } + + impl InMemoryStorage { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + objects: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn get(&self, key: &str) -> Option { + self.objects.lock().unwrap().get(key).cloned() + } + + pub fn len(&self) -> usize { + self.objects.lock().unwrap().len() + } + } + + #[async_trait] + impl StaticStorage for InMemoryStorage { + async fn put( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result<(), StaticStorageError> { + self.objects.lock().unwrap().insert( + key.to_string(), + StoredObject { + data, + content_type: content_type.to_string(), + }, + ); + Ok(()) + } + + fn name(&self) -> &str { + &self.name + } + } + + #[tokio::test] + async fn in_memory_storage_stores_and_retrieves_objects() { + let storage = InMemoryStorage::new("test-bucket"); + + let data = b"hello world".to_vec(); + storage + .put("test/key.txt", data.clone(), "text/plain") + .await + .unwrap(); + + let obj = storage.get("test/key.txt").unwrap(); + assert_eq!(obj.data, data); + assert_eq!(obj.content_type, "text/plain"); + } + + #[tokio::test] + async fn in_memory_storage_returns_correct_name() { + let storage = InMemoryStorage::new("my-bucket"); + assert_eq!(storage.name(), "my-bucket"); + } + + #[tokio::test] + async fn in_memory_storage_overwrites_existing_objects() { + let storage = InMemoryStorage::new("test-bucket"); + + storage + .put("key", b"first".to_vec(), "text/plain") + .await + .unwrap(); + storage + .put("key", b"second".to_vec(), "text/plain") + .await + .unwrap(); + + let obj = storage.get("key").unwrap(); + assert_eq!(obj.data, b"second".to_vec()); + assert_eq!(storage.len(), 1); + } + + #[tokio::test] + async fn mock_storage_can_simulate_failure() { + let mut mock = MockStaticStorage::new(); + mock.expect_put() + .returning(|_, _, _| Err("simulated failure".into())); + mock.expect_name().return_const("mock-bucket".to_string()); + + let result = mock.put("key", vec![], "text/plain").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn mock_storage_can_verify_calls() { + let mut mock = MockStaticStorage::new(); + mock.expect_put() + .withf(|key, data, content_type| { + key == "expected/key.png" && data == b"image data" && content_type == "image/png" + }) + .times(1) + .returning(|_, _, _| Ok(())); + mock.expect_name().return_const("mock-bucket".to_string()); + + mock.put("expected/key.png", b"image data".to_vec(), "image/png") + .await + .unwrap(); + } +} diff --git a/rfd-processor/src/main.rs b/rfd-processor/src/main.rs index 0641d2b5..895ff0ab 100644 --- a/rfd-processor/src/main.rs +++ b/rfd-processor/src/main.rs @@ -31,6 +31,7 @@ mod util; #[derive(Debug, Deserialize)] pub struct AppConfig { pub log_directory: Option, + pub log_filter: Option, #[serde(default)] pub log_format: LogFormat, pub processor_enabled: bool, @@ -45,7 +46,9 @@ pub struct AppConfig { pub auth: AuthConfig, pub source: GitHubSourceRepo, #[serde(default)] - pub static_storage: Vec, + pub gcs_storage: Vec, + #[serde(default)] + pub s3_storage: Vec, #[serde(default)] pub pdf_storage: Option, #[serde(default)] @@ -96,10 +99,17 @@ pub struct GitHubSourceRepo { } #[derive(Debug, Deserialize, Serialize)] -pub struct StaticStorageConfig { +pub struct GcsStorageConfig { pub bucket: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct S3StorageConfig { + pub bucket: String, + pub region: String, + pub endpoint: Option, +} + #[derive(Debug, Deserialize, Serialize)] pub struct PdfStorageConfig { pub drive: Option, @@ -145,10 +155,15 @@ async fn main() -> Result<(), Box> { NonBlocking::new(std::io::stdout()) }; + let env_filter = match config.log_filter { + Some(ref filter) => EnvFilter::new(filter), + None => EnvFilter::from_default_env(), + }; + let subscriber = tracing_subscriber::fmt() .with_file(false) .with_line_number(false) - .with_env_filter(EnvFilter::from_default_env()) + .with_env_filter(env_filter) .with_writer(writer); match config.log_format { diff --git a/rfd-processor/src/updater/copy_images_to_storage.rs b/rfd-processor/src/updater/copy_images_to_storage.rs index ee72a669..73a7768f 100644 --- a/rfd-processor/src/updater/copy_images_to_storage.rs +++ b/rfd-processor/src/updater/copy_images_to_storage.rs @@ -3,7 +3,6 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use async_trait::async_trait; -use google_storage1::api::Object; use tracing::instrument; use crate::{rfd::PersistedRfd, util::decode_base64}; @@ -49,24 +48,25 @@ impl RfdUpdateAction for CopyImagesToStorage { "Writing file to storage buckets" ); - let cursor = std::io::Cursor::new(data); - - for location in &ctx.assets.locations { - tracing::info!(bucket = ?location.bucket, ?object_name, "Writing to location"); + for storage in &ctx.static_storage { + tracing::info!(name = storage.name(), ?object_name, "Writing to storage"); if mode == RfdUpdateMode::Write { - // TODO: Move implementation to a trait and abstract over different storage systems - if let Err(err) = ctx - .assets - .client - .objects() - .insert(Object::default(), &location.bucket) - .name(&object_name) - .upload(cursor.clone(), mime_type.clone()) + if let Err(err) = storage + .put(&object_name, data.clone(), mime_type.as_ref()) .await { - tracing::error!(?err, "Failed to upload static file to GCP"); + tracing::error!( + name = storage.name(), + ?err, + "Failed to upload static file" + ); } + } else { + tracing::warn!( + "CopyImagesToStorage is enabled however RfdUpdateMode is not write: {:?}", + mode + ); } } } diff --git a/rfd-processor/src/updater/update_pdfs.rs b/rfd-processor/src/updater/update_pdfs.rs index 365dcac0..1c20d992 100644 --- a/rfd-processor/src/updater/update_pdfs.rs +++ b/rfd-processor/src/updater/update_pdfs.rs @@ -68,15 +68,21 @@ impl UpdatePdfs { let store_results = match mode { RfdUpdateMode::Read => Vec::new(), - RfdUpdateMode::Write => { - ctx.pdf - .store_rfd_pdf( - new.pdf_external_id.as_deref(), - &new.get_pdf_filename(), - &pdf, - ) - .await - } + RfdUpdateMode::Write => match &ctx.pdf { + Some(pdf_storage) => { + pdf_storage + .store_rfd_pdf( + new.pdf_external_id.as_deref(), + &new.get_pdf_filename(), + &pdf, + ) + .await + } + None => { + tracing::debug!("PDF storage is disabled, skipping upload"); + Vec::new() + } + }, }; Ok(store_results From b4c8828ac951f1bfc6e5bbcfbafbdbb72cd0eefa Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Fri, 6 Feb 2026 11:06:30 -0800 Subject: [PATCH 2/7] Add node and disable meilisearch analytics --- Containerfile | 16 ++++++++++++---- compose.yml | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Containerfile b/Containerfile index 3c0dcfaf..d31ed052 100644 --- a/Containerfile +++ b/Containerfile @@ -2,7 +2,8 @@ # Build stage FROM docker.io/rust:1-trixie AS builder -ARG DIESEL_VER=v2.3.5 +ARG DIESEL_VERSION=v2.3.5 +ARG NODE_VERSION=v24.13.0 WORKDIR /app @@ -11,7 +12,6 @@ RUN apt-get update && apt-get install -y \ libpq-dev \ pkg-config - # Copy workspace files COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ @@ -39,10 +39,15 @@ RUN cargo build --release \ # Download diesel tool for migrations WORKDIR /tmp -RUN curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VER}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz" \ - && curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VER}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256" \ +RUN curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VERSION}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz" \ + && curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VERSION}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256" \ && sha256sum diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256 && tar --strip-components=1 -xJvf diesel_cli-x86_64-unknown-linux-gnu.tar.xz +# Node for search indec +RUN curl -L --output-dir /tmp -O "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \ + && curl -L --output-dir /tmp -o node.SHASUMS256.txt "https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt" \ + && sha256sum node.SHASUMS256.txt && tar --strip-components=1 -xJvf "node-${NODE_VERSION}-linux-x64.tar.xz" + # Runtime stage FROM docker.io/debian:trixie-slim @@ -67,6 +72,9 @@ COPY --from=builder /tmp/diesel /usr/local/bin/ # COPY --from=builder /app/rfd-model/diesel.toml /home/rfd/db/ COPY --from=builder /app/rfd-model/migrations/ /home/rfd/db/migrations/ +# Node for search indexing +COPY --from=builder /tmp/bin/node /usr/local/bin/ + # Create non-root user USER rfd WORKDIR /home/rfd diff --git a/compose.yml b/compose.yml index 136ff31c..8378a8dc 100644 --- a/compose.yml +++ b/compose.yml @@ -115,6 +115,8 @@ services: environment: MEILI_ENV: development MEILI_MASTER_KEY: development-master-key + MEILI_NO_ANALYTICS: "true" + MEILI_DB_PATH: /meili_data/data.ms volumes: - meilisearch_data:/meili_data ports: From 78f92a8ac1dfae68ff096c9ad0ee3712e985bed3 Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Thu, 19 Feb 2026 20:28:57 -0800 Subject: [PATCH 3/7] Generic secrets (#7) * Add SecretString type for path-based secret configuration Introduces a new rfd-secret crate with a SecretString type that allows secrets to be specified either inline or read from a file path. This enables storing secrets outside of configuration files (e.g., in /run/secrets for container deployments). Secrets can now be configured as: - Inline: key = "my-secret" - From file: key = { path = "/run/secrets/my-key" } Updated secrets in rfd-api: - keys[].private and keys[].public (JWT signing keys) - authn.oauth.*.client_id and client_secret - search.key - services.github.auth.private_key and token - magic_link.email_service.resend.key Updated secrets in rfd-processor: - auth.github.private_key and token - search_storage[].key Claude Assisted * Add DatabaseConfig struct for structured database configuration Replace single database_url string with a [database] config section containing host, port, user, password, and database fields. The password field uses SecretString to support both inline values and path-based secrets for containerized deployments. Claude Assisted * Refactor rfd-kube-init to use Clap for CLI argument parsing - Add clap dependency with derive and env features - Create CLI structure with subcommands for extensibility - Convert environment variable configuration to Clap derive structs - Use Clap's native integer parsing for expiration_seconds - Use value_delimiter for comma-separated namespace arguments - Use argument groups to require at least one namespace type - Refactor TokenType to tuple enum containing Option - Track failed namespaces in vector for detailed error reporting - Return errors from init() instead of using exit codes Claude Assisted * added rfd-kube-init to Containerfile * Extract rfd-kube-init as standalone crate with improved tracing Move rfd-kube-init out of the workspace to avoid TLS/crypto feature conflicts between jsonwebtoken (JWT signing) and rustls (TLS). The crate now uses explicit dependency versions with compatible crypto backends (aws-lc-rs). - Upgrade kube crate from 0.98 to 3.0.1 - Add structured tracing with #[instrument] attributes - Enable RUST_LOG env filter support - Add test for crypto provider configuration - Update Containerfile to build rfd-kube-init separately Claude Assisted * testing db-init with direct to database calls (#3) * Revert "testing db-init with direct to database calls (#3)" (#5) This reverts commit 9d6f72870f15932ff8a54738cfc60dc3b0dde6ea. * Init d (#6) * testing db-init with direct to database calls * Apply formatting to config.rs Claude Assisted * Add /init endpoint and oauth-init CLI command - Add /init endpoint to rfd-api for bootstrapping OAuth client - Support multiple redirect_uris in /init request/response - Add oauth-init subcommand to rfd-kube-init CLI - Distribute OAuth credentials to target Kubernetes namespaces - Idempotent: returns success on 409 Conflict * cleaned up status check and removed redundant logging for oauth_init * Refactor init endpoint to use Caller instead of direct v_storage access - Replace direct OAuthClientStore/OAuthClientSecretStore/OAuthClientRedirectUriStore calls with ctx.v_ctx().oauth methods - Add static INIT_CALLER wrapped in OnceLock for singleton initialization - Construct Caller with hardcoded INIT_CALLER_ID for logging visibility - Remove v_storage field and accessor from RfdContext - Update main.rs to create storage inline (matching pre-v_storage pattern) Claude Assisted * Add retry support and accept 201 status for init endpoints - oauth_init: Use reqwest-middleware with exponential backoff retry (1s-30s, configurable max retries via --max-retries/OAUTH_MAX_RETRIES) - oauth_init: Accept 201 (Created) in addition to 200 (OK) for /init - oauth_init: Refactor response handling into handle_init_response() - meilisearch: Add manual retry loop with exponential backoff for get_api_keys() (configurable via --max-retries/MEILI_MAX_RETRIES) - Add reqwest-middleware and reqwest-retry dependencies --- Cargo.lock | 60 +- Cargo.toml | 9 +- Containerfile | 10 +- README.md | 48 + compose.yml | 4 +- rfd-api/Cargo.toml | 1 + rfd-api/config.example.toml | 31 +- rfd-api/src/config.rs | 161 +- rfd-api/src/context.rs | 45 +- rfd-api/src/endpoints/init.rs | 169 + rfd-api/src/endpoints/mod.rs | 1 + rfd-api/src/error.rs | 4 + rfd-api/src/main.rs | 55 +- rfd-api/src/server.rs | 4 + rfd-kube-init/Cargo.lock | 3205 +++++++++++++++++ rfd-kube-init/Cargo.toml | 30 + rfd-kube-init/README.md | 270 ++ rfd-kube-init/src/kube.rs | 89 + rfd-kube-init/src/main.rs | 71 + rfd-kube-init/src/meilisearch.rs | 360 ++ rfd-kube-init/src/oauth_init.rs | 184 + .../2026-02-18_initialization/down.sql | 2 + .../2026-02-18_initialization/up.sql | 8 + rfd-model/src/db.rs | 10 +- rfd-model/src/lib.rs | 1 + rfd-model/src/schema.rs | 9 + rfd-model/src/storage/mock.rs | 33 +- rfd-model/src/storage/mod.rs | 15 +- rfd-model/src/storage/postgres.rs | 49 +- rfd-processor/Cargo.toml | 1 + rfd-processor/config.example.toml | 21 +- rfd-processor/src/context.rs | 46 +- rfd-processor/src/main.rs | 31 +- rfd-secret/Cargo.toml | 14 + rfd-secret/src/lib.rs | 151 + 35 files changed, 5091 insertions(+), 111 deletions(-) create mode 100644 rfd-api/src/endpoints/init.rs create mode 100644 rfd-kube-init/Cargo.lock create mode 100644 rfd-kube-init/Cargo.toml create mode 100644 rfd-kube-init/README.md create mode 100644 rfd-kube-init/src/kube.rs create mode 100644 rfd-kube-init/src/main.rs create mode 100644 rfd-kube-init/src/meilisearch.rs create mode 100644 rfd-kube-init/src/oauth_init.rs create mode 100644 rfd-model/migrations/2026-02-18_initialization/down.sql create mode 100644 rfd-model/migrations/2026-02-18_initialization/up.sql create mode 100644 rfd-secret/Cargo.toml create mode 100644 rfd-secret/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ba3cc583..bbb32e05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -808,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" dependencies = [ "async-trait", - "convert_case", + "convert_case 0.6.0", "json5", "pathdiff", "ron", @@ -856,6 +857,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -2458,6 +2468,7 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ + "aws-lc-rs", "base64", "ed25519-dalek", "getrandom 0.2.17", @@ -2595,11 +2606,11 @@ dependencies = [ [[package]] name = "meilisearch-index-setting-macro" -version = "0.28.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420f67f5943a0236eea7f199720cc465e806c48978d9b0fdc1fb62eceaee7556" +checksum = "e0795d177129fed792fc789cf07d4647bb9e3939409fbe01764805c060afcd0e" dependencies = [ - "convert_case", + "convert_case 0.8.0", "proc-macro2", "quote", "structmeta", @@ -2608,25 +2619,28 @@ dependencies = [ [[package]] name = "meilisearch-sdk" -version = "0.28.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2325355c73c96667178c09675389cfa7afc2382d5aa0e0d34d0cf29793d89090" +checksum = "e4a20b5a4215a39c66854d14cbba97f615e84c49925d68629c05c42a5a85dac1" dependencies = [ "async-trait", "bytes", "either", - "futures", + "futures-channel", + "futures-core", "futures-io", + "futures-util", "iso8601", - "jsonwebtoken 9.3.1", + "jsonwebtoken 10.3.0", "log", "meilisearch-index-setting-macro", "pin-project-lite", "reqwest", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "time", + "tokio", "uuid", "wasm-bindgen-futures", "web-sys", @@ -3830,6 +3844,7 @@ dependencies = [ "rfd-data", "rfd-github", "rfd-model", + "rfd-secret", "ring", "rsa", "schemars 0.8.22", @@ -3976,6 +3991,7 @@ dependencies = [ "rfd-data", "rfd-github", "rfd-model", + "rfd-secret", "rsa", "serde", "tap", @@ -4002,6 +4018,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "rfd-secret" +version = "0.1.0" +dependencies = [ + "serde", + "tempfile", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -4012,7 +4038,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4181,7 +4207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4192,7 +4218,7 @@ checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4204,7 +4230,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4311,7 +4337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5456,6 +5482,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 538a7872..b375eb2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,11 @@ members = [ "rfd-model", "rfd-processor", "rfd-sdk", + "rfd-secret", "trace-request", "xtask" ] +exclude = ["rfd-kube-init"] resolver = "2" [workspace.dependencies] @@ -42,7 +44,8 @@ http = "1.4.0" hyper = "1.8.1" itertools = "0.13.0" jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } -meilisearch-sdk = "0.28.0" +meilisearch-sdk = "0.32.0" +k8s-openapi = { version = "0.27", features = ["v1_32"] } md-5 = "0.10.6" mime_guess = "2.0.5" minijinja = { version = "2.14", features = ["loader"] } @@ -79,6 +82,7 @@ slog = "2.8.2" slog-async = "2.8.0" tabwriter = "1.4.1" tap = "1.0.1" +tempfile = "3" textwrap = "0.16.2" thiserror = "2" tokio = { version = "1.49.0", default-features = false, features = ["rt-multi-thread", "macros"] } @@ -119,3 +123,6 @@ lto = "thin" # v-api-installer = { path = "../v-api/v-api-installer" } # v-model = { path = "../v-api/v-model" } # v-api-permission-derive = { path = "../v-api/v-api-permission-derive" } + +[profile.release] +debug = 1 \ No newline at end of file diff --git a/Containerfile b/Containerfile index d31ed052..2ed6a893 100644 --- a/Containerfile +++ b/Containerfile @@ -21,22 +21,27 @@ COPY rfd-api ./rfd-api COPY rfd-cli ./rfd-cli COPY rfd-data ./rfd-data COPY rfd-github ./rfd-github +COPY rfd-kube-init ./rfd-kube-init COPY rfd-installer ./rfd-installer COPY rfd-model ./rfd-model COPY rfd-processor ./rfd-processor COPY rfd-sdk ./rfd-sdk +COPY rfd-secret ./rfd-secret COPY trace-request ./trace-request COPY xtask ./xtask ENV CARGO_HOME=/data/cargo -# Build all target binaries in release mode -RUN cargo build --release \ +# Build workspace binaries in release mode +RUN cargo build --release \ --package rfd-api \ --package rfd-processor \ --package rfd-cli \ --package rfd-installer +# Build rfd-kube-init separately (excluded from workspace to avoid feature conflicts) +RUN cd rfd-kube-init && cargo build --release + # Download diesel tool for migrations WORKDIR /tmp RUN curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VERSION}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz" \ @@ -66,6 +71,7 @@ COPY --from=builder /app/target/release/rfd-api /usr/local/bin/ COPY --from=builder /app/target/release/rfd-processor /usr/local/bin/ COPY --from=builder /app/target/release/rfd-cli /usr/local/bin/ COPY --from=builder /app/target/release/rfd-installer /usr/local/bin/ +COPY --from=builder /app/rfd-kube-init/target/release/rfd-kube-init /usr/local/bin/ # Database migrations for diesel COPY --from=builder /tmp/diesel /usr/local/bin/ diff --git a/README.md b/README.md index d34868a8..96b09f6d 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,54 @@ The RFD API backend services expect to run against a Postgres database. Running the API requires setting up a configuration file as outlined in `config.example.toml`. +### Initialization + +The `/init` endpoint is used to bootstrap the RFD API with an initial OAuth client. This is designed for automated deployment scenarios where an OAuth client needs to be created before any authenticated access is possible. + +#### Usage + +The endpoint requires no authentication and can only be called **once**. After successful initialization, subsequent calls will fail with a 409 Conflict status. + +```bash +curl -X POST http://localhost:8080/init \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": [ + "https://app.example.com/callback", + "http://localhost:3000/callback" + ] + }' +``` + +#### Response + +On success, the endpoint returns the created OAuth client credentials. **The client secret is only returned in this response and cannot be retrieved later.** + +```json +{ + "client_id": "01234567-89ab-cdef-0123-456789abcdef", + "secret": "rfd_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz", + "redirect_uris": [ + "https://app.example.com/callback", + "http://localhost:3000/callback" + ] +} +``` + +#### Re-initialization + +To re-initialize the system (e.g., for testing or recovery), a human must manually delete the initialization record from the database: + +```sql +-- Delete the initialization record to allow /init to be called again +DELETE FROM initialization; + +-- Optionally also delete existing OAuth clients +DELETE FROM oauth_client_redirect_uri; +DELETE FROM oauth_client_secret; +DELETE FROM oauth_client; +``` + ### Processor Dependencies diff --git a/compose.yml b/compose.yml index 8378a8dc..0e6ef4b8 100644 --- a/compose.yml +++ b/compose.yml @@ -92,13 +92,13 @@ services: condition: service_completed_successfully postgres: - image: docker.io/postgres:14-trixie + image: docker.io/postgres:18-trixie environment: POSTGRES_USER: rfd POSTGRES_PASSWORD: rfd POSTGRES_DB: rfd volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql ports: - "5432:5432" networks: diff --git a/rfd-api/Cargo.toml b/rfd-api/Cargo.toml index 4c75a32b..8f144c1b 100644 --- a/rfd-api/Cargo.toml +++ b/rfd-api/Cargo.toml @@ -43,6 +43,7 @@ ring = { workspace = true } rfd-data = { path = "../rfd-data" } rfd-github = { path = "../rfd-github" } rfd-model = { path = "../rfd-model" } +rfd-secret = { path = "../rfd-secret" } rsa = { workspace = true, features = ["sha2"] } schemars = { workspace = true, features = ["chrono"] } secrecy = { workspace = true, features = ["serde"] } diff --git a/rfd-api/config.example.toml b/rfd-api/config.example.toml index 909851c8..52c61276 100644 --- a/rfd-api/config.example.toml +++ b/rfd-api/config.example.toml @@ -16,8 +16,16 @@ public_url = "" # the server is running behind a proxy) server_port = 8080 -# Full url of the Postgres database to connect to -database_url = "postgres://:@/" +# Database connection configuration +# Password can be specified inline or read from a file: +# Inline: password = "your-password" +# From file: password = { path = "/run/secrets/db-password" } +[database] +host = "localhost" +port = 5432 +user = "rfd" +password = "" +database = "rfd" # Settings for JWT management [jwt] @@ -27,6 +35,10 @@ default_expiration = 3600 # Keys for signing JWTs and generating secrets. GCP Cloud KMS keys and local static keys # are supported. At least one signer and one verifier key must be configured. +# +# Secret values (private, public) can be specified inline or read from a file: +# Inline: private = "-----BEGIN RSA PRIVATE KEY-----\n..." +# From file: private = { path = "/run/secrets/jwt-private-key" } # Cloud KMS - Signer [[keys]] @@ -63,6 +75,10 @@ public = """""" # PEM encoded public key # OAuth Providers # Google and GitHub are supported. An OAuth provider needs to have both a web and device config. # At least one OAuth provider must be configured +# +# Secret values (client_id, client_secret) can be specified inline or read from a file: +# Inline: client_secret = "your-secret" +# From file: client_secret = { path = "/run/secrets/google-client-secret" } [authn.oauth.google.device] client_id = "" @@ -97,13 +113,13 @@ templates = [] # html = "Click here to login" # # [magic_link.email_service.resend] -# key = "re_xxxxxxxx" +# key = "re_xxxxxxxx" # or { path = "/run/secrets/resend-key" } # Search configuration [search] # Remote url of the search service host = "" -# Read-only search key +# Read-only search key (can be inline or { path = "/run/secrets/search-key" }) key = "" # Index to perform searches against index = "" @@ -144,6 +160,10 @@ default_branch = "" # 1. A GitHub App installation that is defined by an app_id, installation_id, and private_key # 2. A GitHub access token # Exactly one authentication must be specified +# +# Secret values (private_key, token) can be specified inline or read from a file: +# Inline: private_key = "-----BEGIN RSA PRIVATE KEY-----\n..." +# From file: private_key = { path = "/run/secrets/github-app-key" } # App Installation [services.github.auth] @@ -152,10 +172,11 @@ app_id = 1111111 # Numeric GitHub App installation id corresponding to the organization that the configured repo # belongs to installation_id = 2222222 -# PEM encoded private key for the GitHub App +# PEM encoded private key for the GitHub App (can be inline or { path = "..." }) private_key = """""" # Access Token [services.github.auth] # This may be any GitHub access token that has permission to the configured repo +# (can be inline or { path = "/run/secrets/github-token" }) token = "" diff --git a/rfd-api/src/config.rs b/rfd-api/src/config.rs index 1179f41e..3d290308 100644 --- a/rfd-api/src/config.rs +++ b/rfd-api/src/config.rs @@ -7,11 +7,137 @@ use std::{collections::HashMap, path::PathBuf}; use config::{Config, ConfigError, Environment, File}; use rfd_data::content::RfdTemplate; use serde::Deserialize; -use v_api::config::{AsymmetricKey, AuthnProviders, JwtConfig}; +use v_api::config::{AsymmetricKey, JwtConfig}; use v_model::schema_ext::MagicLinkMedium; +use rfd_secret::{SecretResolutionError, SecretString}; + use crate::server::SpecConfig; +// ============================================================================ +// Wrapper types for v_api configuration with SecretString support +// ============================================================================ + +/// Wrapper for v_api::config::AsymmetricKey that supports path-based secrets. +/// +/// Use `resolve()` to convert to the actual v_api AsymmetricKey type. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AsymmetricKeyConfig { + LocalSigner { + kid: String, + private: SecretString, + }, + LocalVerifier { + kid: String, + public: SecretString, + }, + CkmsSigner { + kid: String, + version: u16, + key: String, + keyring: String, + location: String, + project: String, + }, + CkmsVerifier { + kid: String, + version: u16, + key: String, + keyring: String, + location: String, + project: String, + }, +} + +impl AsymmetricKeyConfig { + /// Resolves any path-based secrets and converts to v_api AsymmetricKey. + pub fn resolve(self) -> Result { + match self { + AsymmetricKeyConfig::LocalSigner { kid, private } => Ok(AsymmetricKey::LocalSigner { + kid, + private: private.resolve()?, + }), + AsymmetricKeyConfig::LocalVerifier { kid, public } => { + Ok(AsymmetricKey::LocalVerifier { + kid, + public: public.resolve()?, + }) + } + AsymmetricKeyConfig::CkmsSigner { + kid, + version, + key, + keyring, + location, + project, + } => Ok(AsymmetricKey::CkmsSigner { + kid, + version, + key, + keyring, + location, + project, + }), + AsymmetricKeyConfig::CkmsVerifier { + kid, + version, + key, + keyring, + location, + project, + } => Ok(AsymmetricKey::CkmsVerifier { + kid, + version, + key, + keyring, + location, + project, + }), + } + } +} + +/// OAuth client configuration with SecretString support. +#[derive(Debug, Clone, Deserialize)] +pub struct OAuthClientConfig { + pub client_id: SecretString, + pub client_secret: SecretString, +} + +/// OAuth web client configuration with SecretString support. +#[derive(Debug, Clone, Deserialize)] +pub struct OAuthWebClientConfig { + pub client_id: SecretString, + pub client_secret: SecretString, + // pub redirect_uri: String, +} + +/// Per-provider OAuth configuration with device and web clients. +#[derive(Debug, Clone, Deserialize)] +pub struct OAuthProviderConfig { + pub device: OAuthClientConfig, + pub web: OAuthWebClientConfig, +} + +/// OAuth providers configuration wrapper. +#[derive(Debug, Default, Deserialize)] +pub struct OAuthProvidersConfig { + pub github: Option, + pub google: Option, +} + +/// Authentication providers configuration wrapper. +#[derive(Debug, Default, Deserialize)] +pub struct AuthnProvidersConfig { + #[serde(default)] + pub oauth: OAuthProvidersConfig, +} + +// ============================================================================ +// Main application configuration +// ============================================================================ + #[derive(Debug, Deserialize)] pub struct AppConfig { pub log_format: ServerLogFormat, @@ -20,11 +146,11 @@ pub struct AppConfig { pub initial_mappers: Option, pub public_url: String, pub server_port: u16, - pub database_url: String, - pub keys: Vec, + pub database: DatabaseConfig, + pub keys: Vec, pub jwt: JwtConfig, pub spec: Option, - pub authn: AuthnProviders, + pub authn: AuthnProvidersConfig, pub magic_link: MagicLinkConfig, pub search: SearchConfig, pub content: ContentConfig, @@ -41,10 +167,29 @@ pub enum ServerLogFormat { #[derive(Debug, Default, Deserialize)] pub struct SearchConfig { pub host: String, - pub key: String, + pub key: SecretString, pub index: String, } +#[derive(Debug, Deserialize)] +pub struct DatabaseConfig { + pub host: String, + pub port: u16, + pub user: String, + pub password: SecretString, + pub database: String, +} + +impl DatabaseConfig { + pub fn to_url(&self) -> Result { + let password = self.password.resolve()?; + Ok(format!( + "postgres://{}:{}@{}:{}/{}", + self.user, password, self.host, self.port, self.database + )) + } +} + #[derive(Debug, Default, Deserialize)] pub struct ContentConfig { pub templates: HashMap, @@ -70,10 +215,10 @@ pub enum GitHubAuthConfig { Installation { app_id: i64, installation_id: i64, - private_key: String, + private_key: SecretString, }, User { - token: String, + token: SecretString, }, } @@ -97,7 +242,7 @@ pub struct MagicLinkTemplate { #[derive(Debug, Deserialize)] #[serde(rename_all = "lowercase")] pub enum EmailService { - Resend { key: String }, + Resend { key: SecretString }, } impl AppConfig { diff --git a/rfd-api/src/context.rs b/rfd-api/src/context.rs index 93c5a87f..bf681a5d 100644 --- a/rfd-api/src/context.rs +++ b/rfd-api/src/context.rs @@ -313,7 +313,7 @@ impl RfdContext { public_url, storage, search: SearchContext { - client: SearchClient::new(search.host, search.index, search.key), + client: SearchClient::new(search.host, search.index, search.key.resolve()?), }, content: ContentContext { placeholder_template: content @@ -333,24 +333,27 @@ impl RfdContext { app_id, installation_id, private_key, - } => GitHubClient::custom( - "rfd-api", - Credentials::InstallationToken(InstallationTokenGenerator::new( - installation_id, - JWTCredentials::new( - app_id, - RsaPrivateKey::from_pkcs1_pem(&private_key)? - .to_pkcs1_der()? - .to_bytes() - .to_vec(), - )?, - )), - client, - Box::new(NoCache), - ), + } => { + let resolved_key = private_key.resolve()?; + GitHubClient::custom( + "rfd-api", + Credentials::InstallationToken(InstallationTokenGenerator::new( + installation_id, + JWTCredentials::new( + app_id, + RsaPrivateKey::from_pkcs1_pem(&resolved_key)? + .to_pkcs1_der()? + .to_bytes() + .to_vec(), + )?, + )), + client, + Box::new(NoCache), + ) + } GitHubAuthConfig::User { token } => GitHubClient::custom( "rfd-api", - Credentials::Token(token.to_string()), + Credentials::Token(token.resolve()?), client, Box::new(NoCache), ), @@ -956,9 +959,9 @@ pub(crate) mod test_mocks { }; use v_model::storage::postgres::PostgresStore; - use crate::config::{ - ContentConfig, GitHubAuthConfig, GitHubConfig, SearchConfig, ServicesConfig, - }; + use rfd_secret::SecretString; + + use crate::config::{ContentConfig, GitHubAuthConfig, GitHubConfig, SearchConfig, ServicesConfig}; use super::RfdContext; @@ -1031,7 +1034,7 @@ pub(crate) mod test_mocks { path: String::new(), default_branch: String::new(), auth: GitHubAuthConfig::User { - token: String::default(), + token: SecretString::default(), }, }, }, diff --git a/rfd-api/src/endpoints/init.rs b/rfd-api/src/endpoints/init.rs new file mode 100644 index 00000000..2ac006c8 --- /dev/null +++ b/rfd-api/src/endpoints/init.rs @@ -0,0 +1,169 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::{collections::HashMap, sync::OnceLock}; + +use chrono::Utc; +use dropshot::{ + endpoint, ClientErrorStatusCode, HttpError, HttpResponseCreated, RequestContext, TypedBody, +}; +use newtype_uuid::{GenericUuid, TypedUuid}; +use rfd_model::{storage::InitializationStore, InitializationModel}; +use schemars::JsonSchema; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; +use trace_request::trace_request; +use tracing::instrument; +use uuid::Uuid; +use v_api::{ + authn::key::RawKey, + permissions::VPermission, + response::{client_error, to_internal_error}, + ApiContext, +}; +use v_model::{permissions::Caller, OAuthClientId, UserId}; + +use crate::{context::RfdContext, permissions::RfdPermission}; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct InitRequestBody { + pub redirect_uris: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct InitResponse { + pub client_id: TypedUuid, + pub secret: String, + pub redirect_uris: Vec, +} + +/// Initialize the system with an initial OAuth client. +/// +/// This endpoint is unauthenticated and can only be called once. If the system +/// has already been initialized, this endpoint will return a 409 Conflict error. +/// +/// To re-initialize the system, an administrator must manually delete the +/// initialization record from the database. +#[trace_request] +#[endpoint { + method = POST, + path = "/init", + tags = ["hidden"], +}] +#[instrument(skip(rqctx), fields(request_id = rqctx.request_id), err(Debug))] +pub async fn init( + rqctx: RequestContext, + body: TypedBody, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let body = body.into_inner(); + init_op(ctx, body).await +} + +/// Hardcoded caller ID for the init endpoint. This is used for logging purposes +/// to identify that the operation was performed by the init endpoint. +const INIT_CALLER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000001); + +/// Static caller instance for the init endpoint, initialized once on first use. +static INIT_CALLER: OnceLock> = OnceLock::new(); + +/// Internal operation for system initialization, separated for testability. +#[instrument(skip(ctx), err(Debug))] +pub async fn init_op( + ctx: &RfdContext, + body: InitRequestBody, +) -> Result, HttpError> { + // Step 1: Check if initialization record already exists + let existing = InitializationStore::get(&*ctx.storage) + .await + .map_err(to_internal_error)?; + + if existing.is_some() { + return Err(client_error( + ClientErrorStatusCode::CONFLICT, + "System already initialized", + )); + } + + // Step 2: Get the singleton caller with full OAuth permissions for initialization. + // This is safe because we've already verified this is the first initialization via the + // InitializationStore check above. + let caller = INIT_CALLER.get_or_init(|| Caller { + id: TypedUuid::::from_untyped_uuid(INIT_CALLER_ID), + permissions: vec![ + RfdPermission::from(VPermission::CreateOAuthClient), + RfdPermission::from(VPermission::ManageOAuthClientsAll), + ] + .into(), + extensions: HashMap::new(), + }); + + // Step 3: Create the OAuth client + let client = ctx + .v_ctx() + .oauth + .create_oauth_client(&caller) + .await + .map_err(|e| { + tracing::error!(?e, "Failed to create OAuth client"); + to_internal_error(e) + })?; + + // Step 4: Create a secret for the client + let secret_id = TypedUuid::new_v4(); + let secret = RawKey::generate::<24>(secret_id.as_untyped_uuid()) + .sign(ctx.v_ctx().signer()) + .await + .map_err(|e| { + tracing::error!(?e, "Failed to sign OAuth client secret"); + to_internal_error(e) + })?; + + ctx.v_ctx() + .oauth + .add_oauth_secret(&caller, &secret_id, &client.id, &secret.signature().to_string()) + .await + .map_err(|e| { + tracing::error!(?e, "Failed to store OAuth client secret"); + to_internal_error(e) + })?; + + // Step 5: Add all redirect URIs + for redirect_uri in &body.redirect_uris { + ctx.v_ctx() + .oauth + .add_oauth_redirect_uri(&caller, &client.id, redirect_uri) + .await + .map_err(|e| { + tracing::error!(?e, ?redirect_uri, "Failed to add redirect URI"); + to_internal_error(e) + })?; + } + + // Step 6: Write the initialization record + let init_record = InitializationModel { + id: Uuid::new_v4(), + initialized_at: Utc::now(), + oauth_client_id: client.id.into_untyped_uuid(), + }; + + InitializationStore::insert(&*ctx.storage, init_record) + .await + .map_err(|e| { + tracing::error!(?e, "Failed to insert initialization record"); + to_internal_error(e) + })?; + + tracing::info!( + client_id = %client.id, + "System initialized successfully" + ); + + Ok(HttpResponseCreated(InitResponse { + client_id: client.id, + secret: secret.key().expose_secret().to_string(), + redirect_uris: body.redirect_uris, + })) +} + diff --git a/rfd-api/src/endpoints/mod.rs b/rfd-api/src/endpoints/mod.rs index 00f79f75..aebbd7ae 100644 --- a/rfd-api/src/endpoints/mod.rs +++ b/rfd-api/src/endpoints/mod.rs @@ -4,6 +4,7 @@ pub static UNLIMITED: i64 = 9999999; +pub mod init; pub mod job; pub mod rfd; pub mod webhook; diff --git a/rfd-api/src/error.rs b/rfd-api/src/error.rs index 0436a7a3..b4d3e78b 100644 --- a/rfd-api/src/error.rs +++ b/rfd-api/src/error.rs @@ -10,6 +10,8 @@ use thiserror::Error; use v_api::response::{conflict, forbidden, internal_error, not_found, ResourceError}; use v_model::storage::StoreError; +use rfd_secret::SecretResolutionError; + #[derive(Debug, Error)] pub enum AppError { #[error("Failed to construct HTTP client")] @@ -24,6 +26,8 @@ pub enum AppError { NoConfiguredJwtKeys, #[error("Failed to construct GitHub client")] Octorust(#[from] OctorustError), + #[error("Failed to resolve secret")] + SecretResolution(#[from] SecretResolutionError), } #[derive(Debug, Error)] diff --git a/rfd-api/src/main.rs b/rfd-api/src/main.rs index 731934d4..d99116c1 100644 --- a/rfd-api/src/main.rs +++ b/rfd-api/src/main.rs @@ -73,29 +73,50 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Initialized logger"); + // Resolve path-based secrets for asymmetric keys + let resolved_keys: Vec<_> = config + .keys + .into_iter() + .map(|key| { + key.resolve().tap_err(|err| { + tracing::error!(?err, "Failed to resolve asymmetric key secret"); + }) + }) + .collect::>()?; + + // Resolve database URL from config + let database_url = config.database.to_url().tap_err(|err| { + tracing::error!(?err, "Failed to resolve database password secret"); + })?; + let mut v_ctx = VContext::new( config.public_url.clone(), Arc::new( - VApiPostgresStore::new(&config.database_url) + VApiPostgresStore::new(&database_url) .await .tap_err(|err| { tracing::error!(?err, "Failed to establish initial database connection"); })?, ), config.jwt, - config.keys, + resolved_keys, ) .await?; if let Some(github) = config.authn.oauth.github { + let device_client_id = github.device.client_id.resolve()?; + let device_client_secret = github.device.client_secret.resolve()?; + let web_client_id = github.web.client_id.resolve()?; + let web_client_secret = github.web.client_secret.resolve()?; + v_ctx.insert_oauth_provider( OAuthProviderName::GitHub, Box::new(move || { Box::new(GitHubOAuthProvider::new( - github.device.client_id.clone(), - github.device.client_secret.clone(), - github.web.client_id.clone(), - github.web.client_secret.clone(), + device_client_id.clone(), + device_client_secret.clone().into(), + web_client_id.clone(), + web_client_secret.clone().into(), None, )) }), @@ -105,14 +126,19 @@ async fn main() -> anyhow::Result<()> { } if let Some(google) = config.authn.oauth.google { + let device_client_id = google.device.client_id.resolve()?; + let device_client_secret = google.device.client_secret.resolve()?; + let web_client_id = google.web.client_id.resolve()?; + let web_client_secret = google.web.client_secret.resolve()?; + v_ctx.insert_oauth_provider( OAuthProviderName::Google, Box::new(move || { Box::new(GoogleOAuthProvider::new( - google.device.client_id.clone(), - google.device.client_secret.clone(), - google.web.client_id.clone(), - google.web.client_secret.clone(), + device_client_id.clone(), + device_client_secret.clone().into(), + web_client_id.clone(), + web_client_secret.clone().into(), None, )) }), @@ -147,10 +173,9 @@ async fn main() -> anyhow::Result<()> { if let Some(service) = &config.magic_link.email_service { match service { EmailService::Resend { key } => { - v_ctx.magic_link.set_messenger( - target, - ResendMagicLink::new(key.to_string(), template.from), - ); + v_ctx + .magic_link + .set_messenger(target, ResendMagicLink::new(key.resolve()?, template.from)); } } } @@ -162,7 +187,7 @@ async fn main() -> anyhow::Result<()> { let context = RfdContext::new( config.public_url, Arc::new( - VApiPostgresStore::new(&config.database_url) + VApiPostgresStore::new(&database_url) .await .tap_err(|err| { tracing::error!(?err, "Failed to establish initial database connection"); diff --git a/rfd-api/src/server.rs b/rfd-api/src/server.rs index 61ae1f17..cb5f537c 100644 --- a/rfd-api/src/server.rs +++ b/rfd-api/src/server.rs @@ -15,6 +15,7 @@ use v_api::{inject_endpoints, v_system_endpoints}; use crate::{ context::RfdContext, endpoints::{ + init::init, job::list_jobs, rfd::{ discuss_rfd, list_rfd_revisions, list_rfds, publish_rfd, reserve_rfd, search_rfds, @@ -78,6 +79,9 @@ pub fn server( inject_endpoints!(api); + // Initialization + api.register(init).expect("Failed to register endpoint"); + // RFDs api.register(list_rfds) .expect("Failed to register endpoint"); diff --git a/rfd-kube-init/Cargo.lock b/rfd-kube-init/Cargo.lock new file mode 100644 index 00000000..3deb6770 --- /dev/null +++ b/rfd-kube-init/Cargo.lock @@ -0,0 +1,3205 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[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.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[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]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "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 = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633a7320c4bb672863a3782e89b9094ad70285e097ff6832cddd0ec615beadfa" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", +] + +[[package]] +name = "k8s-openapi" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05a6d6f3611ad1d21732adbd7a2e921f598af6c92d71ae6e2620da4b67ee1f0d" +dependencies = [ + "base64", + "jiff", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f96b537b4c4f61fc183594edbecbbefa3037e403feac0701bb24e6eff78e0034" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af97b8b696eb737e5694f087c498ca725b172c2a5bc3a6916328d160225537ee" +dependencies = [ + "base64", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aeade7d2e9f165f96b3c1749ff01a8e2dc7ea954bd333bcfcecc37d5226bdd" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "json-patch", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-runtime" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc158473d6d86ec22692874bd5ddccf07474eab5c6bb41f226c522e945da5244" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "backon", + "educe", + "futures", + "hashbrown 0.16.1", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot 0.12.5", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[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 = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "meilisearch-index-setting-macro" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0795d177129fed792fc789cf07d4647bb9e3939409fbe01764805c060afcd0e" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "structmeta", + "syn", +] + +[[package]] +name = "meilisearch-sdk" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a20b5a4215a39c66854d14cbba97f615e84c49925d68629c05c42a5a85dac1" +dependencies = [ + "async-trait", + "bytes", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "iso8601", + "jsonwebtoken", + "log", + "meilisearch-index-setting-macro", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "uuid", + "wasm-bindgen-futures", + "web-sys", + "yaup", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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 = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "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 = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "reqwest-retry" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.17", + "http", + "hyper", + "parking_lot 0.11.2", + "reqwest", + "reqwest-middleware", + "retry-policies", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-timer", +] + +[[package]] +name = "retry-policies" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "rfd-kube-init" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "k8s-openapi", + "kube", + "meilisearch-sdk", + "reqwest", + "reqwest-middleware", + "reqwest-retry", + "secrecy", + "serde", + "serde_json", + "time", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +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-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[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_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.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "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", + "slab", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64", + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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 = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +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 = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[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 2.11.0", + "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 = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yaup" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6" +dependencies = [ + "form_urlencoded", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rfd-kube-init/Cargo.toml b/rfd-kube-init/Cargo.toml new file mode 100644 index 00000000..07bf6aed --- /dev/null +++ b/rfd-kube-init/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "rfd-kube-init" +version = "0.1.0" +edition = "2021" +description = "Kubernetes initialization tool for RFD services" +repository = "https://github.com/oxidecomputer/rfd-api" + +[[bin]] +name = "rfd-kube-init" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4", features = ["derive", "env"] } +k8s-openapi = { version = "0.27", features = ["v1_32"] } +kube = { version = "3.0.1", features = ["client", "runtime", "rustls-tls", "aws-lc-rs"] } +meilisearch-sdk = { version = "0.32.0", default-features = false, features = ["reqwest", "tls", "jwt_aws_lc_rs"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest-middleware = "0.4" +reqwest-retry = "0.7" +secrecy = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = "0.3" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[package.metadata.dist] +targets = [] diff --git a/rfd-kube-init/README.md b/rfd-kube-init/README.md new file mode 100644 index 00000000..eafdfaa4 --- /dev/null +++ b/rfd-kube-init/README.md @@ -0,0 +1,270 @@ +# rfd-kube-init + +A Kubernetes initialization tool that distributes secrets across namespaces. Supports Meilisearch tenant token generation and RFD API OAuth client initialization. + +## Overview + +This tool is designed to run as a Kubernetes Job or init container. It provides subcommands for different initialization tasks: + +- **meilisearch**: Reads the master key from Kubernetes, generates tenant tokens, and writes them to secrets in target namespaces +- **oauth-init**: Calls the RFD API `/init` endpoint to create an OAuth client and distributes credentials to target namespaces + +## Meilisearch Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `MEILI_MASTER_NAMESPACE` | Yes* | Namespace containing the Meilisearch master key secret | +| `MEILI_MASTER_SECRET_NAME` | Yes* | Name of the secret containing the master key | +| `MEILI_MASTER_SECRET_KEY` | Yes* | Key within the secret that holds the master key value | +| `MEILI_HOST` | Yes | Meilisearch host URL (e.g., `http://meilisearch:7700`) | +| `MEILI_RW_TOKEN_TARGET_NAMESPACES` | Yes** | Comma-delimited list of namespaces for read-write tokens (e.g., `rfd-api`) | +| `MEILI_RO_TOKEN_TARGET_NAMESPACES` | Yes** | Comma-delimited list of namespaces for read-only tokens (e.g., `rfd-web`) | +| `MEILI_SECRET_NAME` | No | Name of the secret to create (default: `meilisearch-token`) | +| `MEILI_API_EXPIRATION_SECONDS` | No | Token expiration in seconds from now (default: no expiration) | +| `MEILI_TOKEN_FILTER` | No | JSON search rules for the tenant token (default: `["*"]` - full access to all indexes) | + +*If any of `MEILI_MASTER_NAMESPACE`, `MEILI_MASTER_SECRET_NAME`, or `MEILI_MASTER_SECRET_KEY` are unset or empty, Meilisearch initialization is skipped entirely. + +**At least one of `MEILI_RW_TOKEN_TARGET_NAMESPACES` or `MEILI_RO_TOKEN_TARGET_NAMESPACES` must be set. + +## Token Types + +The tool generates two types of tenant tokens: + +- **RW (Read-Write)**: Generated from the "Default Admin API Key". Grants full access including document indexing, settings updates, and searches. Use for backend services that need to write to Meilisearch. + +- **RO (Read-Only)**: Generated from the "Default Search API Key". Grants search access only. Use for frontend applications or services that only need to query. + +## How It Works + +1. Reads the Meilisearch master key from the specified Kubernetes secret +2. Connects to Meilisearch and fetches the list of API keys +3. For RW namespaces: finds the "Default Admin API Key" and generates a tenant token +4. For RO namespaces: finds the "Default Search API Key" and generates a tenant token +5. Writes the appropriate token to secrets in each target namespace + +## Secret Format + +The tool creates an `Opaque` secret with the following data: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: meilisearch-token # or MEILI_SECRET_NAME +type: Opaque +stringData: + MEILISEARCH_API_KEY: +``` + +## Kubernetes RBAC + +The service account running this tool needs permissions to: +- **Read** secrets in the namespace containing the master key +- **Create/patch** secrets in target namespaces + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rfd-kube-init + namespace: rfd-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rfd-kube-init +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: rfd-kube-init +subjects: + - kind: ServiceAccount + name: rfd-kube-init + namespace: rfd-system +roleRef: + kind: ClusterRole + name: rfd-kube-init + apiGroup: rbac.authorization.k8s.io +``` + +For namespace-scoped permissions, use `Role` and `RoleBinding` instead. Note that you'll need read access in the source namespace and write access in target namespaces. + +## Example: Kubernetes Job + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: meilisearch-token-init + namespace: rfd-system +spec: + template: + spec: + serviceAccountName: rfd-kube-init + restartPolicy: OnFailure + containers: + - name: init + image: ghcr.io/oxidecomputer/rfd-kube-init:latest + env: + # Source of master key + - name: MEILI_MASTER_NAMESPACE + value: "meilisearch" + - name: MEILI_MASTER_SECRET_NAME + value: "meilisearch-master" + - name: MEILI_MASTER_SECRET_KEY + value: "MEILI_MASTER_KEY" + # Meilisearch connection + - name: MEILI_HOST + value: "http://meilisearch.meilisearch:7700" + # Token distribution + - name: MEILI_RW_TOKEN_TARGET_NAMESPACES + value: "rfd-api,rfd-processor" + - name: MEILI_RO_TOKEN_TARGET_NAMESPACES + value: "rfd-web" + - name: MEILI_API_EXPIRATION_SECONDS + value: "86400" # 24 hours +``` + +## Example: CronJob for Token Rotation + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: meilisearch-token-refresh + namespace: rfd-system +spec: + schedule: "0 0 * * *" # Daily at midnight + jobTemplate: + spec: + template: + spec: + serviceAccountName: rfd-kube-init + restartPolicy: OnFailure + containers: + - name: init + image: ghcr.io/oxidecomputer/rfd-kube-init:latest + env: + - name: MEILI_MASTER_NAMESPACE + value: "meilisearch" + - name: MEILI_MASTER_SECRET_NAME + value: "meilisearch-master" + - name: MEILI_MASTER_SECRET_KEY + value: "MEILI_MASTER_KEY" + - name: MEILI_HOST + value: "http://meilisearch.meilisearch:7700" + - name: MEILI_RW_TOKEN_TARGET_NAMESPACES + value: "rfd-api,rfd-processor" + - name: MEILI_RO_TOKEN_TARGET_NAMESPACES + value: "rfd-web" + - name: MEILI_API_EXPIRATION_SECONDS + value: "90000" # 25 hours (overlap for safety) +``` + +## Token Filter Examples + +The `MEILI_TOKEN_FILTER` environment variable accepts a JSON value defining search rules. By default, the token grants access to all indexes with no filtering. + +### Full access (default) +```bash +MEILI_TOKEN_FILTER='["*"]' +``` + +### Restrict to specific indexes +```bash +MEILI_TOKEN_FILTER='{"rfd_index": null, "other_index": null}' +``` + +### Restrict with filters +```bash +MEILI_TOKEN_FILTER='{"rfd_index": {"filter": "public = true"}}' +``` + +See [Meilisearch Tenant Tokens documentation](https://www.meilisearch.com/docs/learn/security/tenant_tokens) for full search rules syntax. + +## Error Handling + +- If any namespace write fails, the tool logs to stderr and continues processing remaining namespaces +- The tool exits with code 1 if any operation failed, code 0 if all succeeded +- Check Job/Pod logs for detailed error messages + +## OAuth Init + +The `oauth-init` subcommand initializes an OAuth client by calling the RFD API `/init` endpoint and distributes the credentials to target namespaces. + +### OAuth Init Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `RFD_API_HOST` | Yes | RFD API host URL (e.g., `http://rfd-api:8080`) | +| `OAUTH_REDIRECT_URIS` | Yes | Comma-delimited list of redirect URIs for the OAuth client | +| `OAUTH_TARGET_NAMESPACES` | Yes | Comma-delimited list of namespaces to write credentials to | +| `OAUTH_SECRET_NAME` | No | Name of the secret to create (default: `rfd-oauth-client`) | + +### OAuth Init Response + +The `/init` endpoint returns the OAuth client credentials: + +```json +{ + "client_id": "01234567-89ab-cdef-0123-456789abcdef", + "secret": "rfd_abc123def456ghi789jkl012mno345pqr678stu901vwx234yz", + "redirect_uris": [ + "https://app.example.com/callback", + "http://localhost:3000/callback" + ] +} +``` + +### OAuth Init Secret Format + +The tool creates an `Opaque` secret with the following data: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: rfd-oauth-client # or OAUTH_SECRET_NAME +type: Opaque +stringData: + OAUTH_CLIENT_ID: + OAUTH_CLIENT_SECRET: +``` + +### OAuth Init Idempotency + +The `oauth-init` command is idempotent. If the system has already been initialized (409 Conflict), the command logs a warning and exits successfully. This allows the Kubernetes Job to be run multiple times without error. + +### Example: OAuth Init Kubernetes Job + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: rfd-oauth-init + namespace: rfd-system +spec: + template: + spec: + serviceAccountName: rfd-kube-init + restartPolicy: OnFailure + containers: + - name: init + image: ghcr.io/oxidecomputer/rfd-kube-init:latest + args: ["oauth-init"] + env: + - name: RFD_API_HOST + value: "http://rfd-api.rfd-system:8080" + - name: OAUTH_REDIRECT_URIS + value: "https://app.example.com/callback,http://localhost:3000/callback" + - name: OAUTH_TARGET_NAMESPACES + value: "rfd-web,rfd-api" + - name: OAUTH_SECRET_NAME + value: "rfd-oauth-client" +``` diff --git a/rfd-kube-init/src/kube.rs b/rfd-kube-init/src/kube.rs new file mode 100644 index 00000000..36defe01 --- /dev/null +++ b/rfd-kube-init/src/kube.rs @@ -0,0 +1,89 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use anyhow::{anyhow, Result}; +use k8s_openapi::api::core::v1::Secret; +use kube::{ + api::{Api, ObjectMeta, Patch, PatchParams}, + Client, +}; +use secrecy::SecretString; +use std::collections::BTreeMap; +use tracing::{debug, instrument}; + +/// Read a specific key from a Kubernetes secret. +/// Returns the value wrapped in a SecretString to protect it in memory. +#[instrument(skip(client), fields(namespace = %namespace, secret_name = %secret_name, key = %key))] +pub async fn read_secret_key( + client: &Client, + namespace: &str, + secret_name: &str, + key: &str, +) -> Result { + debug!("Reading secret key from Kubernetes"); + let secrets: Api = Api::namespaced(client.clone(), namespace); + let secret = secrets.get(secret_name).await?; + + let data = secret + .data + .ok_or_else(|| anyhow!("Secret '{}' in namespace '{}' has no data", secret_name, namespace))?; + + let value_bytes = data + .get(key) + .ok_or_else(|| { + anyhow!( + "Key '{}' not found in secret '{}' in namespace '{}'", + key, + secret_name, + namespace + ) + })?; + + let value_str = String::from_utf8(value_bytes.0.clone()) + .map_err(|_| anyhow!("Secret key '{}' is not valid UTF-8", key))?; + + debug!("Successfully read secret key"); + Ok(SecretString::from(value_str)) +} + +/// Write key-value pairs to a Kubernetes secret. +/// Creates the secret if it doesn't exist, patches if it does. +/// Uses server-side apply for idempotent create-or-update behavior. +#[instrument(skip(client, data), fields(namespace = %namespace, secret_name = %secret_name, key_count = data.len()))] +pub async fn write_secret( + client: &Client, + namespace: &str, + secret_name: &str, + data: &[(&str, &str)], +) -> Result<()> { + debug!("Writing secret to Kubernetes"); + let secrets: Api = Api::namespaced(client.clone(), namespace); + + let mut string_data = BTreeMap::new(); + for (key, value) in data { + string_data.insert(key.to_string(), value.to_string()); + } + + let secret = Secret { + metadata: ObjectMeta { + name: Some(secret_name.to_string()), + namespace: Some(namespace.to_string()), + ..Default::default() + }, + string_data: Some(string_data), + type_: Some("Opaque".to_string()), + ..Default::default() + }; + + secrets + .patch( + secret_name, + &PatchParams::apply("rfd-kube-init"), + &Patch::Apply(&secret), + ) + .await?; + + debug!("Successfully wrote secret"); + Ok(()) +} diff --git a/rfd-kube-init/src/main.rs b/rfd-kube-init/src/main.rs new file mode 100644 index 00000000..8b5fc4e5 --- /dev/null +++ b/rfd-kube-init/src/main.rs @@ -0,0 +1,71 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +mod kube; +mod meilisearch; +mod oauth_init; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use tracing_subscriber::{filter::LevelFilter, EnvFilter}; + +use crate::meilisearch::MeilisearchArgs; +use crate::oauth_init::OAuthInitArgs; + +#[derive(Parser)] +#[command(name = "rfd-kube-init")] +#[command(about = "Kubernetes initialization tool for RFD services")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize Meilisearch secrets across target namespaces + Meilisearch(MeilisearchArgs), + /// Initialize OAuth client and distribute credentials to target namespaces + OauthInit(OAuthInitArgs), +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .with_writer(std::io::stdout) + .init(); + + tracing::info!("Starting rfd-kube-init"); + + let cli = Cli::parse(); + + match cli.command { + Commands::Meilisearch(args) => { + tracing::info!("Running meilisearch initialization"); + tracing::debug!("Initializing Kubernetes client"); + let kube_client = ::kube::Client::try_default().await?; + tracing::debug!("Kubernetes client initialized"); + let result = meilisearch::init(&kube_client, &args).await; + if result.is_ok() { + tracing::info!("Meilisearch initialization completed successfully"); + } + result + } + Commands::OauthInit(args) => { + tracing::info!("Running OAuth client initialization"); + tracing::debug!("Initializing Kubernetes client"); + let kube_client = ::kube::Client::try_default().await?; + tracing::debug!("Kubernetes client initialized"); + let result = oauth_init::init(&kube_client, &args).await; + if result.is_ok() { + tracing::info!("OAuth client initialization completed successfully"); + } + result + } + } +} diff --git a/rfd-kube-init/src/meilisearch.rs b/rfd-kube-init/src/meilisearch.rs new file mode 100644 index 00000000..fa00d89d --- /dev/null +++ b/rfd-kube-init/src/meilisearch.rs @@ -0,0 +1,360 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use anyhow::{anyhow, Context, Result}; +use clap::Args; +use meilisearch_sdk::client::Client; +use meilisearch_sdk::key::Key; +use secrecy::ExposeSecret; +use std::fmt; +use std::time::Duration as StdDuration; +use time::{Duration, OffsetDateTime}; +use tokio::time::sleep; + +use crate::kube; + +const DEFAULT_SEARCH_API_KEY_NAME: &str = "Default Search API Key"; +const DEFAULT_ADMIN_API_KEY_NAME: &str = "Default Admin API Key"; + +enum TokenType { + ReadOnly(Option), + ReadWrite(Option), +} + +impl TokenType { + fn key_name(&self) -> &'static str { + match self { + TokenType::ReadOnly(_) => DEFAULT_SEARCH_API_KEY_NAME, + TokenType::ReadWrite(_) => DEFAULT_ADMIN_API_KEY_NAME, + } + } + + fn key(self) -> Option { + match self { + TokenType::ReadOnly(key) => key, + TokenType::ReadWrite(key) => key, + } + } +} + +impl fmt::Display for TokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TokenType::ReadOnly(_) => write!(f, "RO"), + TokenType::ReadWrite(_) => write!(f, "RW"), + } + } +} + +#[derive(Args)] +pub struct MeilisearchArgs { + /// Namespace where the Meilisearch master key secret is located + #[arg(long, env = "MEILI_MASTER_NAMESPACE")] + master_namespace: String, + + /// Name of the Kubernetes secret containing the master key + #[arg(long, env = "MEILI_MASTER_SECRET_NAME")] + master_secret_name: String, + + /// Key within the secret that contains the master key value + #[arg(long, env = "MEILI_MASTER_SECRET_KEY")] + master_secret_key: String, + + /// Meilisearch host URL + #[arg(long, env = "MEILI_HOST")] + host: String, + + /// Name of the secret to create in target namespaces + #[arg(long, env = "MEILI_SECRET_NAME", default_value = "meilisearch-token")] + secret_name: String, + + /// Token expiration time in seconds + #[arg(long, env = "MEILI_API_EXPIRATION_SECONDS")] + expiration_seconds: Option, + + /// JSON search rules filter for the tenant token + #[arg(long, env = "MEILI_TOKEN_FILTER")] + token_filter: Option, + + #[command(flatten)] + namespaces: NamespaceArgs, + + /// Maximum number of retry attempts for Meilisearch connection + #[arg(long, env = "MEILI_MAX_RETRIES", default_value = "30")] + max_retries: u32, +} + +#[derive(Args)] +#[group(required = true, multiple = true)] +struct NamespaceArgs { + /// Target namespaces for read-write tokens (comma-separated) + #[arg(long, env = "MEILI_RW_TOKEN_TARGET_NAMESPACES", value_delimiter = ',')] + rw_namespaces: Vec, + + /// Target namespaces for read-only tokens (comma-separated) + #[arg(long, env = "MEILI_RO_TOKEN_TARGET_NAMESPACES", value_delimiter = ',')] + ro_namespaces: Vec, +} + +struct FailedNamespace { + namespace: String, + token_type: String, +} + +impl fmt::Display for FailedNamespace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", self.namespace, self.token_type) + } +} + +/// Fetch API keys from Meilisearch and return the search and admin keys. +async fn get_api_keys(client: &Client) -> Result<(Option, Option)> { + tracing::debug!("Fetching API keys from Meilisearch"); + let keys_result = client + .get_keys() + .await + .context("Failed to fetch API keys from Meilisearch")?; + + tracing::debug!(key_count = keys_result.results.len(), "Retrieved API keys from Meilisearch"); + + let mut search_key = None; + let mut admin_key = None; + + for key in keys_result.results { + match key.name.as_deref() { + Some(DEFAULT_SEARCH_API_KEY_NAME) => { + tracing::debug!("Found default search API key"); + search_key = Some(key); + } + Some(DEFAULT_ADMIN_API_KEY_NAME) => { + tracing::debug!("Found default admin API key"); + admin_key = Some(key); + } + _ => {} + } + } + + if search_key.is_none() { + tracing::warn!("Default search API key not found"); + } + if admin_key.is_none() { + tracing::warn!("Default admin API key not found"); + } + + Ok((search_key, admin_key)) +} + +/// Generate a tenant token and distribute it to the specified namespaces. +/// Returns a list of namespaces that failed to write. +async fn generate_and_distribute_token( + client: &Client, + kube_client: &::kube::Client, + namespaces: &[String], + secret_name: &str, + search_rules: serde_json::Value, + expires_at: Option, + token_type: TokenType, +) -> Result> { + if namespaces.is_empty() { + tracing::debug!(token_type = %token_type, "No namespaces configured, skipping"); + return Ok(Vec::new()); + } + + tracing::info!( + token_type = %token_type, + namespace_count = namespaces.len(), + "Distributing tokens to namespaces" + ); + + let token_type_str = token_type.to_string(); + let key_name = token_type.key_name(); + + let key = match token_type.key() { + Some(key) => key, + None => { + tracing::error!( + key_name = key_name, + token_type = token_type_str.as_str(), + "API key not found in Meilisearch but namespaces are configured" + ); + return Err(anyhow!( + "Could not find '{}' in Meilisearch keys", + key_name + )); + } + }; + + tracing::info!( + key_name = key.name, + token_type = token_type_str.as_str(), + "Found API key for token", + ); + + let token = client.generate_tenant_token(key.uid, search_rules, None, expires_at)?; + + tracing::info!(token_type = token_type_str.as_str(), "Generated Meilisearch tenant token"); + + let mut failures = Vec::new(); + + for ns in namespaces { + match kube::write_secret( + kube_client, + ns, + secret_name, + &[("MEILISEARCH_API_KEY", &token)], + ) + .await + { + Ok(()) => { + tracing::info!( + namespace = ns.as_str(), + secret = secret_name, + token_type = token_type_str.as_str(), + "Wrote meilisearch secret" + ); + } + Err(err) => { + failures.push(FailedNamespace { + namespace: ns.clone(), + token_type: token_type_str.clone(), + }); + tracing::error!( + namespace = ns.as_str(), + secret = secret_name, + token_type = token_type_str.as_str(), + error = %err, + "Failed to write meilisearch secret" + ); + } + } + } + + Ok(failures) +} + +/// Initialize meilisearch secrets across target namespaces. +pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Result<()> { + // Read master key from kubernetes secret + let master_key = kube::read_secret_key( + kube_client, + &args.master_namespace, + &args.master_secret_name, + &args.master_secret_key, + ) + .await + .context("Failed to read Meilisearch master key from Kubernetes secret")?; + + // Calculate expiration time if provided + let expires_at = args + .expiration_seconds + .map(|secs| OffsetDateTime::now_utc() + Duration::seconds(secs)); + + // Parse search rules filter (empty string or missing means wildcard access to all indexes) + let search_rules: serde_json::Value = match &args.token_filter { + Some(filter) if !filter.is_empty() => { + serde_json::from_str(filter).context("token-filter must be valid JSON")? + } + _ => serde_json::json!(["*"]), + }; + + // Create meilisearch client and fetch API keys with retry + let client = Client::new(&args.host, Some(master_key.expose_secret()))?; + let (search_key, admin_key) = 'retry: { + let mut last_error = None; + + for attempt in 0..args.max_retries { + let backoff_secs = std::cmp::min(30, 1u64 << attempt); + + match get_api_keys(&client).await { + Ok(keys) => break 'retry keys, + Err(e) => { + last_error = Some(e); + tracing::warn!( + attempt = attempt + 1, + max_retries = args.max_retries, + backoff_secs = backoff_secs, + "Failed to fetch API keys from Meilisearch, retrying" + ); + sleep(StdDuration::from_secs(backoff_secs)).await; + } + } + } + + return Err(last_error.unwrap_or_else(|| anyhow!("Max retries exceeded"))); + }; + + let mut failures = Vec::new(); + + // Generate and distribute RW token + failures.extend( + generate_and_distribute_token( + &client, + kube_client, + &args.namespaces.rw_namespaces, + &args.secret_name, + search_rules.clone(), + expires_at, + TokenType::ReadWrite(admin_key), + ) + .await?, + ); + + // Generate and distribute RO token + failures.extend( + generate_and_distribute_token( + &client, + kube_client, + &args.namespaces.ro_namespaces, + &args.secret_name, + search_rules, + expires_at, + TokenType::ReadOnly(search_key), + ) + .await?, + ); + + if !failures.is_empty() { + let failed_list: Vec = failures.iter().map(|f| f.to_string()).collect(); + tracing::error!( + failed_count = failures.len(), + failed_namespaces = %failed_list.join(", "), + "Failed to write secrets to some namespaces" + ); + return Err(anyhow!( + "Failed to write secrets to namespaces: {}", + failed_list.join(", ") + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use meilisearch_sdk::client::Client; + + /// Test that crypto providers are properly configured. + /// This exercises both jsonwebtoken (JWT signing) and rustls (TLS) crypto backends. + #[test] + fn test_crypto_providers_configured() { + // Create a meilisearch client - this validates rustls crypto provider is available + let client = Client::new("http://localhost:7700", Some("test_master_key")) + .expect("Failed to create meilisearch client"); + + // Generate a tenant token - this exercises jsonwebtoken crypto + // We use a fake UID but the signing operation still runs + let search_rules = serde_json::json!(["*"]); + let fake_uid = "550e8400-e29b-41d4-a716-446655440000".to_string(); + + let result = client.generate_tenant_token(fake_uid, search_rules, None, None); + + // The token generation should succeed (crypto works) even though + // the key UID is fake - we're testing the signing, not validation + assert!(result.is_ok(), "JWT token generation failed: {:?}", result.err()); + + let token = result.unwrap(); + // JWT tokens have 3 parts separated by dots + assert_eq!(token.split('.').count(), 3, "Generated token should be a valid JWT format"); + } +} diff --git a/rfd-kube-init/src/oauth_init.rs b/rfd-kube-init/src/oauth_init.rs new file mode 100644 index 00000000..bdeba508 --- /dev/null +++ b/rfd-kube-init/src/oauth_init.rs @@ -0,0 +1,184 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use anyhow::{anyhow, Context, Result}; +use clap::Args; +use reqwest_middleware::ClientBuilder; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::kube; + +/// Request body for the /init endpoint +#[derive(Debug, Serialize)] +struct InitRequest { + redirect_uris: Vec, +} + +/// Response from the /init endpoint +#[derive(Debug, Deserialize)] +struct InitResponse { + client_id: String, + secret: String, + redirect_uris: Vec, +} + +/// Result of calling the /init endpoint +enum InitResult { + /// Successfully initialized, contains the response + Success(InitResponse), + /// System was already initialized (409 Conflict) + AlreadyInitialized, +} + +#[derive(Args)] +pub struct OAuthInitArgs { + /// RFD API host URL (e.g., http://rfd-api:8080) + #[arg(long, env = "RFD_API_HOST")] + host: String, + + /// Redirect URIs for the OAuth client (comma-separated) + #[arg(long, env = "OAUTH_REDIRECT_URIS", value_delimiter = ',')] + redirect_uris: Vec, + + /// Target namespaces to write the OAuth client credentials (comma-separated) + #[arg(long, env = "OAUTH_TARGET_NAMESPACES", value_delimiter = ',')] + target_namespaces: Vec, + + /// Name of the secret to create in target namespaces + #[arg(long, env = "OAUTH_SECRET_NAME", default_value = "rfd-oauth-client")] + secret_name: String, + + /// Maximum number of retry attempts for the /init endpoint + #[arg(long, env = "OAUTH_MAX_RETRIES", default_value = "30")] + max_retries: u32, +} + +/// Initialize OAuth client and distribute credentials to target namespaces. +pub async fn init(kube_client: &::kube::Client, args: &OAuthInitArgs) -> Result<()> { + if args.redirect_uris.is_empty() { + return Err(anyhow!("At least one redirect URI must be provided")); + } + + if args.target_namespaces.is_empty() { + return Err(anyhow!("At least one target namespace must be provided")); + } + + tracing::info!( + host = %args.host, + redirect_uri_count = args.redirect_uris.len(), + target_namespace_count = args.target_namespaces.len(), + "Initializing OAuth client" + ); + + // Build retry-enabled HTTP client + let retry_policy = ExponentialBackoff::builder() + .retry_bounds(Duration::from_secs(1), Duration::from_secs(30)) + .build_with_max_retries(args.max_retries); + + let client = ClientBuilder::new(reqwest::Client::new()) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build(); + + let init_url = format!("{}/init", args.host.trim_end_matches('/')); + + let request_body = InitRequest { + redirect_uris: args.redirect_uris.clone(), + }; + + tracing::debug!(url = %init_url, "Calling /init endpoint"); + + let response = client + .post(&init_url) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&request_body)?) + .send() + .await + .context("Failed to send request to /init endpoint")?; + + let init_response = match handle_init_response(response).await? { + InitResult::Success(response) => response, + InitResult::AlreadyInitialized => return Ok(()), + }; + + tracing::info!( + client_id = %init_response.client_id, + redirect_uri_count = init_response.redirect_uris.len(), + "OAuth client created successfully" + ); + + // Distribute credentials to target namespaces + let mut failures = Vec::new(); + + for ns in &args.target_namespaces { + match kube::write_secret( + kube_client, + ns, + &args.secret_name, + &[ + ("OAUTH_CLIENT_ID", &init_response.client_id), + ("OAUTH_CLIENT_SECRET", &init_response.secret), + ], + ) + .await + { + Ok(()) => { + tracing::info!( + namespace = ns.as_str(), + secret = args.secret_name.as_str(), + "Wrote OAuth client credentials" + ); + } + Err(err) => { + failures.push(ns.clone()); + tracing::error!( + namespace = ns.as_str(), + secret = args.secret_name.as_str(), + error = %err, + "Failed to write OAuth client credentials" + ); + } + } + } + + if !failures.is_empty() { + return Err(anyhow!( + "Failed to write secrets to namespaces: {}", + failures.join(", ") + )); + } + + Ok(()) +} + +/// Process the HTTP response from the /init endpoint. +async fn handle_init_response(response: reqwest::Response) -> Result { + let status = response.status(); + + match status { + reqwest::StatusCode::CONFLICT => { + tracing::warn!("System already initialized (409 Conflict), skipping"); + Ok(InitResult::AlreadyInitialized) + } + reqwest::StatusCode::OK | reqwest::StatusCode::CREATED => { + let parsed = response + .json() + .await + .context("Failed to parse /init response")?; + Ok(InitResult::Success(parsed)) + } + _ => { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Err(anyhow!( + "Failed to initialize OAuth client: {} - {}", + status, + error_text + )) + } + } +} diff --git a/rfd-model/migrations/2026-02-18_initialization/down.sql b/rfd-model/migrations/2026-02-18_initialization/down.sql new file mode 100644 index 00000000..dfeb86d5 --- /dev/null +++ b/rfd-model/migrations/2026-02-18_initialization/down.sql @@ -0,0 +1,2 @@ +-- Rollback: drop the initialization table +DROP TABLE IF EXISTS initialization; diff --git a/rfd-model/migrations/2026-02-18_initialization/up.sql b/rfd-model/migrations/2026-02-18_initialization/up.sql new file mode 100644 index 00000000..a790175a --- /dev/null +++ b/rfd-model/migrations/2026-02-18_initialization/up.sql @@ -0,0 +1,8 @@ +-- Migration to create the initialization table +-- This table tracks whether the system has been initialized with an initial OAuth client + +CREATE TABLE initialization ( + id UUID PRIMARY KEY, + initialized_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + oauth_client_id UUID NOT NULL +); diff --git a/rfd-model/src/db.rs b/rfd-model/src/db.rs index a1d44814..486767bf 100644 --- a/rfd-model/src/db.rs +++ b/rfd-model/src/db.rs @@ -9,10 +9,18 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - schema::{job, rfd, rfd_pdf, rfd_revision}, + schema::{initialization, job, rfd, rfd_pdf, rfd_revision}, schema_ext::{rfd_meta_join, rfd_pdf_join, ContentFormat, PdfSource, Visibility}, }; +#[derive(Debug, Deserialize, Serialize, Queryable, Insertable, Selectable)] +#[diesel(table_name = initialization)] +pub struct InitializationModel { + pub id: Uuid, + pub initialized_at: DateTime, + pub oauth_client_id: Uuid, +} + #[derive(Debug, Deserialize, Serialize, Queryable, Insertable, Selectable)] #[diesel(table_name = rfd)] pub struct RfdModel { diff --git a/rfd-model/src/lib.rs b/rfd-model/src/lib.rs index 73f133f3..a0b42b95 100644 --- a/rfd-model/src/lib.rs +++ b/rfd-model/src/lib.rs @@ -7,6 +7,7 @@ use db::{ JobModel, RfdLatestMajorChange, RfdModel, RfdPdfModel, RfdRevisionMetaModel, RfdRevisionModel, RfdRevisionPdfModel, }; +pub use db::InitializationModel; use newtype_uuid::{GenericUuid, TypedUuid, TypedUuidKind, TypedUuidTag}; use partial_struct::partial; use schema_ext::{ContentFormat, PdfSource, Visibility}; diff --git a/rfd-model/src/schema.rs b/rfd-model/src/schema.rs index 8f2b23be..5a753c5c 100644 --- a/rfd-model/src/schema.rs +++ b/rfd-model/src/schema.rs @@ -16,6 +16,14 @@ pub mod sql_types { pub struct RfdVisibility; } +diesel::table! { + initialization (id) { + id -> Uuid, + initialized_at -> Timestamptz, + oauth_client_id -> Uuid, + } +} + diesel::table! { job (id) { id -> Int4, @@ -93,6 +101,7 @@ diesel::joinable!(rfd_pdf -> rfd_revision (rfd_revision_id)); diesel::joinable!(rfd_revision -> rfd (rfd_id)); diesel::allow_tables_to_appear_in_same_query!( + initialization, job, rfd, rfd_pdf, diff --git a/rfd-model/src/storage/mock.rs b/rfd-model/src/storage/mock.rs index 6477ca3c..3974ec8c 100644 --- a/rfd-model/src/storage/mock.rs +++ b/rfd-model/src/storage/mock.rs @@ -8,15 +8,16 @@ use std::sync::Arc; use v_model::storage::StoreError; use crate::{ - Job, NewJob, NewRfd, NewRfdPdf, NewRfdRevision, Rfd, RfdId, RfdMeta, RfdPdf, RfdPdfId, RfdPdfs, - RfdRevision, RfdRevisionId, RfdRevisionMeta, + db::InitializationModel, Job, NewJob, NewRfd, NewRfdPdf, NewRfdRevision, Rfd, RfdId, RfdMeta, + RfdPdf, RfdPdfId, RfdPdfs, RfdRevision, RfdRevisionId, RfdRevisionMeta, }; use super::{ - JobFilter, JobStore, ListPagination, MockJobStore, MockRfdMetaStore, MockRfdPdfStore, - MockRfdPdfsStore, MockRfdRevisionMetaStore, MockRfdRevisionPdfStore, MockRfdRevisionStore, - MockRfdStore, RfdFilter, RfdMetaStore, RfdPdfFilter, RfdPdfStore, RfdPdfsStore, - RfdRevisionFilter, RfdRevisionMetaStore, RfdRevisionStore, RfdStore, + InitializationStore, JobFilter, JobStore, ListPagination, MockInitializationStore, + MockJobStore, MockRfdMetaStore, MockRfdPdfStore, MockRfdPdfsStore, MockRfdRevisionMetaStore, + MockRfdRevisionPdfStore, MockRfdRevisionStore, MockRfdStore, RfdFilter, RfdMetaStore, + RfdPdfFilter, RfdPdfStore, RfdPdfsStore, RfdRevisionFilter, RfdRevisionMetaStore, + RfdRevisionStore, RfdStore, }; pub struct MockStorage { @@ -28,6 +29,7 @@ pub struct MockStorage { pub rfd_revision_pdf_store: Option>, pub rfd_pdf_store: Option>, pub job_store: Option>, + pub initialization_store: Option>, } impl MockStorage { @@ -41,6 +43,7 @@ impl MockStorage { rfd_revision_pdf_store: None, rfd_pdf_store: None, job_store: None, + initialization_store: None, } } } @@ -267,3 +270,21 @@ impl JobStore for MockStorage { self.job_store.as_ref().unwrap().complete(id).await } } + +#[async_trait] +impl InitializationStore for MockStorage { + async fn get(&self) -> Result, StoreError> { + self.initialization_store.as_ref().unwrap().get().await + } + + async fn insert( + &self, + record: InitializationModel, + ) -> Result { + self.initialization_store + .as_ref() + .unwrap() + .insert(record) + .await + } +} diff --git a/rfd-model/src/storage/mod.rs b/rfd-model/src/storage/mod.rs index d7a99d4b..68eea5f3 100644 --- a/rfd-model/src/storage/mod.rs +++ b/rfd-model/src/storage/mod.rs @@ -12,9 +12,9 @@ use std::fmt::Debug; use v_model::storage::{ListPagination, StoreError}; use crate::{ - schema_ext::PdfSource, CommitSha, Job, NewJob, NewRfd, NewRfdPdf, NewRfdRevision, Rfd, RfdId, - RfdMeta, RfdPdf, RfdPdfId, RfdPdfs, RfdRevision, RfdRevisionId, RfdRevisionMeta, - RfdRevisionPdf, + db::InitializationModel, schema_ext::PdfSource, CommitSha, Job, NewJob, NewRfd, NewRfdPdf, + NewRfdRevision, Rfd, RfdId, RfdMeta, RfdPdf, RfdPdfId, RfdPdfs, RfdRevision, RfdRevisionId, + RfdRevisionMeta, RfdRevisionPdf, }; #[cfg(feature = "mock")] @@ -29,6 +29,7 @@ pub trait RfdStorage: + RfdPdfStore + RfdPdfsStore + JobStore + + InitializationStore + Send + Sync + 'static @@ -42,6 +43,7 @@ impl RfdStorage for T where + RfdPdfStore + RfdPdfsStore + JobStore + + InitializationStore + Send + Sync + 'static @@ -338,3 +340,10 @@ pub trait JobStore { async fn start(&self, id: i32) -> Result, StoreError>; async fn complete(&self, id: i32) -> Result, StoreError>; } + +#[cfg_attr(feature = "mock", automock)] +#[async_trait] +pub trait InitializationStore { + async fn get(&self) -> Result, StoreError>; + async fn insert(&self, record: InitializationModel) -> Result; +} diff --git a/rfd-model/src/storage/postgres.rs b/rfd-model/src/storage/postgres.rs index 2d3d2d85..8ae6164a 100644 --- a/rfd-model/src/storage/postgres.rs +++ b/rfd-model/src/storage/postgres.rs @@ -26,10 +26,10 @@ use v_model::storage::postgres::PostgresStore; use crate::{ db::{ - JobModel, RfdLatestMajorChange, RfdMetaJoinRow, RfdModel, RfdPdfJoinRow, RfdPdfModel, - RfdRevisionMetaModel, RfdRevisionModel, RfdRevisionPdfModel, + InitializationModel, JobModel, RfdLatestMajorChange, RfdMetaJoinRow, RfdModel, + RfdPdfJoinRow, RfdPdfModel, RfdRevisionMetaModel, RfdRevisionModel, RfdRevisionPdfModel, }, - schema::{job, rfd, rfd_pdf, rfd_revision}, + schema::{initialization, job, rfd, rfd_pdf, rfd_revision}, schema_ext::Visibility, storage::StoreError, Job, NewJob, NewRfd, NewRfdPdf, NewRfdRevision, Rfd, RfdId, RfdMeta, RfdPdf, RfdPdfId, RfdPdfs, @@ -37,9 +37,9 @@ use crate::{ }; use super::{ - JobFilter, JobStore, ListPagination, RfdFilter, RfdMetaStore, RfdPdfFilter, RfdPdfStore, - RfdPdfsStore, RfdRevisionFilter, RfdRevisionMetaStore, RfdRevisionPdfStore, RfdRevisionStore, - RfdStore, + InitializationStore, JobFilter, JobStore, ListPagination, RfdFilter, RfdMetaStore, + RfdPdfFilter, RfdPdfStore, RfdPdfsStore, RfdRevisionFilter, RfdRevisionMetaStore, + RfdRevisionPdfStore, RfdRevisionStore, RfdStore, }; #[async_trait] @@ -1396,6 +1396,43 @@ impl JobStore for PostgresStore { } } +#[async_trait] +impl InitializationStore for PostgresStore { + async fn get(&self) -> Result, StoreError> { + let result = initialization::table + .first_async::( + &*self.pool.get().await.tap_err(|err| { + tracing::error!(?err, "Failed to acquire database connection") + })?, + ) + .await; + + match result { + Ok(record) => Ok(Some(record)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(e) => Err(e.into()), + } + } + + async fn insert( + &self, + record: InitializationModel, + ) -> Result { + let result = insert_into(initialization::table) + .values(( + initialization::id.eq(record.id), + initialization::initialized_at.eq(record.initialized_at), + initialization::oauth_client_id.eq(record.oauth_client_id), + )) + .get_result_async(&*self.pool.get().await.tap_err(|err| { + tracing::error!(?err, "Failed to acquire database connection") + })?) + .await?; + + Ok(result) + } +} + #[allow(clippy::type_complexity)] fn flatten_predicates( predicates: Vec>>>, diff --git a/rfd-processor/Cargo.toml b/rfd-processor/Cargo.toml index 178b9fb0..4b223496 100644 --- a/rfd-processor/Cargo.toml +++ b/rfd-processor/Cargo.toml @@ -36,6 +36,7 @@ reqwest-tracing = { workspace = true } rfd-data = { path = "../rfd-data" } rfd-github = { path = "../rfd-github" } rfd-model = { path = "../rfd-model" } +rfd-secret = { path = "../rfd-secret" } rsa = { workspace = true } serde = { workspace = true } tap = { workspace = true } diff --git a/rfd-processor/config.example.toml b/rfd-processor/config.example.toml index 385c7f38..f5ff7ab7 100644 --- a/rfd-processor/config.example.toml +++ b/rfd-processor/config.example.toml @@ -23,8 +23,16 @@ scanner_enabled = true # How often the processor scanner should check the remote GitHub repo for RFDs scanner_interval = 900 -# The internal database url to store RFD information -database_url = "postgres://:@/" +# Database connection configuration +# Password can be specified inline or read from a file: +# Inline: password = "your-password" +# From file: password = { path = "/run/secrets/db-password" } +[database] +host = "localhost" +port = 5432 +user = "rfd" +password = "" +database = "rfd" # The list of actions that should be run for each processing job actions = [ @@ -42,6 +50,10 @@ actions = [ # 1. A GitHub App installation that is defined by an app_id, installation_id, and private_key # 2. A GitHub access token # Exactly one authentication must be specified +# +# Secret values (private_key, token) can be specified inline or read from a file: +# Inline: private_key = "-----BEGIN RSA PRIVATE KEY-----\n..." +# From file: private_key = { path = "/run/secrets/github-app-key" } # App Installation [auth.github] @@ -50,12 +62,13 @@ app_id = 1111111 # Numeric GitHub App installation id corresponding to the organization that the configured repo # belongs to installation_id = 2222222 -# PEM encoded private key for the GitHub App +# PEM encoded private key for the GitHub App (can be inline or { path = "..." }) private_key = """""" # Access Token [auth.github] # This may be any GitHub access token that has permission to the configured repo +# (can be inline or { path = "/run/secrets/github-token" }) token = "" # The GitHub repository to use to read and write RFDs @@ -99,7 +112,7 @@ folder = "" [[search_storage]] # Https endpoint of the search instance host = "" -# API Key for reading and writing documents +# API Key for reading and writing documents (can be inline or { path = "/run/secrets/search-key" }) key = "" # Search index to store documents in index = "" diff --git a/rfd-processor/src/context.rs b/rfd-processor/src/context.rs index fbb6ce36..b6c2ff7d 100644 --- a/rfd-processor/src/context.rs +++ b/rfd-processor/src/context.rs @@ -29,6 +29,8 @@ use thiserror::Error; use tracing::instrument; use v_model::storage::postgres::PostgresStore; +use rfd_secret::SecretResolutionError; + use crate::{ pdf::{PdfFileLocation, PdfStorage, RfdPdf, RfdPdfError}, search::{RfdSearchIndex, SearchError}, @@ -85,6 +87,8 @@ pub enum ContextError { InvalidGitHubPrivateKey(#[from] rsa::pkcs1::Error), #[error(transparent)] Search(#[from] SearchError), + #[error(transparent)] + SecretResolution(#[from] SecretResolutionError), } pub struct Context { @@ -120,24 +124,27 @@ impl Context { app_id, installation_id, private_key, - } => GitHubClient::custom( - "rfd-processor", - Credentials::InstallationToken(InstallationTokenGenerator::new( - *installation_id, - JWTCredentials::new( - *app_id, - RsaPrivateKey::from_pkcs1_pem(private_key)? - .to_pkcs1_der()? - .to_bytes() - .to_vec(), - )?, - )), - client, - http_cache, - ), + } => { + let resolved_key = private_key.resolve()?; + GitHubClient::custom( + "rfd-processor", + Credentials::InstallationToken(InstallationTokenGenerator::new( + *installation_id, + JWTCredentials::new( + *app_id, + RsaPrivateKey::from_pkcs1_pem(&resolved_key)? + .to_pkcs1_der()? + .to_bytes() + .to_vec(), + )?, + )), + client, + http_cache, + ) + } GitHubAuthConfig::User { token } => GitHubClient::custom( "rfd-processor", - Credentials::Token(token.to_string()), + Credentials::Token(token.resolve()?), client, http_cache, ), @@ -446,11 +453,14 @@ pub struct SearchCtx { } impl SearchCtx { - pub fn new(entries: &[SearchConfig]) -> Result { + pub fn new(entries: &[SearchConfig]) -> Result { Ok(Self { indexes: entries .iter() - .map(|c| RfdSearchIndex::new(&c.host, &c.key, &c.index)) + .map(|c| { + let key = c.key.resolve()?; + RfdSearchIndex::new(&c.host, key, &c.index).map_err(ContextError::from) + }) .collect::, _>>()?, }) } diff --git a/rfd-processor/src/main.rs b/rfd-processor/src/main.rs index 895ff0ab..ef4c34fb 100644 --- a/rfd-processor/src/main.rs +++ b/rfd-processor/src/main.rs @@ -4,6 +4,7 @@ use config::{Config, ConfigError, Environment, File}; use processor::{processor, JobError}; +use rfd_secret::SecretString; use serde::{Deserialize, Serialize}; use std::sync::Arc; use thiserror::Error; @@ -41,7 +42,7 @@ pub struct AppConfig { pub processor_update_mode: RfdUpdateMode, pub scanner_enabled: bool, pub scanner_interval: u64, - pub database_url: String, + pub database: DatabaseConfig, pub actions: Vec, pub auth: AuthConfig, pub source: GitHubSourceRepo, @@ -83,10 +84,10 @@ pub enum GitHubAuthConfig { Installation { app_id: i64, installation_id: i64, - private_key: String, + private_key: SecretString, }, User { - token: String, + token: SecretString, }, } @@ -119,10 +120,29 @@ pub struct PdfStorageConfig { #[derive(Debug, Deserialize, Serialize)] pub struct SearchConfig { pub host: String, - pub key: String, + pub key: SecretString, pub index: String, } +#[derive(Debug, Deserialize)] +pub struct DatabaseConfig { + pub host: String, + pub port: u16, + pub user: String, + pub password: SecretString, + pub database: String, +} + +impl DatabaseConfig { + pub fn to_url(&self) -> Result { + let password = self.password.resolve()?; + Ok(format!( + "postgres://{}:{}@{}:{}/{}", + self.user, password, self.host, self.port, self.database + )) + } +} + impl AppConfig { pub fn new(config_sources: Option>) -> Result { let mut config = Config::builder() @@ -171,7 +191,8 @@ async fn main() -> Result<(), Box> { LogFormat::Json => subscriber.json().init(), } - let ctx = Arc::new(Context::new(Database::new(&config.database_url).await, &config).await?); + let database_url = config.database.to_url()?; + let ctx = Arc::new(Context::new(Database::new(&database_url).await, &config).await?); let scanner_ctx = ctx.clone(); let scanner_handle = tokio::spawn(async move { diff --git a/rfd-secret/Cargo.toml b/rfd-secret/Cargo.toml new file mode 100644 index 00000000..5d7f9bca --- /dev/null +++ b/rfd-secret/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rfd-secret" +version = "0.1.0" +edition = "2021" +description = "Secret string utilities for RFD API configuration" +repository = "https://github.com/oxidecomputer/rfd-api" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +toml = { workspace = true } diff --git a/rfd-secret/src/lib.rs b/rfd-secret/src/lib.rs new file mode 100644 index 00000000..e70567ae --- /dev/null +++ b/rfd-secret/src/lib.rs @@ -0,0 +1,151 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Secret string utilities for RFD API configuration. +//! +//! This crate provides a [`SecretString`] type that can be deserialized from either +//! an inline value or a file path, allowing secrets to be stored outside of +//! configuration files. +//! +//! # TOML Usage +//! +//! Inline value: +//! ```toml +//! key = "my-secret-value" +//! ``` +//! +//! Path-based value (reads secret from file at runtime): +//! ```toml +//! key = { path = "/run/secrets/my-key" } +//! ``` + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SecretResolutionError { + #[error("Failed to read secret from path '{path}'")] + FileRead { + path: String, + #[source] + source: std::io::Error, + }, +} + +/// A secret string that can be specified either inline or as a path to a file. +/// +/// When deserialized from TOML/JSON, accepts either: +/// - A plain string: `"my-secret"` +/// - An object with path: `{ path = "/path/to/secret" }` +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum SecretString { + /// Secret value specified directly inline + Inline(String), + /// Path to a file containing the secret + FromPath { path: PathBuf }, +} + +impl SecretString { + /// Resolves the secret value, reading from file if necessary. + /// + /// For inline values, returns the value directly. + /// For path-based values, reads the file contents and trims trailing whitespace. + pub fn resolve(&self) -> Result { + match self { + SecretString::Inline(value) => Ok(value.clone()), + SecretString::FromPath { path } => { + let content = + std::fs::read_to_string(path).map_err(|source| SecretResolutionError::FileRead { + path: path.display().to_string(), + source, + })?; + // Trim trailing whitespace/newlines that are common in secret files + Ok(content.trim_end().to_string()) + } + } + } +} + +impl Default for SecretString { + fn default() -> Self { + SecretString::Inline(String::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_inline_value() { + let secret = SecretString::Inline("my-secret".to_string()); + assert_eq!(secret.resolve().unwrap(), "my-secret"); + } + + #[test] + fn test_from_path() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "file-secret").unwrap(); + + let secret = SecretString::FromPath { + path: file.path().to_path_buf(), + }; + assert_eq!(secret.resolve().unwrap(), "file-secret"); + } + + #[test] + fn test_from_path_trims_trailing_whitespace() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "file-secret").unwrap(); + writeln!(file).unwrap(); + + let secret = SecretString::FromPath { + path: file.path().to_path_buf(), + }; + assert_eq!(secret.resolve().unwrap(), "file-secret"); + } + + #[test] + fn test_from_path_file_not_found() { + let secret = SecretString::FromPath { + path: PathBuf::from("/nonexistent/path"), + }; + let result = secret.resolve(); + assert!(matches!(result, Err(SecretResolutionError::FileRead { .. }))); + } + + #[test] + fn test_deserialize_inline() { + let toml = r#"key = "inline-value""#; + + #[derive(Deserialize)] + struct Config { + key: SecretString, + } + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.key.resolve().unwrap(), "inline-value"); + } + + #[test] + fn test_deserialize_from_path() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "path-value").unwrap(); + + let toml = format!(r#"key = {{ path = "{}" }}"#, file.path().display()); + + #[derive(Deserialize)] + struct Config { + key: SecretString, + } + + let config: Config = toml::from_str(&toml).unwrap(); + assert_eq!(config.key.resolve().unwrap(), "path-value"); + } +} From 488f8c798cebc5d9f7a9de0d1d7972f01d9745b3 Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Thu, 19 Feb 2026 21:48:46 -0800 Subject: [PATCH 4/7] Add retry middleware to Meilisearch HTTP client Implement a custom HttpClient for meilisearch-sdk that wraps reqwest-middleware with exponential backoff retry support. This replaces the manual retry loop with middleware-based retries, consistent with oauth_init. --- rfd-kube-init/Cargo.lock | 5 + rfd-kube-init/Cargo.toml | 4 + rfd-kube-init/src/main.rs | 1 + rfd-kube-init/src/meilisearch.rs | 223 ++++++++++++------------ rfd-kube-init/src/meilisearch_client.rs | 63 +++++++ rfd-kube-init/src/oauth_init.rs | 3 +- 6 files changed, 188 insertions(+), 111 deletions(-) create mode 100644 rfd-kube-init/src/meilisearch_client.rs diff --git a/rfd-kube-init/Cargo.lock b/rfd-kube-init/Cargo.lock index 3deb6770..7fd35bad 100644 --- a/rfd-kube-init/Cargo.lock +++ b/rfd-kube-init/Cargo.lock @@ -1876,7 +1876,9 @@ name = "rfd-kube-init" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap", + "futures-io", "k8s-openapi", "kube", "meilisearch-sdk", @@ -1888,8 +1890,10 @@ dependencies = [ "serde_json", "time", "tokio", + "tokio-util", "tracing", "tracing-subscriber", + "yaup", ] [[package]] @@ -2403,6 +2407,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "slab", diff --git a/rfd-kube-init/Cargo.toml b/rfd-kube-init/Cargo.toml index 07bf6aed..63621a8f 100644 --- a/rfd-kube-init/Cargo.toml +++ b/rfd-kube-init/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0" +async-trait = "0.1" +futures-io = "0.3" clap = { version = "4", features = ["derive", "env"] } k8s-openapi = { version = "0.27", features = ["v1_32"] } kube = { version = "3.0.1", features = ["client", "runtime", "rustls-tls", "aws-lc-rs"] } @@ -23,6 +25,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" time = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tokio-util = { version = "0.7", features = ["compat", "io"] } +yaup = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/rfd-kube-init/src/main.rs b/rfd-kube-init/src/main.rs index 8b5fc4e5..82238779 100644 --- a/rfd-kube-init/src/main.rs +++ b/rfd-kube-init/src/main.rs @@ -4,6 +4,7 @@ mod kube; mod meilisearch; +mod meilisearch_client; mod oauth_init; use anyhow::Result; diff --git a/rfd-kube-init/src/meilisearch.rs b/rfd-kube-init/src/meilisearch.rs index fa00d89d..a1e995a8 100644 --- a/rfd-kube-init/src/meilisearch.rs +++ b/rfd-kube-init/src/meilisearch.rs @@ -6,13 +6,15 @@ use anyhow::{anyhow, Context, Result}; use clap::Args; use meilisearch_sdk::client::Client; use meilisearch_sdk::key::Key; +use reqwest_middleware::ClientBuilder; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; use secrecy::ExposeSecret; use std::fmt; -use std::time::Duration as StdDuration; -use time::{Duration, OffsetDateTime}; -use tokio::time::sleep; +use std::time::Duration; +use time::OffsetDateTime; use crate::kube; +use crate::meilisearch_client::RetryingMeilisearchClient; const DEFAULT_SEARCH_API_KEY_NAME: &str = "Default Search API Key"; const DEFAULT_ADMIN_API_KEY_NAME: &str = "Default Admin API Key"; @@ -108,15 +110,105 @@ impl fmt::Display for FailedNamespace { } } +/// Initialize meilisearch secrets across target namespaces. +pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Result<()> { + // Read master key from kubernetes secret + let master_key = kube::read_secret_key( + kube_client, + &args.master_namespace, + &args.master_secret_name, + &args.master_secret_key, + ) + .await + .context("Failed to read Meilisearch master key from Kubernetes secret")?; + + // Calculate expiration time if provided + let expires_at = args + .expiration_seconds + .map(|secs| OffsetDateTime::now_utc() + time::Duration::seconds(secs)); + + // Parse search rules filter (empty string or missing means wildcard access to all indexes) + let search_rules: serde_json::Value = match &args.token_filter { + Some(filter) if !filter.is_empty() => { + serde_json::from_str(filter).context("token-filter must be valid JSON")? + } + _ => serde_json::json!(["*"]), + }; + + // Create meilisearch client with retry middleware + let retry_policy = ExponentialBackoff::builder() + .retry_bounds(Duration::from_secs(1), Duration::from_secs(30)) + .build_with_max_retries(args.max_retries); + + let http_client = RetryingMeilisearchClient::new( + ClientBuilder::new(reqwest::Client::new()) + .with(RetryTransientMiddleware::new_with_policy(retry_policy)) + .build(), + ); + + let client = Client::new_with_client(&args.host, Some(master_key.expose_secret()), http_client); + let (search_key, admin_key) = get_api_keys(&client).await?; + + let mut failures = Vec::new(); + + // Generate and distribute RW token + failures.extend( + generate_and_distribute_token( + &client, + kube_client, + &args.namespaces.rw_namespaces, + &args.secret_name, + search_rules.clone(), + expires_at, + TokenType::ReadWrite(admin_key), + ) + .await?, + ); + + // Generate and distribute RO token + failures.extend( + generate_and_distribute_token( + &client, + kube_client, + &args.namespaces.ro_namespaces, + &args.secret_name, + search_rules, + expires_at, + TokenType::ReadOnly(search_key), + ) + .await?, + ); + + if !failures.is_empty() { + let failed_list: Vec = failures.iter().map(|f| f.to_string()).collect(); + tracing::error!( + failed_count = failures.len(), + failed_namespaces = %failed_list.join(", "), + "Failed to write secrets to some namespaces" + ); + return Err(anyhow!( + "Failed to write secrets to namespaces: {}", + failed_list.join(", ") + )); + } + + Ok(()) +} + /// Fetch API keys from Meilisearch and return the search and admin keys. -async fn get_api_keys(client: &Client) -> Result<(Option, Option)> { +async fn get_api_keys( + client: &Client, +) -> Result<(Option, Option)> { tracing::debug!("Fetching API keys from Meilisearch"); let keys_result = client .get_keys() .await .context("Failed to fetch API keys from Meilisearch")?; - tracing::debug!(key_count = keys_result.results.len(), "Retrieved API keys from Meilisearch"); + tracing::debug!( + key_count = keys_result.results.len(), + "Retrieved API keys from Meilisearch" + ); let mut search_key = None; let mut admin_key = None; @@ -148,7 +240,7 @@ async fn get_api_keys(client: &Client) -> Result<(Option, Option)> { /// Generate a tenant token and distribute it to the specified namespaces. /// Returns a list of namespaces that failed to write. async fn generate_and_distribute_token( - client: &Client, + client: &Client, kube_client: &::kube::Client, namespaces: &[String], secret_name: &str, @@ -178,10 +270,7 @@ async fn generate_and_distribute_token( token_type = token_type_str.as_str(), "API key not found in Meilisearch but namespaces are configured" ); - return Err(anyhow!( - "Could not find '{}' in Meilisearch keys", - key_name - )); + return Err(anyhow!("Could not find '{}' in Meilisearch keys", key_name)); } }; @@ -193,7 +282,10 @@ async fn generate_and_distribute_token( let token = client.generate_tenant_token(key.uid, search_rules, None, expires_at)?; - tracing::info!(token_type = token_type_str.as_str(), "Generated Meilisearch tenant token"); + tracing::info!( + token_type = token_type_str.as_str(), + "Generated Meilisearch tenant token" + ); let mut failures = Vec::new(); @@ -233,103 +325,6 @@ async fn generate_and_distribute_token( Ok(failures) } -/// Initialize meilisearch secrets across target namespaces. -pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Result<()> { - // Read master key from kubernetes secret - let master_key = kube::read_secret_key( - kube_client, - &args.master_namespace, - &args.master_secret_name, - &args.master_secret_key, - ) - .await - .context("Failed to read Meilisearch master key from Kubernetes secret")?; - - // Calculate expiration time if provided - let expires_at = args - .expiration_seconds - .map(|secs| OffsetDateTime::now_utc() + Duration::seconds(secs)); - - // Parse search rules filter (empty string or missing means wildcard access to all indexes) - let search_rules: serde_json::Value = match &args.token_filter { - Some(filter) if !filter.is_empty() => { - serde_json::from_str(filter).context("token-filter must be valid JSON")? - } - _ => serde_json::json!(["*"]), - }; - - // Create meilisearch client and fetch API keys with retry - let client = Client::new(&args.host, Some(master_key.expose_secret()))?; - let (search_key, admin_key) = 'retry: { - let mut last_error = None; - - for attempt in 0..args.max_retries { - let backoff_secs = std::cmp::min(30, 1u64 << attempt); - - match get_api_keys(&client).await { - Ok(keys) => break 'retry keys, - Err(e) => { - last_error = Some(e); - tracing::warn!( - attempt = attempt + 1, - max_retries = args.max_retries, - backoff_secs = backoff_secs, - "Failed to fetch API keys from Meilisearch, retrying" - ); - sleep(StdDuration::from_secs(backoff_secs)).await; - } - } - } - - return Err(last_error.unwrap_or_else(|| anyhow!("Max retries exceeded"))); - }; - - let mut failures = Vec::new(); - - // Generate and distribute RW token - failures.extend( - generate_and_distribute_token( - &client, - kube_client, - &args.namespaces.rw_namespaces, - &args.secret_name, - search_rules.clone(), - expires_at, - TokenType::ReadWrite(admin_key), - ) - .await?, - ); - - // Generate and distribute RO token - failures.extend( - generate_and_distribute_token( - &client, - kube_client, - &args.namespaces.ro_namespaces, - &args.secret_name, - search_rules, - expires_at, - TokenType::ReadOnly(search_key), - ) - .await?, - ); - - if !failures.is_empty() { - let failed_list: Vec = failures.iter().map(|f| f.to_string()).collect(); - tracing::error!( - failed_count = failures.len(), - failed_namespaces = %failed_list.join(", "), - "Failed to write secrets to some namespaces" - ); - return Err(anyhow!( - "Failed to write secrets to namespaces: {}", - failed_list.join(", ") - )); - } - - Ok(()) -} - #[cfg(test)] mod tests { use meilisearch_sdk::client::Client; @@ -351,10 +346,18 @@ mod tests { // The token generation should succeed (crypto works) even though // the key UID is fake - we're testing the signing, not validation - assert!(result.is_ok(), "JWT token generation failed: {:?}", result.err()); + assert!( + result.is_ok(), + "JWT token generation failed: {:?}", + result.err() + ); let token = result.unwrap(); // JWT tokens have 3 parts separated by dots - assert_eq!(token.split('.').count(), 3, "Generated token should be a valid JWT format"); + assert_eq!( + token.split('.').count(), + 3, + "Generated token should be a valid JWT format" + ); } } diff --git a/rfd-kube-init/src/meilisearch_client.rs b/rfd-kube-init/src/meilisearch_client.rs new file mode 100644 index 00000000..eba5b897 --- /dev/null +++ b/rfd-kube-init/src/meilisearch_client.rs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use futures_io::AsyncRead; +use meilisearch_sdk::errors::Error; +use meilisearch_sdk::request::{parse_response, HttpClient, Method}; +use reqwest_middleware::ClientWithMiddleware; +use serde::{de::DeserializeOwned, Serialize}; +use tokio_util::compat::FuturesAsyncReadCompatExt; +use tokio_util::io::ReaderStream; + +#[derive(Clone)] +pub struct RetryingMeilisearchClient { + client: ClientWithMiddleware, +} + +impl RetryingMeilisearchClient { + pub fn new(client: ClientWithMiddleware) -> Self { + Self { client } + } +} + +#[async_trait::async_trait] +impl HttpClient for RetryingMeilisearchClient { + async fn stream_request< + Query: Serialize + Send + Sync, + Body: AsyncRead + Send + Sync + 'static, + Output: DeserializeOwned + 'static, + >( + &self, + url: &str, + method: Method, + content_type: &str, + expected_status_code: u16, + ) -> Result { + let url = format!("{url}?{}", yaup::to_string(method.query())?); + + let request = match method { + Method::Get { .. } => self.client.get(&url), + Method::Delete { .. } => self.client.delete(&url), + Method::Post { body, .. } => self.client.post(&url).body(to_body(body)), + Method::Put { body, .. } => self.client.put(&url).body(to_body(body)), + Method::Patch { body, .. } => self.client.patch(&url).body(to_body(body)), + } + .header(reqwest::header::CONTENT_TYPE, content_type); + + let response = request.send().await.map_err(|e| Error::Other(e.into()))?; + let status = response.status().as_u16(); + let body = response.text().await.map_err(|e| Error::Other(e.into()))?; + let body = if body.is_empty() { "null" } else { &body }; + + parse_response(status, expected_status_code, body, url.to_string()) + } + + fn is_tokio(&self) -> bool { + true + } +} + +fn to_body(reader: impl AsyncRead + Send + Sync + 'static) -> reqwest::Body { + reqwest::Body::wrap_stream(ReaderStream::new(reader.compat())) +} diff --git a/rfd-kube-init/src/oauth_init.rs b/rfd-kube-init/src/oauth_init.rs index bdeba508..3f13b860 100644 --- a/rfd-kube-init/src/oauth_init.rs +++ b/rfd-kube-init/src/oauth_init.rs @@ -4,6 +4,7 @@ use anyhow::{anyhow, Context, Result}; use clap::Args; +use reqwest::Response; use reqwest_middleware::ClientBuilder; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; use serde::{Deserialize, Serialize}; @@ -90,7 +91,7 @@ pub async fn init(kube_client: &::kube::Client, args: &OAuthInitArgs) -> Result< tracing::debug!(url = %init_url, "Calling /init endpoint"); - let response = client + let response: Response = client .post(&init_url) .header("Content-Type", "application/json") .body(serde_json::to_string(&request_body)?) From 2a6c00a0cccfaad1cdbfab5ea8400ea38e81e73b Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Fri, 20 Feb 2026 13:51:21 -0800 Subject: [PATCH 5/7] Set Authorization header in Meilisearch HTTP client Claude Assisted --- Cargo.lock | 24 +- Cargo.toml | 2 +- rfd-kube-init/Cargo.lock | 415 ++++++++++++++++++++---- rfd-kube-init/Cargo.toml | 4 +- rfd-kube-init/src/meilisearch.rs | 1 + rfd-kube-init/src/meilisearch_client.rs | 7 +- 6 files changed, 368 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbb32e05..ce3a9030 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] @@ -2183,7 +2182,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-layer", @@ -2468,7 +2467,6 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "aws-lc-rs", "base64", "ed25519-dalek", "getrandom 0.2.17", @@ -3409,7 +3407,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -3446,7 +3444,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -4038,7 +4036,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -4207,7 +4205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -4218,7 +4216,7 @@ checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -4230,7 +4228,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -4337,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -5482,12 +5480,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index b375eb2d..74f71a52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ http = "1.4.0" hyper = "1.8.1" itertools = "0.13.0" jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } -meilisearch-sdk = "0.32.0" +meilisearch-sdk = { version = "0.32.0", default-features = false, features = ["reqwest", "tls", "jwt_rust_crypto"] } k8s-openapi = { version = "0.27", features = ["v1_32"] } md-5 = "0.10.6" mime_guess = "2.0.5" diff --git a/rfd-kube-init/Cargo.lock b/rfd-kube-init/Cargo.lock index 7fd35bad..840a98c6 100644 --- a/rfd-kube-init/Cargo.lock +++ b/rfd-kube-init/Cargo.lock @@ -143,29 +143,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "backon" version = "1.6.0" @@ -177,12 +154,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -223,8 +212,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -280,15 +267,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -304,6 +282,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.8.0" @@ -344,6 +328,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -354,6 +350,44 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.6" @@ -392,7 +426,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -407,10 +443,42 @@ dependencies = [ ] [[package]] -name = "dunce" -version = "1.0.5" +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] [[package]] name = "educe" @@ -433,6 +501,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -496,6 +585,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -529,12 +634,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.32" @@ -631,6 +730,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -685,6 +785,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -730,6 +841,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1050,16 +1179,6 @@ dependencies = [ "syn", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.85" @@ -1111,12 +1230,18 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "aws-lc-rs", "base64", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", + "rand 0.8.5", + "rsa", "serde", "serde_json", + "sha2", "signature", ] @@ -1229,6 +1354,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1242,6 +1370,12 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "litemap" version = "0.8.1" @@ -1362,12 +1496,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1375,6 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1404,6 +1575,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1468,6 +1663,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1549,6 +1753,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1598,6 +1823,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1871,6 +2105,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd-kube-init" version = "0.1.0" @@ -1906,10 +2150,30 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1931,7 +2195,6 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -1969,10 +2232,9 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -2002,6 +2264,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -2160,6 +2436,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -2185,6 +2462,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2568,12 +2861,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/rfd-kube-init/Cargo.toml b/rfd-kube-init/Cargo.toml index 63621a8f..5b92ed57 100644 --- a/rfd-kube-init/Cargo.toml +++ b/rfd-kube-init/Cargo.toml @@ -15,8 +15,8 @@ async-trait = "0.1" futures-io = "0.3" clap = { version = "4", features = ["derive", "env"] } k8s-openapi = { version = "0.27", features = ["v1_32"] } -kube = { version = "3.0.1", features = ["client", "runtime", "rustls-tls", "aws-lc-rs"] } -meilisearch-sdk = { version = "0.32.0", default-features = false, features = ["reqwest", "tls", "jwt_aws_lc_rs"] } +kube = { version = "3.0.1", features = ["client", "runtime", "rustls-tls"] } +meilisearch-sdk = { version = "0.32.0", default-features = false, features = ["reqwest", "tls", "jwt_rust_crypto"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest-middleware = "0.4" reqwest-retry = "0.7" diff --git a/rfd-kube-init/src/meilisearch.rs b/rfd-kube-init/src/meilisearch.rs index a1e995a8..f2eabbfe 100644 --- a/rfd-kube-init/src/meilisearch.rs +++ b/rfd-kube-init/src/meilisearch.rs @@ -144,6 +144,7 @@ pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Resul ClientBuilder::new(reqwest::Client::new()) .with(RetryTransientMiddleware::new_with_policy(retry_policy)) .build(), + master_key.expose_secret().to_string(), ); let client = Client::new_with_client(&args.host, Some(master_key.expose_secret()), http_client); diff --git a/rfd-kube-init/src/meilisearch_client.rs b/rfd-kube-init/src/meilisearch_client.rs index eba5b897..43f49433 100644 --- a/rfd-kube-init/src/meilisearch_client.rs +++ b/rfd-kube-init/src/meilisearch_client.rs @@ -13,11 +13,12 @@ use tokio_util::io::ReaderStream; #[derive(Clone)] pub struct RetryingMeilisearchClient { client: ClientWithMiddleware, + api_key: String, } impl RetryingMeilisearchClient { - pub fn new(client: ClientWithMiddleware) -> Self { - Self { client } + pub fn new(client: ClientWithMiddleware, api_key: String) -> Self { + Self { client, api_key } } } @@ -45,6 +46,8 @@ impl HttpClient for RetryingMeilisearchClient { } .header(reqwest::header::CONTENT_TYPE, content_type); + let request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", self.api_key)); + let response = request.send().await.map_err(|e| Error::Other(e.into()))?; let status = response.status().as_u16(); let body = response.text().await.map_err(|e| Error::Other(e.into()))?; From 6d0ce5886b74e2c738c054ad293f759fc2ac9af9 Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Fri, 20 Feb 2026 15:09:18 -0800 Subject: [PATCH 6/7] token generation to use key --- rfd-kube-init/src/meilisearch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfd-kube-init/src/meilisearch.rs b/rfd-kube-init/src/meilisearch.rs index f2eabbfe..c269a57e 100644 --- a/rfd-kube-init/src/meilisearch.rs +++ b/rfd-kube-init/src/meilisearch.rs @@ -281,7 +281,7 @@ async fn generate_and_distribute_token( "Found API key for token", ); - let token = client.generate_tenant_token(key.uid, search_rules, None, expires_at)?; + let token = client.generate_tenant_token(key.uid, search_rules, Some(&key.key), expires_at)?; tracing::info!( token_type = token_type_str.as_str(), From 65f068868da0beda9a4e34c8288ba9e6474ee974 Mon Sep 17 00:00:00 2001 From: Nick Downs Date: Fri, 20 Feb 2026 21:30:43 -0800 Subject: [PATCH 7/7] Meilisearch scoped API keys and auto-index creation Change rfd-kube-init to generate scoped write API keys instead of tenant tokens for RW namespaces. The new keys are limited to specific indexes via the MEILI_RW_INDEXES configuration. This provides better security by restricting write access to only the necessary indexes. Add automatic index creation in rfd-processor at startup, ensuring the search index exists before attempting to index documents. This removes the need for manual index creation. Claude Assisted --- Containerfile.local | 91 +++++++ SETUP.md | 73 ++++++ rfd-kube-init/Cargo.lock | 1 + rfd-kube-init/Cargo.toml | 1 + rfd-kube-init/README.md | 27 +- rfd-kube-init/src/meilisearch.rs | 231 ++++++++++++------ rfd-kube-init/src/meilisearch_client.rs | 19 +- rfd-processor/src/context.rs | 24 +- rfd-processor/src/search/mod.rs | 34 ++- .../src/updater/update_search_index.rs | 2 +- 10 files changed, 406 insertions(+), 97 deletions(-) create mode 100644 Containerfile.local diff --git a/Containerfile.local b/Containerfile.local new file mode 100644 index 00000000..7fb794ef --- /dev/null +++ b/Containerfile.local @@ -0,0 +1,91 @@ +# syntax=docker/dockerfile:1 +# Build stage +FROM docker.io/rust:1-trixie AS builder + +ARG DIESEL_VERSION=v2.3.5 +ARG NODE_VERSION=v24.13.0 + + +# Install build dependencies for diesel/postgres +RUN apt-get update && apt-get install -y \ + libpq-dev \ + pkg-config + +# Download diesel tool for migrations +WORKDIR /tmp +RUN curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VERSION}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz" \ + && curl -L --output-dir /tmp -O "https://github.com/diesel-rs/diesel/releases/download/${DIESEL_VERSION}/diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256" \ + && sha256sum diesel_cli-x86_64-unknown-linux-gnu.tar.xz.sha256 && tar --strip-components=1 -xJvf diesel_cli-x86_64-unknown-linux-gnu.tar.xz + +# Node for search indec +RUN curl -L --output-dir /tmp -O "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz" \ + && curl -L --output-dir /tmp -o node.SHASUMS256.txt "https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt" \ + && sha256sum node.SHASUMS256.txt && tar --strip-components=1 -xJvf "node-${NODE_VERSION}-linux-x64.tar.xz" + +WORKDIR /app +# Copy workspace files +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ + +# Copy all workspace members +COPY parse-rfd ./parse-rfd +COPY rfd-api ./rfd-api +COPY rfd-cli ./rfd-cli +COPY rfd-data ./rfd-data +COPY rfd-github ./rfd-github +COPY rfd-installer ./rfd-installer +COPY rfd-model ./rfd-model +COPY rfd-processor ./rfd-processor +COPY rfd-sdk ./rfd-sdk +COPY rfd-secret ./rfd-secret +COPY trace-request ./trace-request +COPY xtask ./xtask + +ENV CARGO_HOME=/data/cargo + +# Build workspace binaries in release mode +RUN cargo build --release \ + --package rfd-api \ + --package rfd-processor \ + --package rfd-cli \ + --package rfd-installer + +# Build rfd-kube-init separately (excluded from workspace to avoid feature conflicts) +COPY rfd-kube-init ./rfd-kube-init +RUN cd rfd-kube-init && cargo build --release --target-dir /app/target + +# Runtime stage +FROM docker.io/debian:trixie-slim + +# Install runtime dependencies and tini +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libpq5 \ + tini \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --user-group rfd \ + && mkdir /home/rfd/db + +# Copy binaries from builder +COPY ./local_data/target/release/rfd-api /usr/local/bin/ +COPY ./local_data/target/release/rfd-processor /usr/local/bin/ +COPY ./local_data/target/release/rfd-cli /usr/local/bin/ +COPY ./local_data/target/release/rfd-installer /usr/local/bin/ +COPY ./local_data/target/release/rfd-kube-init /usr/local/bin/ + +# Database migrations for diesel +COPY --from=builder /tmp/diesel /usr/local/bin/ +COPY ./rfd-model/migrations/ /home/rfd/db/migrations/ + +# Node for search indexing +COPY --from=builder /tmp/bin/node /usr/local/bin/ + +# Create non-root user +USER rfd +WORKDIR /home/rfd + +# Use tini as entrypoint for proper signal handling +ENTRYPOINT ["/usr/bin/tini", "--"] + +# Default to running rfd-api, can be overridden with CMD +CMD ["rfd-api"] diff --git a/SETUP.md b/SETUP.md index 2416b351..f8540796 100644 --- a/SETUP.md +++ b/SETUP.md @@ -146,3 +146,76 @@ S3 uses the AWS SDK default credential chain for authentication: The optional `endpoint` field allows using S3-compatible services such as MinIO, Backblaze B2, or Cloudflare R2. + +##### Search (Meilisearch) + +The processor can index RFD content to Meilisearch for full-text search. To enable this feature, +add `UpdateSearch` to the `actions` list and configure at least one search backend. + +```toml +[[search_storage]] +host = "http://meilisearch:7700" +key = "your-api-key" +index = "rfd" +``` + +The `key` field supports reading from a file using the secret path syntax: + +```toml +[[search_storage]] +host = "http://meilisearch:7700" +key = { path = "/run/secrets/meilisearch-key" } +index = "rfd" +``` + +**Index Creation** + +The processor will automatically create the search index on startup if it does not exist. This +requires the API key to have the `indexes.create` permission. + +**Scoped API Keys** + +When using scoped API keys (recommended for production), the key must have the following +Meilisearch actions: + +| Action | Required | Purpose | +|--------|----------|---------| +| `indexes.create` | Yes | Create the index if it doesn't exist | +| `indexes.get` | Yes | Check index existence and fetch info | +| `documents.add` | Yes | Index RFD content | +| `documents.get` | Yes | Retrieve existing documents | +| `documents.delete` | Yes | Remove outdated documents when re-indexing | +| `settings.get` | Yes | Read index settings | +| `settings.update` | Yes | Configure filterable attributes | +| `search` | Yes | Query for existing documents by RFD number | + +The key should be scoped to the specific index (e.g., `rfd`) rather than using a wildcard. + +Example key creation using the Meilisearch master key: + +```bash +curl -X POST "http://meilisearch:7700/keys" \ + -H "Authorization: Bearer $MEILI_MASTER_KEY" \ + -H "Content-Type: application/json" \ + --data '{ + "name": "rfd-processor", + "description": "Scoped key for rfd-processor", + "actions": [ + "indexes.create", + "indexes.get", + "documents.add", + "documents.get", + "documents.delete", + "settings.get", + "settings.update", + "search" + ], + "indexes": ["rfd"], + "expiresAt": null + }' +``` + +> [!NOTE] +> +> If using `rfd-kube-init` to manage Meilisearch API keys in Kubernetes, it will automatically +> create scoped keys with the required permissions. See the `rfd-kube-init` README for details. diff --git a/rfd-kube-init/Cargo.lock b/rfd-kube-init/Cargo.lock index 840a98c6..c1cf612d 100644 --- a/rfd-kube-init/Cargo.lock +++ b/rfd-kube-init/Cargo.lock @@ -2123,6 +2123,7 @@ dependencies = [ "async-trait", "clap", "futures-io", + "futures-util", "k8s-openapi", "kube", "meilisearch-sdk", diff --git a/rfd-kube-init/Cargo.toml b/rfd-kube-init/Cargo.toml index 5b92ed57..662e4624 100644 --- a/rfd-kube-init/Cargo.toml +++ b/rfd-kube-init/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" anyhow = "1.0" async-trait = "0.1" futures-io = "0.3" +futures-util = { version = "0.3", features = ["io"] } clap = { version = "4", features = ["derive", "env"] } k8s-openapi = { version = "0.27", features = ["v1_32"] } kube = { version = "3.0.1", features = ["client", "runtime", "rustls-tls"] } diff --git a/rfd-kube-init/README.md b/rfd-kube-init/README.md index eafdfaa4..c4b4d456 100644 --- a/rfd-kube-init/README.md +++ b/rfd-kube-init/README.md @@ -19,29 +19,33 @@ This tool is designed to run as a Kubernetes Job or init container. It provides | `MEILI_HOST` | Yes | Meilisearch host URL (e.g., `http://meilisearch:7700`) | | `MEILI_RW_TOKEN_TARGET_NAMESPACES` | Yes** | Comma-delimited list of namespaces for read-write tokens (e.g., `rfd-api`) | | `MEILI_RO_TOKEN_TARGET_NAMESPACES` | Yes** | Comma-delimited list of namespaces for read-only tokens (e.g., `rfd-web`) | +| `MEILI_RW_INDEXES` | Yes*** | Comma-delimited list of indexes the RW key can access. Supports wildcards (e.g., `rfd-*`). | | `MEILI_SECRET_NAME` | No | Name of the secret to create (default: `meilisearch-token`) | -| `MEILI_API_EXPIRATION_SECONDS` | No | Token expiration in seconds from now (default: no expiration) | -| `MEILI_TOKEN_FILTER` | No | JSON search rules for the tenant token (default: `["*"]` - full access to all indexes) | +| `MEILI_API_EXPIRATION_SECONDS` | No | Token/key expiration in seconds from now (default: no expiration) | +| `MEILI_TOKEN_FILTER` | No | JSON search rules for the RO tenant token (default: `["*"]` - full access to all indexes) | +| `MEILI_MAX_RETRIES` | No | Maximum number of retry attempts for Meilisearch connection (default: 30) | -*If any of `MEILI_MASTER_NAMESPACE`, `MEILI_MASTER_SECRET_NAME`, or `MEILI_MASTER_SECRET_KEY` are unset or empty, Meilisearch initialization is skipped entirely. +*These three variables must be provided together. If any one is set, all three are required. If none are set, Meilisearch initialization is skipped entirely. **At least one of `MEILI_RW_TOKEN_TARGET_NAMESPACES` or `MEILI_RO_TOKEN_TARGET_NAMESPACES` must be set. +***Required when `MEILI_RW_TOKEN_TARGET_NAMESPACES` is set. + ## Token Types -The tool generates two types of tenant tokens: +The tool generates two types of API credentials: -- **RW (Read-Write)**: Generated from the "Default Admin API Key". Grants full access including document indexing, settings updates, and searches. Use for backend services that need to write to Meilisearch. +- **RW (Read-Write)**: A scoped API key with write permissions limited to specific indexes (configured via `MEILI_RW_INDEXES`). Grants document add/get/delete, index create/get, settings get/update, and search permissions on the specified indexes only. Use for backend services that need to write to Meilisearch. -- **RO (Read-Only)**: Generated from the "Default Search API Key". Grants search access only. Use for frontend applications or services that only need to query. +- **RO (Read-Only)**: A tenant token generated from the "Default Search API Key". Grants search access only. Use for frontend applications or services that only need to query. ## How It Works 1. Reads the Meilisearch master key from the specified Kubernetes secret -2. Connects to Meilisearch and fetches the list of API keys -3. For RW namespaces: finds the "Default Admin API Key" and generates a tenant token +2. Connects to Meilisearch using the master key +3. For RW namespaces: creates a scoped API key with write permissions limited to the indexes specified in `MEILI_RW_INDEXES` 4. For RO namespaces: finds the "Default Search API Key" and generates a tenant token -5. Writes the appropriate token to secrets in each target namespace +5. Writes the appropriate key/token to secrets in each target namespace ## Secret Format @@ -125,6 +129,8 @@ spec: # Token distribution - name: MEILI_RW_TOKEN_TARGET_NAMESPACES value: "rfd-api,rfd-processor" + - name: MEILI_RW_INDEXES + value: "rfd-*" # Wildcards supported - name: MEILI_RO_TOKEN_TARGET_NAMESPACES value: "rfd-web" - name: MEILI_API_EXPIRATION_SECONDS @@ -161,6 +167,8 @@ spec: value: "http://meilisearch.meilisearch:7700" - name: MEILI_RW_TOKEN_TARGET_NAMESPACES value: "rfd-api,rfd-processor" + - name: MEILI_RW_INDEXES + value: "rfd-*" - name: MEILI_RO_TOKEN_TARGET_NAMESPACES value: "rfd-web" - name: MEILI_API_EXPIRATION_SECONDS @@ -206,6 +214,7 @@ The `oauth-init` subcommand initializes an OAuth client by calling the RFD API ` | `OAUTH_REDIRECT_URIS` | Yes | Comma-delimited list of redirect URIs for the OAuth client | | `OAUTH_TARGET_NAMESPACES` | Yes | Comma-delimited list of namespaces to write credentials to | | `OAUTH_SECRET_NAME` | No | Name of the secret to create (default: `rfd-oauth-client`) | +| `OAUTH_MAX_RETRIES` | No | Maximum number of retry attempts for the /init endpoint (default: 30) | ### OAuth Init Response diff --git a/rfd-kube-init/src/meilisearch.rs b/rfd-kube-init/src/meilisearch.rs index c269a57e..4914f2fc 100644 --- a/rfd-kube-init/src/meilisearch.rs +++ b/rfd-kube-init/src/meilisearch.rs @@ -5,7 +5,7 @@ use anyhow::{anyhow, Context, Result}; use clap::Args; use meilisearch_sdk::client::Client; -use meilisearch_sdk::key::Key; +use meilisearch_sdk::key::{Action, Key, KeyBuilder}; use reqwest_middleware::ClientBuilder; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; use secrecy::ExposeSecret; @@ -19,49 +19,50 @@ use crate::meilisearch_client::RetryingMeilisearchClient; const DEFAULT_SEARCH_API_KEY_NAME: &str = "Default Search API Key"; const DEFAULT_ADMIN_API_KEY_NAME: &str = "Default Admin API Key"; -enum TokenType { - ReadOnly(Option), - ReadWrite(Option), +enum TokenType<'a> { + /// Read-only tenant token derived from the search key + ReadOnly { search_key: Option }, + /// Scoped write API key with access to specific indexes + ReadWrite { indexes: &'a [String] }, } -impl TokenType { - fn key_name(&self) -> &'static str { - match self { - TokenType::ReadOnly(_) => DEFAULT_SEARCH_API_KEY_NAME, - TokenType::ReadWrite(_) => DEFAULT_ADMIN_API_KEY_NAME, - } - } - - fn key(self) -> Option { - match self { - TokenType::ReadOnly(key) => key, - TokenType::ReadWrite(key) => key, - } - } -} - -impl fmt::Display for TokenType { +impl fmt::Display for TokenType<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - TokenType::ReadOnly(_) => write!(f, "RO"), - TokenType::ReadWrite(_) => write!(f, "RW"), + TokenType::ReadOnly { .. } => write!(f, "RO"), + TokenType::ReadWrite { .. } => write!(f, "RW"), } } } #[derive(Args)] pub struct MeilisearchArgs { - /// Namespace where the Meilisearch master key secret is located - #[arg(long, env = "MEILI_MASTER_NAMESPACE")] - master_namespace: String, - - /// Name of the Kubernetes secret containing the master key - #[arg(long, env = "MEILI_MASTER_SECRET_NAME")] - master_secret_name: String, - - /// Key within the secret that contains the master key value - #[arg(long, env = "MEILI_MASTER_SECRET_KEY")] - master_secret_key: String, + /// Namespace where the Meilisearch master key secret is located. + /// If set, --master-secret-name and --master-secret-key must also be provided. + #[arg( + long, + env = "MEILI_MASTER_NAMESPACE", + requires_all = ["master_secret_name", "master_secret_key"] + )] + master_namespace: Option, + + /// Name of the Kubernetes secret containing the master key. + /// If set, --master-namespace and --master-secret-key must also be provided. + #[arg( + long, + env = "MEILI_MASTER_SECRET_NAME", + requires_all = ["master_namespace", "master_secret_key"] + )] + master_secret_name: Option, + + /// Key within the secret that contains the master key value. + /// If set, --master-namespace and --master-secret-name must also be provided. + #[arg( + long, + env = "MEILI_MASTER_SECRET_KEY", + requires_all = ["master_namespace", "master_secret_name"] + )] + master_secret_key: Option, /// Meilisearch host URL #[arg(long, env = "MEILI_HOST")] @@ -79,6 +80,11 @@ pub struct MeilisearchArgs { #[arg(long, env = "MEILI_TOKEN_FILTER")] token_filter: Option, + /// Indexes that the RW key can access (comma-separated). Supports wildcards (e.g., "rfd-*"). + /// Required when --rw-namespaces is provided. + #[arg(long, env = "MEILI_RW_INDEXES", value_delimiter = ',')] + rw_indexes: Vec, + #[command(flatten)] namespaces: NamespaceArgs, @@ -90,8 +96,14 @@ pub struct MeilisearchArgs { #[derive(Args)] #[group(required = true, multiple = true)] struct NamespaceArgs { - /// Target namespaces for read-write tokens (comma-separated) - #[arg(long, env = "MEILI_RW_TOKEN_TARGET_NAMESPACES", value_delimiter = ',')] + /// Target namespaces for read-write tokens (comma-separated). + /// Requires --rw-indexes to be specified. + #[arg( + long, + env = "MEILI_RW_TOKEN_TARGET_NAMESPACES", + value_delimiter = ',', + requires = "rw_indexes" + )] rw_namespaces: Vec, /// Target namespaces for read-only tokens (comma-separated) @@ -112,12 +124,25 @@ impl fmt::Display for FailedNamespace { /// Initialize meilisearch secrets across target namespaces. pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Result<()> { - // Read master key from kubernetes secret - let master_key = kube::read_secret_key( - kube_client, + // Check if master key configuration is provided (clap ensures all three are set together) + let (master_namespace, master_secret_name, master_secret_key) = match ( &args.master_namespace, &args.master_secret_name, &args.master_secret_key, + ) { + (Some(ns), Some(name), Some(key)) => (ns, name, key), + _ => { + tracing::info!("Meilisearch master key not configured, skipping initialization"); + return Ok(()); + } + }; + + // Read master key from kubernetes secret + let master_key = kube::read_secret_key( + kube_client, + master_namespace, + master_secret_name, + master_secret_key, ) .await .context("Failed to read Meilisearch master key from Kubernetes secret")?; @@ -148,11 +173,11 @@ pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Resul ); let client = Client::new_with_client(&args.host, Some(master_key.expose_secret()), http_client); - let (search_key, admin_key) = get_api_keys(&client).await?; + let (search_key, _admin_key) = get_api_keys(&client).await?; let mut failures = Vec::new(); - // Generate and distribute RW token + // Generate and distribute RW key (scoped write API key) failures.extend( generate_and_distribute_token( &client, @@ -161,12 +186,14 @@ pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Resul &args.secret_name, search_rules.clone(), expires_at, - TokenType::ReadWrite(admin_key), + TokenType::ReadWrite { + indexes: &args.rw_indexes, + }, ) .await?, ); - // Generate and distribute RO token + // Generate and distribute RO token (tenant token) failures.extend( generate_and_distribute_token( &client, @@ -175,7 +202,7 @@ pub async fn init(kube_client: &::kube::Client, args: &MeilisearchArgs) -> Resul &args.secret_name, search_rules, expires_at, - TokenType::ReadOnly(search_key), + TokenType::ReadOnly { search_key }, ) .await?, ); @@ -238,7 +265,74 @@ async fn get_api_keys( Ok((search_key, admin_key)) } -/// Generate a tenant token and distribute it to the specified namespaces. +/// Create a scoped API key with write permissions limited to specific indexes. +async fn create_scoped_write_key( + client: &Client, + indexes: &[String], + expires_at: Option, +) -> Result { + let timestamp = OffsetDateTime::now_utc().unix_timestamp(); + let key_name = format!("rfd-kube-init-rw-{}", timestamp); + + tracing::info!( + key_name = key_name.as_str(), + indexes = ?indexes, + "Creating scoped write API key" + ); + + let mut key_builder = KeyBuilder::new(); + key_builder + .with_name(&key_name) + .with_description("Scoped write key created by rfd-kube-init") + .with_indexes(indexes) + .with_actions([ + Action::DocumentsAdd, + Action::DocumentsGet, + Action::DocumentsDelete, + Action::IndexesGet, + Action::IndexesCreate, + Action::SettingsGet, + Action::SettingsUpdate, + Action::Search, + ]); + + if let Some(expires) = expires_at { + key_builder.with_expires_at(expires); + } + + let key = client + .create_key(&key_builder) + .await + .context("Failed to create scoped write API key")?; + + tracing::info!( + key_name = key.name, + key_uid = %key.uid, + "Created scoped write API key" + ); + + Ok(key) +} + +/// Generate a tenant token from the given search key. +fn generate_tenant_token( + client: &Client, + search_key: &Key, + search_rules: serde_json::Value, + expires_at: Option, +) -> Result { + let token = client.generate_tenant_token( + search_key.uid.clone(), + search_rules, + Some(&search_key.key), + expires_at, + )?; + + tracing::info!("Generated Meilisearch tenant token"); + Ok(token) +} + +/// Generate an API key or token and distribute it to the specified namespaces. /// Returns a list of namespaces that failed to write. async fn generate_and_distribute_token( client: &Client, @@ -247,7 +341,7 @@ async fn generate_and_distribute_token( secret_name: &str, search_rules: serde_json::Value, expires_at: Option, - token_type: TokenType, + token_type: TokenType<'_>, ) -> Result> { if namespaces.is_empty() { tracing::debug!(token_type = %token_type, "No namespaces configured, skipping"); @@ -261,33 +355,34 @@ async fn generate_and_distribute_token( ); let token_type_str = token_type.to_string(); - let key_name = token_type.key_name(); - let key = match token_type.key() { - Some(key) => key, - None => { - tracing::error!( - key_name = key_name, + let api_key = match token_type { + TokenType::ReadWrite { indexes } => { + let key = create_scoped_write_key(client, indexes, expires_at).await?; + key.key + } + TokenType::ReadOnly { search_key } => { + let key = search_key.ok_or_else(|| { + tracing::error!( + key_name = DEFAULT_SEARCH_API_KEY_NAME, + "API key not found in Meilisearch but namespaces are configured" + ); + anyhow!( + "Could not find '{}' in Meilisearch keys", + DEFAULT_SEARCH_API_KEY_NAME + ) + })?; + + tracing::info!( + key_name = key.name, token_type = token_type_str.as_str(), - "API key not found in Meilisearch but namespaces are configured" + "Found API key for token", ); - return Err(anyhow!("Could not find '{}' in Meilisearch keys", key_name)); + + generate_tenant_token(client, &key, search_rules, expires_at)? } }; - tracing::info!( - key_name = key.name, - token_type = token_type_str.as_str(), - "Found API key for token", - ); - - let token = client.generate_tenant_token(key.uid, search_rules, Some(&key.key), expires_at)?; - - tracing::info!( - token_type = token_type_str.as_str(), - "Generated Meilisearch tenant token" - ); - let mut failures = Vec::new(); for ns in namespaces { @@ -295,7 +390,7 @@ async fn generate_and_distribute_token( kube_client, ns, secret_name, - &[("MEILISEARCH_API_KEY", &token)], + &[("MEILISEARCH_API_KEY", &api_key)], ) .await { diff --git a/rfd-kube-init/src/meilisearch_client.rs b/rfd-kube-init/src/meilisearch_client.rs index 43f49433..ad65b51f 100644 --- a/rfd-kube-init/src/meilisearch_client.rs +++ b/rfd-kube-init/src/meilisearch_client.rs @@ -2,13 +2,14 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use std::pin::pin; + use futures_io::AsyncRead; +use futures_util::io::AsyncReadExt; use meilisearch_sdk::errors::Error; use meilisearch_sdk::request::{parse_response, HttpClient, Method}; use reqwest_middleware::ClientWithMiddleware; use serde::{de::DeserializeOwned, Serialize}; -use tokio_util::compat::FuturesAsyncReadCompatExt; -use tokio_util::io::ReaderStream; #[derive(Clone)] pub struct RetryingMeilisearchClient { @@ -40,9 +41,9 @@ impl HttpClient for RetryingMeilisearchClient { let request = match method { Method::Get { .. } => self.client.get(&url), Method::Delete { .. } => self.client.delete(&url), - Method::Post { body, .. } => self.client.post(&url).body(to_body(body)), - Method::Put { body, .. } => self.client.put(&url).body(to_body(body)), - Method::Patch { body, .. } => self.client.patch(&url).body(to_body(body)), + Method::Post { body, .. } => self.client.post(&url).body(to_body(body).await), + Method::Put { body, .. } => self.client.put(&url).body(to_body(body).await), + Method::Patch { body, .. } => self.client.patch(&url).body(to_body(body).await), } .header(reqwest::header::CONTENT_TYPE, content_type); @@ -61,6 +62,10 @@ impl HttpClient for RetryingMeilisearchClient { } } -fn to_body(reader: impl AsyncRead + Send + Sync + 'static) -> reqwest::Body { - reqwest::Body::wrap_stream(ReaderStream::new(reader.compat())) +async fn to_body(reader: impl AsyncRead + Send + Sync + 'static) -> reqwest::Body { + let mut buf = Vec::new(); + let mut pinned = pin!(reader); + // Buffer the body so it can be cloned by retry middleware + let _ = pinned.read_to_end(&mut buf).await; + reqwest::Body::from(buf) } diff --git a/rfd-processor/src/context.rs b/rfd-processor/src/context.rs index b6c2ff7d..470c9fc7 100644 --- a/rfd-processor/src/context.rs +++ b/rfd-processor/src/context.rs @@ -183,7 +183,7 @@ impl Context { .collect::, RfdUpdaterError>>()?, static_storage: build_static_storage(&config.gcs_storage, &config.s3_storage).await?, pdf: PdfStorageCtx::new(&config.pdf_storage).await?, - search: SearchCtx::new(&config.search_storage)?, + search: SearchCtx::new(&config.search_storage).await?, }) } } @@ -453,16 +453,18 @@ pub struct SearchCtx { } impl SearchCtx { - pub fn new(entries: &[SearchConfig]) -> Result { - Ok(Self { - indexes: entries - .iter() - .map(|c| { - let key = c.key.resolve()?; - RfdSearchIndex::new(&c.host, key, &c.index).map_err(ContextError::from) - }) - .collect::, _>>()?, - }) + pub async fn new(entries: &[SearchConfig]) -> Result { + tracing::debug!("Search config entries: {:?}", entries); + let mut indexes = Vec::with_capacity(entries.len()); + + for config in entries { + let key = config.key.resolve()?; + let index = RfdSearchIndex::new(&config.host, key, &config.index)?; + index.ensure_index_exists().await?; + indexes.push(index); + } + + Ok(Self { indexes }) } } diff --git a/rfd-processor/src/search/mod.rs b/rfd-processor/src/search/mod.rs index c959d101..00a3a88c 100644 --- a/rfd-processor/src/search/mod.rs +++ b/rfd-processor/src/search/mod.rs @@ -28,7 +28,7 @@ pub enum SearchError { #[derive(Debug)] pub struct RfdSearchIndex { client: Client, - index: String, + pub index: String, } impl RfdSearchIndex { @@ -43,6 +43,38 @@ impl RfdSearchIndex { }) } + /// Ensure the search index exists, creating it if necessary. + /// This should be called at startup before attempting to index documents. + #[instrument(skip(self), fields(index = ?self.index), err(Debug))] + pub async fn ensure_index_exists(&self) -> Result<(), SearchError> { + tracing::info!(index = ?self.index, "Ensuring search index exists"); + + match self + .client + .create_index(&self.index, Some("objectID")) + .await + { + Ok(task) => { + tracing::info!(index = ?self.index, task_uid = task.task_uid, "Search index creation task enqueued"); + } + Err(MeiliError::Meilisearch(err)) + if err.error_code == ErrorCode::IndexAlreadyExists => + { + tracing::info!(index = ?self.index, "Search index already exists"); + } + Err(err) => { + return Err(err.into()); + } + } + + // Configure the required filterable attributes + let index = self.client.index(&self.index); + let settings = Settings::new().with_filterable_attributes(["rfd_number"]); + index.set_settings(&settings).await?; + + Ok(()) + } + /// Trigger updating the search index for the RFD. #[instrument(skip(self, content), fields(index = ?self.index), err(Debug))] pub async fn index_rfd( diff --git a/rfd-processor/src/updater/update_search_index.rs b/rfd-processor/src/updater/update_search_index.rs index c10edc38..74db4b0b 100644 --- a/rfd-processor/src/updater/update_search_index.rs +++ b/rfd-processor/src/updater/update_search_index.rs @@ -28,7 +28,7 @@ impl RfdUpdateAction for UpdateSearch { let RfdUpdateActionContext { ctx, .. } = ctx; for (i, index) in ctx.search.indexes.iter().enumerate() { - tracing::info!("Updating search index"); + tracing::info!("Updating search index {}", index.index); if mode == RfdUpdateMode::Write { let public = match new.rfd.visibility {