diff --git a/.github/workflows/release-registry.yml b/.github/workflows/release-registry.yml new file mode 100644 index 00000000..8afc95a2 --- /dev/null +++ b/.github/workflows/release-registry.yml @@ -0,0 +1,20 @@ +name: Release registry +on: + push: + paths: ['applications/release-registry/**', '.github/workflows/release-registry.yml'] + pull_request: + paths: ['applications/release-registry/**', '.github/workflows/release-registry.yml'] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/release-registry + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt + - run: cargo fmt --check + - run: cargo test --locked + - run: cargo build --locked diff --git a/applications/release-registry/.env.example b/applications/release-registry/.env.example new file mode 100644 index 00000000..cda32341 --- /dev/null +++ b/applications/release-registry/.env.example @@ -0,0 +1,11 @@ +PGHOST=YOUR_DIRECT_CLOUD_POSTGRES_HOSTNAME +PGPORT=5432 +PGDATABASE=postgres +PGUSER=registry_app +PGPASSWORD=YOUR_RUNTIME_PASSWORD +PGSSLROOTCERT=/absolute/path/to/postgres-ca.pem +PORT=3000 +PROJECT_TOKENS='{"00000000-0000-4000-8000-000000000001":"YOUR_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS","00000000-0000-4000-8000-000000000002":"ANOTHER_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS"}' +# Integration tests only: dedicated service, no production data. +# TEST_MIGRATOR_PASSWORD=YOUR_MIGRATOR_PASSWORD +# TEST_WRONG_CA=/absolute/path/to/unrelated-ca.pem diff --git a/applications/release-registry/.gitignore b/applications/release-registry/.gitignore new file mode 100644 index 00000000..db8b485c --- /dev/null +++ b/applications/release-registry/.gitignore @@ -0,0 +1,4 @@ +/target/ +.deployment/ +.env +*.log diff --git a/applications/release-registry/Cargo.lock b/applications/release-registry/Cargo.lock new file mode 100644 index 00000000..b436693a --- /dev/null +++ b/applications/release-registry/Cargo.lock @@ -0,0 +1,2094 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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 = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[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 = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +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", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "release-registry" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlx", + "subtle", + "tokio", + "uuid", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "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", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[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 = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/applications/release-registry/Cargo.toml b/applications/release-registry/Cargo.toml new file mode 100644 index 00000000..f330bbb6 --- /dev/null +++ b/applications/release-registry/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "release-registry" +version = "0.1.0" +edition = "2024" +rust-version = "1.99" +publish = false + +[dependencies] +axum = "=0.8.9" +tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread", "net", "signal"] } +sqlx = { version = "=0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "uuid", "chrono", "json", "macros", "migrate"] } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +uuid = { version = "=1.26.1", features = ["serde"] } +chrono = { version = "=0.4.45", features = ["serde"] } +sha2 = "=0.11.0" +subtle = "=2.6.1" + +[dev-dependencies] +reqwest = { version = "=0.13.5", default-features = false, features = ["json"] } diff --git a/applications/release-registry/README.md b/applications/release-registry/README.md new file mode 100644 index 00000000..cf26be43 --- /dev/null +++ b/applications/release-registry/README.md @@ -0,0 +1,283 @@ +# Release registry + +Register immutable release metadata and change channel pointers with **Rust, +Axum, SQLx, Tokio and ClickHouse Managed Postgres**. A version belongs +to one project. Matching registration retries return the original release; +conflicting data returns 409. Promotion requires the channel's current revision. +This API records decisions; it never deploys software or downloads artifacts. + +[ClickHouse Cloud](https://clickhouse.com/cloud) is the managed data platform. +[ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres/overview) +stores projects, releases and channels. This example needs no analytical database. + +```mermaid +flowchart LR + Client[CI or operator + project token] --> API[Rust / Axum API] + API -->|SQLx / verified TLS| PG[ClickHouse Managed Postgres] + Admin[Migration CLI / separate credential] --> PG +``` + +| Route | Behavior | +| --- | --- | +| `POST /releases` | 201 for a new version, 200 for matching canonical data, 409 for a mismatch | +| `GET /releases?limit=20&offset=0` | List your project's releases, newest first | +| `GET /releases/{version}` | Inspect your project's version; absent or foreign returns 404 | +| `GET /channels/{name}` | Read staging or production and its revision | +| `PUT /channels/{name}` | Set a project-owned release if `expected_revision` matches; otherwise 409 | +| `GET /health` | Public connectivity probe with no release data | + +Except health, every route needs a configured project bearer token. The service +binds localhost. Tokens are bounded server-to-server credentials, not a user +account system; keep them out of browser code. A hosted application needs HTTPS, +rate limits and network controls. This example doesn't provision an app host. + +## Build in Linux + +Use Rust 1.99.0, Git, `curl`, `jq`, OpenSSL, build tools and `psql` 15+. +Validation used native storage in an Ubuntu 24.04 ARM64 VM. Install Rust with +[rustup](https://rustup.rs/) and build from the app directory: + +```sh +git clone https://github.com/ClickHouse/examples.git +cd examples/applications/release-registry +cargo fmt --check +cargo test --locked +cargo build --locked +``` + +The toolchain file pins Rust, Cargo.lock pins dependencies, and Cargo.toml pins +Axum 0.8.9, SQLx 0.9.0 and Tokio 1.53.1. Queries use static SQL, bound parameters +and `query_as` with `FromRow`. SQL and column mappings are checked when executed, +not against a database during compilation. No `DATABASE_URL` or offline query +metadata is needed to build. The migration macro embeds local SQL files only. +CI runs two unit tests and builds without Cloud credentials; six Cloud tests are +ignored unless explicitly selected. + +### Create the Cloud service + +Install [clickhousectl](https://clickhouse.com/docs/concepts/features/interfaces/cli), authenticate +with an Admin Cloud API key, and choose your organization. Interactive login +avoids secrets in shell history. Commands were validated with CLI 0.5.0: + +```sh +curl -fsSL https://clickhouse.com/cli | sh +export PATH="$HOME/.local/bin:$PATH" +clickhousectl cloud auth login --interactive +clickhousectl cloud auth status +clickhousectl cloud org list +umask 077 +mkdir -p .deployment +``` + +Create `.deployment/resources.env` in an editor: + +```dotenv +CH_ORG_ID=YOUR_ORGANIZATION_ID +CLOUD_REGION=us-east-1 +PG_SIZE=c6gd.large +``` + +Use a supported region/size for your organization. This fixture used AWS +`us-east-1`, `c6gd.large`, Postgres 18 and no HA, matching the shape in the +[CLI example](https://clickhouse.com/blog/clickhousectl-v0-2-0-postgres-clickpipes-more). +Review [pricing](https://clickhouse.com/docs/products/managed-postgres/pricing): +compute, storage, backups and network can incur charges. Stopping Rust or your VM +doesn't delete the Cloud service or end its charges. + +```sh +source .deployment/resources.env +clickhousectl cloud postgres create --org-id "$CH_ORG_ID" \ + --name release-registry-example --provider aws --region "$CLOUD_REGION" \ + --size "$PG_SIZE" --pg-version 18 --ha-type none --tag project=release-registry \ + --json > .deployment/postgres-create.json +PG_SERVICE_ID="$(jq -er '.id' .deployment/postgres-create.json)" +printf 'PG_SERVICE_ID=%s\n' "$PG_SERVICE_ID" >> .deployment/resources.env +``` + +Preserve the private receipt: it includes the initial password. If creation is +interrupted, reconcile with `postgres list` before retrying. Repeat **get**, not +create, until `state` is `running`; then retrieve the PEM certificate: + +```sh +clickhousectl cloud postgres get "$PG_SERVICE_ID" --org-id "$CH_ORG_ID" \ + --json > .deployment/postgres-status.json +jq '{id, state, size, postgresVersion}' .deployment/postgres-status.json +clickhousectl cloud postgres certs get "$PG_SERVICE_ID" --org-id "$CH_ORG_ID" \ + --output .deployment/postgres-ca.pem +``` + +If provisioning elsewhere, transfer only the receipt and CA privately to Linux. + +### Bootstrap, migrate and seed + +From the Linux app directory, generate passwords once. Preserve this file when +retrying; bootstrap intentionally fails if the roles already exist. + +```sh +umask 077 +cat > .deployment/passwords.env < .deployment/release.json +curl --fail-with-body -sS http://127.0.0.1:3000/releases \ + -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @.deployment/release.json > .deployment/release-response.json +jq . .deployment/release-response.json +``` + +Versions are case-sensitive opaque labels, 1–64 ASCII letters/digits/`._-`, not +ordered SemVer. Digests require `sha256:` plus 64 hex digits; hex is normalized +to lowercase. The API stores an assertion and never verifies an artifact's bytes. +Metadata is a string map: at most ten keys, 1–40 bytes per key, 256 bytes per +value, and 2,048 bytes of canonical serialized JSON. Bodies are limited to 8 KiB. +JSON whitespace/key order doesn't affect replay; metadata string changes do. +Metadata keys and values reject NUL characters. + +Repeating the POST returns 200 and the same ID; different digest/metadata for the +same project/version returns 409. A different project can use the same version. +Missing metadata and `{}` are equivalent. Extra request fields are rejected. + +Read the current channel revision before promotion: + +```sh +curl --fail-with-body -sS http://127.0.0.1:3000/channels/staging \ + -H "Authorization: Bearer $API_TOKEN" > .deployment/channel.json +jq -n --arg id "$(jq -r .id .deployment/release-response.json)" \ + --argjson rev "$(jq -r .revision .deployment/channel.json)" \ + '{release_id:$id,expected_revision:$rev}' > .deployment/promotion.json +curl --fail-with-body -sS -X PUT http://127.0.0.1:3000/channels/staging \ + -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @.deployment/promotion.json | jq +``` + +Every accepted promotion increments the revision, even when the pointer already +matches. A repeated old revision returns 409: after a timeout or lost response, +read the channel again to discover whether the decision committed. There is no +deployment side effect or promotion history. Registration and promotion are +separate atomic operations; registration doesn't automatically promote. + +List order is `(created_at DESC, id DESC)`, with limit 1–100 and offset 0–10,000. +This bounded offset list isn't a snapshot: new registrations can shift pages and +cause duplicates/omissions while browsing. Inspect by version for stable identity. + +## Validate against a disposable Cloud service + +**Cloud tests truncate releases and channels. Use a dedicated service only.** +In a fresh shell, load the runtime endpoint/CA fields and the separate migration +password. The restart script launches its child API with only the runtime fields: + +```sh +set -a; source .deployment/app.env; set +a +source .deployment/passwords.env +export TEST_MIGRATOR_PASSWORD="$REGISTRY_MIGRATOR_PASSWORD" +openssl req -x509 -newkey rsa:2048 -nodes -keyout .deployment/wrong-key.pem \ + -out .deployment/wrong-ca.pem -days 1 -subj /CN=unrelated.invalid +export TEST_WRONG_CA="$PWD/.deployment/wrong-ca.pem" +cargo test --locked --test cloud -- --ignored --test-threads=1 --nocapture +bash scripts/process-restart.sh +``` + +Six tests prove a 12-request duplicate race, conflicting retries, concurrent +promotion and stale replay, scoped reads and the database FK, invalid/oversized +requests, restricted privileges, rollback, and both wrong-CA and wrong-hostname +TLS rejection. The separate script starts, stops and restarts the executable, +checks persisted release/channel identity, and exercises replay after restart. +Its local results go to ignored `.deployment/restart-results`. + +Validation on 2 October 2026 passed two unit tests, all six Cloud tests, the real +process restart and migrate/down/up lifecycle against PostgreSQL 18.6. A fresh +target directory built with all database/token variables absent. These are +correctness checks, not load or performance benchmarks. + +## Cleanup + +Stop the API, then delete only the disposable service you created: + +```sh +source .deployment/resources.env +clickhousectl cloud postgres delete "$PG_SERVICE_ID" --org-id "$CH_ORG_ID" +clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --json +``` + +Confirm its ID is absent. If keeping the service for unrelated work, remove just +this example's schema/roles using the receipt's **administrator** credential; +`app.env` contains the restricted runtime login: + +```sh +set -a; source .deployment/app.env; set +a +export PGSSLMODE=verify-full +export PGPASSWORD="$(jq -er '.password' .deployment/postgres-create.json)" +psql -X -v ON_ERROR_STOP=1 -U "$(jq -er '.username' .deployment/postgres-create.json)" \ + -f sql/cleanup.sql +``` + +After cleanup, securely remove private receipts, passwords and tokens. Never +commit `.deployment`, `.env`, target directories or Cloud credentials. diff --git a/applications/release-registry/migrations/0001_initial.down.sql b/applications/release-registry/migrations/0001_initial.down.sql new file mode 100644 index 00000000..3d7c1735 --- /dev/null +++ b/applications/release-registry/migrations/0001_initial.down.sql @@ -0,0 +1 @@ +DROP TABLE registry.channels, registry.releases, registry.projects; diff --git a/applications/release-registry/migrations/0001_initial.up.sql b/applications/release-registry/migrations/0001_initial.up.sql new file mode 100644 index 00000000..a1bbeea9 --- /dev/null +++ b/applications/release-registry/migrations/0001_initial.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE registry.projects (id UUID PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE registry.releases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES registry.projects(id), + version TEXT NOT NULL CHECK (length(version) BETWEEN 1 AND 64), + digest TEXT NOT NULL CHECK (digest ~ '^sha256:[0-9a-f]{64}$'), + metadata JSONB NOT NULL CHECK (jsonb_typeof(metadata) = 'object' AND octet_length(metadata::text) <= 4096), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, version), UNIQUE (project_id, id) +); +CREATE TABLE registry.channels ( + project_id UUID NOT NULL REFERENCES registry.projects(id), + name TEXT NOT NULL CHECK (name IN ('staging', 'production')), + release_id UUID, + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, name), + FOREIGN KEY (project_id, release_id) REFERENCES registry.releases(project_id, id) +); +CREATE INDEX releases_project_created ON registry.releases (project_id, created_at DESC, id DESC); +GRANT SELECT ON registry.projects, registry.releases, registry.channels TO registry_app; +GRANT INSERT (project_id, version, digest, metadata) ON registry.releases TO registry_app; +GRANT UPDATE (release_id, revision, updated_at) ON registry.channels TO registry_app; diff --git a/applications/release-registry/rust-toolchain.toml b/applications/release-registry/rust-toolchain.toml new file mode 100644 index 00000000..2b03d5c1 --- /dev/null +++ b/applications/release-registry/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.0" +profile = "minimal" +components = ["rustfmt"] diff --git a/applications/release-registry/scripts/process-restart.sh b/applications/release-registry/scripts/process-restart.sh new file mode 100644 index 00000000..58abaa93 --- /dev/null +++ b/applications/release-registry/scripts/process-restart.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Run after loading app.env on a disposable Cloud service. Changes production. +set -euo pipefail +umask 077 +export PORT="${RESTART_PORT:-3043}" +ROOT="http://127.0.0.1:$PORT" +RESULTS=.deployment/restart-results +mkdir -p "$RESULTS" +API_TOKEN="$(printf '%s' "$PROJECT_TOKENS" | jq -er '.["00000000-0000-4000-8000-000000000001"]')" +VERSION="restart-$(date +%s)-$$" +pid='' +trap 'if [[ -n "$pid" ]]; then kill -TERM "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; fi' EXIT + +start() { + env -i PGHOST="$PGHOST" PGPORT="${PGPORT:-5432}" PGDATABASE="$PGDATABASE" \ + PGUSER="$PGUSER" PGPASSWORD="$PGPASSWORD" PGSSLROOTCERT="$PGSSLROOTCERT" \ + PROJECT_TOKENS="$PROJECT_TOKENS" PORT="$PORT" \ + target/debug/release-registry serve > "$RESULTS/server-$1.txt" 2>&1 & + pid=$! + for attempt in {1..30}; do + if curl --silent --fail "$ROOT/health" >/dev/null; then return; fi + sleep 1 + done + printf 'Server did not become ready\n' >&2 + exit 1 +} + +start before +first=$pid +curl --silent --fail -H "Authorization: Bearer $API_TOKEN" \ + "$ROOT/channels/production" > "$RESULTS/channel-initial.json" +revision="$(jq -er .revision "$RESULTS/channel-initial.json")" +jq -n --arg version "$VERSION" \ + '{version:$version,digest:("sha256:"+("a"*64)),metadata:{commit:"restart-proof"}}' \ + > "$RESULTS/register.json" +status="$(curl --silent -o "$RESULTS/release-before.json" -w '%{http_code}' \ + -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @"$RESULTS/register.json" "$ROOT/releases")" +[[ "$status" == 201 ]] +id="$(jq -er .id "$RESULTS/release-before.json")" +jq -n --arg id "$id" --argjson revision "$revision" \ + '{release_id:$id,expected_revision:$revision}' > "$RESULTS/promotion.json" +status="$(curl --silent -o "$RESULTS/channel-before.json" -w '%{http_code}' -X PUT \ + -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @"$RESULTS/promotion.json" "$ROOT/channels/production")" +[[ "$status" == 200 ]] +kill -TERM "$pid" +wait "$pid" +pid='' + +start after +second=$pid +[[ "$first" != "$second" ]] +curl --silent --fail -H "Authorization: Bearer $API_TOKEN" \ + "$ROOT/releases/$VERSION" > "$RESULTS/release-after.json" +curl --silent --fail -H "Authorization: Bearer $API_TOKEN" \ + "$ROOT/channels/production" > "$RESULTS/channel-after.json" +[[ "$(jq -r .id "$RESULTS/release-after.json")" == "$id" ]] +[[ "$(jq -r .release_id "$RESULTS/channel-after.json")" == "$id" ]] +[[ "$(jq -r .revision "$RESULTS/channel-after.json")" == "$((revision+1))" ]] +status="$(curl --silent -o "$RESULTS/stale.json" -w '%{http_code}' -X PUT \ + -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @"$RESULTS/promotion.json" "$ROOT/channels/production")" +[[ "$status" == 409 ]] +status="$(curl --silent -o "$RESULTS/replay.json" -w '%{http_code}' \ + -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @"$RESULTS/register.json" "$ROOT/releases")" +[[ "$status" == 200 ]] +[[ "$(jq -r .id "$RESULTS/replay.json")" == "$id" ]] +printf 'External HTTP process restart: PASS\nBefore PID: %s\nAfter PID: %s\nRelease and channel survived at revision %s.\nStale promotion returned 409; registration replay returned 200 with original ID.\n' \ + "$first" "$second" "$((revision+1))" | tee "$RESULTS/result.txt" diff --git a/applications/release-registry/sql/bootstrap.sql b/applications/release-registry/sql/bootstrap.sql new file mode 100644 index 00000000..6c82cd01 --- /dev/null +++ b/applications/release-registry/sql/bootstrap.sql @@ -0,0 +1,11 @@ +\set ON_ERROR_STOP on +\getenv migrator_password REGISTRY_MIGRATOR_PASSWORD +\getenv app_password REGISTRY_APP_PASSWORD +CREATE ROLE registry_owner NOLOGIN; +CREATE ROLE registry_migrator LOGIN PASSWORD :'migrator_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE ROLE registry_app LOGIN PASSWORD :'app_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +GRANT registry_owner TO registry_migrator; +CREATE SCHEMA registry AUTHORIZATION registry_owner; +REVOKE ALL ON SCHEMA registry FROM PUBLIC; +GRANT USAGE ON SCHEMA registry TO registry_app; +ALTER ROLE registry_app SET statement_timeout = '5s'; diff --git a/applications/release-registry/sql/cleanup.sql b/applications/release-registry/sql/cleanup.sql new file mode 100644 index 00000000..082cb993 --- /dev/null +++ b/applications/release-registry/sql/cleanup.sql @@ -0,0 +1,5 @@ +-- Destructive: inspect and run explicitly as the service administrator. +DROP SCHEMA registry CASCADE; +DROP ROLE registry_app; +DROP ROLE registry_migrator; +DROP ROLE registry_owner; diff --git a/applications/release-registry/sql/seed.sql b/applications/release-registry/sql/seed.sql new file mode 100644 index 00000000..83b81125 --- /dev/null +++ b/applications/release-registry/sql/seed.sql @@ -0,0 +1,7 @@ +INSERT INTO registry.projects (id, name) VALUES +('00000000-0000-4000-8000-000000000001','Documentation site'), +('00000000-0000-4000-8000-000000000002','Marketing site') +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name; +INSERT INTO registry.channels (project_id,name) +SELECT id, channel FROM registry.projects CROSS JOIN (VALUES ('staging'),('production')) AS names(channel) +ON CONFLICT DO NOTHING; diff --git a/applications/release-registry/src/database.rs b/applications/release-registry/src/database.rs new file mode 100644 index 00000000..4ad4abdf --- /dev/null +++ b/applications/release-registry/src/database.rs @@ -0,0 +1,39 @@ +use sqlx::{ + ConnectOptions, PgPool, + postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}, +}; +use std::{env, error::Error, path::PathBuf, time::Duration}; +pub type BoxError = Box; +pub fn required(key: &str) -> Result { + env::var(key).map_err(|_| format!("Missing {key}").into()) +} +pub fn options(user: &str, password: &str) -> Result { + let port: u16 = env::var("PGPORT") + .unwrap_or_else(|_| "5432".into()) + .parse()?; + if port == 0 { + return Err("Invalid PGPORT".into()); + } + let mut options = PgConnectOptions::new_without_pgpass() + .host(&required("PGHOST")?) + .port(port) + .database(&required("PGDATABASE")?) + .username(user) + .password(password) + .ssl_mode(PgSslMode::VerifyFull) + .ssl_root_cert(PathBuf::from(required("PGSSLROOTCERT")?)) + .application_name("release-registry") + .options([("search_path", "registry"), ("statement_timeout", "5000")]) + .disable_statement_logging(); + if user == "registry_migrator" { + options = options.options([("role", "registry_owner")]); + } + Ok(options) +} +pub async fn pool(user: &str, password: &str) -> Result { + Ok(PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(options(user, password)?) + .await?) +} diff --git a/applications/release-registry/src/lib.rs b/applications/release-registry/src/lib.rs new file mode 100644 index 00000000..28576813 --- /dev/null +++ b/applications/release-registry/src/lib.rs @@ -0,0 +1,358 @@ +pub mod database; +use axum::{ + Extension, Json, Router, + extract::{DefaultBodyLimit, Path, Query, Request, State}, + http::StatusCode, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::{FromRow, PgPool, types::Json as DbJson}; +use std::{collections::BTreeMap, sync::Arc}; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + tokens: Arc>, +} +pub fn tokens(raw: &str) -> Result, database::BoxError> { + let values: BTreeMap = serde_json::from_str(raw)?; + if values.is_empty() || values.len() > 20 { + return Err("Configure 1-20 projects".into()); + } + let mut tokens = Vec::new(); + for (id, token) in values { + if token.len() < 32 || token.len() > 256 || token.chars().any(char::is_whitespace) { + return Err("Invalid project token".into()); + } + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + if tokens.iter().any(|(existing, _)| *existing == hash) { + return Err("Duplicate project token".into()); + } + tokens.push((hash, id)); + } + Ok(tokens) +} +pub async fn app(pool: PgPool, raw_tokens: &str) -> Result { + let tokens = tokens(raw_tokens)?; + for (_, id) in &tokens { + let exists: bool = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM registry.projects WHERE id=$1)") + .bind(id) + .fetch_one(&pool) + .await?; + if !exists { + return Err("Configured project is not seeded".into()); + } + } + let state = AppState { + pool, + tokens: Arc::new(tokens), + }; + Ok(Router::new() + .route("/releases", post(register).get(list)) + .route("/releases/{version}", get(inspect)) + .route("/channels/{name}", get(channel).put(promote)) + .route_layer(middleware::from_fn_with_state(state.clone(), authenticate)) + .route("/health", get(health)) + .layer(DefaultBodyLimit::max(8192)) + .with_state(state)) +} +async fn authenticate(State(state): State, mut request: Request, next: Next) -> Response { + let token = request + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + let Some(token) = token.filter(|v| v.len() <= 256) else { + return ApiError(StatusCode::UNAUTHORIZED, "Invalid token").into_response(); + }; + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + let mut project = None; + for (expected, id) in state.tokens.iter() { + if bool::from(hash.ct_eq(expected)) { + project = Some(*id); + } + } + let Some(project) = project else { + return ApiError(StatusCode::UNAUTHORIZED, "Invalid token").into_response(); + }; + request.extensions_mut().insert(project); + next.run(request).await +} +#[derive(Debug)] +pub struct ApiError(StatusCode, &'static str); +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(json!({"error":self.1}))).into_response() + } +} +impl From for ApiError { + fn from(_: sqlx::Error) -> Self { + Self(StatusCode::SERVICE_UNAVAILABLE, "Registry unavailable") + } +} +#[derive(Serialize, Deserialize, FromRow, Debug, Clone)] +pub struct Release { + pub id: Uuid, + pub project_id: Uuid, + pub version: String, + pub digest: String, + pub metadata: DbJson>, + pub created_at: DateTime, +} +#[derive(Serialize, Deserialize, FromRow, Debug)] +pub struct Channel { + pub project_id: Uuid, + pub name: String, + pub release_id: Option, + pub revision: i64, + pub updated_at: DateTime, +} +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct Register { + pub version: String, + pub digest: String, + #[serde(default)] + pub metadata: BTreeMap, +} +pub fn validate(input: &mut Register) -> Result<(), ApiError> { + if input.version.is_empty() + || input.version.len() > 64 + || !input + .version + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + { + return Err(ApiError(StatusCode::BAD_REQUEST, "Invalid version")); + } + let hex = input + .digest + .strip_prefix("sha256:") + .ok_or(ApiError(StatusCode::BAD_REQUEST, "Invalid SHA256 digest"))?; + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "Invalid SHA256 digest")); + } + input.digest = input.digest.to_ascii_lowercase(); + if input.metadata.len() > 10 + || input.metadata.iter().any(|(k, v)| { + k.is_empty() || k.len() > 40 || v.len() > 256 || k.contains('\0') || v.contains('\0') + }) + || serde_json::to_vec(&input.metadata) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "Invalid metadata"))? + .len() + > 2048 + { + return Err(ApiError(StatusCode::BAD_REQUEST, "Metadata exceeds limits")); + } + Ok(()) +} +async fn register( + State(state): State, + Extension(project): Extension, + Json(mut input): Json, +) -> Result { + validate(&mut input)?; + let inserted = sqlx::query_as::<_, Release>( + r#" + INSERT INTO registry.releases (project_id, version, digest, metadata) + VALUES ($1, $2, $3, $4) + ON CONFLICT (project_id, version) DO NOTHING + RETURNING id, project_id, version, digest, metadata, created_at + "#, + ) + .bind(project) + .bind(&input.version) + .bind(&input.digest) + .bind(DbJson(&input.metadata)) + .fetch_optional(&state.pool) + .await?; + if let Some(release) = inserted { + return Ok((StatusCode::CREATED, Json(release)).into_response()); + } + let release = sqlx::query_as::<_, Release>( + r#" + SELECT id, project_id, version, digest, metadata, created_at + FROM registry.releases + WHERE project_id = $1 AND version = $2 + "#, + ) + .bind(project) + .bind(&input.version) + .fetch_one(&state.pool) + .await?; + if release.digest != input.digest || release.metadata.0 != input.metadata { + return Err(ApiError( + StatusCode::CONFLICT, + "Version already has different data", + )); + } + Ok((StatusCode::OK, Json(release)).into_response()) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ListQuery { + limit: Option, + offset: Option, +} +async fn list( + State(state): State, + Extension(project): Extension, + Query(query): Query, +) -> Result>, ApiError> { + let limit = query.limit.unwrap_or(20); + let offset = query.offset.unwrap_or(0); + if !(1..=100).contains(&limit) || !(0..=10000).contains(&offset) { + return Err(ApiError(StatusCode::BAD_REQUEST, "Invalid pagination")); + } + Ok(Json( + sqlx::query_as::<_, Release>( + r#" + SELECT id, project_id, version, digest, metadata, created_at + FROM registry.releases + WHERE project_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(project) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await?, + )) +} +async fn inspect( + State(state): State, + Extension(project): Extension, + Path(version): Path, +) -> Result, ApiError> { + let release = sqlx::query_as::<_, Release>( + r#" + SELECT id, project_id, version, digest, metadata, created_at + FROM registry.releases + WHERE project_id = $1 AND version = $2 + "#, + ) + .bind(project) + .bind(version) + .fetch_optional(&state.pool) + .await? + .ok_or(ApiError(StatusCode::NOT_FOUND, "Release not found"))?; + Ok(Json(release)) +} +fn channel_name(name: &str) -> Result<(), ApiError> { + if !["staging", "production"].contains(&name) { + return Err(ApiError(StatusCode::BAD_REQUEST, "Unknown channel")); + }; + Ok(()) +} +async fn channel( + State(state): State, + Extension(project): Extension, + Path(name): Path, +) -> Result, ApiError> { + channel_name(&name)?; + Ok(Json( + sqlx::query_as::<_, Channel>( + r#" + SELECT project_id, name, release_id, revision, updated_at + FROM registry.channels + WHERE project_id = $1 AND name = $2 + "#, + ) + .bind(project) + .bind(name) + .fetch_one(&state.pool) + .await?, + )) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Promotion { + release_id: Uuid, + expected_revision: i64, +} +async fn promote( + State(state): State, + Extension(project): Extension, + Path(name): Path, + Json(input): Json, +) -> Result, ApiError> { + channel_name(&name)?; + if input.expected_revision < 0 { + return Err(ApiError(StatusCode::BAD_REQUEST, "Invalid revision")); + } + let owned: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM registry.releases WHERE project_id=$1 AND id=$2)", + ) + .bind(project) + .bind(input.release_id) + .fetch_one(&state.pool) + .await?; + if !owned { + return Err(ApiError(StatusCode::NOT_FOUND, "Release not found")); + } + let changed = sqlx::query_as::<_, Channel>( + r#" + UPDATE registry.channels + SET release_id = $3, revision = revision + 1, updated_at = clock_timestamp() + WHERE project_id = $1 AND name = $2 AND revision = $4 + RETURNING project_id, name, release_id, revision, updated_at + "#, + ) + .bind(project) + .bind(name) + .bind(input.release_id) + .bind(input.expected_revision) + .fetch_optional(&state.pool) + .await?; + Ok(Json(changed.ok_or(ApiError( + StatusCode::CONFLICT, + "Channel revision changed; read it again", + ))?)) +} +async fn health(State(state): State) -> Result, ApiError> { + sqlx::query("SELECT 1").execute(&state.pool).await?; + Ok(Json(json!({"status":"ok"}))) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn canonical_and_bounded_registration() { + let mut input = Register { + version: "1.0.0".into(), + digest: format!("sha256:{}", "A".repeat(64)), + metadata: BTreeMap::new(), + }; + validate(&mut input).unwrap(); + assert!(input.digest.ends_with(&"a".repeat(64))); + input.version = "invalid version".into(); + assert!(validate(&mut input).is_err()); + input.version = "1.0.0".into(); + input.metadata.insert("notes".into(), "x".repeat(257)); + assert!(validate(&mut input).is_err()); + assert!( + serde_json::from_str::( + r#"{"version":"1","digest":"x","project_id":"attacker"}"# + ) + .is_err() + ); + } + #[test] + fn token_scope_config() { + let token = "a".repeat(32); + let config = json!({"00000000-0000-4000-8000-000000000001":token,"00000000-0000-4000-8000-000000000002":token}); + assert!(tokens(&config.to_string()).is_err()); + assert!(tokens("{}").is_err()); + } +} diff --git a/applications/release-registry/src/main.rs b/applications/release-registry/src/main.rs new file mode 100644 index 00000000..b5e66ef5 --- /dev/null +++ b/applications/release-registry/src/main.rs @@ -0,0 +1,67 @@ +use release_registry::{ + app, + database::{BoxError, pool, required}, +}; +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +#[tokio::main] +async fn main() { + if run().await.is_err() { + eprintln!("Registry command failed; check configuration, role, CA and database readiness"); + std::process::exit(1); + } +} +async fn run() -> Result<(), BoxError> { + let mode = std::env::args().nth(1).unwrap_or_else(|| "serve".into()); + let user = required("PGUSER")?; + if !["serve", "migrate", "revert", "seed"].contains(&mode.as_str()) + || user + != if mode == "serve" { + "registry_app" + } else { + "registry_migrator" + } + { + return Err("Use the command's restricted role".into()); + } + let pool = pool(&user, &required("PGPASSWORD")?).await?; + match mode.as_str() { + "migrate" => MIGRATOR.run(&pool).await?, + "revert" => MIGRATOR.undo(&pool, 0).await?, + "seed" => { + let mut tx = pool.begin().await?; + sqlx::raw_sql(include_str!("../sql/seed.sql")) + .execute(&mut *tx) + .await?; + tx.commit().await?; + } + _ => { + let router = app(pool.clone(), &required("PROJECT_TOKENS")?).await?; + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "3000".into()) + .parse()?; + if port == 0 { + return Err("Invalid PORT".into()); + } + let listener = + tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).await?; + println!("Release registry listening on http://127.0.0.1:{port}"); + axum::serve(listener, router) + .with_graceful_shutdown(shutdown()) + .await?; + } + } + pool.close().await; + Ok(()) +} +async fn shutdown() { + let interrupt = async { + let _ = tokio::signal::ctrl_c().await; + }; + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .unwrap() + .recv() + .await; + }; + tokio::select! {_=interrupt=>{},_=terminate=>{}} +} diff --git a/applications/release-registry/tests/cloud.rs b/applications/release-registry/tests/cloud.rs new file mode 100644 index 00000000..1e3f72c1 --- /dev/null +++ b/applications/release-registry/tests/cloud.rs @@ -0,0 +1,404 @@ +use release_registry::{ + Release, app, + database::{options, pool, required}, +}; +use serde_json::{Value, json}; +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::collections::BTreeMap; +use uuid::Uuid; +const A: &str = "00000000-0000-4000-8000-000000000001"; +const B: &str = "00000000-0000-4000-8000-000000000002"; + +struct Fixture { + runtime: PgPool, + owner: PgPool, + url: String, + client: reqwest::Client, + token_a: String, + token_b: String, + server: tokio::task::JoinHandle<()>, +} +impl Fixture { + async fn new() -> Self { + let runtime = pool( + &required("PGUSER").unwrap(), + &required("PGPASSWORD").unwrap(), + ) + .await + .unwrap(); + let owner = pool( + "registry_migrator", + &required("TEST_MIGRATOR_PASSWORD").unwrap(), + ) + .await + .unwrap(); + sqlx::raw_sql("TRUNCATE registry.releases CASCADE;") + .execute(&owner) + .await + .unwrap(); + sqlx::raw_sql(include_str!("../sql/seed.sql")) + .execute(&owner) + .await + .unwrap(); + let raw = required("PROJECT_TOKENS").unwrap(); + let tokens: BTreeMap = serde_json::from_str(&raw).unwrap(); + let router = app(runtime.clone(), &raw).await.unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + Self { + runtime, + owner, + url, + client: reqwest::Client::new(), + token_a: tokens[A].clone(), + token_b: tokens[B].clone(), + server, + } + } + async fn request( + &self, + method: &str, + path: &str, + token: &str, + body: Option, + ) -> (u16, Value) { + let mut request = self + .client + .request(method.parse().unwrap(), format!("{}{}", self.url, path)) + .bearer_auth(token); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.unwrap(); + let status = response.status().as_u16(); + let text = response.text().await.unwrap(); + ( + status, + serde_json::from_str(&text).unwrap_or(Value::String(text)), + ) + } + async fn register(&self, token: &str, version: &str) -> Value { + let (status, body) = self + .request("POST", "/releases", token, Some(payload(version))) + .await; + assert_eq!(status, 201); + body + } + async fn close(self) { + self.server.abort(); + self.runtime.close().await; + self.owner.close().await; + } +} +fn payload(version: &str) -> Value { + json!({"version":version,"digest":format!("sha256:{}","a".repeat(64)),"metadata":{"commit":"abc123","built_by":"ci"}}) +} + +#[tokio::test] +#[ignore = "dedicated Cloud service; destructive fixtures; run --ignored --test-threads=1"] +async fn duplicate_registration_race_and_canonical_conflicts() { + let f = Fixture::new().await; + let mut tasks = Vec::new(); + for _ in 0..12 { + let client = f.client.clone(); + let url = f.url.clone(); + let token = f.token_a.clone(); + tasks.push(tokio::spawn(async move { + client + .post(format!("{url}/releases")) + .bearer_auth(token) + .json(&payload("1.0.0")) + .send() + .await + .unwrap() + .status() + .as_u16() + })); + } + let mut created = 0; + for task in tasks { + match task.await.unwrap() { + 201 => created += 1, + 200 => {} + _ => panic!("duplicate not accepted"), + } + } + assert_eq!(created, 1); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM registry.releases") + .fetch_one(&f.owner) + .await + .unwrap(); + assert_eq!(count, 1); + let mut equivalent = payload("1.0.0"); + equivalent["digest"] = json!(format!("sha256:{}", "A".repeat(64))); + assert_eq!( + f.request("POST", "/releases", &f.token_a, Some(equivalent)) + .await + .0, + 200 + ); + let mut mismatch = payload("1.0.0"); + mismatch["metadata"]["commit"] = json!("different"); + assert_eq!( + f.request("POST", "/releases", &f.token_a, Some(mismatch)) + .await + .0, + 409 + ); + let mut mismatch = payload("1.0.0"); + mismatch["digest"] = json!(format!("sha256:{}", "b".repeat(64))); + assert_eq!( + f.request("POST", "/releases", &f.token_a, Some(mismatch)) + .await + .0, + 409 + ); + f.close().await; +} +#[tokio::test] +#[ignore = "dedicated Cloud service; destructive fixtures"] +async fn promotion_compare_and_swap_race_and_stale_replay() { + let f = Fixture::new().await; + let first = f.register(&f.token_a, "1.0.0").await; + let second = f.register(&f.token_a, "2.0.0").await; + let mut tasks = Vec::new(); + for id in [first["id"].clone(), second["id"].clone()] { + let client = f.client.clone(); + let url = f.url.clone(); + let token = f.token_a.clone(); + tasks.push(tokio::spawn(async move { + client + .put(format!("{url}/channels/production")) + .bearer_auth(token) + .json(&json!({"release_id":id,"expected_revision":0})) + .send() + .await + .unwrap() + .status() + .as_u16() + })); + } + let mut statuses = Vec::new(); + for task in tasks { + statuses.push(task.await.unwrap()); + } + statuses.sort(); + assert_eq!(statuses, [200, 409]); + let (_, channel) = f + .request("GET", "/channels/production", &f.token_a, None) + .await; + assert_eq!(channel["revision"], 1); + assert_eq!( + f.request( + "PUT", + "/channels/production", + &f.token_a, + Some(json!({"release_id":first["id"],"expected_revision":0})) + ) + .await + .0, + 409 + ); + assert_eq!( + f.request( + "PUT", + "/channels/production", + &f.token_a, + Some(json!({"release_id":first["id"],"expected_revision":1})) + ) + .await + .0, + 200 + ); + let (_, channel) = f + .request("GET", "/channels/production", &f.token_a, None) + .await; + assert_eq!(channel["revision"], 2); + assert_eq!(channel["release_id"], first["id"]); + f.close().await; +} +#[tokio::test] +#[ignore = "dedicated Cloud service; destructive fixtures"] +async fn project_reads_and_composite_foreign_key() { + let f = Fixture::new().await; + let foreign = f.register(&f.token_b, "foreign-only").await; + f.register(&f.token_a, "same-version").await; + f.register(&f.token_b, "same-version").await; + assert_eq!( + f.request("GET", "/releases/foreign-only", &f.token_a, None) + .await + .0, + 404 + ); + let (_, list) = f.request("GET", "/releases", &f.token_a, None).await; + assert_eq!(list.as_array().unwrap().len(), 1); + assert_eq!( + f.request( + "PUT", + "/channels/staging", + &f.token_a, + Some(json!({"release_id":foreign["id"],"expected_revision":0})) + ) + .await + .0, + 404 + ); + let foreign_id: Uuid = foreign["id"].as_str().unwrap().parse().unwrap(); + let project: Uuid = A.parse().unwrap(); + let error = sqlx::query( + "UPDATE registry.channels SET release_id=$1 WHERE project_id=$2 AND name='staging'", + ) + .bind(foreign_id) + .bind(project) + .execute(&f.runtime) + .await + .unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("23503") + ); + f.close().await; +} +#[tokio::test] +#[ignore = "dedicated Cloud service; destructive fixtures"] +async fn invalid_requests_and_immutable_runtime_privileges() { + let f = Fixture::new().await; + f.register(&f.token_a, "1.0.0").await; + assert_eq!(f.request("GET", "/releases", "wrong", None).await.0, 401); + for input in [ + json!({"version":"a","digest":"invalid"}), + json!({"version":"spaces invalid","digest":format!("sha256:{}","a".repeat(64))}), + json!({"version":"valid","digest":format!("sha256:{}","a".repeat(64)),"metadata":{"oversized":"x".repeat(257)}}), + json!({"version":"nul-value","digest":format!("sha256:{}","a".repeat(64)),"metadata":{"bad":"\u{0000}"}}), + json!({"version":"nul-key","digest":format!("sha256:{}","a".repeat(64)),"metadata":{"\u{0000}":"bad"}}), + ] { + assert_eq!( + f.request("POST", "/releases", &f.token_a, Some(input)) + .await + .0, + 400 + ); + } + assert_eq!( + f.request( + "POST", + "/releases", + &f.token_a, + Some(json!({"version":"v","digest":"x","project_id":B})) + ) + .await + .0, + 422 + ); + assert_eq!( + f.request( + "POST", + "/releases", + &f.token_a, + Some(json!({"padding":"x".repeat(9000)})) + ) + .await + .0, + 413 + ); + assert_eq!( + f.request("GET", "/releases?limit=101", &f.token_a, None) + .await + .0, + 400 + ); + for sql in [ + "UPDATE registry.releases SET digest='x'", + "DELETE FROM registry.releases", + "CREATE TABLE registry.denied (id INTEGER)", + "SELECT * FROM registry._sqlx_migrations", + ] { + let error = sqlx::query(sql).execute(&f.runtime).await.unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("42501") + ); + } + f.close().await; +} +#[tokio::test] +#[ignore = "dedicated Cloud service; destructive fixtures"] +async fn rollback_discards_registration_and_promotion() { + let f = Fixture::new().await; + let original = f.register(&f.token_a, "1.0.0").await; + let project: Uuid = A.parse().unwrap(); + let mut tx = f.runtime.begin().await.unwrap(); + let inserted: Release = sqlx::query_as( + r#" + INSERT INTO registry.releases (project_id, version, digest, metadata) + VALUES ($1, 'rollback', $2, '{}'::jsonb) + RETURNING id, project_id, version, digest, metadata, created_at + "#, + ) + .bind(project) + .bind(format!("sha256:{}", "b".repeat(64))) + .fetch_one(&mut *tx) + .await + .unwrap(); + sqlx::query( + r#" + UPDATE registry.channels + SET release_id = $1, revision = revision + 1 + WHERE project_id = $2 AND name = 'staging' AND revision = 0 + "#, + ) + .bind(inserted.id) + .bind(project) + .execute(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); + assert_eq!( + f.request("GET", "/releases/rollback", &f.token_a, None) + .await + .0, + 404 + ); + let (_, channel) = f + .request("GET", "/channels/staging", &f.token_a, None) + .await; + assert_eq!(channel["revision"], 0); + assert!(channel["release_id"].is_null()); + assert!(!original["id"].is_null()); + f.close().await; +} +#[tokio::test] +#[ignore = "real Cloud TLS and private certificate fixture"] +async fn tls_rejects_wrong_ca_and_hostname() { + let user = required("PGUSER").unwrap(); + let password = required("PGPASSWORD").unwrap(); + let address = tokio::net::lookup_host((required("PGHOST").unwrap().as_str(), 5432)) + .await + .unwrap() + .next() + .unwrap() + .ip(); + for cfg in [ + options(&user, &password) + .unwrap() + .ssl_root_cert(std::path::PathBuf::from(required("TEST_WRONG_CA").unwrap())), + options(&user, &password) + .unwrap() + .host(&address.to_string()), + ] { + let result = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(10)) + .connect_with(cfg) + .await; + let error = result.unwrap_err(); + let message = error.to_string().to_lowercase(); + assert!( + message.contains("certificate") || message.contains("cert"), + "expected certificate rejection" + ); + } +}