diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index adc4d2c70..0b7c94b75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,6 +114,16 @@ jobs: - name: Run workspace tests run: cargo test --workspace --all-targets + - name: Native store dependency isolation + run: bash scripts/check_native_store_dependency_contract.sh + + - name: Native AWS provider and startup contracts (loopback only) + run: | + cargo test --offline --locked -p edgezero-store-aws --no-default-features --features appconfig-agent --lib + cargo test --offline --locked -p edgezero-store-aws --no-default-features --features secrets-manager --lib + cargo test --offline --locked -p edgezero-store-aws --all-features --lib + scripts/run_test_nonzero.sh production_runner_loads_envelope_and_real_sdk_secret_before_serving_mixed_stores cargo test --offline --locked -p edgezero-adapter-axum --features aws-stores,test-utils --test native_aws_startup + - name: Bundled demo logging contract run: scripts/run_test_nonzero.sh demo_respects_off_without_hiding_terminal_errors cargo test --offline --locked -p edgezero-cli --features demo-example --test demo_logging @@ -147,6 +157,11 @@ jobs: bash scripts/check_adapter_feature_matrix.sh "${adapter}" native done + - name: Generated native AWS startup (loopback only) + env: + EDGEZERO_GENERATED_TEST_TARGET_DIR: ${{ github.workspace }}/target + run: scripts/run_test_nonzero.sh --ignored generated_cli_build_features_and_native_aws_startup_are_proven_together cargo test --offline --locked -p edgezero-cli --test native_store_generated_proof + - name: Verify a generated project compiles run: scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds diff --git a/Cargo.lock b/Cargo.lock index 1ea350eb7..4848a4b07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,15 @@ dependencies = [ "validator", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "async-compression" version = "0.4.43" @@ -178,6 +187,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -200,6 +252,356 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.120.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832dc9d5dbd19c0ac6b24e21ef9f3e553695d0f3928de7134567407b3b7a447d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.116.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http", +] + +[[package]] +name = "aws-smithy-types" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http", + "http-body", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -270,6 +672,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -300,6 +712,15 @@ 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 = "brotli" version = "8.0.4" @@ -333,6 +754,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cast" version = "0.3.0" @@ -436,6 +867,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -479,6 +916,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -532,6 +975,15 @@ dependencies = [ "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 = "ctor" version = "1.0.13" @@ -542,6 +994,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -634,7 +1095,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common", + "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]] @@ -681,6 +1154,7 @@ dependencies = [ "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", "flate2", "futures", "futures-util", @@ -701,6 +1175,7 @@ dependencies = [ "toml", "tower", "tracing", + "validator", "walkdir", ] @@ -725,7 +1200,7 @@ dependencies = [ "toml_edit", "walkdir", "wasm-bindgen-test", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker", ] @@ -880,6 +1355,28 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +dependencies = [ + "async-trait", + "aws-config", + "aws-sdk-secretsmanager", + "bytes", + "edgezero-core", + "futures", + "futures-util", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tokio", + "toml", + "url", +] + [[package]] name = "either" version = "1.16.0" @@ -1194,6 +1691,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "handlebars" version = "6.4.3" @@ -1252,6 +1768,21 @@ 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 = "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.4.2" @@ -1297,6 +1828,15 @@ 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.12.0" @@ -1305,7 +1845,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", + "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1327,6 +1869,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1756,6 +2299,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -1820,6 +2372,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1895,6 +2453,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + [[package]] name = "pkg-config" version = "0.3.33" @@ -2093,6 +2657,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2129,12 +2699,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -2426,6 +2998,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2450,6 +3033,17 @@ dependencies = [ "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.0", + "digest 0.11.3", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2811,6 +3405,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -3004,6 +3612,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3016,6 +3630,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "validator" version = "0.20.0" @@ -3058,6 +3682,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3252,6 +3882,19 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.6.0" @@ -3756,7 +4399,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker-macros", "worker-sys", @@ -3796,6 +4439,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 0837012a9..1519a0665 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/edgezero-cli", "crates/edgezero-core", "crates/edgezero-macros", + "crates/edgezero-store-aws", ] exclude = ["examples/app-demo"] resolver = "2" @@ -39,6 +40,8 @@ async-compression = { version = "=0.4.43", default-features = false, features = ] } async-stream = "0.3" async-trait = "0.1" +aws-config = { version = "=1.12.0", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso"] } +aws-sdk-secretsmanager = { version = "=1.120.0", default-features = false, features = ["default-https-client", "rt-tokio"] } axum = { version = "0.8", default-features = true } brotli = "=8.0.4" brotli-decompressor = "=5.0.1" @@ -54,6 +57,7 @@ edgezero-adapter-cloudflare = { path = "crates/edgezero-adapter-cloudflare", def edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-features = false } edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } +edgezero-store-aws = { path = "crates/edgezero-store-aws", default-features = false } edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "=0.13.1" fastly-shared = "=0.13.1" @@ -91,6 +95,7 @@ serde_urlencoded = "0.7" sha2 = "0.10" similar = "2" simple_logger = "5" +percent-encoding = "=2.3.2" proc-macro2 = { version = "1", features = ["span-locations"] } quote = "1" syn = { version = "3", features = ["full", "extra-traits", "visit"] } diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index ea2f57bdd..5d06acbc0 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [features] default = ["axum"] axum = [ + "native-bindings", "dep:async-stream", "dep:axum", "dep:tokio", @@ -24,14 +25,18 @@ axum = [ "dep:reqwest", "dep:redb", ] +native-bindings = ["dep:toml", "dep:edgezero-store-aws"] +aws-appconfig-agent = ["axum", "edgezero-store-aws/appconfig-agent"] +aws-secrets-manager = ["axum", "edgezero-store-aws/secrets-manager"] +aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] cli = [ + "native-bindings", "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", - "dep:toml", "dep:walkdir", ] -test-utils = [] +test-utils = ["dep:validator"] [dependencies] edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ @@ -55,6 +60,7 @@ redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } +validator = { workspace = true, optional = true } simple_logger = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true } @@ -63,9 +69,29 @@ tower = { workspace = true, optional = true } tracing = { workspace = true } walkdir = { workspace = true, optional = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +edgezero-store-aws = { workspace = true, optional = true } + [target.'cfg(unix)'.dependencies] libc = { workspace = true } +[[bin]] +name = "edgezero-hosting-fixture" +path = "tests/fixtures/production_host.rs" +required-features = ["axum", "test-utils"] +test = false + +[[bin]] +name = "edgezero-native-aws-host" +path = "tests/fixtures/native_aws_host.rs" +required-features = ["aws-stores", "test-utils"] +test = false + +[[test]] +name = "native_aws_startup" +path = "tests/native_aws_startup.rs" +required-features = ["aws-stores", "test-utils"] + [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 111e06d67..daf25eb80 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,3 +1,4 @@ +use crate::native_build::NativeCargoSelection; use std::collections::BTreeMap; use std::env; use std::fs; @@ -116,6 +117,7 @@ struct AxumProject { axum_host: Option, axum_manifest: PathBuf, axum_port: Option, + build_features: Vec, cargo_manifest: PathBuf, crate_dir: PathBuf, crate_name: String, @@ -466,7 +468,15 @@ fn deploy(_extra_args: &[String]) -> Result<(), String> { fn locate_project() -> Result { let cwd = env::current_dir().map_err(|err| err.to_string())?; let manifest = find_axum_manifest(&cwd)?; - read_axum_project(&manifest) + let mut project = read_axum_project(&manifest)?; + if let Some(edgezero_manifest) = find_manifest_upwards(&cwd, "edgezero.toml") { + let loader = ManifestLoader::from_path(&edgezero_manifest) + .map_err(|err| format!("failed to load {}: {err}", edgezero_manifest.display()))?; + if let Some((_name, adapter)) = loader.manifest().adapter_entry("axum") { + project.build_features.clone_from(&adapter.build.features); + } + } + Ok(project) } fn cargo_command( @@ -495,7 +505,17 @@ fn cargo_command( .to_str() .ok_or_else(|| format!("invalid manifest path {}", project.cargo_manifest.display()))?, ); - command.args(extra_args); + let selection = NativeCargoSelection::normalize_args(&project.build_features, extra_args)?; + command.args(selection.cargo_args()); + match subcommand { + "run" if !selection.run_args.is_empty() => { + command.arg("--").args(&selection.run_args); + } + "build" if !selection.run_args.is_empty() => { + return Err("`cargo build` does not accept runtime arguments after `--`".to_owned()); + } + _ => {} + } command.current_dir(&project.crate_dir); // Canonical env vars. The runtime's `EnvConfig` reads only the // `EDGEZERO__*` form (see `crates/edgezero-core/src/env_config.rs`); @@ -776,6 +796,9 @@ fn read_axum_project_with_env( axum_host: config_host, axum_manifest: manifest.to_path_buf(), axum_port: config_port, + // Pinned targets use only the feature inputs selected by the caller; + // the CLI dispatcher has already forwarded manifest build features. + build_features: Vec::new(), cargo_manifest, crate_dir, crate_name, @@ -799,6 +822,7 @@ mod tests { axum_host: None, axum_manifest: dir.path().join("axum.toml"), axum_port: None, + build_features: Vec::new(), cargo_manifest: dir.path().join("Cargo.toml"), crate_dir: dir.path().to_path_buf(), crate_name: "fixture".to_owned(), diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 41e7e9d08..c30f8d72d 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -114,6 +114,23 @@ impl AxumConfigStore { path: &Path, limits: ConfigStoreLimits, existing: usize, + ) -> Result { + Self::load_snapshot(path, limits, existing, false) + } + + pub(crate) fn load_required_at_startup( + path: &Path, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + Self::load_snapshot(path, limits, existing, true) + } + + fn load_snapshot( + path: &Path, + limits: ConfigStoreLimits, + existing: usize, + required: bool, ) -> Result { let mut options = fs::OpenOptions::new(); options.read(true); @@ -137,7 +154,12 @@ impl AxumConfigStore { } Snapshot::load(&mut file, limits, existing)? } - Err(err) if err.kind() == ErrorKind::NotFound => Snapshot::empty(limits, existing)?, + Err(err) if err.kind() == ErrorKind::NotFound && !required => { + Snapshot::empty(limits, existing)? + } + Err(err) if err.kind() == ErrorKind::NotFound => { + return Err(ConfigStoreError::unavailable("config file missing")); + } Err(_io) => { return Err(ConfigStoreError::unavailable("config snapshot open failed")); } diff --git a/crates/edgezero-adapter-axum/src/connection.rs b/crates/edgezero-adapter-axum/src/connection.rs index e99c2a7f2..16e77ff68 100644 --- a/crates/edgezero-adapter-axum/src/connection.rs +++ b/crates/edgezero-adapter-axum/src/connection.rs @@ -2,6 +2,7 @@ use std::error::Error as StdError; use std::future::{self, Future}; use std::io; +use edgezero_core::probe::LifecyclePhase; use http_body::Body as HttpBody; use hyper::Request; use hyper::body::Incoming; @@ -9,6 +10,7 @@ use hyper::server::conn::http1::Builder as Http1Builder; use hyper::service::Service; use hyper_util::rt::TokioIo; use tokio::net::TcpStream; +use tokio::sync::watch; use tokio::time::{Instant as TokioInstant, sleep_until}; use crate::header_deadline::{HeaderDeadline, HeaderDeadlineExceeded, HeaderIo, HeaderService}; @@ -24,15 +26,33 @@ pub(crate) enum ConnectionExit { DeadlineExceeded, } -/// Owns HTTP/1 parsing, idle/header expiry and the existing response deadline race. +#[cfg(test)] +pub(crate) fn serve_http1( + stream: TcpStream, + service: ServiceType, + responses: EgressConnection, + limits: AxumIngressConfig, +) -> impl Future> +where + ServiceType: Service, Response = hyper::Response>, + ServiceType::Error: Into>, + ServiceType::Future: 'static, + ResponseBody: HttpBody + 'static, + ResponseBody::Error: Into>, +{ + serve_http1_with_shutdown(stream, service, responses, None, limits) +} + +/// Owns bounded HTTP/1 parsing, response deadlines and graceful lifecycle drain. #[expect( clippy::integer_division_remainder_used, reason = "tokio::select! expands to internal randomized branch selection arithmetic" )] -pub(crate) fn serve_http1( +pub(crate) fn serve_http1_with_shutdown( stream: TcpStream, service: ServiceType, responses: EgressConnection, + mut phase: Option>, limits: AxumIngressConfig, ) -> impl Future> where @@ -59,6 +79,22 @@ where let mut connection = Box::pin(connection_future); loop { + if phase.as_ref().is_some_and(|reader| { + matches!( + *reader.borrow(), + LifecyclePhase::Draining | LifecyclePhase::Stopped + ) + }) { + connection.as_mut().graceful_shutdown(); + phase = None; + } + let draining = async { + if let Some(reader) = phase.as_mut() { + let _changed = reader.changed().await; + } else { + future::pending::<()>().await; + } + }; if head .deadline() .is_some_and(|deadline| TokioInstant::now() >= deadline) @@ -95,6 +131,7 @@ where } () = head.changed() => {} () = responses.changed() => {} + () = draining => {} } } } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 5c0031689..b3fe8d2d4 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,22 +1,39 @@ +#![expect( + clippy::pub_use, + reason = "validated options retain the existing dev_server API location" +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "compatibility entrypoints, normalization, native ownership and startup policy remain grouped by responsibility" +)] + use std::any::Any; +use std::env::vars_os; use std::fs; -use std::future; +use std::future::{self, Future}; #[cfg(test)] use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::sync::Arc; +use std::time::Duration; +pub use crate::run_options::AxumRunOptions; +use crate::run_options::{failure, shutdown_grace}; use anyhow::Context as _; -use futures_util::FutureExt as _; -use futures_util::StreamExt as _; -use futures_util::stream::FuturesUnordered; +use edgezero_core::probe::LifecyclePhase; +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; use tokio::signal; +#[cfg(test)] use tokio::sync::oneshot::{Receiver as ShutdownReceiver, Sender as ShutdownSender, channel}; -use tokio::task::{LocalSet, spawn_blocking}; +use tokio::sync::watch; +use tokio::task::LocalSet; +#[cfg(test)] +use tokio::task::spawn_blocking; use edgezero_core::addr; use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; @@ -29,26 +46,65 @@ use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use log::LevelFilter; use simple_logger::SimpleLogger; + use std::collections::BTreeMap; +use std::io::ErrorKind; +use std::process; +use tokio::task::yield_now; +use tokio::time::{Instant as NativeInstant, sleep_until}; use crate::config_store::AxumConfigStore; use crate::config_store_limits::ConfigStoreLimits; -use crate::connection::{ConnectionExit, serve_http1}; +use crate::connection::{ConnectionExit, serve_http1_with_shutdown}; use crate::ingress_config::AxumIngressConfig; use crate::key_value_store::PersistentKvStore; +use crate::native_stores::{self, NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; +use crate::outbound::AxumOutboundClient; use crate::response::EgressConnection; use crate::secret_store::EnvSecretStore; use crate::service::AxumServiceState; +/// Local application-owned startup work, borrowing the unshared App and prepared stores. +pub type LocalStartupFuture<'startup> = + Pin> + 'startup>>; + +/// The exact registries prepared for requests. Handles may be cloned by the application; +/// retained KV clones must be released by their owners before reopening or backing up. +#[derive(Default)] +pub struct PreparedStores { + config: Option, + kv: Option, + secrets: Option, +} + +impl PreparedStores { + #[must_use] + #[inline] + pub fn config(&self) -> Option<&ConfigRegistry> { + self.config.as_ref() + } + + #[must_use] + #[inline] + pub fn kv(&self) -> Option<&KvRegistry> { + self.kv.as_ref() + } + + #[must_use] + #[inline] + pub fn secrets(&self) -> Option<&SecretRegistry> { + self.secrets.as_ref() + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum KvInitRequirement { Optional, Required, } -type ConnectionResult = Result, Box>; - /// Configuration used when running the dev server embedding `EdgeZero` into Axum. #[derive(Clone)] pub struct AxumDevServerConfig { @@ -105,39 +161,19 @@ impl AxumDevServer { /// Returns an error if the dev server fails to bind, the Tokio runtime fails to start, or the underlying request loop returns an error. #[inline] pub fn run(self) -> anyhow::Result<()> { - let runtime = RuntimeBuilder::new_multi_thread() - .enable_all() - .build() - .context("failed to build tokio runtime")?; - - runtime.block_on(async move { self.run_async().await }) - } - - async fn run_async(self) -> anyhow::Result<()> { - let AxumDevServer { + let Self { router, config, stores, } = self; - - // Allow binding to already-open listener if caller created one to surface errors early. - let std_listener = StdTcpListener::bind(config.addr) - .with_context(|| format!("failed to bind dev server to {}", config.addr))?; - std_listener - .set_nonblocking(true) - .context("failed to set listener to non-blocking")?; - - let listener = TokioTcpListener::from_std(std_listener) - .context("failed to adopt std listener into tokio")?; - - serve_with_stores( - App::new(router), - listener, + run_local( + ListenerSource::Bind(config.addr), config.enable_ctrl_c, + Duration::from_secs(10), config.ingress, - stores, + future::pending::<()>(), + async move { prepare_transport(App::new(router), stores.into()) }, ) - .await } #[cfg(test)] @@ -228,18 +264,16 @@ fn kv_init_requirement(stores: StoresMetadata) -> KvInitRequirement { } } -fn kv_store_path(store_name: &str) -> PathBuf { - // Every declared id gets its own slug-based filename. The - // pre-rewrite hard-coded `.edgezero/kv.redb` shortcut for - // store_name == "EDGEZERO_KV" is gone -- the runtime no longer - // hands out a default name; if you reach here you have a real - // declared id and the slug encoding handles every shape - // uniformly. - PathBuf::from(".edgezero").join(format!( +fn kv_store_file_name(store_name: &str) -> String { + format!( "kv-{}-{:016x}.redb", store_name_slug(store_name), stable_store_name_hash(store_name) - )) + ) +} + +fn kv_store_path(store_name: &str) -> PathBuf { + PathBuf::from(".edgezero").join(kv_store_file_name(store_name)) } fn store_name_slug(store_name: &str) -> String { @@ -299,6 +333,39 @@ fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { Ok(KvHandle::new(kv_store)) } +impl From for PreparedStores { + #[inline] + fn from(stores: Stores) -> Self { + Self { + config: stores.config_registry.or_else(|| { + stores.config_store.map(|handle| { + ConfigRegistry::single_id( + "default".to_owned(), + ConfigStoreBinding { + handle, + default_key: "default".to_owned(), + }, + ) + }) + }), + kv: stores.kv_registry.or_else(|| { + stores + .kv + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }), + secrets: stores.secret_registry.or_else(|| { + stores.secrets.map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }), + } + } +} + +#[cfg(test)] async fn serve_with_stores( app: App, listener: TokioTcpListener, @@ -336,6 +403,7 @@ async fn serve_with_stores( result } +#[cfg(test)] fn serve_local( app: App, std_listener: StdTcpListener, @@ -344,78 +412,280 @@ fn serve_local( stores: Stores, shutdown_receiver: ShutdownReceiver<()>, ) -> anyhow::Result<()> { - let mut service = AxumServiceState::from_app(app); - if let Some(registry) = stores.config_registry { - service = service.with_config_registry(registry); - } - if let Some(handle) = stores.config_store { - service = service.with_config_store_handle(handle); - } - if let Some(registry) = stores.kv_registry { - service = service.with_kv_registry(registry); - } - if let Some(handle) = stores.kv { - service = service.with_kv_handle(handle); + run_local( + ListenerSource::Prebound(std_listener), + enable_ctrl_c, + Duration::ZERO, + ingress, + async move { + let _closed = shutdown_receiver.await; + }, + async move { prepare_transport(app, stores.into()) }, + ) +} + +type PreparedApp = (App, PreparedStores, Arc); +type ConnectionResult = Result, Box>; +type Connections = FuturesUnordered>>>; + +enum ListenerSource { + Bind(SocketAddr), + #[cfg(test)] + Prebound(StdTcpListener), +} + +#[cfg_attr( + not(test), + expect( + clippy::needless_pass_by_value, + reason = "test embedding consumes a prebound listener through the same source" + ) +)] +fn adopt_listener(source: ListenerSource) -> anyhow::Result { + let listener = match source { + ListenerSource::Bind(addr) => StdTcpListener::bind(addr) + .map_err(|_error| failure("bind", "listener", "address unavailable"))?, + #[cfg(test)] + ListenerSource::Prebound(listener) => listener, + }; + listener + .set_nonblocking(true) + .map_err(|_error| failure("bind", "listener", "nonblocking setup failed"))?; + TokioTcpListener::from_std(listener) + .map_err(|_error| failure("bind", "listener", "runtime adoption failed")) +} + +fn prepare_transport(app: App, stores: PreparedStores) -> anyhow::Result { + let transport = AxumOutboundClient::try_transport() + .map_err(|_error| failure("transport", "HTTP client", "initialization failed"))?; + Ok((app, stores, transport)) +} + +struct Signals { + #[cfg(unix)] + interrupt: signal::unix::Signal, + #[cfg(unix)] + terminate: signal::unix::Signal, +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "registration precedes observation in this lifecycle owner" +)] +impl Signals { + fn register(enabled: bool) -> anyhow::Result> { + if !enabled { + return Ok(None); + } + #[cfg(unix)] + { + let interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .map_err(|_error| failure("signals", "SIGINT", "registration failed"))?; + let terminate = signal::unix::signal(signal::unix::SignalKind::terminate()) + .map_err(|_error| failure("signals", "SIGTERM", "registration failed"))?; + Ok(Some(Self { + interrupt, + terminate, + })) + } + #[cfg(not(unix))] + { + Ok(Some(Self {})) + } } - if let Some(registry) = stores.secret_registry { - service = service.with_secret_registry(registry); + + #[expect( + clippy::integer_division_remainder_used, + reason = "tokio select branch arithmetic" + )] + async fn next(&mut self) -> anyhow::Result<()> { + #[cfg(unix)] + { + let received = tokio::select! { + received = self.interrupt.recv() => received, + received = self.terminate.recv() => received, + }; + received.ok_or_else(|| failure("signals", "termination", "observation closed")) + } + #[cfg(not(unix))] + { + signal::ctrl_c() + .await + .map_err(|_error| failure("signals", "SIGINT", "registration failed")) + } } - if let Some(handle) = stores.secrets { - service = service.with_secret_handle(handle); +} + +async fn next_signal(signals: &mut Option) -> anyhow::Result<()> { + match signals { + Some(source) => source.next().await, + None => future::pending().await, } +} +fn run_local( + listener: ListenerSource, + enable_signals: bool, + grace: Duration, + ingress: AxumIngressConfig, + stop: Stop, + prepare: Prepare, +) -> anyhow::Result<()> +where + Prepare: Future>, + Stop: Future, +{ let runtime = RuntimeBuilder::new_current_thread() .enable_all() .build() - .context("failed to build axum connection runtime")?; + .map_err(|_error| failure("runtime", "local runner", "creation failed"))?; let local = LocalSet::new(); - #[expect( - clippy::integer_division_remainder_used, - reason = "tokio::select! expands to internal randomized branch selection arithmetic" - )] - let serve = async move { - let listener = TokioTcpListener::from_std(std_listener) - .context("failed to adopt listener on axum connection runtime")?; - let shutdown = async move { - let _closed = shutdown_receiver.await; - }; - let ctrl_c = async move { - if enable_ctrl_c { - let _signal = signal::ctrl_c().await; - } else { - future::pending::<()>().await; - } - }; - tokio::pin!(shutdown); - tokio::pin!(ctrl_c); - let mut connections = FuturesUnordered::new(); + runtime.block_on(local.run_until(async move { + let mut signals = Signals::register(enable_signals)?; + let (phase, reader) = watch::channel(LifecyclePhase::Starting); + let result = run_owned( + listener, + grace, + ingress, + stop, + prepare, + &mut signals, + &phase, + reader, + ) + .await; + phase.send_replace(LifecyclePhase::Stopped); + result + })) +} - loop { - tokio::select! { - biased; - () = &mut shutdown => break, - () = &mut ctrl_c => break, - Some(result) = connections.next(), if !connections.is_empty() => { - report_connection_exit(&result); - } - accepted = listener.accept() => { - let (stream, remote_addr) = accepted.context("axum listener accept failed")?; - // One transient accepted socket; no parser, task or waiter on refusal. - if connections.len() >= ingress.max_connections() { - drop(stream); - continue; +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio select branch arithmetic" +)] +#[expect( + clippy::too_many_arguments, + reason = "one lifecycle owner carries the validated ingress policy and stop sources" +)] +async fn run_owned( + listener_source: ListenerSource, + grace: Duration, + ingress: AxumIngressConfig, + stop: Stop, + prepare: Prepare, + signals: &mut Option, + phase: &watch::Sender, + reader: watch::Receiver, +) -> anyhow::Result<()> +where + Prepare: Future>, + Stop: Future, +{ + tokio::pin!(stop); + tokio::pin!(prepare); + let prepared = tokio::select! { + biased; + signal = next_signal(signals) => { + signal?; + phase.send_replace(LifecyclePhase::Draining); + return Ok(()); + } + () = &mut stop => { + phase.send_replace(LifecyclePhase::Draining); + return Ok(()); + } + prepared = &mut prepare => prepared?, + }; + let (app, stores, transport) = prepared; + let mut service = AxumServiceState::with_transport(app, transport).with_phase(reader.clone()); + if let Some(registry) = stores.config { + service = service.with_config_registry(registry); + } + if let Some(registry) = stores.kv { + service = service.with_kv_registry(registry); + } + if let Some(registry) = stores.secrets { + service = service.with_secret_registry(registry); + } + // Give stop observation another poll before bind/adoption and readiness. + let listener = tokio::select! { + biased; + signal = next_signal(signals) => { signal?; phase.send_replace(LifecyclePhase::Draining); return Ok(()); } + () = &mut stop => { phase.send_replace(LifecyclePhase::Draining); return Ok(()); } + result = async { yield_now().await; adopt_listener(listener_source) } => result?, + }; + phase.send_replace(LifecyclePhase::Ready); + log::info!("[edgezero] native server ready"); + let mut tasks = Connections::new(); + let mut outcome = Ok(()); + loop { + tokio::select! { + biased; + signal = next_signal(signals) => { outcome = signal; break; } + () = &mut stop => break, + Some(result) = tasks.next(), if !tasks.is_empty() => { + report_connection_exit(&result); + } + accepted = listener.accept() => { + match accepted { + Ok((stream, remote_addr)) => { + if tasks.len() >= ingress.max_connections() { + drop(stream); + continue; + } + let responses = EgressConnection::default(); + let connection_service = service.for_connection(remote_addr, responses.clone()); + let connection = serve_http1_with_shutdown(stream, connection_service, responses, Some(reader.clone()), ingress); + tasks.push(Box::pin(AssertUnwindSafe(connection).catch_unwind())); + } + Err(_error) => { + outcome = Err(failure("runner", "listener", "accept failed")); + break; } - let responses = EgressConnection::default(); - let connection_service = service.for_connection(remote_addr, responses.clone()); - connections.push(AssertUnwindSafe(serve_http1(stream, connection_service, responses, ingress)).catch_unwind()); } } } - // Dropping the bounded owner releases sockets, parser buffers and egress attempts. - drop(connections); - Ok(()) + } + phase.send_replace(LifecyclePhase::Draining); + drop(listener); + let result = drain_tasks(&mut tasks, grace, signals, outcome).await; + // Service/registry clones are dropped only after every owned connection settles. + drop(service); + result +} + +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio select branch arithmetic" +)] +async fn drain_tasks( + tasks: &mut Connections, + grace: Duration, + signals: &mut Option, + mut outcome: anyhow::Result<()>, +) -> anyhow::Result<()> { + let Some(deadline) = NativeInstant::now().checked_add(grace) else { + tasks.clear(); + return Err(failure("shutdown", "budget", "unusable deadline")); }; - runtime.block_on(local.run_until(serve)) + while !tasks.is_empty() { + tokio::select! { + biased; + signal = next_signal(signals) => { + outcome = Err(signal.err().unwrap_or_else(|| failure("shutdown", "signal", "forced stop"))); + break; + } + () = sleep_until(deadline) => { + outcome = Err(failure("shutdown", "budget", "grace exhausted")); + break; + } + Some(result) = tasks.next() => { + report_connection_exit(&result); + } + } + } + tasks.clear(); + outcome } fn report_connection_exit(result: &ConnectionResult) { @@ -434,7 +704,8 @@ fn report_connection_exit(result: &ConnectionResult) { /// # Errors /// Returns an error if the dev server fails to bind or any required store handle cannot be initialised. fn build_app_for_dispatch() -> anyhow::Result { - App::build::(crate::AXUM_PLATFORM).context("application configuration failed") + App::build::(crate::AXUM_PLATFORM) + .map_err(|_error| anyhow::anyhow!("application configuration failed")) } fn app_logger(env: &EnvConfig) -> SimpleLogger { @@ -443,6 +714,44 @@ fn app_logger(env: &EnvConfig) -> SimpleLogger { .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) } +/// Generated application entrypoint: retain the development runner unless an +/// explicit native bindings file is selected. A selected file always enters +/// strict production startup, where a provider compiled out of the binary fails +/// closed instead of falling back to local stores. +/// +/// # Errors +/// Returns startup or listener errors from the selected runner. +#[inline] +pub fn run_generated_app() -> anyhow::Result<()> { + let bindings_selected = + vars_os().any(|(key, _value)| key.to_str().is_some_and(is_store_bindings_locator_key)); + if bindings_selected { + // `run_production_app` captures all bootstrap settings and the selected + // bindings document once, then rejects unavailable compiled providers. + run_production_app::() + } else { + // Preserve existing generated local-development behavior exactly when + // no native bindings locator was selected. + run_app::() + } +} + +fn is_store_bindings_locator_key(key: &str) -> bool { + let prefix_len = "EDGEZERO__".len(); + let Some(prefix) = key.get(..prefix_len) else { + return false; + }; + let prefix_matches = if cfg!(windows) { + prefix.eq_ignore_ascii_case("EDGEZERO__") + } else { + prefix == "EDGEZERO__" + }; + prefix_matches + && key + .get(prefix_len..) + .is_some_and(|path| path.eq_ignore_ascii_case("STORE_BINDINGS_FILE")) +} + /// Runs an application with the Axum development server. /// /// # Errors @@ -453,14 +762,10 @@ pub fn run_app() -> anyhow::Result<()> { run_app_with_preflight::(|| Ok(())) } -/// Run a startup check after logger initialization and before application configuration. -/// -/// Useful for capability validation in embedded runners. Applications that own logging -/// must install their backend and filters before calling this function. +/// Runs a startup check after logging and policy validation, before app configuration. /// /// # Errors -/// Returns an error if logger setup, `preflight`, application configuration, runtime setup, -/// store initialization, listener binding, or connection serving fails. +/// Returns an error for logging, preflight, configuration, stores, or serving failure. #[inline] pub fn run_app_with_preflight(preflight: F) -> anyhow::Result<()> where @@ -478,44 +783,359 @@ where log::info!("[edgezero] axum HTTP/1 ingress limits: {ingress:?}"); log::info!("[edgezero] axum config snapshot limits: {config_limits:?}"); preflight()?; - let app = build_app_for_dispatch::()?; - let stores = A::stores(); - let kv_init_requirement = kv_init_requirement(stores); - let kv_registry = build_kv_registry(stores.kv, &env, kv_init_requirement)?; - let config_registry = build_config_registry(stores.config, &env, config_limits)?; - let secret_registry = build_secret_registry(stores.secrets, &env); - + let grace = shutdown_grace(&env)?; let resolution = resolve_addr(&env); - for warning in &resolution.warnings { - log::warn!(target: BOOT_LOG_TARGET, "{warning}"); + run_local( + ListenerSource::Bind(resolution.addr), + true, + grace, + ingress, + future::pending::<()>(), + async move { + let app = build_app_for_dispatch::()?; + let metadata = A::stores(); + for warning in &resolution.warnings { + log::warn!(target: BOOT_LOG_TARGET, "{warning}"); + } + let stores = PreparedStores { + kv: build_kv_registry(metadata.kv, &env, kv_init_requirement(metadata))?, + config: build_config_registry(metadata.config, &env, config_limits)?, + secrets: build_secret_registry(metadata.secrets, &env), + }; + prepare_transport(app, stores) + }, + ) +} + +fn init_logging(level: LevelFilter) -> anyhow::Result<()> { + if !A::owns_logging() { + SimpleLogger::new() + .with_level(level) + .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) + .init() + .map_err(|_error| failure("logging", "application", "initialization failed"))?; } - let addr = resolution.addr; - log::info!("[edgezero] starting axum server on http://{addr}"); + Ok(()) +} - let runtime = RuntimeBuilder::new_multi_thread() - .enable_all() - .build() - .context("failed to build tokio runtime")?; - - runtime.block_on(async move { - let std_listener = StdTcpListener::bind(addr) - .with_context(|| format!("failed to bind dev server to {addr}"))?; - std_listener - .set_nonblocking(true) - .context("failed to set listener to non-blocking")?; - let listener = TokioTcpListener::from_std(std_listener) - .context("failed to adopt std listener into tokio")?; - - let request_stores = Stores { - config_registry, - kv_registry, - secret_registry, - ..Stores::default() +fn no_initialize<'startup>( + _app: &'startup mut App, + _stores: &'startup PreparedStores, +) -> LocalStartupFuture<'startup> { + Box::pin(future::ready(Ok(()))) +} + +/// Runs strict framework startup with SIGINT and Unix SIGTERM. No callback is +/// required. Ready does not claim application-specific schema/bootstrap checks. +/// +/// # Errors +/// Returns classified startup/runner errors or non-success for forced shutdown. +#[inline] +pub fn run_production_app() -> anyhow::Result<()> { + run_production_selected::( + AxumRunOptions::from_env()?, + NativeStoreOverrides::default(), + true, + future::pending::<()>(), + no_initialize, + ) +} + +/// Runs strict startup plus exactly one application-owned local initializer. +/// Pass an inline `|app, stores| Box::pin(async move { ... })` or a boxed function +/// so Rust can infer the higher-ranked borrow lifetime. Futures need not be Send. +/// +/// # Errors +/// Returns safe classified errors; callback/provider source chains are discarded. +#[inline] +pub fn run_production_app_with_initializer( + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_production_selected::( + AxumRunOptions::from_env()?, + NativeStoreOverrides::default(), + true, + future::pending::<()>(), + initialize, + ) +} + +/// Runs production with explicit options and caller-owned stop, installing no +/// process-wide signal handlers and merging no ambient hosting/store settings. +/// +/// # Errors +/// Returns classified startup/runner errors or grace exhaustion. +#[inline] +pub fn run_app_with_options(options: AxumRunOptions, stop: Stop) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, +{ + run_app_with_options_and_initializer::(options, stop, no_initialize) +} + +/// Runs production embedding with app-selected startup checks before acceptance. +/// Retained cloned KV handles remain the caller's responsibility after return. +/// +/// # Errors +/// Returns classified startup/runner errors or grace exhaustion. +#[inline] +pub fn run_app_with_options_and_initializer( + options: AxumRunOptions, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_app_with_native_stores::( + options, + NativeStoreOverrides::default(), + stop, + initialize, + ) +} + +/// Runs explicit native bindings and app-owned checks before accepting requests. +/// Setup futures are polled sequentially on the existing current-thread runtime. +/// +/// # Errors +/// Rejects invalid sources before opening local providers or polling deferred setup. +/// Preparation failures discard all staged handles and skip initializer/readiness. +#[inline] +pub fn run_app_with_native_stores( + options: AxumRunOptions, + overrides: NativeStoreOverrides, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_production_selected::(options, overrides, false, stop, initialize) +} + +fn run_production_selected( + options: AxumRunOptions, + overrides: NativeStoreOverrides, + signals: bool, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, +{ + run_local( + ListenerSource::Bind(options.addr()), + signals, + options.shutdown_grace(), + AxumIngressConfig::from_env(options.env_config())?, + stop, + async move { + let metadata = A::stores(); + let sources = NativeSourcePlan::validate(metadata, &options, overrides)?; + let mut app = build_app_for_dispatch::()?; + init_logging::(options.logging_level())?; + let stores = build_production_stores(metadata, &options, sources).await?; + let transport = AxumOutboundClient::try_transport() + .map_err(|_error| failure("transport", "HTTP client", "initialization failed"))?; + initialize(&mut app, &stores).await.map_err(|error| { + let category = error.store_extraction_reason().map_or_else( + || "application checks failed".to_owned(), + |reason| format!("{reason:?}"), + ); + failure("initializer", "application", &category) + })?; + Ok((app, stores, transport)) + }, + ) +} + +fn check_data_write_access(root: &Path) -> anyhow::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _attempt in 0..8_u8 { + let sequence = NEXT.fetch_add(1, Ordering::Relaxed); + let path = root.join(format!( + ".edgezero-write-check-{}-{sequence}", + process::id() + )); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => { + drop(file); + fs::remove_file(path).map_err(|_error| { + failure("stores", "DATA_DIR", "write-probe cleanup failed") + })?; + return Ok(()); + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(_error) => return Err(failure("stores", "DATA_DIR", "directory is not writable")), + } + } + Err(failure("stores", "DATA_DIR", "write-probe unavailable")) +} + +#[expect( + clippy::too_many_lines, + reason = "keep all sequential staging in one ownership scope before atomic publication" +)] +async fn build_production_stores( + metadata: StoresMetadata, + options: &AxumRunOptions, + sources: NativeSourcePlan, +) -> anyhow::Result { + let mut config = BTreeMap::new(); + let mut kv = BTreeMap::new(); + let mut secrets = BTreeMap::new(); + let mut resident = 0_usize; + let mut aws = sources.session()?; + for (id, source) in sources.config { + let binding = match source { + NativeStoreSource::BuiltIn(builtin) => { + native_stores::prepare_aws_config(builtin, &mut aws, id).await? + } + local @ (NativeStoreSource::Local + | NativeStoreSource::Ready(_) + | NativeStoreSource::Prepare(_)) => { + native_stores::prepare(local, "config", id, || { + let root = options + .config_dir() + .ok_or_else(|| failure("settings", "CONFIG_DIR", "required"))?; + let store = AxumConfigStore::load_required_at_startup( + &root.join(format!("local-config-{id}.json")), + sources.config_limits, + resident, + ) + .map_err(|error| { + let category = match error { + ConfigStoreError::ValueTooLarge => "snapshot size limit", + ConfigStoreError::Unavailable { message } + if message == "config file missing" => + { + "required snapshot missing" + } + ConfigStoreError::Unavailable { message } + if message == "config snapshot requires a regular file" => + { + "snapshot requires a regular file" + } + ConfigStoreError::DeadlineExceeded + | ConfigStoreError::Internal { .. } + | ConfigStoreError::InvalidKey { .. } + | ConfigStoreError::Unavailable { .. } + | _ => "required snapshot unavailable or malformed", + }; + failure("config", id, category) + })?; + resident = resident + .checked_add(store.resident_allocation_bytes()) + .ok_or_else(|| failure("config", id, "snapshot allocation limit"))?; + Ok(ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: options.env_config().store_key("config", id), + }) + }) + .await? + } + }; + config.insert(id.to_owned(), binding); + } + let mut handles: BTreeMap = BTreeMap::new(); + if sources + .kv + .values() + .any(|source| matches!(source, NativeStoreSource::Local)) + { + check_data_write_access( + options + .data_dir() + .ok_or_else(|| failure("settings", "DATA_DIR", "required"))?, + )?; + } + for (id, source) in sources.kv { + let handle = native_stores::prepare(source, "kv", id, || { + let root = options + .data_dir() + .ok_or_else(|| failure("settings", "DATA_DIR", "required"))?; + let name = options.env_config().store_name("kv", id); + if let Some(handle) = handles.get(&name) { + return Ok(handle.clone()); + } + let store = PersistentKvStore::new(root.join(kv_store_file_name(&name))) + .map_err(|_error| failure("kv", id, "database unavailable or locked"))?; + let handle = KvHandle::new(Arc::new(store)); + handles.insert(name, handle.clone()); + Ok(handle) + }) + .await?; + kv.insert(id.to_owned(), handle); + } + let environment_secrets = SecretHandle::new(Arc::new(EnvSecretStore::new())); + for (id, source) in sources.secrets { + let binding = match source { + NativeStoreSource::BuiltIn(builtin) => { + native_stores::prepare_aws_secrets(builtin, &mut aws, id).await? + } + local @ (NativeStoreSource::Local + | NativeStoreSource::Ready(_) + | NativeStoreSource::Prepare(_)) => { + native_stores::prepare(local, "secrets", id, || { + Ok(BoundSecretStore::new( + environment_secrets.clone(), + options.env_config().store_name("secrets", id), + )) + }) + .await? + } }; - serve_with_stores(app, listener, true, ingress, request_stores).await + secrets.insert(id.to_owned(), binding); + } + Ok(PreparedStores { + config: finish_registry(metadata.config, config, "config")?, + kv: finish_registry(metadata.kv, kv, "kv")?, + secrets: finish_registry(metadata.secrets, secrets, "secrets")?, }) } +fn finish_registry( + metadata: Option, + handles: BTreeMap, + kind: &str, +) -> anyhow::Result>> { + metadata + .map(|meta| { + StoreRegistry::from_parts(handles, meta.default.to_owned()) + .ok_or_else(|| failure(kind, meta.default, "invalid declared default binding")) + }) + .transpose() +} + /// Build the per-request KV registry from baked store metadata. /// /// Each declared id resolves to a [`PersistentKvStore`] at @@ -531,33 +1151,27 @@ fn build_kv_registry( }; let mut by_id: BTreeMap = BTreeMap::new(); + let mut handles: BTreeMap = BTreeMap::new(); for id in meta.ids { let store_name = env.store_name("kv", id); + if let Some(handle) = handles.get(&store_name) { + by_id.insert((*id).to_owned(), handle.clone()); + continue; + } let kv_path = kv_store_path(&store_name); let handle = match kv_handle_from_path(&kv_path) { Ok(handle) => handle, - Err(err) => match init { + Err(_error) => match init { KvInitRequirement::Optional => { - log::warn!( - target: BOOT_LOG_TARGET, - "KV store '{}' (id `{}`) could not be initialized at {}: {}", - store_name, - id, - kv_path.display(), - err - ); + log::warn!(target: BOOT_LOG_TARGET, "KV store id `{id}` unavailable; omitting optional binding"); continue; } KvInitRequirement::Required => { - return Err(err.context(format!( - "KV store '{}' (id `{}`) is explicitly configured for axum but could not be initialized at {}", - store_name, - id, - kv_path.display() - ))); + return Err(failure("KV", id, "declared database unavailable or locked")); } }, }; + handles.insert(store_name, handle.clone()); by_id.insert((*id).to_owned(), handle); } @@ -689,6 +1303,22 @@ mod tests { use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn generated_entry_selects_native_mode_only_for_bindings_locator() { + assert!(is_store_bindings_locator_key( + "EDGEZERO__STORE_BINDINGS_FILE" + )); + assert!(is_store_bindings_locator_key( + "EDGEZERO__store_bindings_file" + )); + assert!(!is_store_bindings_locator_key( + "EDGEZERO__ADAPTER__DATA_DIR" + )); + assert!(!is_store_bindings_locator_key( + "EDGEZERO_STORE_BINDINGS_FILE" + )); + } + struct FailingConfiguration; struct LoggingConfiguration; @@ -918,6 +1548,45 @@ mod tests { assert_eq!(app.platform(), crate::AXUM_PLATFORM); } + #[test] + fn initializer_borrows_inputs_across_await_and_captures_rc() { + use futures::executor::block_on; + use std::cell::Cell; + use std::rc::Rc; + + async fn initialize( + app: &mut App, + stores: &PreparedStores, + callback: Initialize, + ) -> Result<(), EdgeError> + where + Initialize: for<'startup> FnOnce( + &'startup mut App, + &'startup PreparedStores, + ) -> LocalStartupFuture<'startup>, + { + callback(app, stores).await + } + let calls = Rc::new(Cell::new(0_u32)); + let recorded = Rc::clone(&calls); + let mut owned_app = App::new(RouterService::builder().build()); + block_on(initialize( + &mut owned_app, + &PreparedStores::default(), + move |app, stores| { + Box::pin(async move { + future::ready(()).await; + assert!(stores.config().is_none()); + app.insert_state(Arc::::from("initialized")); + recorded.set(recorded.get() + 1); + Ok(()) + }) + }, + )) + .expect("initialize"); + assert_eq!(calls.get(), 1); + } + #[test] fn default_config_uses_expected_address() { let config = AxumDevServerConfig::default(); @@ -2262,3 +2931,640 @@ mod integration_tests { server.handle.abort(); } } + +#[cfg(test)] +mod native_store_tests { + use super::*; + use crate::native_stores::NativeStorePreparationError; + use bytes::Bytes; + use edgezero_core::config_store::{ConfigStore, finish_bounded_config_read}; + use edgezero_core::context::RequestContext; + use edgezero_core::extractor::State; + use edgezero_core::key_value_store::NoopKvStore; + use edgezero_core::secret_store::InMemorySecretStore; + use edgezero_core::{ConfigValue, EdgeError, action}; + use futures::executor::block_on; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::TcpStream; + use tokio::time::timeout; + + struct ProbeConfig { + drops: Arc, + reads: Arc, + } + + impl Drop for ProbeConfig { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[async_trait::async_trait(?Send)] + impl ConfigStore for ProbeConfig { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.reads.fetch_add(1, Ordering::SeqCst); + Ok((key == "owned_key").then(|| ConfigValue::from("snapshot"))) + } + + async fn get_bounded( + &self, + key: &str, + clock: &edgezero_core::MonotonicClock, + deadline: edgezero_core::Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } + } + + fn binding(drops: &Arc, reads: &Arc) -> ConfigStoreBinding { + ConfigStoreBinding { + default_key: "owned_key".to_owned(), + handle: ConfigStoreHandle::new(Arc::new(ProbeConfig { + drops: Arc::clone(drops), + reads: Arc::clone(reads), + })), + } + } + + fn options() -> AxumRunOptions { + let listener = StdTcpListener::bind("127.0.0.1:0").unwrap(); + AxumRunOptions::new(listener.local_addr().unwrap()).unwrap() + } + + fn metadata() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["first", "second"], + default: "first", + }), + ..StoresMetadata::default() + } + } + + struct CustomApp; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only routes, store declarations and logging ownership" + )] + impl Hooks for CustomApp { + fn owns_logging() -> bool { + true + } + fn stores() -> StoresMetadata { + metadata() + } + fn routes() -> RouterService { + RouterService::builder().get("/", read_snapshot).build() + } + } + + #[action] + async fn read_snapshot( + ctx: RequestContext, + State(state): State>, + ) -> Result { + let selected = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing fixture binding")))?; + let value = selected + .handle + .get(&selected.default_key) + .await? + .unwrap_or_default(); + Ok(format!("{}:{state}", value.as_str())) + } + + #[test] + fn ready_and_rc_deferred_inputs_share_completed_registries_with_requests() { + let options = options(); + let address = options.addr(); + let drops = Arc::new(AtomicUsize::new(0)); + let reads = Arc::new(AtomicUsize::new(0)); + let initialized = Rc::new(Cell::new(false)); + let polls = Rc::new(Cell::new(0_usize)); + let mut overrides = NativeStoreOverrides::default(); + let deferred = binding(&drops, &reads); + let counted = Rc::clone(&polls); + overrides + .prepare_config("first", async move { + counted.set(counted.get() + 1); + yield_now().await; + Ok(deferred) + }) + .unwrap(); + overrides + .insert_config("second", binding(&drops, &Arc::new(AtomicUsize::new(0)))) + .unwrap(); + let completed = Rc::clone(&initialized); + run_app_with_native_stores::( + options, + overrides, + async { + timeout(Duration::from_secs(5), async { + while !initialized.get() { + yield_now().await; + } + let mut socket = loop { + if let Ok(socket) = TcpStream::connect(address).await { + break socket; + } + yield_now().await; + }; + socket + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let mut response = Vec::new(); + socket.read_to_end(&mut response).await.unwrap(); + assert!(response.ends_with(b"snapshot:initialized")); + }) + .await + .unwrap(); + }, + move |app, stores| { + Box::pin(async move { + let registry = stores.config().unwrap(); + assert_eq!(registry.default_id(), "first"); + let selected = registry.default_ref().unwrap(); + assert_eq!(selected.default_key, "owned_key"); + assert_eq!( + selected + .handle + .get(&selected.default_key) + .await + .unwrap() + .unwrap() + .as_str(), + "snapshot" + ); + assert!(registry.named("second").is_some()); + app.insert_state(Arc::::from("initialized")); + completed.set(true); + Ok(()) + }) + }, + ) + .unwrap(); + assert_eq!(polls.get(), 1); + assert_eq!(reads.load(Ordering::SeqCst), 2); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + + #[test] + fn ready_and_deferred_kv_need_no_local_data_root() { + let opened = Rc::new(Cell::new(0_usize)); + let tracked = Rc::clone(&opened); + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_kv("first", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + overrides + .prepare_kv("second", async move { + tracked.set(tracked.get() + 1); + Ok(KvHandle::new(Arc::new(NoopKvStore))) + }) + .unwrap(); + let metadata = StoresMetadata { + kv: Some(StoreMetadata { + ids: &["first", "second"], + default: "second", + }), + ..StoresMetadata::default() + }; + let options = options(); + let plan = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + let stores = block_on(build_production_stores(metadata, &options, plan)).unwrap(); + assert_eq!(opened.get(), 1); + assert_eq!(stores.kv().unwrap().default_id(), "second"); + assert!(stores.kv().unwrap().named("first").is_some()); + } + + #[test] + fn malformed_metadata_is_rejected_before_any_deferred_poll() { + for declaration in [ + StoreMetadata { + default: "missing", + ids: &["first"], + }, + StoreMetadata { + default: "first", + ids: &["first", "first"], + }, + StoreMetadata { + default: "bad/id", + ids: &["bad/id"], + }, + StoreMetadata { + default: "first", + ids: &[], + }, + ] { + let polled = Rc::new(Cell::new(false)); + let observed = Rc::clone(&polled); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_kv("first", async move { + observed.set(true); + Ok(KvHandle::new(Arc::new(NoopKvStore))) + }) + .unwrap(); + let metadata = StoresMetadata { + kv: Some(declaration), + ..StoresMetadata::default() + }; + assert!(NativeSourcePlan::validate(metadata, &options(), overrides).is_err()); + assert!(!polled.get()); + } + } + + #[test] + fn duplicate_inputs_never_replace_an_existing_source_and_debug_is_redacted() { + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_kv("SENTINEL", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + assert_eq!( + overrides.prepare_kv("SENTINEL", async { panic!("must not poll") }), + Err(NativeStorePreparationError::DuplicateBinding) + ); + let drops = Arc::new(AtomicUsize::new(0)); + overrides + .insert_config("SENTINEL", binding(&drops, &drops)) + .unwrap(); + assert_eq!( + overrides.insert_config("SENTINEL", binding(&drops, &drops)), + Err(NativeStorePreparationError::DuplicateBinding) + ); + let secret = BoundSecretStore::new( + SecretHandle::new(Arc::new(InMemorySecretStore::new([("right/key", "value")]))), + "right".to_owned(), + ); + overrides + .insert_secrets("SENTINEL", secret.clone()) + .unwrap(); + assert_eq!( + overrides.prepare_secrets("SENTINEL", async move { Ok(secret) }), + Err(NativeStorePreparationError::DuplicateBinding) + ); + assert!(!format!("{overrides:?}").contains("SENTINEL")); + } + + #[test] + fn file_and_inline_source_errors_precede_deferred_polls_and_local_opens() { + use crate::native_bindings::{NativeBindingsLimits, NativeStoreBindings}; + + for file_input in [false, true] { + for invalid in ["duplicate", "undeclared", "overlay", "disabled"] { + if invalid == "disabled" && cfg!(feature = "aws-appconfig-agent") { + continue; + } + let data = tempfile::tempdir().unwrap(); + let bootstrap = tempfile::tempdir().unwrap(); + let mut selected = options().with_data_dir(data.path()).unwrap(); + if invalid == "overlay" { + selected = AxumRunOptions::from_vars([( + "EDGEZERO__STORES__CONFIG__FIRST__KEY", + "OVERLAY_SENTINEL", + )]) + .unwrap() + .with_data_dir(data.path()) + .unwrap(); + } + let input = "version = 1\n[config.first]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\n[config.first.documents.settings]\napplication = 'example'\nenvironment = 'production'\nprofile = 'PROFILE_SENTINEL'\n"; + let document = if invalid == "undeclared" { + input.replace("config.first", "config.UNKNOWN_SENTINEL") + } else { + input.to_owned() + }; + if file_input { + let path = bootstrap.path().join("bindings.toml"); + fs::write(&path, document).unwrap(); + selected = selected.with_store_bindings_file(&path).unwrap(); + fs::remove_file(path).unwrap(); + } else { + selected = selected + .with_store_bindings( + NativeStoreBindings::parse(&document, NativeBindingsLimits::default()) + .unwrap(), + ) + .unwrap(); + } + let polls = Rc::new(Cell::new(0_usize)); + let tracked = Rc::clone(&polls); + let drops = Arc::new(AtomicUsize::new(0)); + let handle = binding(&drops, &drops); + let mut overrides = NativeStoreOverrides::default(); + let id = if invalid == "duplicate" { + "first" + } else { + "second" + }; + overrides + .prepare_config(id, async move { + tracked.set(tracked.get() + 1); + Ok(handle) + }) + .unwrap(); + let initialized = Rc::new(Cell::new(false)); + let called = Rc::clone(&initialized); + let error = run_app_with_native_stores::( + selected, + overrides, + future::pending(), + move |_, _| { + called.set(true); + Box::pin(future::ready(Ok(()))) + }, + ) + .unwrap_err(); + assert_eq!(polls.get(), 0); + assert!(!initialized.get()); + assert_eq!(fs::read_dir(data.path()).unwrap().count(), 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(!format!("{error:?}").contains("SENTINEL")); + let expected = match invalid { + "duplicate" => "duplicate", + "undeclared" => "undeclared", + "overlay" => "NAME/KEY", + _ => "not compiled", + }; + assert!(error.to_string().contains(expected), "{error}"); + } + } + } + + #[test] + fn source_errors_do_not_poll_futures_open_databases_or_run_initializer() { + for invalid in ["undeclared", "name", "key", "config_root", "data_root"] { + let root = tempfile::tempdir().unwrap(); + let mut options = options().with_data_dir(root.path()).unwrap(); + if matches!(invalid, "name" | "key") { + options = AxumRunOptions::from_vars([( + format!( + "EDGEZERO__STORES__CONFIG__FIRST__{}", + invalid.to_uppercase() + ), + "OVERLAY_SENTINEL".to_owned(), + )]) + .unwrap() + .with_data_dir(root.path()) + .unwrap(); + } + if invalid == "data_root" { + options = AxumRunOptions::new(options.addr()).unwrap(); + } + let polled = Rc::new(Cell::new(0_usize)); + let tracked = Rc::clone(&polled); + let drops = Arc::new(AtomicUsize::new(0)); + let supplied = binding(&drops, &Arc::new(AtomicUsize::new(0))); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_config("first", async move { + tracked.set(tracked.get() + 1); + Ok(supplied) + }) + .unwrap(); + if invalid != "config_root" { + overrides + .insert_config("second", binding(&drops, &Arc::new(AtomicUsize::new(0)))) + .unwrap(); + } + if invalid == "undeclared" { + overrides + .insert_kv("OTHER_SENTINEL", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + } + let calls = Rc::new(Cell::new(0_usize)); + let called = Rc::clone(&calls); + let result = run_app_with_native_stores::( + options, + overrides, + future::pending(), + move |_, _| { + called.set(called.get() + 1); + Box::pin(future::ready(Ok(()))) + }, + ); + let error = result.unwrap_err(); + assert_eq!(polled.get(), 0, "{invalid}"); + assert_eq!(calls.get(), 0); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + struct WithLocalKv; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only routes, store declarations and logging ownership" + )] + impl Hooks for WithLocalKv { + fn owns_logging() -> bool { + true + } + fn stores() -> StoresMetadata { + StoresMetadata { + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + ..metadata() + } + } + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn local_roots_are_required_only_for_remaining_local_ids() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("local-config-second.json"), + r#"{"second":"local"}"#, + ) + .unwrap(); + let drops = Arc::new(AtomicUsize::new(0)); + let config = binding(&drops, &Arc::new(AtomicUsize::new(0))); + let mut overrides = NativeStoreOverrides::default(); + overrides.insert_config("first", config).unwrap(); + overrides + .insert_kv("state", KvHandle::new(Arc::new(NoopKvStore))) + .unwrap(); + let metadata = WithLocalKv::stores(); + let options = options().with_config_dir(root.path()).unwrap(); + let plan = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + let stores = block_on(build_production_stores(metadata, &options, plan)).unwrap(); + let registry = stores.config().unwrap(); + assert_eq!( + registry.named_ref("first").unwrap().default_key, + "owned_key" + ); + let local = registry.named_ref("second").unwrap(); + assert_eq!(local.default_key, "second"); + assert_eq!( + block_on(local.handle.get("second")) + .unwrap() + .unwrap() + .as_str(), + "local" + ); + assert!(stores.kv().unwrap().named("state").is_some()); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + + #[test] + fn supplied_secret_namespace_is_preserved_and_wrong_namespaces_miss() { + let handle = + SecretHandle::new(Arc::new(InMemorySecretStore::new([("right/key", "value")]))); + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_secrets( + "first", + BoundSecretStore::new(handle.clone(), "right".to_owned()), + ) + .unwrap(); + overrides + .prepare_secrets("second", async move { + Ok(BoundSecretStore::new(handle, "wrong".to_owned())) + }) + .unwrap(); + let metadata = StoresMetadata { + secrets: Some(StoreMetadata { + ids: &["first", "second"], + default: "second", + }), + ..StoresMetadata::default() + }; + let options = options(); + let plan = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); + let stores = block_on(build_production_stores(metadata, &options, plan)).unwrap(); + let registry = stores.secrets().unwrap(); + assert_eq!(registry.default_id(), "second"); + assert_eq!( + block_on(registry.named("first").unwrap().get_bytes("key")).unwrap(), + Some(Bytes::from("value")) + ); + assert_eq!( + block_on(registry.default().unwrap().get_bytes("key")).unwrap(), + None + ); + } + + #[test] + fn later_failure_drops_staged_handles_and_unpolled_futures_without_readiness() { + let options = options(); + let address = options.addr(); + let drops = Arc::new(AtomicUsize::new(0)); + let order = Rc::new(Cell::new(0_usize)); + let first = binding(&drops, &Arc::new(AtomicUsize::new(0))); + let recorded = Rc::clone(&order); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_config("first", async move { + assert_eq!(recorded.get(), 0); + recorded.set(1); + Ok(first) + }) + .unwrap(); + let failing_order = Rc::clone(&order); + overrides + .prepare_config("second", async move { + assert_eq!(failing_order.get(), 1); + failing_order.set(2); + Err(NativeStorePreparationError::Unavailable) + }) + .unwrap(); + let unused = binding(&drops, &Arc::new(AtomicUsize::new(0))); + overrides + .prepare_secrets("third", async move { + let _retained = unused; + panic!("later future must not poll after preparation failure") + }) + .unwrap(); + let result = run_app_with_native_stores::( + options, + overrides, + future::pending(), + |_, _| panic!("initializer must not run after failed preparation"), + ); + assert!(result.is_err()); + assert_eq!(order.get(), 2); + assert_eq!(drops.load(Ordering::SeqCst), 2); + StdTcpListener::bind(address).unwrap(); + } + + struct WithSecrets; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only routes, store declarations and logging ownership" + )] + impl Hooks for WithSecrets { + fn owns_logging() -> bool { + true + } + fn stores() -> StoresMetadata { + StoresMetadata { + secrets: Some(StoreMetadata { + ids: &["third"], + default: "third", + }), + ..metadata() + } + } + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn stop_during_pending_preparation_drops_staged_state_without_binding() { + let options = options(); + let address = options.addr(); + let drops = Arc::new(AtomicUsize::new(0)); + let mut overrides = NativeStoreOverrides::default(); + overrides + .insert_config("first", binding(&drops, &Arc::new(AtomicUsize::new(0)))) + .unwrap(); + let entered = Rc::new(Cell::new(false)); + let observed = Rc::clone(&entered); + overrides + .prepare_config("second", async move { + observed.set(true); + future::pending().await + }) + .unwrap(); + run_app_with_native_stores::( + options, + overrides, + async { + while !entered.get() { + yield_now().await; + } + }, + |_, _| panic!("initializer must not run after startup stop"), + ) + .unwrap(); + assert_eq!(drops.load(Ordering::SeqCst), 1); + StdTcpListener::bind(address).unwrap(); + } +} diff --git a/crates/edgezero-adapter-axum/src/key_value_store.rs b/crates/edgezero-adapter-axum/src/key_value_store.rs index bb375cf39..c4cf72b08 100644 --- a/crates/edgezero-adapter-axum/src/key_value_store.rs +++ b/crates/edgezero-adapter-axum/src/key_value_store.rs @@ -158,11 +158,9 @@ impl PersistentKvStore { /// Returns an error if the database file cannot be opened or initialised (corrupted file, locked by another process, or insufficient permissions). #[inline] pub fn new>(path: P) -> Result { - let db_path = path.as_ref().display().to_string(); - let db = Database::create(path).map_err(|err| { + let db = Database::create(path).map_err(|_error| { KvError::Internal(anyhow::anyhow!( - "Failed to open KV database at {db_path}. If the file is corrupted or locked \ - by another process, try deleting it and restarting: {err}" + "KV database unavailable; release other owners or check storage permissions, capacity and integrity" )) })?; diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 789e35da7..e6c5292b7 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -16,6 +16,12 @@ mod header_deadline; pub mod ingress_config; #[cfg(feature = "axum")] pub mod key_value_store; +#[cfg(all(feature = "native-bindings", not(target_arch = "wasm32")))] +pub mod native_bindings; +#[cfg(all(feature = "native-bindings", not(target_arch = "wasm32")))] +pub mod native_build; +#[cfg(feature = "axum")] +pub mod native_stores; #[cfg(feature = "axum")] pub mod outbound; #[cfg(feature = "axum")] @@ -23,6 +29,8 @@ pub mod request; #[cfg(feature = "axum")] mod response; #[cfg(feature = "axum")] +mod run_options; +#[cfg(feature = "axum")] pub mod secret_store; #[cfg(feature = "axum")] mod service; diff --git a/crates/edgezero-adapter-axum/src/native_bindings.rs b/crates/edgezero-adapter-axum/src/native_bindings.rs new file mode 100644 index 000000000..3a552e6d4 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/native_bindings.rs @@ -0,0 +1,976 @@ +//! Native bootstrap schema and pure per-ID source decisions, without service clients. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "schema, bootstrap I/O and source validation are grouped" +)] + +#[cfg(unix)] +use libc::O_NONBLOCK; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::OpenOptions; +use std::io::Read as _; +use std::iter::empty; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; + +use edgezero_core::app::{StoreMetadata, StoresMetadata}; +use edgezero_core::env_config::EnvConfig; +use edgezero_core::manifest::{ManifestStores, StoreDeclaration}; +use edgezero_core::secret_store::MAX_NAME_LEN; +use edgezero_store_aws::settings::{ + AppConfigAgentSettings, AwsPreparationLimits, SecretsManagerSettings, +}; +use serde::Deserialize; + +/// Closed bootstrap failures. File, TOML and provider inputs never become source chains. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum NativeBindingsError { + #[error("duplicate binding source")] + Duplicate, + #[error("invalid native bindings")] + Invalid, + #[error("invalid declared metadata")] + InvalidMetadata, + #[error("explicit binding conflicts with NAME/KEY overlay")] + OverlayConflict, + #[error("undeclared binding")] + Undeclared, + #[error("malformed native bindings")] + Malformed, + #[error("native bindings size limit")] + SizeLimit, + #[error("native bindings file unavailable")] + Unavailable, + #[error("provider not compiled")] + UnsupportedProvider, +} + +/// Trusted bootstrap bounds. The file cannot raise its own read/ID limits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NativeBindingsLimits { + pub max_file_bytes: usize, + pub max_explicit_ids: usize, +} + +impl Default for NativeBindingsLimits { + #[inline] + fn default() -> Self { + Self { + max_file_bytes: 256 * 1_024, + max_explicit_ids: 128, + } + } +} + +impl NativeBindingsLimits { + /// Checks positive counters and the cap+1 overflow probe. + /// + /// # Errors + /// Rejects zero or unusable counter values. + #[inline] + pub fn validate(&self) -> Result<(), NativeBindingsError> { + if self.max_file_bytes == 0 + || self.max_explicit_ids == 0 + || self.max_file_bytes.checked_add(1).is_none() + || u64::try_from(self.max_file_bytes).is_err() + { + return Err(NativeBindingsError::Invalid); + } + Ok(()) + } +} + +/// Providers present in a resolved target, independent of the running CLI's features. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct NativeProviderCapabilities { + pub appconfig_agent: bool, + pub secrets_manager: bool, +} + +/// Strict configuration dispatch. Provider-specific fields are validated by AWS settings. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +pub enum NativeConfigBinding { + #[serde(rename = "aws-appconfig-agent")] + AppConfigAgent(AppConfigAgentSettings), +} + +/// Strict secret dispatch. No arbitrary remote names or per-request discovery. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +pub enum NativeSecretBinding { + #[serde(rename = "aws-secrets-manager")] + SecretsManager(SecretsManagerSettings), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LimitsTable { + aws: AwsPreparationLimits, +} + +/// Version 1 native document. Logical IDs and lookup keys remain case-sensitive. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeStoreBindings { + version: u32, + #[serde(default)] + config: BTreeMap, + #[serde(default)] + secrets: BTreeMap, + limits: Option, +} + +impl fmt::Debug for NativeStoreBindings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NativeStoreBindings") + .finish_non_exhaustive() + } +} + +impl NativeStoreBindings { + /// Builds a typed document with no file or ambient environment access. + #[inline] + #[must_use] + pub fn new( + config: BTreeMap, + secrets: BTreeMap, + ) -> Self { + Self { + version: 1, + config, + secrets, + limits: None, + } + } + + /// Parses strict TOML and validates resource shapes and trusted metadata bounds. + /// + /// # Errors + /// Returns fixed categories, never TOML excerpts or raw settings. + #[inline] + pub fn parse(input: &str, limits: NativeBindingsLimits) -> Result { + Self::parse_with_policy(input, limits, None) + } + + /// Parses using exactly one document or explicit remote policy source. + /// + /// # Errors + /// Rejects competing policies and malformed/invalid/over-limit documents. + #[inline] + pub fn parse_with_policy( + input: &str, + limits: NativeBindingsLimits, + aws: Option, + ) -> Result { + limits.validate()?; + if input.len() > limits.max_file_bytes { + return Err(NativeBindingsError::SizeLimit); + } + let bindings: Self = + toml::from_str(input).map_err(|_error| NativeBindingsError::Malformed)?; + bindings.validate(limits, aws)?; + Ok(bindings) + } + + /// Reads one explicitly selected absolute regular file, capped before parsing. + /// + /// # Errors + /// Missing, non-file, malformed and cap+1 inputs fail without echoing paths/contents. + #[inline] + pub fn read(path: &Path, limits: NativeBindingsLimits) -> Result { + Self::read_with_policy(path, limits, None) + } + + /// Captures one capped file under an explicit remote limits policy if supplied. + /// + /// # Errors + /// Rejects competing policies, unusable files and malformed/over-limit documents. + #[inline] + pub fn read_with_policy( + path: &Path, + limits: NativeBindingsLimits, + aws: Option, + ) -> Result { + limits.validate()?; + if !path.is_absolute() { + return Err(NativeBindingsError::Invalid); + } + let mut open = OpenOptions::new(); + open.read(true); + // A named pipe must not block before metadata can reject it. The check + // uses the opened handle, so replacing a path cannot bypass its type. + #[cfg(unix)] + open.custom_flags(O_NONBLOCK); + let file = open + .open(path) + .map_err(|_error| NativeBindingsError::Unavailable)?; + if !file + .metadata() + .map_err(|_error| NativeBindingsError::Unavailable)? + .is_file() + { + return Err(NativeBindingsError::Unavailable); + } + let probe = limits + .max_file_bytes + .checked_add(1) + .ok_or(NativeBindingsError::Invalid)?; + let mut bytes = Vec::new(); + file.take(u64::try_from(probe).map_err(|_error| NativeBindingsError::Invalid)?) + .read_to_end(&mut bytes) + .map_err(|_error| NativeBindingsError::Unavailable)?; + if bytes.len() > limits.max_file_bytes { + return Err(NativeBindingsError::SizeLimit); + } + let input = String::from_utf8(bytes).map_err(|_error| NativeBindingsError::Malformed)?; + Self::parse_with_policy(&input, limits, aws) + } + + /// Checks the whole settings document before constructing providers. + /// + /// # Errors + /// Rejects unsupported schema, mappings, limits and competing policy sources. + #[inline] + pub fn validate( + &self, + limits: NativeBindingsLimits, + explicit_aws: Option, + ) -> Result { + limits.validate()?; + if self.version != 1 { + return Err(NativeBindingsError::Invalid); + } + if self + .config + .len() + .checked_add(self.secrets.len()) + .ok_or(NativeBindingsError::SizeLimit)? + > limits.max_explicit_ids + { + return Err(NativeBindingsError::SizeLimit); + } + if self.limits.is_some() && explicit_aws.is_some() { + return Err(NativeBindingsError::Duplicate); + } + let policy = explicit_aws + .or_else(|| self.limits.as_ref().map(|table| table.aws)) + .unwrap_or_default(); + policy + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + let mut mappings = 0_u64; + for binding in self.config.values() { + let NativeConfigBinding::AppConfigAgent(settings) = binding; + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + mappings = mappings + .checked_add( + u64::try_from(settings.documents.len()) + .map_err(|_error| NativeBindingsError::SizeLimit)?, + ) + .ok_or(NativeBindingsError::SizeLimit)?; + } + for binding in self.secrets.values() { + let NativeSecretBinding::SecretsManager(settings) = binding; + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + mappings = mappings + .checked_add( + u64::try_from(settings.entries.len()) + .map_err(|_error| NativeBindingsError::SizeLimit)?, + ) + .ok_or(NativeBindingsError::SizeLimit)?; + } + if mappings > policy.max_remote_values { + return Err(NativeBindingsError::SizeLimit); + } + Ok(policy) + } + + /// Explicit configuration mappings. + #[inline] + #[must_use] + pub fn config(&self) -> &BTreeMap { + &self.config + } + + /// Explicit secret mappings. + #[inline] + #[must_use] + pub fn secrets(&self) -> &BTreeMap { + &self.secrets + } + + /// Selects one typed remote limits policy; it does not merge policy sources. + #[inline] + #[must_use] + pub fn with_aws_limits(mut self, limits: AwsPreparationLimits) -> Self { + self.limits = Some(LimitsTable { aws: limits }); + self + } +} + +/// Cloneable bootstrap selection. File contents are captured once at selection. +#[derive(Clone, Default)] +pub enum NativeStoreBindingsInput { + #[default] + LocalDefaults, + File { + path: PathBuf, + bindings: NativeStoreBindings, + }, + Inline(NativeStoreBindings), +} + +impl NativeStoreBindingsInput { + /// Captured settings, without reopening the selected file. + #[inline] + #[must_use] + pub fn document(&self) -> Option<&NativeStoreBindings> { + match self { + Self::LocalDefaults => None, + Self::File { bindings, .. } | Self::Inline(bindings) => Some(bindings), + } + } +} + +impl fmt::Debug for NativeStoreBindingsInput { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::LocalDefaults => "LocalDefaults", + Self::File { .. } => "File([redacted])", + Self::Inline(_) => "Inline([redacted])", + }) + } +} + +/// Pure source classification. Custom handles are not inspected or quota-accounted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeBindingSource { + Local, + Supplied, + BuiltIn, +} + +/// Supplied IDs only. This carries no handles/futures and is usable by offline CLI checks. +#[derive(Default)] +pub struct NativeSuppliedIds { + pub config: BTreeSet, + pub kv: BTreeSet, + pub secrets: BTreeSet, +} + +/// Borrowed declarations for compiled metadata or a CLI-owned portable manifest. +pub struct NativeStoreDeclarations<'declarations> { + pub config: Option>, + pub kv: Option>, + pub secrets: Option>, +} + +/// Names remain borrowed from the caller; validation does not leak or intern IDs. +pub struct NativeStoreDeclaration<'declarations> { + pub ids: Vec<&'declarations str>, + pub default: &'declarations str, +} + +impl From for NativeStoreDeclaration<'static> { + #[inline] + fn from(metadata: StoreMetadata) -> Self { + Self { + ids: metadata.ids.to_vec(), + default: metadata.default, + } + } +} + +impl From for NativeStoreDeclarations<'static> { + #[inline] + fn from(metadata: StoresMetadata) -> Self { + Self { + config: metadata.config.map(Into::into), + kv: metadata.kv.map(Into::into), + secrets: metadata.secrets.map(Into::into), + } + } +} + +impl<'declarations> TryFrom<&'declarations StoreDeclaration> + for NativeStoreDeclaration<'declarations> +{ + type Error = NativeBindingsError; + + #[inline] + fn try_from(declaration: &'declarations StoreDeclaration) -> Result { + if !declaration.legacy.is_empty() + || (declaration.ids.len() > 1 && declaration.default.is_none()) + { + return Err(NativeBindingsError::InvalidMetadata); + } + Ok(Self { + ids: declaration.ids.iter().map(String::as_str).collect(), + default: declaration.default_id(), + }) + } +} + +impl<'declarations> TryFrom<&'declarations ManifestStores> + for NativeStoreDeclarations<'declarations> +{ + type Error = NativeBindingsError; + + #[inline] + fn try_from(stores: &'declarations ManifestStores) -> Result { + Ok(Self { + config: stores + .config + .as_ref() + .map(NativeStoreDeclaration::try_from) + .transpose()?, + kv: stores + .kv + .as_ref() + .map(NativeStoreDeclaration::try_from) + .transpose()?, + secrets: stores + .secrets + .as_ref() + .map(NativeStoreDeclaration::try_from) + .transpose()?, + }) + } +} + +/// Complete per-ID decisions. Registries keep their original metadata/defaults. +pub struct NativeSourceSelection<'declarations> { + pub config: BTreeMap<&'declarations str, NativeBindingSource>, + pub kv: BTreeMap<&'declarations str, NativeBindingSource>, + pub secrets: BTreeMap<&'declarations str, NativeBindingSource>, +} + +impl<'declarations> NativeSourceSelection<'declarations> { + /// Validates metadata, every source and overlay, then checks resolved capabilities. + /// + /// # Errors + /// Duplicate/undeclared sources, scalar collisions and absent target providers fail closed. + #[inline] + pub fn validate>>( + metadata: Declarations, + bindings: &NativeStoreBindings, + supplied: &NativeSuppliedIds, + env: &EnvConfig, + capabilities: NativeProviderCapabilities, + limits: NativeBindingsLimits, + aws: Option, + ) -> Result { + bindings.validate(limits, aws)?; + let declarations = metadata.into(); + let selection = Self { + config: resolve( + declarations.config, + &supplied.config, + bindings.config.keys(), + env, + "config", + )?, + kv: resolve(declarations.kv, &supplied.kv, empty(), env, "kv")?, + secrets: resolve( + declarations.secrets, + &supplied.secrets, + bindings.secrets.keys(), + env, + "secrets", + )?, + }; + if (!bindings.config.is_empty() && !capabilities.appconfig_agent) + || (!bindings.secrets.is_empty() && !capabilities.secrets_manager) + { + return Err(NativeBindingsError::UnsupportedProvider); + } + Ok(selection) + } +} + +fn resolve<'declarations, 'settings, Ids: Iterator>( + metadata: Option>, + supplied: &BTreeSet, + bound: Ids, + env: &EnvConfig, + kind: &str, +) -> Result, NativeBindingsError> { + let settings: BTreeSet<&str> = bound.map(String::as_str).collect(); + let Some(meta) = metadata else { + return if supplied.is_empty() && settings.is_empty() { + Ok(BTreeMap::new()) + } else { + Err(NativeBindingsError::Undeclared) + }; + }; + if meta.ids.is_empty() + || !meta.ids.contains(&meta.default) + || meta.ids.iter().enumerate().any(|(index, id)| { + id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || meta.ids.iter().take(index).any(|previous| previous == id) + }) + { + return Err(NativeBindingsError::InvalidMetadata); + } + if supplied.iter().any(|id| !meta.ids.contains(&id.as_str())) + || settings.iter().any(|id| !meta.ids.contains(id)) + { + return Err(NativeBindingsError::Undeclared); + } + let mut result = BTreeMap::new(); + for id in meta.ids { + let source = match (supplied.contains(id), settings.contains(id)) { + (true, true) => return Err(NativeBindingsError::Duplicate), + (true, false) => NativeBindingSource::Supplied, + (false, true) => NativeBindingSource::BuiltIn, + (false, false) => NativeBindingSource::Local, + }; + if source != NativeBindingSource::Local + && ["name", "key"] + .iter() + .any(|field| env.store_setting(kind, id, field).is_some()) + { + return Err(NativeBindingsError::OverlayConflict); + } + if source == NativeBindingSource::Local + && ["name", "key"].iter().any(|field| { + env.store_setting(kind, id, field).is_some_and(|value| { + value.trim().is_empty() || value.chars().any(char::is_control) + }) + }) + { + return Err(NativeBindingsError::Invalid); + } + if kind == "secrets" && source != NativeBindingSource::Supplied { + let namespace = if source == NativeBindingSource::Local { + env.store_setting(kind, id, "name").map_or(id, str::trim) + } else { + id + }; + if namespace.len() > MAX_NAME_LEN { + return Err( + if source == NativeBindingSource::Local + && env.store_setting(kind, id, "name").is_some() + { + NativeBindingsError::Invalid + } else { + NativeBindingsError::InvalidMetadata + }, + ); + } + } + result.insert(id, source); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::assertions_on_result_states, + reason = "strict parser tests compare accepted/rejected input matrices" + )] + #![expect( + clippy::panic_in_result_fn, + reason = "tests use fallible setup and assertions" + )] + + use std::collections::{BTreeMap, BTreeSet}; + use std::error::Error; + use std::fmt::Write as _; + use std::fs; + use std::path::Path; + + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + use edgezero_core::env_config::EnvConfig; + use edgezero_core::manifest::ManifestStores; + use edgezero_store_aws::settings::AwsPreparationLimits; + + use super::{ + NativeBindingSource, NativeBindingsError, NativeBindingsLimits, NativeProviderCapabilities, + NativeSourceSelection, NativeStoreBindings, NativeStoreDeclarations, NativeSuppliedIds, + }; + + const CONFIG: &str = "version = 1\n[config.app]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\n[config.app.documents.settings]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n"; + + fn metadata() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["app", "local"], + default: "app", + }), + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + secrets: None, + } + } + + #[test] + fn manifest_ids_remain_borrowed_and_invalid_declarations_fail() -> Result<(), Box> { + let owned: ManifestStores = + toml::from_str("[config]\nids = ['app', 'local']\ndefault = 'app'\n")?; + let bindings = NativeStoreBindings::parse(CONFIG, NativeBindingsLimits::default())?; + let selection = NativeSourceSelection::validate( + NativeStoreDeclarations::try_from(&owned)?, + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: false, + }, + NativeBindingsLimits::default(), + None, + )?; + assert_eq!( + selection.config.get("app"), + Some(&NativeBindingSource::BuiltIn) + ); + let stored_id = selection + .config + .get_key_value("app") + .ok_or(NativeBindingsError::InvalidMetadata)? + .0; + let original_id = &owned + .config + .as_ref() + .ok_or(NativeBindingsError::InvalidMetadata)? + .ids[0]; + assert_eq!(stored_id.as_ptr(), original_id.as_ptr()); + for input in [ + "[config]\nids = ['app', 'local']\n", + "[config]\nids = ['app']\nname = 'legacy'\n", + ] { + let invalid: ManifestStores = toml::from_str(input)?; + assert!(matches!( + NativeStoreDeclarations::try_from(&invalid), + Err(NativeBindingsError::InvalidMetadata) + )); + } + Ok(()) + } + + #[test] + fn secret_namespace_limits_are_checked_before_preparation() { + let metadata = StoresMetadata { + config: None, + kv: None, + secrets: Some(StoreMetadata { + ids: &["signing"], + default: "signing", + }), + }; + let bindings = NativeStoreBindings::new(BTreeMap::new(), BTreeMap::new()); + for (bytes, accepted) in [(512, true), (513, false)] { + let env = EnvConfig::from_vars([( + "EDGEZERO__STORES__SECRETS__SIGNING__NAME", + "x".repeat(bytes), + )]); + let result = NativeSourceSelection::validate( + metadata, + &bindings, + &NativeSuppliedIds::default(), + &env, + NativeProviderCapabilities::default(), + NativeBindingsLimits::default(), + None, + ); + assert_eq!(result.is_ok(), accepted); + } + } + + #[test] + fn strict_tagged_schema_and_case_sensitive_mappings() { + let limits = NativeBindingsLimits::default(); + assert!(NativeStoreBindings::parse(CONFIG, limits).is_ok()); + for input in [ + CONFIG.replace("version = 1", "version = 2"), + CONFIG.replace("version = 1", "version = 1\nunknown = 'sentinel'"), + CONFIG.replace("aws-appconfig-agent", "dynamodb"), + CONFIG.replace("default_key = 'settings'", "default_key = 'Settings'"), + CONFIG.replace("endpoint =", "unknown = 'sentinel'\nendpoint ="), + CONFIG.replace("application =", "unknown = 'sentinel'\napplication ="), + CONFIG.replace("[config.app.documents.settings]", "[config.app.documents]"), + format!("{CONFIG}\n[kv.state]\nprovider = 'dynamodb'"), + format!("{CONFIG}\n[unknown]\nvalue = 'sentinel'"), + CONFIG.replace("version = 1", "version = 1\nversion = 1"), + format!("{CONFIG}\n[config.app]\nprovider = 'aws-appconfig-agent'"), + format!("{CONFIG}\n[config.app.documents.settings]\nprofile = 'sentinel'"), + ] { + let error = NativeStoreBindings::parse(&input, limits).err(); + assert!(error.is_some(), "accepted malformed bindings"); + assert!(!format!("{error:?}").contains("sentinel")); + } + } + + #[test] + fn capped_file_read_and_trusted_id_limit() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("sentinel.toml"); + let limits = NativeBindingsLimits { + max_file_bytes: CONFIG.len(), + max_explicit_ids: 1, + }; + fs::write(&path, CONFIG)?; + assert!(NativeStoreBindings::read(&path, limits).is_ok()); + fs::write(&path, format!("{CONFIG} "))?; + assert_eq!( + NativeStoreBindings::read(&path, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + fs::write(&path, [0xff])?; + let malformed = NativeStoreBindings::read(&path, limits) + .err() + .ok_or(NativeBindingsError::Invalid)?; + assert_eq!(malformed, NativeBindingsError::Malformed); + assert!(!format!("{malformed:?}").contains("sentinel")); + assert!(malformed.source().is_none()); + assert_eq!( + NativeStoreBindings::read(directory.path(), limits).err(), + Some(NativeBindingsError::Unavailable) + ); + assert_eq!( + NativeStoreBindings::read(Path::new("relative.toml"), limits).err(), + Some(NativeBindingsError::Invalid) + ); + let mut many = String::from("version = 1\n"); + for index in 0..=128_u32 { + many.push_str( + &CONFIG + .replace("version = 1\n", "") + .replace("config.app", &format!("config.store{index}")), + ); + if index == 127 { + assert!(NativeStoreBindings::parse(&many, NativeBindingsLimits::default()).is_ok()); + } + } + assert_eq!( + NativeStoreBindings::parse(&many, NativeBindingsLimits::default()).err(), + Some(NativeBindingsError::SizeLimit) + ); + Ok(()) + } + + #[test] + fn whole_source_validation_and_target_capabilities() -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let bindings = NativeStoreBindings::parse(CONFIG, limits)?; + let available = NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: false, + }; + let mut supplied = NativeSuppliedIds::default(); + let env = EnvConfig::default(); + let selection = NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + available, + limits, + None, + )?; + assert_eq!( + selection.config.get("app"), + Some(&NativeBindingSource::BuiltIn) + ); + assert_eq!( + selection.config.get("local"), + Some(&NativeBindingSource::Local) + ); + assert_eq!(selection.kv.get("state"), Some(&NativeBindingSource::Local)); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + NativeProviderCapabilities::default(), + limits, + None + ), + Err(NativeBindingsError::UnsupportedProvider) + )); + supplied.config = BTreeSet::from(["app".to_owned()]); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + available, + limits, + None + ), + Err(NativeBindingsError::Duplicate) + )); + supplied.config = BTreeSet::from(["APP".to_owned()]); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &env, + available, + limits, + None + ), + Err(NativeBindingsError::Undeclared) + )); + supplied.config.clear(); + for field in ["NAME", "KEY"] { + let overlay = + EnvConfig::from_vars([(format!("EDGEZERO__STORES__CONFIG__APP__{field}"), "")]); + assert!(matches!( + NativeSourceSelection::validate( + metadata(), + &bindings, + &supplied, + &overlay, + available, + limits, + None + ), + Err(NativeBindingsError::OverlayConflict) + )); + } + Ok(()) + } + + #[test] + fn explicit_policy_can_raise_mapping_caps_before_file_parsing() -> Result<(), Box> { + let mut input = CONFIG.to_owned(); + for index in 0..1_024_u32 { + writeln!( + input, + "\n[config.app.documents.alias{index}]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'" + )?; + } + let limits = NativeBindingsLimits::default(); + assert_eq!( + NativeStoreBindings::parse(&input, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + let aws = AwsPreparationLimits { + max_remote_values: 2_048, + ..AwsPreparationLimits::default() + }; + let root = tempfile::tempdir()?; + let path = root.path().join("bindings.toml"); + fs::write(&path, input)?; + let bindings = NativeStoreBindings::read_with_policy(&path, limits, Some(aws))?; + assert_eq!( + bindings.validate(limits, Some(aws))?.max_remote_values, + 2_048 + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn named_pipe_is_rejected_without_waiting_for_a_writer() -> Result<(), Box> { + use std::process::Command; + let root = tempfile::tempdir()?; + let path = root.path().join("bindings.fifo"); + assert!(Command::new("mkfifo").arg(&path).status()?.success()); + assert_eq!( + NativeStoreBindings::read(&path, NativeBindingsLimits::default()).err(), + Some(NativeBindingsError::Unavailable) + ); + Ok(()) + } + + #[test] + fn secret_schema_rejects_irrelevant_fields_and_preserves_selectors() + -> Result<(), NativeBindingsError> { + let input = "version = 1\n[secrets.signing]\nprovider = 'aws-secrets-manager'\nregion = 'us-east-1'\n[secrets.signing.entries.active]\nsecret_id = 'arn:aws:secretsmanager:us-east-1:111122223333:secret:SENTINEL-AbCdEf'\nversion_stage = 'AWSCURRENT'\n"; + let limits = NativeBindingsLimits::default(); + let document = NativeStoreBindings::parse(input, limits)?; + assert_eq!(document.secrets().len(), 1); + assert!(!format!("{document:?}").contains("SENTINEL")); + for bad in [ + input.replace("region =", "endpoint = 'http://127.0.0.1'\nregion ="), + input.replace("version_stage =", "unknown = 'SENTINEL'\nversion_stage ="), + input.replace("us-east-1'\n[secrets", "us-west-2'\n[secrets"), + input.replace( + "version_stage =", + "version_id = '12345678901234567890123456789012'\nversion_stage =", + ), + input.replace("secret_id =", "secret_id = 'partial-name'\nother ="), + ] { + let error = NativeStoreBindings::parse(&bad, limits) + .err() + .ok_or(NativeBindingsError::Invalid)?; + assert!(!format!("{error:?}").contains("SENTINEL")); + assert!(error.source().is_none()); + } + Ok(()) + } + + #[test] + fn exact_mapping_boundary_uses_one_file_or_explicit_policy() -> Result<(), NativeBindingsError> + { + let limits = NativeBindingsLimits::default(); + let aws = AwsPreparationLimits { + max_remote_values: 1, + ..AwsPreparationLimits::default() + }; + let explicit = NativeStoreBindings::parse_with_policy(CONFIG, limits, Some(aws))?; + assert_eq!(explicit.validate(limits, Some(aws))?, aws); + let file = NativeStoreBindings::parse( + &format!("{CONFIG}\n[limits.aws]\nmax_remote_values = 1"), + limits, + )?; + assert_eq!(file.validate(limits, None)?, aws); + let alias = "\n[config.app.documents.alias]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n"; + assert_eq!( + NativeStoreBindings::parse_with_policy(&format!("{CONFIG}{alias}"), limits, Some(aws)) + .err(), + Some(NativeBindingsError::SizeLimit) + ); + assert_eq!( + NativeStoreBindings::parse( + &format!("{CONFIG}{alias}\n[limits.aws]\nmax_remote_values = 1"), + limits + ) + .err(), + Some(NativeBindingsError::SizeLimit) + ); + Ok(()) + } + + #[test] + fn limit_policy_sources_do_not_merge_and_aliases_count_before_dedup() + -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let with_policy = format!("{CONFIG}\n[limits.aws]\nmax_snapshot_bytes = 16777216"); + let bindings = NativeStoreBindings::parse(&with_policy, limits)?; + assert_eq!( + bindings.validate(limits, Some(AwsPreparationLimits::default())), + Err(NativeBindingsError::Duplicate) + ); + let aliases = format!( + "{CONFIG}\n[config.app.documents.alias]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n[limits.aws]\nmax_remote_values = 1" + ); + assert_eq!( + NativeStoreBindings::parse(&aliases, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + Ok(()) + } +} diff --git a/crates/edgezero-adapter-axum/src/native_build.rs b/crates/edgezero-adapter-axum/src/native_build.rs new file mode 100644 index 000000000..d76d62399 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/native_build.rs @@ -0,0 +1,310 @@ +//! SDK-free native Cargo selection and exact generated Axum command matching. + +use std::collections::BTreeSet; + +/// Normalized Cargo feature and target inputs shared by native CLI paths. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[must_use] +pub struct NativeCargoSelection { + /// Whether Cargo should enable every package feature. + pub all_features: bool, + /// Non-feature Cargo args before `--`. + pub cargo_args: Vec, + /// Whether Cargo should suppress package defaults. + pub no_default_features: bool, + /// Explicit additions, including `[adapters.axum.build].features`. + pub requested_features: Vec, + /// Arguments after Cargo's `--`, for `cargo run` only. + pub run_args: Vec, + /// Cargo target triple. `None` means Cargo's host target. + pub target: Option, +} + +impl NativeCargoSelection { + /// Cargo arguments in normalized form; runtime args are intentionally omitted. + #[inline] + #[must_use] + pub fn cargo_args(&self) -> Vec { + let mut args = self.cargo_args.clone(); + if !self.requested_features.is_empty() { + args.extend(["--features".to_owned(), self.requested_features.join(",")]); + } + args + } + + /// Merge manifest features with Cargo feature controls, preserving unrelated + /// arguments and separating runtime args after `--`. + /// + /// # Errors + /// Returns an error for missing/empty feature lists, missing or conflicting + /// targets, or the non-Cargo `native` target sentinel. + #[inline] + pub fn normalize_args(manifest_features: &[String], args: &[String]) -> Result { + let mut selection = Self::default(); + let mut feature_values = Vec::new(); + let mut pending_value: Option<&str> = None; + let mut after_separator = false; + + for arg in args { + if let Some(option) = pending_value.take() { + if option == "--target" { + selection.set_target(arg)?; + selection + .cargo_args + .extend([option.to_owned(), arg.clone()]); + } else { + feature_values.extend(split_features(arg)?); + } + continue; + } + if after_separator { + selection.run_args.push(arg.clone()); + continue; + } + match arg.as_str() { + "--" => after_separator = true, + "--features" | "-F" | "--target" => pending_value = Some(arg), + "--all-features" => { + selection.all_features = true; + selection.cargo_args.push(arg.clone()); + } + "--no-default-features" => { + selection.no_default_features = true; + selection.cargo_args.push(arg.clone()); + } + _ => { + if let Some(value) = arg.strip_prefix("--features=") { + feature_values.extend(split_features(value)?); + } else if let Some(value) = arg.strip_prefix("-F") { + if value.is_empty() { + selection.cargo_args.push(arg.clone()); + } else { + feature_values.extend(split_features(value)?); + } + } else if let Some(value) = arg.strip_prefix("--target=") { + selection.set_target(value)?; + selection.cargo_args.push(arg.clone()); + } else { + selection.cargo_args.push(arg.clone()); + } + } + } + } + if let Some(option) = pending_value { + return Err(if option == "--target" { + "--target requires a triple".to_owned() + } else { + format!("{option} requires a feature list") + }); + } + + let mut seen = BTreeSet::new(); + for feature in manifest_features.iter().chain(feature_values.iter()) { + for normalized in split_features(feature)? { + if seen.insert(normalized.clone()) { + selection.requested_features.push(normalized); + } + } + } + Ok(selection) + } + + fn set_target(&mut self, target: &str) -> Result<(), String> { + if target.trim().is_empty() { + return Err("--target requires a non-empty target triple".to_owned()); + } + if target == "native" { + return Err("`native` selects Cargo's host target; omit `--target`".to_owned()); + } + match &self.target { + Some(previous) if previous != target => { + Err("conflicting --target selections; refusing to silently choose one".to_owned()) + } + _ => { + self.target = Some(target.to_owned()); + Ok(()) + } + } + } +} + +/// Recognize only the exact generated Axum command base, never arbitrary shell text. +/// +/// # Examples +/// ``` +/// assert_eq!( +/// edgezero_adapter_axum::native_build::generated_axum_cargo_action("cargo build -p app"), +/// Some(("build", "app")), +/// ); +/// ``` +#[inline] +#[must_use] +pub fn generated_axum_cargo_action(command: &str) -> Option<(&'static str, &str)> { + let mut words = command.split_ascii_whitespace(); + let (Some("cargo"), Some(action_word), Some("-p"), Some(package), None) = ( + words.next(), + words.next(), + words.next(), + words.next(), + words.next(), + ) else { + return None; + }; + let action = match action_word { + "build" => "build", + "run" => "run", + _ => return None, + }; + if !valid_package_token(package) || command != format!("cargo {action} -p {package}") { + return None; + } + Some((action, package)) +} + +fn split_features(value: &str) -> Result, String> { + let values: Vec<_> = value + .split(|ch: char| ch == ',' || ch.is_ascii_whitespace()) + .filter(|feature| !feature.is_empty()) + .map(str::to_owned) + .collect(); + if values.is_empty() { + return Err("Cargo feature list must not be empty".to_owned()); + } + Ok(values) +} + +fn valid_package_token(package: &str) -> bool { + let mut bytes = package.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + first.is_ascii_alphanumeric() + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +#[cfg(test)] +mod tests { + use super::{NativeCargoSelection, generated_axum_cargo_action}; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn controls_after_separator_are_runtime_args() { + let selection = NativeCargoSelection::normalize_args( + &[], + &strings(&["--features", "compile", "--", "--features", "runtime"]), + ) + .expect("feature controls should parse"); + assert_eq!(selection.requested_features, strings(&["compile"])); + assert_eq!(selection.run_args, strings(&["--features", "runtime"])); + } + + #[test] + fn normalizes_repeated_features_and_preserves_cargo_controls() { + let selection = NativeCargoSelection::normalize_args( + &strings(&["aws-appconfig-agent", "shared"]), + &strings(&[ + "--features", + "shared,aws-secrets-manager", + "-F", + "logging extra\ttabbed", + "--features=metrics", + "-Fcache", + "--all-features", + "--no-default-features", + "--target", + "aarch64-unknown-linux-gnu", + "--locked", + ]), + ) + .expect("Cargo controls should normalize"); + assert_eq!( + selection.requested_features, + strings(&[ + "aws-appconfig-agent", + "shared", + "aws-secrets-manager", + "logging", + "extra", + "tabbed", + "metrics", + "cache", + ]) + ); + assert!(selection.all_features); + assert!(selection.no_default_features); + assert_eq!( + selection.target.as_deref(), + Some("aarch64-unknown-linux-gnu") + ); + assert_eq!( + selection.cargo_args(), + strings(&[ + "--all-features", + "--no-default-features", + "--target", + "aarch64-unknown-linux-gnu", + "--locked", + "--features", + "aws-appconfig-agent,shared,aws-secrets-manager,logging,extra,tabbed,metrics,cache", + ]) + ); + } + + #[test] + fn recognizes_only_exact_generated_package_commands() { + assert_eq!( + generated_axum_cargo_action("cargo build -p app_cli"), + Some(("build", "app_cli")) + ); + assert_eq!( + generated_axum_cargo_action("cargo run -p app-cli"), + Some(("run", "app-cli")) + ); + for command in [ + "cargo build -p app; touch /tmp/x", + "cargo build -p app | cat", + "cargo build -p 'app'", + "cargo build -p caf\u{e9}", + "cargo build -p app.extra", + "CARGO build -p app", + "cargo run -p app -- --help", + "cargo\tbuild -p app", + ] { + assert_eq!(generated_axum_cargo_action(command), None, "{command}"); + } + } + + #[test] + fn rejects_missing_features_and_invalid_or_conflicting_targets() { + assert_eq!( + NativeCargoSelection::normalize_args(&[], &strings(&["--features"])) + .expect_err("missing --features value must fail"), + "--features requires a feature list" + ); + assert_eq!( + NativeCargoSelection::normalize_args(&[], &strings(&["-F"])) + .expect_err("missing -F value must fail"), + "-F requires a feature list" + ); + assert_eq!( + NativeCargoSelection::normalize_args( + &[], + &strings(&[ + "--target", + "x86_64-unknown-linux-gnu", + "--target=aarch64-unknown-linux-gnu", + ]), + ) + .expect_err("conflicting targets must fail"), + "conflicting --target selections; refusing to silently choose one" + ); + assert_eq!( + NativeCargoSelection::normalize_args(&[], &strings(&["--target=native"])) + .expect_err("native is not a Cargo target triple"), + "`native` selects Cargo's host target; omit `--target`" + ); + } +} diff --git a/crates/edgezero-adapter-axum/src/native_stores.rs b/crates/edgezero-adapter-axum/src/native_stores.rs new file mode 100644 index 000000000..77758e0a1 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/native_stores.rs @@ -0,0 +1,452 @@ +//! Native per-ID provider inputs. Deferred opening belongs inside the future body. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "typed inputs and per-kind insertion methods precede source validation and preparation" +)] + +use std::collections::{BTreeMap, btree_map::Entry}; +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +use edgezero_core::app::StoresMetadata; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::store_registry::{BoundSecretStore, ConfigStoreBinding}; +#[cfg(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager"))] +use edgezero_core::time::MonotonicClock; +#[cfg(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager"))] +use edgezero_store_aws::AwsPreparation; +use edgezero_store_aws::settings::{ + AgentAccessToken, AppConfigAgentSettings, AwsPreparationLimits, SecretsManagerSettings, +}; + +use crate::config_store_limits::ConfigStoreLimits; +use crate::native_bindings::{ + NativeBindingSource, NativeConfigBinding, NativeProviderCapabilities, NativeSecretBinding, + NativeSourceSelection, NativeStoreBindings, NativeSuppliedIds, +}; +use crate::run_options::{AxumRunOptions, failure}; + +/// Closed preparation categories. Never wrap provider errors, settings or payloads. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum NativeStorePreparationError { + #[error("invalid binding")] + InvalidBinding, + #[error("duplicate binding")] + DuplicateBinding, + #[error("provider unavailable")] + Unavailable, + #[error("preparation deadline")] + Deadline, + #[error("size limit")] + SizeLimit, + #[error("malformed value")] + Malformed, +} + +/// Owned native setup. No `Send` bound; provider handles retain their core bounds. +pub type NativeStoreFuture = + Pin> + 'static>>; + +pub(crate) enum NativeStoreSource { + Local, + Ready(H), + Prepare(NativeStoreFuture), + BuiltIn(NativeBuiltInSource), +} + +pub(crate) enum NativeBuiltInSource { + #[cfg_attr( + not(feature = "aws-appconfig-agent"), + expect( + dead_code, + reason = "typed settings remain SDK-free; payload is read only by the enabled provider" + ) + )] + AppConfigAgent(AppConfigAgentSettings, Option), + #[cfg_attr( + not(feature = "aws-secrets-manager"), + expect( + dead_code, + reason = "typed settings remain SDK-free; payload is read only by the enabled provider" + ) + )] + SecretsManager(SecretsManagerSettings), +} + +#[cfg(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager"))] +pub(crate) type NativeAwsSession = Option; +#[cfg(not(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager")))] +pub(crate) struct NativeAwsSession; + +/// Move-only inputs, separate from cloneable hosting options. +/// +/// Deferred construction must be side-effect-free. The runner promises not to poll +/// setup until source validation succeeds; it cannot undo earlier caller-owned work. +#[derive(Default)] +pub struct NativeStoreOverrides { + config: BTreeMap>, + kv: BTreeMap>, + secrets: BTreeMap>, +} + +impl fmt::Debug for NativeStoreOverrides { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NativeStoreOverrides") + .finish_non_exhaustive() + } +} + +impl NativeStoreOverrides { + /// Supplies an existing config binding, including its own default key. + /// + /// # Errors + /// Rejects a second supplied input for the same config ID. + #[inline] + pub fn insert_config>( + &mut self, + id: Id, + binding: ConfigStoreBinding, + ) -> Result<(), NativeStorePreparationError> { + insert( + &mut self.config, + id.into(), + NativeStoreSource::Ready(binding), + ) + } + + /// Defers config opening until the runner has validated all sources. + /// + /// # Errors + /// Rejects a second supplied input for the same config ID. + #[inline] + pub fn prepare_config( + &mut self, + id: Id, + future: Setup, + ) -> Result<(), NativeStorePreparationError> + where + Id: Into, + Setup: Future> + 'static, + { + insert( + &mut self.config, + id.into(), + NativeStoreSource::Prepare(Box::pin(future)), + ) + } + + /// Supplies an already-open KV handle. + /// + /// # Errors + /// Rejects a second supplied input for the same KV ID. + #[inline] + pub fn insert_kv>( + &mut self, + id: Id, + handle: KvHandle, + ) -> Result<(), NativeStorePreparationError> { + insert(&mut self.kv, id.into(), NativeStoreSource::Ready(handle)) + } + + /// Defers KV opening until the runner has validated all sources. + /// + /// # Errors + /// Rejects a second supplied input for the same KV ID. + #[inline] + pub fn prepare_kv( + &mut self, + id: Id, + future: Setup, + ) -> Result<(), NativeStorePreparationError> + where + Id: Into, + Setup: Future> + 'static, + { + insert( + &mut self.kv, + id.into(), + NativeStoreSource::Prepare(Box::pin(future)), + ) + } + + /// Supplies a secret binding, preserving its explicit namespace. + /// + /// # Errors + /// Rejects a second supplied input for the same secret ID. + #[inline] + pub fn insert_secrets>( + &mut self, + id: Id, + binding: BoundSecretStore, + ) -> Result<(), NativeStorePreparationError> { + insert( + &mut self.secrets, + id.into(), + NativeStoreSource::Ready(binding), + ) + } + + /// Defers secret opening until the runner has validated all sources. + /// + /// # Errors + /// Rejects a second supplied input for the same secret ID. + #[inline] + pub fn prepare_secrets( + &mut self, + id: Id, + future: Setup, + ) -> Result<(), NativeStorePreparationError> + where + Id: Into, + Setup: Future> + 'static, + { + insert( + &mut self.secrets, + id.into(), + NativeStoreSource::Prepare(Box::pin(future)), + ) + } +} + +fn insert( + map: &mut BTreeMap>, + id: String, + source: NativeStoreSource, +) -> Result<(), NativeStorePreparationError> { + match map.entry(id) { + Entry::Vacant(entry) => { + entry.insert(source); + Ok(()) + } + Entry::Occupied(_) => Err(NativeStorePreparationError::DuplicateBinding), + } +} + +pub(crate) struct NativeSourcePlan { + pub config_limits: ConfigStoreLimits, + pub aws_limits: AwsPreparationLimits, + pub config: BTreeMap<&'static str, NativeStoreSource>, + pub kv: BTreeMap<&'static str, NativeStoreSource>, + pub secrets: BTreeMap<&'static str, NativeStoreSource>, +} + +impl NativeSourcePlan { + pub(crate) fn validate( + metadata: StoresMetadata, + options: &AxumRunOptions, + overrides: NativeStoreOverrides, + ) -> anyhow::Result { + options.validate_store_overlays(metadata)?; + let local_defaults = NativeStoreBindings::new(BTreeMap::new(), BTreeMap::new()); + let bindings = options.bindings().document().unwrap_or(&local_defaults); + let supplied = NativeSuppliedIds { + config: overrides.config.keys().cloned().collect(), + kv: overrides.kv.keys().cloned().collect(), + secrets: overrides.secrets.keys().cloned().collect(), + }; + let selection = NativeSourceSelection::validate( + metadata, + bindings, + &supplied, + options.env_config(), + NativeProviderCapabilities { + appconfig_agent: cfg!(feature = "aws-appconfig-agent"), + secrets_manager: cfg!(feature = "aws-secrets-manager"), + }, + options.bindings_limits(), + options.aws_limits(), + ) + .map_err(|category| failure("sources", "bindings", &category.to_string()))?; + for binding in bindings.config().values() { + let NativeConfigBinding::AppConfigAgent(settings) = binding; + if let Some(name) = &settings.access_token_env { + options.agent_token(name)?; + } + } + let plan = Self { + config_limits: ConfigStoreLimits::from_env(options.env_config())?, + aws_limits: bindings + .validate(options.bindings_limits(), options.aws_limits()) + .map_err(|category| failure("sources", "bindings", &category.to_string()))?, + config: resolve(selection.config, overrides.config, "config", |id| { + let NativeConfigBinding::AppConfigAgent(settings) = bindings + .config() + .get(id) + .ok_or_else(|| failure("config", id, "invalid binding"))?; + let token = settings + .access_token_env + .as_ref() + .map(|name| options.agent_token(name)) + .transpose()?; + Ok(NativeBuiltInSource::AppConfigAgent(settings.clone(), token)) + })?, + kv: resolve(selection.kv, overrides.kv, "kv", |id| { + Err(failure("kv", id, "invalid binding")) + })?, + secrets: resolve(selection.secrets, overrides.secrets, "secrets", |id| { + let NativeSecretBinding::SecretsManager(settings) = bindings + .secrets() + .get(id) + .ok_or_else(|| failure("secrets", id, "invalid binding"))?; + Ok(NativeBuiltInSource::SecretsManager(settings.clone())) + })?, + }; + options.validate_local_roots( + plan.config + .values() + .any(|source| matches!(source, NativeStoreSource::Local)), + plan.kv + .values() + .any(|source| matches!(source, NativeStoreSource::Local)), + )?; + Ok(plan) + } + + #[cfg_attr( + not(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager")), + expect( + clippy::unnecessary_wraps, + reason = "the same fallible session boundary is used when native services are compiled" + ) + )] + pub(crate) fn session(&self) -> anyhow::Result { + #[cfg(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager"))] + { + let selected = self + .config + .values() + .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))) + || self + .secrets + .values() + .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))); + if selected { + return AwsPreparation::new(MonotonicClock::default(), self.aws_limits) + .map(Some) + .map_err(|category| failure("sources", "bindings", &category.to_string())); + } + Ok(None) + } + #[cfg(not(any(feature = "aws-appconfig-agent", feature = "aws-secrets-manager")))] + { + let _: AwsPreparationLimits = self.aws_limits; + Ok(NativeAwsSession) + } + } +} + +fn resolve anyhow::Result>( + selection: BTreeMap<&'static str, NativeBindingSource>, + mut supplied: BTreeMap>, + kind: &str, + builtin: BuiltIn, +) -> anyhow::Result>> { + selection + .into_iter() + .map(|(id, selected)| { + let source = match selected { + NativeBindingSource::Local => NativeStoreSource::Local, + NativeBindingSource::Supplied => supplied + .remove(id) + .ok_or_else(|| failure(kind, id, "invalid binding"))?, + NativeBindingSource::BuiltIn => NativeStoreSource::BuiltIn(builtin(id)?), + }; + Ok((id, source)) + }) + .collect() +} + +pub(crate) async fn prepare( + source: NativeStoreSource, + kind: &str, + id: &str, + local: Open, +) -> anyhow::Result +where + Open: FnOnce() -> anyhow::Result, +{ + match source { + NativeStoreSource::Local => local(), + NativeStoreSource::Ready(handle) => Ok(handle), + NativeStoreSource::Prepare(future) => future + .await + .map_err(|category| failure(kind, id, &category.to_string())), + NativeStoreSource::BuiltIn(_) => Err(failure(kind, id, "provider not compiled")), + } +} + +#[cfg_attr( + not(feature = "aws-appconfig-agent"), + expect( + clippy::unused_async, + reason = "compiled-out provider retains the same preparation future boundary" + ) +)] +pub(crate) async fn prepare_aws_config( + builtin: NativeBuiltInSource, + session: &mut NativeAwsSession, + id: &str, +) -> anyhow::Result { + #[cfg(feature = "aws-appconfig-agent")] + { + match builtin { + NativeBuiltInSource::AppConfigAgent(settings, token) => { + let handle = session + .as_mut() + .ok_or_else(|| failure("config", id, "invalid preparation session"))? + .prepare_appconfig(&settings, token.as_ref()) + .await + .map_err(|category| failure("config", id, &category.to_string()))?; + Ok(ConfigStoreBinding { + default_key: settings.default_key, + handle, + }) + } + NativeBuiltInSource::SecretsManager(_) => Err(failure("config", id, "invalid binding")), + } + } + #[cfg(not(feature = "aws-appconfig-agent"))] + { + let _: (NativeBuiltInSource, &mut NativeAwsSession) = (builtin, session); + Err(failure("config", id, "provider not compiled")) + } +} + +#[cfg_attr( + not(feature = "aws-secrets-manager"), + expect( + clippy::unused_async, + reason = "compiled-out provider retains the same preparation future boundary" + ) +)] +pub(crate) async fn prepare_aws_secrets( + builtin: NativeBuiltInSource, + session: &mut NativeAwsSession, + id: &str, +) -> anyhow::Result { + #[cfg(feature = "aws-secrets-manager")] + { + match builtin { + NativeBuiltInSource::SecretsManager(settings) => { + let handle = session + .as_mut() + .ok_or_else(|| failure("secrets", id, "invalid preparation session"))? + .prepare_secrets(&settings, id) + .await + .map_err(|category| failure("secrets", id, &category.to_string()))?; + Ok(BoundSecretStore::new(handle, id.to_owned())) + } + NativeBuiltInSource::AppConfigAgent(_, _) => { + Err(failure("secrets", id, "invalid binding")) + } + } + } + #[cfg(not(feature = "aws-secrets-manager"))] + { + let _: (NativeBuiltInSource, &mut NativeAwsSession) = (builtin, session); + Err(failure("secrets", id, "provider not compiled")) + } +} diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs new file mode 100644 index 000000000..64ddb8efd --- /dev/null +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -0,0 +1,931 @@ +use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use std::env::{var_os, vars_os}; +use std::ffi::OsString; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::str::FromStr as _; +use std::time::{Duration, Instant}; + +use edgezero_core::addr; +use edgezero_core::app::StoresMetadata; +use edgezero_core::env_config::EnvConfig; +use edgezero_store_aws::settings::{AgentAccessToken, AwsPreparationLimits}; +use log::LevelFilter; + +use crate::native_bindings::{ + NativeBindingsLimits, NativeConfigBinding, NativeStoreBindings, NativeStoreBindingsInput, +}; + +/// Validated explicit production hosting options. Construction never selects a +/// policy from build profile, cwd or container detection. Explicit constructors +/// do not merge environment values; only `from_env` reads hosting environment. +#[derive(Clone)] +pub struct AxumRunOptions { + addr: SocketAddr, + aws_limits: Option, + bindings: NativeStoreBindingsInput, + bindings_limits: NativeBindingsLimits, + bootstrap_values: BTreeMap, + config_dir: Option, + data_dir: Option, + env: EnvConfig, + grace: Duration, + level: LevelFilter, + non_unicode_overrides: Vec>, +} + +#[derive(Clone)] +enum BootstrapValue { + Ambiguous, + Value(String), +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "scalar validation precedes local-root validation; compatibility constructors remain grouped" +)] +impl AxumRunOptions { + pub(crate) fn addr(&self) -> SocketAddr { + self.addr + } + + pub(crate) fn bindings(&self) -> &NativeStoreBindingsInput { + &self.bindings + } + + pub(crate) fn bindings_limits(&self) -> NativeBindingsLimits { + self.bindings_limits + } + + pub(crate) fn aws_limits(&self) -> Option { + self.aws_limits + } + + pub(crate) fn agent_token(&self, name: &str) -> anyhow::Result { + let value = match self.bootstrap_values.get(name) { + Some(BootstrapValue::Value(value)) => value.clone(), + Some(BootstrapValue::Ambiguous) => { + return Err(failure( + "settings", + "Agent token", + "duplicate configured value", + )); + } + None => { + return Err(failure( + "settings", + "Agent token", + "configured value absent", + )); + } + }; + AgentAccessToken::new(value) + .map_err(|_category| failure("settings", "Agent token", "invalid configured value")) + } + + pub(crate) fn config_dir(&self) -> Option<&Path> { + self.config_dir.as_deref() + } + + pub(crate) fn data_dir(&self) -> Option<&Path> { + self.data_dir.as_deref() + } + + pub(crate) fn env_config(&self) -> &EnvConfig { + &self.env + } + + /// Captures hosting settings and store overrides once. Secret values still + /// use the existing environment-backed secret backend. + /// + /// # Errors + /// Returns safe setting categories for supplied invalid hosting settings. + #[inline] + pub fn from_env() -> anyhow::Result { + let mut pairs = Vec::new(); + let mut non_unicode = Vec::new(); + for (os_key, os_value) in vars_os() { + let Some(key) = os_key.to_str() else { continue }; + let Some(rest) = key.strip_prefix("EDGEZERO__") else { + continue; + }; + let path: Vec = rest.split("__").map(str::to_ascii_lowercase).collect(); + if let Some(value) = os_value.to_str() { + pairs.push((key.to_owned(), value.to_owned())); + } else if let Some(setting) = fixed_setting(&path) { + return Err(failure("settings", setting, "non-Unicode value")); + } else { + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + if matches!( + segments.as_slice(), + ["stores", "config" | "kv" | "secrets", _, "name" | "key"] + ) { + non_unicode.push(path); + } + } + } + let mut options = Self::from_vars(pairs)?; + options.non_unicode_overrides = non_unicode; + // Only this env-loading boundary may resolve named token variables. + // Explicit constructors and selection methods never call var_os. + options.capture_agent_tokens(|name| var_os(name))?; + Ok(options) + } + + fn capture_agent_tokens(&mut self, mut lookup: Lookup) -> anyhow::Result<()> + where + Lookup: FnMut(&str) -> Option, + { + let names: BTreeSet = self + .bindings + .document() + .into_iter() + .flat_map(|document| document.config().values()) + .filter_map(|binding| { + let NativeConfigBinding::AppConfigAgent(settings) = binding; + settings.access_token_env.clone() + }) + .collect(); + for name in names { + let value = lookup(&name) + .and_then(|value| value.into_string().ok()) + .ok_or_else(|| { + failure( + "settings", + "Agent token", + "configured value absent or non-Unicode", + ) + })?; + AgentAccessToken::new(value.clone()).map_err(|_category| { + failure("settings", "Agent token", "invalid configured value") + })?; + self.bootstrap_values + .insert(name, BootstrapValue::Value(value)); + } + Ok(()) + } + + /// Constructs options using only supplied string pairs and omission defaults. + /// Declared-store overrides are validated against baked metadata at startup. + /// + /// # Errors + /// Returns safe setting categories for invalid bind, logging, roots or grace. + #[inline] + pub fn from_vars(vars: I) -> anyhow::Result + where + I: IntoIterator, + K: AsRef, + V: Into, + { + let pairs: Vec<(String, String)> = vars + .into_iter() + .map(|(key, value)| (key.as_ref().to_owned(), value.into())) + .collect(); + let mut seen = BTreeSet::new(); + for (key, _value) in &pairs { + if let Some(rest) = key.strip_prefix("EDGEZERO__") { + let path: Vec = rest.split("__").map(str::to_ascii_lowercase).collect(); + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + if (fixed_setting(&path).is_some() + || matches!( + segments.as_slice(), + ["stores", "config" | "kv" | "secrets", _, "name" | "key"] + )) + && !seen.insert(path) + { + return Err(failure( + "settings", + "bootstrap", + "duplicate normalized setting", + )); + } + } + } + let env = EnvConfig::from_vars(pairs.iter().map(|(key, value)| (key, value.clone()))); + let host = env.adapter_host().map_or(Ok(addr::DEFAULT_HOST), |raw| { + raw.parse() + .map_err(|_error| failure("settings", "HOST", "invalid IP literal")) + })?; + let port = env.adapter_port().map_or(Ok(addr::DEFAULT_PORT), |raw| { + raw.parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| failure("settings", "PORT", "invalid nonzero port")) + })?; + let mut options = Self::new(SocketAddr::new(host, port))?; + if let Some(level) = env.logging_level() { + options.level = LevelFilter::from_str(level) + .map_err(|_error| failure("settings", "LOGGING__LEVEL", "invalid level"))?; + } + options.grace = shutdown_grace(&env)?; + if let Some(root) = env.get(&["adapter", "config_dir"]) { + options = options.with_config_dir(root)?; + } + if let Some(root) = env.get(&["adapter", "data_dir"]) { + options = options.with_data_dir(root)?; + } + options.env = env; + for (key, value) in pairs { + match options.bootstrap_values.entry(key) { + Entry::Vacant(entry) => { + entry.insert(BootstrapValue::Value(value)); + } + Entry::Occupied(mut entry) => { + entry.insert(BootstrapValue::Ambiguous); + } + } + } + if let Some(path) = options.env.get(&["store_bindings_file"]).map(str::to_owned) { + options = options.with_store_bindings_file(path)?; + } + Ok(options) + } + + pub(crate) fn logging_level(&self) -> LevelFilter { + self.level + } + + /// Explicit bind with default grace/logging, no roots, and no store overrides. + /// + /// # Errors + /// Rejects port zero. Production omission defaults apply only to omitted values. + #[inline] + pub fn new(addr: SocketAddr) -> anyhow::Result { + if addr.port() == 0 { + return Err(failure("settings", "PORT", "invalid nonzero port")); + } + Ok(Self { + bindings: NativeStoreBindingsInput::LocalDefaults, + bindings_limits: NativeBindingsLimits::default(), + aws_limits: None, + bootstrap_values: BTreeMap::new(), + addr, + config_dir: None, + data_dir: None, + env: EnvConfig::default(), + grace: Duration::from_secs(10), + level: LevelFilter::Info, + non_unicode_overrides: Vec::new(), + }) + } + + pub(crate) fn shutdown_grace(&self) -> Duration { + self.grace + } + + #[cfg(test)] + fn validate_stores(&self, metadata: StoresMetadata) -> anyhow::Result<()> { + self.validate_store_overlays(metadata)?; + self.validate_local_roots(metadata.config.is_some(), metadata.kv.is_some()) + } + + pub(crate) fn validate_store_overlays(&self, metadata: StoresMetadata) -> anyhow::Result<()> { + for (kind, declaration) in [ + ("config", metadata.config), + ("kv", metadata.kv), + ("secrets", metadata.secrets), + ] { + if let Some(meta) = declaration { + for id in meta.ids { + for field in ["name", "key"] { + if self + .non_unicode_overrides + .iter() + .any(|path| match path.as_slice() { + [_, selected_kind, selected_id, selected_field] => { + selected_kind == kind + && selected_id.eq_ignore_ascii_case(id) + && selected_field == field + } + _ => false, + }) + { + return Err(failure("settings", id, "non-Unicode store override")); + } + if self.env.store_setting(kind, id, field).is_some_and(|raw| { + raw.is_empty() + || raw.chars().all(char::is_whitespace) + || raw.chars().any(char::is_control) + }) { + return Err(failure("settings", id, "invalid store NAME/KEY override")); + } + } + } + } + } + Ok(()) + } + + pub(crate) fn validate_local_roots(&self, config: bool, kv: bool) -> anyhow::Result<()> { + for (required, root, label) in [ + (config, self.config_dir.as_deref(), "CONFIG_DIR"), + (kv, self.data_dir.as_deref(), "DATA_DIR"), + ] { + if required && root.is_none() { + return Err(failure("settings", label, "required for declared stores")); + } + if let Some(directory) = root { + validate_root(directory, label)?; + } + } + Ok(()) + } + + /// Selects typed bindings. A second explicit selection is an error. + /// + /// # Errors + /// Rejects duplicate selection and invalid settings/policy sources. + #[inline] + pub fn with_store_bindings(mut self, bindings: NativeStoreBindings) -> anyhow::Result { + if !matches!(self.bindings, NativeStoreBindingsInput::LocalDefaults) { + return Err(failure("settings", "bindings", "duplicate selection")); + } + bindings + .validate(self.bindings_limits, self.aws_limits) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + self.bindings = NativeStoreBindingsInput::Inline(bindings); + Ok(self) + } + + /// Captures one absolute bindings file once. Explicit selection does not read environment values. + /// + /// # Errors + /// Rejects duplicate/relative/missing/malformed/oversized selections. + #[inline] + pub fn with_store_bindings_file>( + mut self, + locator: Root, + ) -> anyhow::Result { + if !matches!(self.bindings, NativeStoreBindingsInput::LocalDefaults) { + return Err(failure("settings", "bindings", "duplicate selection")); + } + let path = locator.into(); + let bindings = + NativeStoreBindings::read_with_policy(&path, self.bindings_limits, self.aws_limits) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + bindings + .validate(self.bindings_limits, self.aws_limits) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + self.bindings = NativeStoreBindingsInput::File { path, bindings }; + Ok(self) + } + + /// Supplies named bootstrap values explicitly, never merging ambient variables. + /// + /// # Errors + /// Rejects duplicate variable inputs rather than selecting precedence. + #[inline] + pub fn with_bootstrap_values(mut self, values: I) -> anyhow::Result + where + I: IntoIterator, + K: Into, + V: Into, + { + for (name, value) in values { + if self + .bootstrap_values + .insert(name.into(), BootstrapValue::Value(value.into())) + .is_some() + { + return Err(failure("settings", "bootstrap", "duplicate variable")); + } + } + Ok(self) + } + + /// Selects trusted file/ID bounds before selecting a document. + /// + /// # Errors + /// Rejects invalid bounds or policy changes after a file/inline selection. + #[inline] + pub fn with_native_bindings_limits( + mut self, + limits: NativeBindingsLimits, + ) -> anyhow::Result { + if !matches!(self.bindings, NativeStoreBindingsInput::LocalDefaults) { + return Err(failure( + "settings", + "bindings", + "limits must precede selection", + )); + } + limits + .validate() + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + self.bindings_limits = limits; + Ok(self) + } + + /// Selects one explicit remote preparation policy, not an override/merge of file policies. + /// + /// # Errors + /// Rejects duplicate policies, competing document policies and invalid limits. + #[inline] + pub fn with_aws_preparation_limits( + mut self, + limits: AwsPreparationLimits, + ) -> anyhow::Result { + if self.aws_limits.is_some() { + return Err(failure("settings", "AWS limits", "duplicate policy")); + } + limits + .validate() + .map_err(|category| failure("settings", "AWS limits", &category.to_string()))?; + if let Some(document) = self.bindings.document() { + document + .validate(self.bindings_limits, Some(limits)) + .map_err(|category| failure("settings", "AWS limits", &category.to_string()))?; + } + self.aws_limits = Some(limits); + Ok(self) + } + + /// Selects an existing absolute config root; never creates it. + /// + /// # Errors + /// Rejects relative, missing or non-directory roots. + #[inline] + pub fn with_config_dir>(mut self, root: Root) -> anyhow::Result { + let directory = root.into(); + validate_root(&directory, "CONFIG_DIR")?; + self.config_dir = Some(directory); + Ok(self) + } + + /// Selects an existing absolute data root. Startup also checks actual write access. + /// + /// # Errors + /// Rejects relative, missing or non-directory roots. + #[inline] + pub fn with_data_dir>(mut self, root: Root) -> anyhow::Result { + let directory = root.into(); + validate_root(&directory, "DATA_DIR")?; + self.data_dir = Some(directory); + Ok(self) + } + + #[inline] + #[must_use] + pub fn with_logging_level(mut self, level: LevelFilter) -> Self { + self.level = level; + self + } + + /// Selects a positive, representable integer-second drain budget. + /// + /// # Errors + /// Rejects zero, fractional seconds and unusable native timer values. + #[inline] + pub fn with_shutdown_grace(mut self, grace: Duration) -> anyhow::Result { + validate_grace(grace)?; + self.grace = grace; + Ok(self) + } +} + +pub(crate) fn failure(stage: &str, subject: &str, category: &str) -> anyhow::Error { + // No source chain survives this boundary. Subject is a fixed setting name or + // baked logical id, never settings/config/secret contents or a provider string. + anyhow::anyhow!("startup {stage}: {subject}: {category}") +} + +fn fixed_setting(path: &[String]) -> Option<&'static str> { + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + match segments.as_slice() { + ["adapter", "host"] => Some("HOST"), + ["adapter", "port"] => Some("PORT"), + ["adapter", "config_dir"] => Some("CONFIG_DIR"), + ["adapter", "data_dir"] => Some("DATA_DIR"), + ["adapter", "shutdown_grace_seconds"] => Some("SHUTDOWN_GRACE_SECONDS"), + ["logging", "level"] => Some("LOGGING__LEVEL"), + ["store_bindings_file"] => Some("STORE_BINDINGS_FILE"), + _ => None, + } +} + +pub(crate) fn shutdown_grace(env: &EnvConfig) -> anyhow::Result { + let Some(raw) = env.get(&["adapter", "shutdown_grace_seconds"]) else { + return Ok(Duration::from_secs(10)); + }; + let seconds = raw + .parse::() + .ok() + .filter(|_seconds| raw.bytes().all(|byte| byte.is_ascii_digit())) + .ok_or_else(|| { + failure( + "settings", + "SHUTDOWN_GRACE_SECONDS", + "invalid positive integer seconds", + ) + })?; + let grace = Duration::from_secs(seconds); + validate_grace(grace)?; + Ok(grace) +} + +fn validate_grace(grace: Duration) -> anyhow::Result<()> { + if grace.is_zero() || grace.subsec_nanos() != 0 || Instant::now().checked_add(grace).is_none() { + return Err(failure( + "settings", + "SHUTDOWN_GRACE_SECONDS", + "invalid or unusable positive integer seconds", + )); + } + Ok(()) +} + +fn validate_root(root: &Path, setting: &str) -> anyhow::Result<()> { + if !root.is_absolute() || !root.is_dir() { + return Err(failure( + "settings", + setting, + "requires an absolute existing directory", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use edgezero_core::app::StoreMetadata; + + const BINDINGS: &str = "version = 1\n[config.app]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\naccess_token_env = 'AGENT_TOKEN'\n[config.app.documents.settings]\napplication = 'example'\nenvironment = 'production'\nprofile = 'envelope'\n"; + + fn explicit() -> AxumRunOptions { + AxumRunOptions::new(SocketAddr::from(([127, 0, 0, 1], 1234))).expect("explicit options") + } + + #[test] + fn file_selection_is_captured_once_and_conflicts_do_not_read_a_second_file() { + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + fs::write(&path, BINDINGS).expect("write bindings"); + let options = explicit() + .with_store_bindings_file(&path) + .expect("selected file"); + fs::remove_file(&path).expect("remove file after capture"); + assert_eq!( + options + .bindings() + .document() + .expect("captured") + .config() + .len(), + 1 + ); + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("typed bindings"); + assert!( + options + .clone() + .with_store_bindings(document) + .err() + .expect("duplicate") + .to_string() + .contains("duplicate") + ); + assert!( + options + .with_store_bindings_file(path) + .err() + .expect("duplicate file") + .to_string() + .contains("duplicate") + ); + assert!( + explicit() + .with_store_bindings_file("relative.toml") + .is_err() + ); + } + + #[test] + fn explicit_bootstrap_values_do_not_consult_ambient_variables() { + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("bindings"); + let unconfigured = explicit().with_store_bindings(document).expect("inline"); + let missing = unconfigured + .agent_token("AGENT_TOKEN") + .expect_err("missing token"); + assert_eq!(missing.chain().count(), 1); + let options = unconfigured + .with_bootstrap_values([("AGENT_TOKEN", "TOKEN_SENTINEL")]) + .expect("supplied token"); + assert_eq!( + options + .agent_token("AGENT_TOKEN") + .expect("captured") + .expose(), + "TOKEN_SENTINEL" + ); + assert!(!format!("{:?}", options.agent_token("AGENT_TOKEN")).contains("SENTINEL")); + assert!( + options + .with_bootstrap_values([("AGENT_TOKEN", "OTHER_SENTINEL")]) + .is_err() + ); + } + + #[test] + fn explicit_vars_capture_locator_and_named_values_without_reopening() { + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + fs::write(&path, BINDINGS).expect("write bindings"); + let options = AxumRunOptions::from_vars([ + ( + "EDGEZERO__STORE_BINDINGS_FILE", + path.to_str().expect("path"), + ), + ("AGENT_TOKEN", "TOKEN_SENTINEL"), + ]) + .expect("supplied variables"); + fs::remove_file(path).expect("remove"); + assert!(options.bindings().document().is_some()); + assert_eq!( + options.agent_token("AGENT_TOKEN").expect("token").expose(), + "TOKEN_SENTINEL" + ); + for (first, second) in [ + ( + "EDGEZERO__STORE_BINDINGS_FILE", + "EDGEZERO__store_bindings_file", + ), + ( + "EDGEZERO__STORES__CONFIG__APP__NAME", + "EDGEZERO__stores__config__app__name", + ), + ("EDGEZERO__ADAPTER__PORT", "EDGEZERO__adapter__port"), + ] { + let error = AxumRunOptions::from_vars([(first, "SENTINEL"), (second, "SENTINEL")]) + .err() + .expect("duplicate normalized path"); + assert!(error.to_string().contains("duplicate")); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + #[test] + fn duplicate_unrelated_vars_keep_existing_env_semantics_but_tokens_fail_closed() { + let options = AxumRunOptions::from_vars([ + ("EDGEZERO__UNRELATED", "first"), + ("EDGEZERO__UNRELATED", "second"), + ("AGENT_TOKEN", "FIRST_SENTINEL"), + ("AGENT_TOKEN", "SECOND_SENTINEL"), + ]) + .expect("unrelated env keys retain existing semantics"); + assert_eq!(options.env.get(&["unrelated"]), Some("second")); + let error = options + .agent_token("AGENT_TOKEN") + .expect_err("ambiguous token"); + assert!(error.to_string().contains("duplicate")); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + + #[test] + fn named_token_capture_is_once_per_variable_and_rejects_missing_empty() { + let duplicate = format!( + "{BINDINGS}\n{}", + BINDINGS + .replace("version = 1\n", "") + .replace("config.app", "config.other") + ); + let document = NativeStoreBindings::parse(&duplicate, NativeBindingsLimits::default()) + .expect("shared token bindings"); + let options = explicit().with_store_bindings(document).expect("inline"); + let mut captured = options.clone(); + let mut calls = 0_usize; + captured + .capture_agent_tokens(|name| { + assert_eq!(name, "AGENT_TOKEN"); + calls += 1; + Some(OsString::from("TOKEN_SENTINEL")) + }) + .expect("capture"); + assert_eq!(calls, 1); + for value in [None, Some(OsString::new())] { + let error = options + .clone() + .capture_agent_tokens(|_name| value.clone()) + .expect_err("configured token invalid"); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + #[cfg(unix)] + #[test] + fn non_unicode_configured_token_fails_closed() { + use std::os::unix::ffi::OsStringExt as _; + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("bindings"); + let mut options = explicit().with_store_bindings(document).expect("inline"); + let error = options + .capture_agent_tokens(|_name| Some(OsString::from_vec(vec![0xff]))) + .expect_err("non-Unicode token"); + assert_eq!(error.chain().count(), 1); + assert!(error.to_string().contains("non-Unicode")); + } + + #[test] + fn trusted_caps_and_remote_policy_sources_are_explicit() { + let document = NativeStoreBindings::parse(BINDINGS, NativeBindingsLimits::default()) + .expect("bindings"); + let options = explicit().with_store_bindings(document).expect("inline"); + assert!( + options + .with_native_bindings_limits(NativeBindingsLimits::default()) + .is_err() + ); + let with_policy = NativeStoreBindings::parse( + &format!("{BINDINGS}\n[limits.aws]\nmax_snapshot_bytes = 16777216"), + NativeBindingsLimits::default(), + ) + .expect("policy"); + assert!( + explicit() + .with_aws_preparation_limits(AwsPreparationLimits::default()) + .expect("explicit policy") + .with_store_bindings(with_policy.clone()) + .is_err() + ); + assert!( + explicit() + .with_store_bindings(with_policy) + .expect("file policy") + .with_aws_preparation_limits(AwsPreparationLimits::default()) + .is_err() + ); + } + + #[test] + fn env_bootstrap_child() { + use std::env; + + let Some(expected) = env::var_os("EDGEZERO_NATIVE_BOOTSTRAP_TEST") else { + return; + }; + if expected == "valid" { + let options = AxumRunOptions::from_env().expect("env token capture"); + assert_eq!( + options + .agent_token("AGENT_TOKEN") + .expect("captured token") + .expose(), + "TOKEN_SENTINEL" + ); + let path = env::var_os("EDGEZERO__STORE_BINDINGS_FILE").expect("locator"); + fs::write( + PathBuf::from(path), + BINDINGS.replace("AGENT_TOKEN", "OTHER_SENTINEL"), + ) + .expect("change file after capture"); + let NativeConfigBinding::AppConfigAgent(settings) = options + .bindings() + .document() + .expect("captured document") + .config() + .get("app") + .expect("captured binding"); + assert_eq!(settings.access_token_env.as_deref(), Some("AGENT_TOKEN")); + assert_eq!( + options + .agent_token("AGENT_TOKEN") + .expect("retained token") + .expose(), + "TOKEN_SENTINEL" + ); + } else { + let error = AxumRunOptions::from_env() + .err() + .expect("invalid env bootstrap"); + assert_eq!(error.chain().count(), 1); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + } + + #[cfg(unix)] + #[test] + fn real_env_entrypoint_captures_named_tokens_and_rejects_non_unicode_inputs() { + use std::env::current_exe; + use std::os::unix::ffi::OsStringExt as _; + use std::process::Command; + + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + for (token, valid) in [ + (Some(OsString::from("TOKEN_SENTINEL")), true), + (None, false), + (Some(OsString::new()), false), + (Some(OsString::from_vec(vec![0xff])), false), + ] { + fs::write(&path, BINDINGS).expect("fresh bindings"); + let mut command = Command::new(current_exe().expect("test binary")); + command + .env_clear() + .arg("--exact") + .arg("run_options::tests::env_bootstrap_child") + .env( + "EDGEZERO_NATIVE_BOOTSTRAP_TEST", + if valid { "valid" } else { "invalid" }, + ) + .env("EDGEZERO__STORE_BINDINGS_FILE", &path); + if let Some(value) = token { + command.env("AGENT_TOKEN", value); + } + let output = command.output().expect("isolated env fixture"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("SENTINEL")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("SENTINEL")); + } + let output = Command::new(current_exe().expect("binary")) + .env_clear() + .arg("--exact") + .arg("run_options::tests::env_bootstrap_child") + .env("EDGEZERO_NATIVE_BOOTSTRAP_TEST", "invalid") + .env( + "EDGEZERO__STORE_BINDINGS_FILE", + OsString::from_vec(vec![0xff]), + ) + .output() + .expect("non-Unicode locator fixture"); + assert!(output.status.success()); + } + + #[test] + fn defaults_and_invalid_supplied_scalars_are_distinct() { + let options = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); + assert_eq!( + options.addr, + SocketAddr::from((addr::DEFAULT_HOST, addr::DEFAULT_PORT)) + ); + assert_eq!(options.grace, Duration::from_secs(10)); + for (key, values) in [ + ( + "EDGEZERO__ADAPTER__HOST", + vec!["sentinel-host", "", "localhost"], + ), + ( + "EDGEZERO__ADAPTER__PORT", + vec!["0", "65536", "sentinel-port", ""], + ), + ("EDGEZERO__LOGGING__LEVEL", vec!["sentinel-level", ""]), + ( + "EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", + vec![ + "0", + "", + "-1", + "+1", + "sentinel-duration", + "18446744073709551615", + ], + ), + ] { + for value in values { + let error = AxumRunOptions::from_vars([(key, value)]) + .err() + .expect("invalid"); + assert!(error.chain().count() == 1); + assert!(!format!("{error:?}").contains("sentinel")); + } + } + } + + #[test] + fn selected_override_validation_precedes_normalization() { + let metadata = StoresMetadata { + config: Some(StoreMetadata { + ids: &["settings"], + default: "settings", + }), + ..StoresMetadata::default() + }; + let root = tempfile::tempdir().expect("root"); + for value in ["", " ", "sentinel\n"] { + let options = AxumRunOptions::from_vars([ + ( + "EDGEZERO__ADAPTER__CONFIG_DIR", + root.path().to_str().expect("path"), + ), + ("EDGEZERO__STORES__CONFIG__SETTINGS__KEY", value), + ("EDGEZERO__STORES__KV__UNDECLARED__NAME", "\n"), + ]) + .expect("native settings"); + let error = options + .validate_stores(metadata) + .expect_err("declared invalid override"); + assert!(!format!("{error:?}").contains("sentinel")); + } + let options = + AxumRunOptions::new(SocketAddr::from(([127, 0, 0, 1], 1234))).expect("explicit"); + assert!(options.env.adapter_host().is_none()); + assert_eq!(options.env.store_key("config", "settings"), "settings"); + assert!(options.validate_stores(metadata).is_err()); + } +} diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 4f5925afa..44d61034d 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -17,6 +17,7 @@ use edgezero_core::ingress::{ IngressHeadParts, validate_normalized_ingress_parts, }; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::probe::{LifecyclePhase, LifecycleReader}; #[cfg(test)] use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; @@ -26,6 +27,7 @@ use edgezero_core::store_registry::{ use edgezero_core::time::MonotonicInstant; use hyper::body::Incoming; use hyper::service::Service as HyperService; +use tokio::sync::watch; #[cfg(test)] use tower::Service as TowerService; @@ -33,6 +35,7 @@ use tower::Service as TowerService; use crate::connection::{ConnectionExit, serve_http1}; #[cfg(test)] use crate::ingress_config::AxumIngressConfig; +#[cfg(test)] use crate::outbound::AxumOutboundClient; use crate::request::into_core_request_parts; use crate::response::{AxumEgressBody, EgressConnection, prepare_egress_response}; @@ -51,6 +54,7 @@ pub(crate) struct AxumServiceState { kv_handle: Option, kv_registry: Option, outbound_transport: Option>, + phase: Option>, secret_handle: Option, secret_registry: Option, } @@ -63,14 +67,23 @@ impl AxumServiceState { /// Creates a service that preserves all policies configured on `App`. #[must_use] #[inline] + #[cfg(test)] pub fn from_app(app: App) -> Self { + Self::with_transport( + app, + AxumOutboundClient::try_transport().expect("test transport"), + ) + } + + pub(crate) fn with_transport(app: App, transport: Arc) -> Self { Self { app: Arc::new(app), config_registry: None, config_store_handle: None, kv_handle: None, kv_registry: None, - outbound_transport: AxumOutboundClient::try_transport().ok(), + outbound_transport: Some(transport), + phase: None, secret_handle: None, secret_registry: None, } @@ -103,6 +116,7 @@ impl AxumServiceState { /// directly. #[must_use] #[inline] + #[cfg(test)] pub fn with_config_store_handle(mut self, handle: ConfigStoreHandle) -> Self { self.config_store_handle = Some(handle); self @@ -120,6 +134,7 @@ impl AxumServiceState { /// directly. #[must_use] #[inline] + #[cfg(test)] pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { self.kv_handle = Some(handle); self @@ -146,6 +161,7 @@ impl AxumServiceState { /// [`Self::with_secret_registry`] directly. #[must_use] #[inline] + #[cfg(test)] pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { self.secret_handle = Some(handle); self @@ -159,6 +175,11 @@ impl AxumServiceState { self } + pub(crate) fn with_phase(mut self, phase: watch::Receiver) -> Self { + self.phase = Some(phase); + self + } + pub(crate) fn for_connection( &self, remote_addr: SocketAddr, @@ -207,12 +228,23 @@ impl AxumServiceState { (config_registry, kv_registry, secret_registry) } + fn admission_open(&self) -> bool { + self.phase + .as_ref() + .is_none_or(|phase| *phase.borrow() == LifecyclePhase::Ready) + } + async fn dispatch_request( &self, mut request: Request, connection: &EgressConnection, remote_addr: Option, ) -> Result, AxumIngressAbort> { + // This private gate defines dispatched work. Public extensions and app + // admission policy cannot admit new work after the owner starts draining. + if !self.admission_open() { + return Err(AxumIngressAbort); + } if let Some(peer_addr) = remote_addr { request.extensions_mut().insert(ConnectInfo(peer_addr)); } @@ -293,6 +325,11 @@ impl AxumServiceState { } }; + if let Some(phase) = self.phase.clone() { + core_request + .extensions_mut() + .insert(LifecycleReader::new(move || Some(*phase.borrow()))); + } if let Some(registry) = config_registry { core_request.extensions_mut().insert(registry); } diff --git a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs index 6823d9071..9d11050d8 100644 --- a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs @@ -24,3 +24,9 @@ log = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing = { workspace = true } + +[features] +default = [] +aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] +aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] +aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] diff --git a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs index a73eb8769..19030546f 100644 --- a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs @@ -1,6 +1,6 @@ -use edgezero_adapter_axum::dev_server::run_app; +use edgezero_adapter_axum::dev_server::run_generated_app; fn main() -> anyhow::Result<()> { - run_app::<{{proj_core_mod}}::App>()?; + run_generated_app::<{{proj_core_mod}}::App>()?; Ok(()) } diff --git a/crates/edgezero-adapter-axum/tests/fixtures/native_aws_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/native_aws_host.rs new file mode 100644 index 000000000..178376e9c --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/native_aws_host.rs @@ -0,0 +1,109 @@ +//! Child executable used by `tests/native_aws_startup.rs`. + +use std::future; +use std::sync::Arc; + +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, LocalStartupFuture, PreparedStores, run_app_with_native_stores, +}; +use edgezero_adapter_axum::native_stores::NativeStoreOverrides; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig as TypedAppConfig, State}; +use edgezero_core::router::RouterService; +use edgezero_core::{EdgeError, action}; +use serde::Deserialize; +use validator::Validate; + +struct NativeAwsApp; + +#[derive(Deserialize, Validate, edgezero_core::AppConfig)] +struct FixtureConfig { + #[validate(length(min = 4_u64))] + greeting: String, + #[secret] + #[validate(length(min = 8_u64))] + token: String, +} + +#[derive(Clone)] +struct Snapshot { + greeting: String, + token: String, +} + +#[expect( + clippy::missing_trait_methods, + reason = "fixture retains unused Hooks defaults" +)] +impl Hooks for NativeAwsApp { + fn routes() -> RouterService { + RouterService::builder() + .get("/snapshot", snapshot) + .get("/local-secret", local_secret) + .build() + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["app"], + default: "app", + }), + kv: None, + secrets: Some(StoreMetadata { + ids: &["vault", "local"], + default: "vault", + }), + } + } +} + +#[action] +async fn snapshot(State(snapshot): State>) -> Result { + Ok(format!("{}|{}", snapshot.greeting, snapshot.token)) +} + +#[action] +async fn local_secret(ctx: RequestContext) -> Result { + let local = ctx + .secret_store("local") + .ok_or_else(|| EdgeError::service_unavailable("local fixture store missing"))?; + Ok(local.require_str("LOCAL_FIXTURE_SECRET").await?) +} + +fn initialize<'startup>( + app: &'startup mut App, + stores: &'startup PreparedStores, +) -> LocalStartupFuture<'startup> { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.named_ref("app")) + .ok_or_else(|| EdgeError::service_unavailable("config fixture missing"))?; + let config = TypedAppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + app.insert_state(Arc::new(Snapshot { + greeting: config.greeting, + token: config.token, + })); + Ok(()) + }) +} + +fn main() -> anyhow::Result<()> { + let options = AxumRunOptions::from_env()?; + run_app_with_native_stores::( + options, + NativeStoreOverrides::default(), + future::pending::<()>(), + initialize, + )?; + Ok(()) +} diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs new file mode 100644 index 000000000..059cf88ba --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs @@ -0,0 +1,353 @@ +//! Test-only executable for real startup, signal, drain and storage acceptance. +#![expect(clippy::print_stdout, reason = "bounded subprocess IPC markers")] + +#[path = "../../../edgezero-core/tests/fixtures/production_handlers.rs"] +mod fixture; + +use std::cell::Cell; +use std::env; +use std::future; +use std::net::SocketAddr; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, run_app, run_app_with_options_and_initializer, run_production_app, + run_production_app_with_initializer, +}; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig, State}; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::probe::{self, LifecyclePhase, LifecycleReader}; +use edgezero_core::router::RouterService; +use edgezero_core::{Body, EdgeError, MonotonicClock, MonotonicInstant, action}; +use futures::stream; +use tokio::sync::oneshot; +use tokio::time::sleep; + +struct FixtureApp; + +#[expect( + clippy::missing_trait_methods, + reason = "fixture retains unused Hooks defaults" +)] +impl Hooks for FixtureApp { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if mode() == "hook-error" { + return Err(EdgeError::internal(anyhow::anyhow!("HOOK_SENTINEL"))); + } + if env::var_os("FIXTURE_FREEZE_CLOCK").is_some() { + let frozen = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || frozen)); + } + Ok(()) + } + + fn owns_logging() -> bool { + true + } + + fn routes() -> RouterService { + RouterService::builder() + .get("/ready", probe::readiness) + .get("/live", probe::liveness) + .get("/state", fixture::state) + .get("/typed", fixture::typed) + .get("/finite", finite) + .get("/pending", pending) + .get("/stream", endless_stream) + .get("/flood", flood) + .get("/after", after) + .get("/write", write) + .get("/read", read) + .get("/alias", alias) + .get("/distinct", distinct) + .get("/snapshot", snapshot) + .get("/stop", stop) + .build() + } + + fn stores() -> StoresMetadata { + if matches!( + mode().as_str(), + "bare" + | "bare-dev" + | "hook-error" + | "initializer-error" + | "starting" + | "embedding-bare" + ) { + return StoresMetadata::default(); + } + StoresMetadata { + config: Some(StoreMetadata { + ids: &["settings"], + default: "settings", + }), + kv: Some(StoreMetadata { + ids: &["first", "second", "third"], + default: "first", + }), + secrets: Some(StoreMetadata { + ids: &["vault"], + default: "vault", + }), + } + } +} + +struct WorkGuard(&'static str); + +impl Drop for WorkGuard { + fn drop(&mut self) { + println!("{}", self.0); + } +} + +struct StopState(Mutex>>); + +fn mode() -> String { + env::var("FIXTURE_MODE").unwrap_or_else(|_| "initialized".to_owned()) +} + +fn reader(ctx: &RequestContext) -> Result { + ctx.extensions() + .get::() + .cloned() + .ok_or_else(|| EdgeError::service_unavailable("fixture reader missing")) +} + +#[action] +async fn after() -> Result<&'static str, EdgeError> { + println!("FORBIDDEN_DISPATCH"); + Ok("forbidden dispatch") +} + +#[action] +async fn finite(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let observed = reader(&ctx)?; + let _guard = WorkGuard("FINITE_DROPPED"); + println!("DISPATCH"); + while observed.phase() == Some(LifecyclePhase::Ready) { + sleep(Duration::from_millis(5)).await; + } + println!("DRAINING"); + sleep(Duration::from_millis(80)).await; + Ok("finite-complete") +} + +#[action] +async fn pending(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let observed = reader(&ctx)?; + let _guard = WorkGuard("WORK_DROPPED"); + println!("DISPATCH"); + let mut reported = false; + #[expect( + clippy::infinite_loop, + reason = "fixture proves process shutdown cancels a never-ending handler" + )] + loop { + if !reported && observed.phase() == Some(LifecyclePhase::Draining) { + println!("DRAINING"); + reported = true; + } + sleep(Duration::from_millis(5)).await; + } +} + +#[action] +async fn endless_stream(ctx: RequestContext) -> Result { + let observed = reader(&ctx)?; + let guard = Rc::new(WorkGuard("STREAM_DROPPED")); + println!("STREAM_READY"); + Ok(Body::from_stream(async_stream::stream! { + let _retained = guard; + yield Ok(Bytes::from_static(b"stream-prefix")); + let mut reported = false; + loop { + if !reported && observed.phase() == Some(LifecyclePhase::Draining) { + println!("DRAINING"); + reported = true; + } + sleep(Duration::from_millis(5)).await; + } + })) +} + +#[action] +async fn flood() -> Result { + static BLOCK: [u8; 0x0001_0000] = [b'x'; 0x0001_0000]; + let guard = Rc::new(WorkGuard("STREAM_DROPPED")); + println!("STREAM_READY"); + Ok(Body::from_stream(stream::repeat_with(move || { + let _retained = &guard; + Ok(Bytes::from_static(&BLOCK)) + }))) +} + +fn kv(ctx: &RequestContext, id: &str) -> Result { + ctx.kv_store(id) + .ok_or_else(|| EdgeError::service_unavailable("fixture KV missing")) +} + +#[action] +async fn write(ctx: RequestContext) -> Result<&'static str, EdgeError> { + kv(&ctx, "first")? + .put("persisted", &"durable-value") + .await?; + Ok("written") +} + +#[action] +async fn read(ctx: RequestContext) -> Result { + Ok(kv(&ctx, "first")? + .get::("persisted") + .await? + .unwrap_or_default()) +} + +#[action] +async fn alias(ctx: RequestContext) -> Result { + Ok(kv(&ctx, "second")? + .get::("persisted") + .await? + .unwrap_or_default()) +} + +#[action] +async fn distinct(ctx: RequestContext) -> Result { + Ok(kv(&ctx, "third")? + .get::("persisted") + .await? + .unwrap_or_default()) +} + +#[action] +async fn snapshot(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::service_unavailable("fixture config missing"))?; + Ok(binding + .handle + .get(&binding.default_key) + .await? + .unwrap_or_default() + .as_str() + .to_owned()) +} + +#[action] +async fn stop(State(state): State>) -> Result<&'static str, EdgeError> { + if let Some(sender) = state + .0 + .lock() + .map_err(|_error| EdgeError::internal(anyhow::anyhow!("fixture stop lock poisoned")))? + .take() + { + let _sent = sender.send(()); + } + Ok("stopping") +} + +#[expect( + clippy::panic_in_result_fn, + reason = "test-only executable asserts initializer ownership and call counts" +)] +fn main() -> anyhow::Result<()> { + let selected = mode(); + if matches!(selected.as_str(), "development" | "bare-dev") { + return run_app::(); + } + if matches!(selected.as_str(), "framework" | "bare" | "hook-error") { + return run_production_app::(); + } + if selected == "starting" { + return run_production_app_with_initializer::(|_app, _stores| { + Box::pin(async { + let _guard = WorkGuard("STARTUP_DROPPED"); + println!("INITIALIZING"); + future::pending::>().await + }) + }); + } + if selected == "initializer-error" { + return run_production_app_with_initializer::(|_app, _stores| { + Box::pin(async { Err(EdgeError::internal(anyhow::anyhow!("CALLBACK_SENTINEL"))) }) + }); + } + let calls = Rc::new(Cell::new(0_u32)); + let initialized = Rc::clone(&calls); + if selected.starts_with("embedding") { + let address: SocketAddr = env::var("FIXTURE_BIND")?.parse()?; + let mut options = AxumRunOptions::new(address)?; + if selected == "embedding" { + options = options + .with_config_dir(env::var("FIXTURE_CONFIG_ROOT")?)? + .with_data_dir(env::var("FIXTURE_DATA_ROOT")?)?; + } + let (sender, receiver) = oneshot::channel(); + let stop_capture = Rc::clone(&calls); + return run_app_with_options_and_initializer::( + options, + async move { + let _closed = receiver.await; + assert_eq!( + stop_capture.get(), + 1, + "initializer ran once before caller stop" + ); + }, + move |app, stores| { + Box::pin(async move { + if let Some(binding) = + stores.config().and_then(|registry| registry.default_ref()) + { + let config = AppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + app.insert_state(Arc::new(fixture::HostState { + greeting: config.greeting, + })); + } + app.insert_state(Arc::new(StopState(Mutex::new(Some(sender))))); + initialized.set(initialized.get().saturating_add(1)); + println!("INITIALIZED"); + Ok(()) + }) + }, + ); + } + run_production_app_with_initializer::(move |app, stores| { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.default_ref()) + .ok_or_else(|| { + EdgeError::service_unavailable("required fixture binding missing") + })?; + let config = AppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + app.insert_state(Arc::new(fixture::HostState { + greeting: config.greeting, + })); + initialized.set(initialized.get().saturating_add(1)); + assert_eq!(initialized.get(), 1, "initializer runs exactly once"); + println!("INITIALIZED"); + Ok(()) + }) + }) +} diff --git a/crates/edgezero-adapter-axum/tests/native_aws_startup.rs b/crates/edgezero-adapter-axum/tests/native_aws_startup.rs new file mode 100644 index 000000000..4fb941002 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/native_aws_startup.rs @@ -0,0 +1,639 @@ +//! Loopback-only production startup test proposal. +//! +//! Proposed Cargo wiring for this file and `fixtures/native_aws_host.rs`: +//! +//! ```toml +//! [[bin]] +//! name = "edgezero-native-aws-host" +//! path = "tests/fixtures/native_aws_host.rs" +//! required-features = ["aws-stores", "test-utils"] +//! test = false +//! +//! [[test]] +//! name = "native_aws_startup" +//! path = "tests/native_aws_startup.rs" +//! required-features = ["aws-stores", "test-utils"] +//! ``` +//! +//! It exercises the current public runner and real pinned SDK, not a provider seam. Run only +//! after the parent integrates this draft and the completed SDK/provider code. No AWS account, +//! DNS hostname, external socket, image, or real cloud credential is used. + +#[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "loopback fixture helpers remain grouped before startup scenarios" +)] +mod tests { + use std::fmt::Write as _; + use std::fs; + use std::io::{ErrorKind, Read, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::Path; + use std::process::{Child, Command, Stdio}; + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::TempDir; + + const WAIT: Duration = Duration::from_secs(12); + const ARN: &str = "arn:aws:secretsmanager:us-east-1:111122223333:secret:native-fixture-AbCdEf"; + const TOKEN_SENTINEL: &str = "native-fixture-secret-value"; + const LOCAL_SENTINEL: &str = "native-fixture-local-secret"; + const AGENT_TOKEN: &str = "native-fixture-agent-token"; + const FALLBACK_SENTINEL: &str = "local-fallback-must-not-be-used"; + + #[derive(Debug)] + struct Request { + headers: String, + body: String, + } + + struct Reply { + status: u16, + reason: &'static str, + body: String, + content_type: &'static str, + } + + impl Reply { + fn ok(body: String, content_type: &'static str) -> Self { + Self { + status: 200, + reason: "OK", + body, + content_type, + } + } + } + + /// One finite server bound to an IPv4 loopback literal. `expected=false` watches briefly for an + /// unexpected request, which makes the Agent-failure/no-Secrets-call assertion observable. + struct FixtureServer { + endpoint: String, + request: mpsc::Receiver, + worker: Option>>, + } + + impl FixtureServer { + fn once(reply: Reply, expected: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback only"); + listener + .set_nonblocking(true) + .expect("nonblocking loopback listener"); + let address = listener.local_addr().expect("fixture address"); + let endpoint = format!("http://{address}"); + let (sender, request_receiver) = mpsc::channel(); + let worker = thread::spawn(move || { + let watch = if expected { + WAIT + } else { + Duration::from_millis(300) + }; + let deadline = Instant::now() + .checked_add(watch) + .expect("fixture deadline fits the monotonic clock"); + loop { + match listener.accept() { + Ok((mut stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture peer was not loopback"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded request read"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("bounded response write"); + let request = read_request(&mut stream); + send_response(&mut stream, &reply); + let _sent = sender.send(Request { + headers: request.headers.clone(), + body: request.body.clone(), + }); + return Some(request); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + assert!(!expected, "expected fixture request did not arrive"); + return None; + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("loopback fixture accept failed: {error}"), + } + } + }); + Self { + endpoint, + request: request_receiver, + worker: Some(worker), + } + } + + fn endpoint(&self) -> &str { + &self.endpoint + } + + fn take_request(&self) -> Request { + self.request + .recv_timeout(WAIT) + .expect("fixture captured one request") + } + + #[expect( + clippy::unwrap_in_result, + reason = "a broken fixture or panicked thread must fail the test, never become a normal absence" + )] + fn finish(mut self) -> Option { + self.worker + .take() + .expect("fixture worker") + .join() + .expect("fixture thread") + } + } + + fn read_request(stream: &mut TcpStream) -> Request { + let mut bytes = Vec::new(); + let mut scratch = [0_u8; 2048]; + let header_end = loop { + let count = stream.read(&mut scratch).expect("read request"); + assert_ne!(count, 0, "peer closed before request headers"); + let received = scratch + .get(..count) + .expect("read count does not exceed scratch buffer"); + bytes.extend_from_slice(received); + assert!(bytes.len() <= 64 * 1024, "bounded fixture request"); + if let Some(offset) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + break offset + .checked_add(4) + .expect("header delimiter offset fits request"); + } + }; + let header_bytes = bytes + .get(..header_end) + .expect("header terminator is within received request"); + let headers = String::from_utf8_lossy(header_bytes).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (_, value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))?; + value.trim().parse::().ok() + }) + .unwrap_or(0); + let body_end = header_end + .checked_add(content_length) + .expect("bounded request body end fits usize"); + while bytes.len() < body_end { + let count = stream.read(&mut scratch).expect("read bounded body"); + assert_ne!(count, 0, "peer closed before request body"); + let received = scratch + .get(..count) + .expect("read count does not exceed scratch buffer"); + bytes.extend_from_slice(received); + assert!( + bytes.len() <= body_end, + "request body stays within declared length" + ); + } + let body_bytes = bytes + .get(header_end..body_end) + .expect("declared request body is within received bytes"); + Request { + headers: headers.to_ascii_lowercase(), + body: String::from_utf8_lossy(body_bytes).into_owned(), + } + } + + fn send_response(stream: &mut TcpStream, reply: &Reply) { + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reply.reason, + reply.content_type, + reply.body.len() + ) + .expect("write response headers"); + stream + .write_all(reply.body.as_bytes()) + .expect("write response body"); + } + + struct ChildHost { + child: Child, + address: SocketAddr, + output: Arc>>, + readers: Vec>, + } + + impl ChildHost { + fn spawn(root: &TempDir, bindings: &Path, secrets_endpoint: &str) -> Self { + let home = root.path().join("home"); + let profiles = root.path().join("profiles"); + fs::create_dir_all(&home).expect("isolated HOME"); + fs::create_dir_all(&profiles).expect("isolated profile directory"); + let config = profiles.join("config"); + let credentials = profiles.join("credentials"); + fs::write(&config, "").expect("empty AWS config; no profiles"); + fs::write(&credentials, "").expect("empty AWS credentials; no profiles"); + let address = reserve_address(); + let mut child = Command::new(env!("CARGO_BIN_EXE_edgezero-native-aws-host")) + .env_clear() + .current_dir(root.path()) + .env("HOME", home) + .env("AWS_CONFIG_FILE", config) + .env("AWS_SHARED_CREDENTIALS_FILE", credentials) + .env("AWS_REGION", "us-east-1") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ACCESS_KEY_ID", "DUMMY_LOOPBACK_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY", "DUMMY_LOOPBACK_SECRET_KEY") + .env("AWS_ENDPOINT_URL", secrets_endpoint) + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", secrets_endpoint) + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", address.port().to_string()) + .env("EDGEZERO__LOGGING__LEVEL", "off") + .env("APPCONFIG_AGENT_TOKEN", AGENT_TOKEN) + .env("LOCAL_FIXTURE_SECRET", LOCAL_SENTINEL) + .env("token", FALLBACK_SENTINEL) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start production host fixture"); + let output = Arc::new(Mutex::new(Vec::new())); + let streams: [Box; 2] = [ + Box::new(child.stdout.take().expect("child stdout")), + Box::new(child.stderr.take().expect("child stderr")), + ]; + let readers = streams + .into_iter() + .map(|mut stream| { + let captured_output = Arc::clone(&output); + thread::spawn(move || { + let mut captured = Vec::new(); + stream + .read_to_end(&mut captured) + .expect("capture child output"); + captured_output + .lock() + .expect("captured output") + .extend(captured); + }) + }) + .collect(); + Self { + child, + address, + output, + readers, + } + } + + fn wait_http(&mut self, path: &str) -> String { + let deadline = Instant::now() + .checked_add(WAIT) + .expect("host readiness deadline fits the monotonic clock"); + loop { + if let Some(status) = self.child.try_wait().expect("child status") { + self.child.wait().expect("reap exited child"); + let output = self.collect_output(); + panic!("fixture exited before {path} (status {status}): {output}"); + } + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(40)) + { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded app response read"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .expect("write app request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read complete app response"); + if response.starts_with("HTTP/1.1 200") { + return response; + } + } + assert!(Instant::now() < deadline, "host readiness deadline"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn wait_failure(&mut self) -> String { + let deadline = Instant::now() + .checked_add(WAIT) + .expect("startup failure deadline fits the monotonic clock"); + loop { + if let Some(status) = self.child.try_wait().expect("child status") { + assert!(!status.success(), "expected startup failure"); + self.child.wait().expect("reap failed child"); + return self.collect_output(); + } + assert!(Instant::now() < deadline, "failed startup stayed alive"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn stop(&mut self) { + if self.child.try_wait().expect("child status").is_none() { + self.child.kill().expect("stop loopback host"); + } + self.child.wait().expect("reap host"); + let _output = self.collect_output(); + } + + fn collect_output(&mut self) -> String { + for reader in self.readers.drain(..) { + reader.join().expect("child output reader"); + } + String::from_utf8_lossy(&self.output.lock().expect("captured output")).into_owned() + } + + fn assert_no_listener(&self) { + TcpStream::connect_timeout(&self.address, Duration::from_millis(50)) + .expect_err("startup failure must happen before listener acceptance"); + } + } + + impl Drop for ChildHost { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _killed = self.child.kill(); + } + let _status = self.child.wait(); + for reader in self.readers.drain(..) { + let _joined = reader.join(); + } + } + } + + fn reserve_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve literal loopback port"); + listener.local_addr().expect("reserved address") + } + + fn response_body(response: &str) -> &str { + response + .split_once("\r\n\r\n") + .expect("HTTP response header terminator") + .1 + } + + fn envelope(token_reference: &str, greeting: &str) -> String { + serde_json::to_string(&BlobEnvelope::new( + json!({"greeting": greeting, "token": token_reference}), + "2026-10-07T12:00:00Z".to_owned(), + )) + .expect("serialize actual core BlobEnvelope") + } + + fn agent_reply(document: &str, status: u16) -> Reply { + if status == 200 { + Reply::ok(document.to_owned(), "application/json") + } else { + Reply { + status, + reason: "Service Unavailable", + body: "AGENT_BODY_SENTINEL".to_owned(), + content_type: "text/plain", + } + } + } + + fn secrets_reply(value: &str) -> Reply { + Reply::ok( + json!({ + "ARN": ARN, + "Name": "native-fixture", + "VersionId": "12345678901234567890123456789012", + "SecretString": value, + }) + .to_string(), + "application/x-amz-json-1.1", + ) + } + + fn denied_secrets_reply() -> Reply { + Reply { + status: 400, + reason: "Bad Request", + body: json!({ + "__type": "AccessDeniedException", + "message": "AWS_ERROR_SENTINEL ARN_SENTINEL CREDENTIAL_SENTINEL", + }) + .to_string(), + content_type: "application/x-amz-json-1.1", + } + } + + fn bindings_file(path: &Path, agent: &str, aws_limits: Option<(usize, usize, usize)>) { + let mut content = format!( + r#"version = 1 + +[config.app] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "{agent}" +access_token_env = "APPCONFIG_AGENT_TOKEN" + +[config.app.documents.settings] +application = "native-fixture" +environment = "test" +profile = "startup-proof" + +[secrets.vault] +provider = "aws-secrets-manager" +region = "us-east-1" + +[secrets.vault.entries.token] +secret_id = "{ARN}" +"# + ); + if let Some((snapshot, config, secret)) = aws_limits { + write!(content, "\n[limits.aws]\nmax_snapshot_bytes = {snapshot}\nmax_config_document_bytes = {config}\nmax_secret_value_bytes = {secret}\n").expect("format fixture limits"); + } + fs::write(path, content).expect("write explicit fictional bindings"); + } + + fn assert_safe_error(text: &str) { + for sentinel in [ + TOKEN_SENTINEL, + LOCAL_SENTINEL, + AGENT_TOKEN, + FALLBACK_SENTINEL, + "AGENT_BODY_SENTINEL", + "AWS_ERROR_SENTINEL", + "ARN_SENTINEL", + "CREDENTIAL_SENTINEL", + "DUMMY_LOOPBACK_ACCESS_KEY", + "DUMMY_LOOPBACK_SECRET_KEY", + ] { + assert!(!text.contains(sentinel), "startup output leaked {sentinel}"); + } + } + + fn request_body_is_serialized_get_secret(request: &Request) { + assert!( + request + .headers + .contains("x-amz-target: secretsmanager.getsecretvalue"), + "real SDK request target observed" + ); + assert!( + request + .headers + .contains("credential=dummy_loopback_access_key/") + ); + let body: Value = serde_json::from_str(&request.body).expect("SDK JSON request body"); + assert_eq!(body["SecretId"], ARN); + assert!(body.get("VersionStage").is_none()); + assert!(body.get("VersionId").is_none()); + } + + #[test] + fn production_runner_loads_envelope_and_real_sdk_secret_before_serving_mixed_stores() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("token", "startup-greeting"); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(secrets_reply(TOKEN_SENTINEL), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + + // The app initializer loads the actual serialized BlobEnvelope through core AppConfig, + // resolves its #[secret] field from the SDK-backed snapshot, and inserts typed state. + let first = child.wait_http("/snapshot"); + assert_eq!( + response_body(&first), + "startup-greeting|native-fixture-secret-value" + ); + let second = child.wait_http("/snapshot"); + assert_eq!( + response_body(&second), + "startup-greeting|native-fixture-secret-value" + ); + let local = child.wait_http("/local-secret"); + assert_eq!(response_body(&local), "native-fixture-local-secret"); + + let agent_request = agent.take_request(); + assert!(agent_request.headers.starts_with( + "get /applications/native-fixture/environments/test/configurations/startup-proof " + )); + assert!( + agent_request + .headers + .contains(&format!("authorization: bearer {AGENT_TOKEN}")) + ); + assert_eq!(agent_request.body, ""); + assert!(agent_request.headers.contains("accept-encoding: identity")); + let secret_request = secrets.take_request(); + request_body_is_serialized_get_secret(&secret_request); + assert_eq!( + agent.finish().expect("Agent call").headers, + agent_request.headers + ); + assert_eq!( + secrets.finish().expect("SDK call").body, + secret_request.body + ); + + // Each fixture accepted exactly one startup GET. Both request-time reads still succeeded + // after their fixture listeners closed, so no request-time AWS call occurred. + child.stop(); + } + + #[test] + fn agent_startup_error_stops_before_secret_client_request_and_listener() { + let root = tempfile::tempdir().expect("temporary process root"); + let agent = FixtureServer::once(agent_reply("AGENT_BODY_SENTINEL", 503), true); + let secrets = FixtureServer::once(denied_secrets_reply(), false); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + child.assert_no_listener(); + assert!(agent.finish().is_some(), "Agent request observed"); + assert!( + secrets.finish().is_none(), + "no SDK request after Agent failure" + ); + } + + #[test] + fn explicit_secret_denial_fails_without_local_fallback_or_listener() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("token", "startup-greeting"); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(denied_secrets_reply(), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + child.assert_no_listener(); + assert!(agent.finish().is_some()); + let request = secrets.finish().expect("real SDK request captured"); + request_body_is_serialized_get_secret(&request); + } + + #[test] + fn nested_secret_miss_fails_initializer_before_listener() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("unmapped-secret-key", "startup-greeting"); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(secrets_reply(TOKEN_SENTINEL), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file(&bindings, agent.endpoint(), None); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + child.assert_no_listener(); + assert!(agent.finish().is_some()); + assert!(secrets.finish().is_some()); + } + + #[test] + fn shared_payload_quota_spans_appconfig_and_secrets_before_readiness() { + let root = tempfile::tempdir().expect("temporary process root"); + let document = envelope("token", "startup-greeting"); + let secret = TOKEN_SENTINEL; + let aggregate_cap = document.len().max(secret.len()); + let combined_payload_bytes = document + .len() + .checked_add(secret.len()) + .expect("fixture payload lengths fit usize"); + assert!(aggregate_cap < combined_payload_bytes); + let agent = FixtureServer::once(agent_reply(&document, 200), true); + let secrets = FixtureServer::once(secrets_reply(secret), true); + let bindings = root.path().join("native-stores.toml"); + bindings_file( + &bindings, + agent.endpoint(), + Some((aggregate_cap, document.len(), secret.len())), + ); + let mut child = ChildHost::spawn(&root, &bindings, secrets.endpoint()); + let output = child.wait_failure(); + assert_safe_error(&output); + assert!( + output.contains("size limit"), + "aggregate quota category: {output}" + ); + child.assert_no_listener(); + assert!(agent.finish().is_some(), "config snapshot charged first"); + assert!( + secrets.finish().is_some(), + "secret response fetched before aggregate charge" + ); + } +} diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs new file mode 100644 index 000000000..97e8db9a7 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -0,0 +1,652 @@ +//! Real local processes, public entrypoints and ordinary main termination output. +#![cfg(all(feature = "axum", feature = "test-utils", unix))] + +#[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "subprocess fixtures group setup, process ownership and acceptance scenarios" +)] +mod tests { + use std::fs; + use std::io::{self, BufRead as _, BufReader, Read, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; + use std::os::unix::process::ExitStatusExt as _; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::sync::{Arc, Mutex, mpsc}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::TempDir; + + const WAIT: Duration = Duration::from_secs(5); + + struct Roots { + config: TempDir, + cwd: TempDir, + data: TempDir, + } + + impl Roots { + fn new() -> Self { + let roots = Self { + config: tempfile::tempdir().expect("config root"), + cwd: tempfile::tempdir().expect("unrelated cwd"), + data: tempfile::tempdir().expect("data root"), + }; + roots.map(&json!({"settings": blob("configured-greeting", "fixture_token")})); + roots + } + + fn map(&self, value: &Value) { + fs::write( + self.config.path().join("local-config-settings.json"), + serde_json::to_vec(value).expect("JSON"), + ) + .expect("config fixture"); + } + } + + fn blob(greeting: &str, token: &str) -> String { + serde_json::to_string(&BlobEnvelope::new( + json!({"greeting": greeting, "token": token}), + "2026-10-05T00:00:00Z".to_owned(), + )) + .expect("envelope") + } + + fn address() -> SocketAddr { + TcpListener::bind("127.0.0.1:0") + .expect("port reservation") + .local_addr() + .expect("address") + } + + fn command(mode: &str, roots: &Roots, addr: SocketAddr) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero-hosting-fixture")); + command + .env_clear() + .current_dir(roots.cwd.path()) + .env("FIXTURE_MODE", mode) + .env("FIXTURE_BIND", addr.to_string()) + .env("FIXTURE_CONFIG_ROOT", roots.config.path()) + .env("FIXTURE_DATA_ROOT", roots.data.path()) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", addr.port().to_string()) + .env("EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", "1") + .env("EDGEZERO__ADAPTER__CONFIG_DIR", roots.config.path()) + .env("EDGEZERO__ADAPTER__DATA_DIR", roots.data.path()) + .env("EDGEZERO__STORES__KV__FIRST__NAME", "shared-fixture") + .env("EDGEZERO__STORES__KV__SECOND__NAME", "shared-fixture") + .env("fixture_token", "resolved-fixture-token"); + command + } + + struct Server { + addr: SocketAddr, + child: Child, + events: mpsc::Receiver, + output: Arc>, + readers: Vec>, + } + + impl Server { + fn spawn(mut command: Command, addr: SocketAddr) -> Self { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("fixture process"); + let output = Arc::new(Mutex::new(String::new())); + let (sender, events) = mpsc::channel(); + let stdout = child.stdout.take().expect("stdout"); + let stderr = child.stderr.take().expect("stderr"); + let mut readers = Vec::new(); + let streams: [Box; 2] = [Box::new(stdout), Box::new(stderr)]; + for stream in streams { + let captured = Arc::clone(&output); + let stream_sender = sender.clone(); + readers.push(thread::spawn(move || { + for result in BufReader::new(stream).lines() { + let Ok(line) = result else { break }; + let mut captured_output = captured.lock().expect("output"); + captured_output.push_str(&line); + captured_output.push('\n'); + drop(captured_output); + let _sent = stream_sender.send(line); + } + })); + } + Self { + addr, + child, + events, + output, + readers, + } + } + + fn log(&self) -> String { + self.output.lock().expect("output").clone() + } + + fn ready(&mut self) { + let deadline = Instant::now().checked_add(WAIT).expect("startup timer"); + loop { + assert!( + Instant::now() < deadline, + "startup deadline: {}", + self.log() + ); + assert!( + self.child.try_wait().expect("status").is_none(), + "early process failure: {}", + self.log() + ); + if let Ok(response) = self.request("/ready") + && response.starts_with("HTTP/1.1 200") + { + return; + } + thread::sleep(Duration::from_millis(5)); + } + } + + fn request(&self, path: &str) -> io::Result { + let mut socket = self.socket()?; + write!( + socket, + "GET {path} HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n" + )?; + let mut response = String::new(); + socket.read_to_string(&mut response)?; + Ok(response) + } + + fn signal(&self, name: &str) { + assert!( + Command::new("kill") + .arg(format!("-{name}")) + .arg(self.child.id().to_string()) + .status() + .expect("send signal") + .success() + ); + } + + fn socket(&self) -> io::Result { + let socket = TcpStream::connect_timeout(&self.addr, Duration::from_millis(50))?; + socket.set_read_timeout(Some(WAIT))?; + socket.set_write_timeout(Some(WAIT))?; + Ok(socket) + } + + fn wait(&mut self) -> ExitStatus { + let deadline = Instant::now().checked_add(WAIT).expect("exit timer"); + loop { + if let Some(status) = self.child.try_wait().expect("process status") { + for reader in self.readers.drain(..) { + reader.join().expect("output reader"); + } + return status; + } + assert!( + Instant::now() < deadline, + "process exit deadline: {}", + self.log() + ); + thread::sleep(Duration::from_millis(5)); + } + } + + fn wait_line(&self, expected: &str) { + let deadline = Instant::now().checked_add(WAIT).expect("event timer"); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let line = self.events.recv_timeout(remaining).unwrap_or_else(|error| { + panic!("waiting for {expected}: {error}; {}", self.log()) + }); + if line == expected { + return; + } + } + } + } + + impl Drop for Server { + fn drop(&mut self) { + let _killed = self.child.kill(); + let _status = self.child.wait(); + for reader in self.readers.drain(..) { + let _joined = reader.join(); + } + } + } + + fn failed(command: Command, addr: SocketAddr, category: &str) -> String { + let mut server = Server::spawn(command, addr); + assert!(!server.wait().success()); + let log = server.log(); + assert!(log.contains(category), "missing category {category}: {log}"); + assert!( + !log.contains("SENTINEL"), + "startup diagnostic leaked fixture contents: {log}" + ); + assert!(!log.contains("INITIALIZED")); + TcpStream::connect_timeout(&addr, Duration::from_millis(50)) + .expect_err("failed startup never accepts connections"); + log + } + + #[test] + fn both_signals_stop_idle_processes() { + for signal in ["INT", "TERM"] { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + server.signal(signal); + assert!(server.wait().success(), "{}", server.log()); + } + } + + #[test] + fn sigterm_drains_dispatched_response_closes_idle_and_gates_pipeline() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + let mut idle = server.socket().expect("idle socket"); + idle.write_all(b"GET /ready HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("idle request"); + let mut idle_response = Vec::new(); + while !idle_response.ends_with(b"ready") { + let mut buffer = [0_u8; 1024]; + let read = idle.read(&mut buffer).expect("idle response"); + assert_ne!(read, 0); + idle_response.extend_from_slice(buffer.get(..read).expect("read length")); + } + let mut client = server.socket().expect("finite socket"); + client.write_all(b"GET /finite HTTP/1.1\r\nHost: fixture\r\n\r\nGET /after HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n") + .expect("pipeline"); + server.wait_line("DISPATCH"); + server.signal("TERM"); + server.wait_line("DRAINING"); + let mut response = String::new(); + client + .read_to_string(&mut response) + .expect("complete finite response"); + assert!(response.contains("finite-complete")); + assert!(!response.contains("forbidden")); + assert_eq!(idle.read(&mut [0_u8; 1]).expect("idle close"), 0); + assert!(server.wait().success(), "{}", server.log()); + assert!(!server.log().contains("FORBIDDEN_DISPATCH")); + } + + #[test] + fn process_budget_forces_pending_handler_stream_and_zero_read_peer() { + for (path, marker, dropped) in [ + ("/pending", "DISPATCH", "WORK_DROPPED"), + ("/stream", "STREAM_READY", "STREAM_DROPPED"), + ("/flood", "STREAM_READY", "STREAM_DROPPED"), + ] { + let roots = Roots::new(); + let addr = address(); + let mut cmd = command("bare", &roots, addr); + cmd.env("FIXTURE_FREEZE_CLOCK", "1"); + let mut server = Server::spawn(cmd, addr); + server.ready(); + let mut client = server.socket().expect("pending socket"); + write!(client, "GET {path} HTTP/1.1\r\nHost: fixture\r\n\r\n").expect("request"); + server.wait_line(marker); + let started = Instant::now(); + server.signal("TERM"); + assert!(!server.wait().success()); + assert!(started.elapsed() >= Duration::from_millis(700)); + assert!(started.elapsed() < Duration::from_secs(4)); + assert!(server.log().contains("grace exhausted"), "{}", server.log()); + assert_eq!(server.log().matches(dropped).count(), 1); + } + } + + #[test] + fn second_signal_forces_before_budget_exhaustion() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + let mut client = server.socket().expect("pending socket"); + client + .write_all(b"GET /pending HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("request"); + server.wait_line("DISPATCH"); + server.signal("TERM"); + server.wait_line("DRAINING"); + let started = Instant::now(); + server.signal("INT"); + assert!(!server.wait().success()); + assert!(started.elapsed() < Duration::from_millis(700)); + assert!(server.log().contains("forced stop")); + assert_eq!(server.log().matches("WORK_DROPPED").count(), 1); + } + + #[test] + fn stop_during_startup_never_binds_or_becomes_ready() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("starting", &roots, addr), addr); + server.wait_line("INITIALIZING"); + TcpStream::connect_timeout(&addr, Duration::from_millis(50)) + .expect_err("initialization precedes binding"); + server.signal("TERM"); + assert!(server.wait().success()); + assert!(server.log().contains("STARTUP_DROPPED")); + } + + #[test] + fn invalid_settings_and_callback_sources_are_redacted_nonzero_failures() { + let roots = Roots::new(); + for (key, value, category) in [ + ("EDGEZERO__ADAPTER__HOST", "HOST_SENTINEL", "HOST"), + ("EDGEZERO__ADAPTER__PORT", "PORT_SENTINEL", "PORT"), + ("EDGEZERO__ADAPTER__PORT", "0", "PORT"), + ("EDGEZERO__LOGGING__LEVEL", "LOGGING_SENTINEL", "LEVEL"), + ( + "EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", + "0", + "SHUTDOWN_GRACE_SECONDS", + ), + ( + "EDGEZERO__ADAPTER__CONFIG_DIR", + "ROOT_SENTINEL", + "CONFIG_DIR", + ), + ( + "EDGEZERO__STORES__CONFIG__SETTINGS__KEY", + "KEY_SENTINEL\n", + "settings", + ), + ] { + let addr = address(); + let mut cmd = command("initialized", &roots, addr); + cmd.env(key, value); + failed(cmd, addr, category); + } + for (mode, category) in [ + ("hook-error", "application configuration"), + ("initializer-error", "initializer"), + ] { + let addr = address(); + failed(command(mode, &roots, addr), addr, category); + } + let addr = address(); + let mut cmd = command("initialized", &roots, addr); + cmd.env_remove("EDGEZERO__ADAPTER__CONFIG_DIR"); + failed(cmd, addr, "CONFIG_DIR"); + let missing = roots.cwd.path().join("missing-data-SENTINEL"); + let missing_addr = address(); + let mut missing_cmd = command("initialized", &roots, missing_addr); + missing_cmd.env("EDGEZERO__ADAPTER__DATA_DIR", &missing); + failed(missing_cmd, missing_addr, "DATA_DIR"); + assert!(!missing.exists()); + } + + #[test] + fn occupied_bind_is_a_real_redacted_process_failure() { + let roots = Roots::new(); + let occupied = TcpListener::bind("127.0.0.1:0").expect("occupied port"); + let addr = occupied.local_addr().expect("address"); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + assert!(!server.wait().success()); + assert!(server.log().contains("bind")); + assert!(!server.log().contains("SENTINEL")); + } + + #[test] + fn required_files_and_typed_initializer_fail_before_acceptance() { + let roots = Roots::new(); + let file = roots.config.path().join("local-config-settings.json"); + fs::remove_file(&file).expect("missing file fixture"); + let addr = address(); + failed(command("initialized", &roots, addr), addr, "missing"); + fs::create_dir_all(&file).expect("non-regular fixture"); + let directory_addr = address(); + failed( + command("initialized", &roots, directory_addr), + directory_addr, + "regular file", + ); + fs::remove_dir(&file).expect("remove fixture directory"); + roots.map(&json!({"settings": {"CONFIG_SENTINEL": true}})); + let malformed_addr = address(); + failed( + command("initialized", &roots, malformed_addr), + malformed_addr, + "malformed", + ); + for (map, category) in [ + (json!({}), "MissingBlob"), + (json!({"settings": "BLOB_SENTINEL"}), "MalformedEnvelope"), + ( + json!({"settings": blob("x", "fixture_token")}), + "Validation", + ), + ( + json!({"settings": blob("valid-greeting", "MISSING_SECRET_SENTINEL")}), + "MissingSecret", + ), + ] { + roots.map(&map); + let typed_addr = address(); + failed( + command("initialized", &roots, typed_addr), + typed_addr, + category, + ); + } + let mut envelope: Value = + serde_json::from_str(&blob("valid-greeting", "fixture_token")).expect("envelope"); + envelope["data"]["greeting"] = json!("CHANGED_CONFIG_SENTINEL"); + roots.map(&json!({"settings": serde_json::to_string(&envelope).expect("envelope")})); + let integrity_addr = address(); + failed( + command("initialized", &roots, integrity_addr), + integrity_addr, + "IntegrityMismatch", + ); + } + + #[test] + fn convenience_checks_framework_without_guessing_required_schema() { + let roots = Roots::new(); + roots.map(&json!({"settings": "INVALID_TYPED_SENTINEL"})); + let addr = address(); + let mut server = Server::spawn(command("framework", &roots, addr), addr); + server.ready(); + assert!( + !server + .request("/typed") + .expect("typed failure") + .starts_with("HTTP/1.1 200") + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert!(!server.log().contains("INITIALIZED")); + } + + #[test] + fn retained_state_key_aliases_snapshot_restart_and_closed_backup_restore() { + let roots = Roots::new(); + roots.map(&json!({"settings": blob("wrong-key-greeting", "fixture_token"), "selected": blob("selected-greeting", "fixture_token")})); + let addr = address(); + let mut cmd = command("initialized", &roots, addr); + cmd.env("EDGEZERO__STORES__CONFIG__SETTINGS__KEY", "selected"); + let mut server = Server::spawn(cmd, addr); + server.ready(); + for _request in 0..3_u8 { + assert!( + server + .request("/state") + .expect("state") + .ends_with("selected-greeting") + ); + assert!( + server + .request("/typed") + .expect("typed") + .ends_with("selected-greeting") + ); + } + assert!( + server + .request("/write") + .expect("write") + .ends_with("written") + ); + assert!( + server + .request("/alias") + .expect("alias") + .ends_with("durable-value") + ); + assert!( + !server + .request("/distinct") + .expect("distinct") + .contains("durable-value") + ); + roots.map(&json!({"settings": blob("restart-greeting", "fixture_token")})); + assert!( + server + .request("/typed") + .expect("snapshot") + .ends_with("selected-greeting") + ); + let second_addr = address(); + failed( + command("initialized", &roots, second_addr), + second_addr, + "locked", + ); + assert_eq!( + fs::read_dir(roots.data.path()).expect("databases").count(), + 2 + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert_eq!(server.log().matches("INITIALIZED").count(), 1); + let mut restarted = Server::spawn(command("initialized", &roots, addr), addr); + restarted.ready(); + assert!( + restarted + .request("/state") + .expect("restarted config") + .ends_with("restart-greeting") + ); + assert!( + restarted + .request("/read") + .expect("persisted") + .ends_with("durable-value") + ); + restarted.signal("TERM"); + assert!(restarted.wait().success()); + let restore = tempfile::tempdir().expect("restore root"); + for result in fs::read_dir(roots.data.path()).expect("closed databases") { + let entry = result.expect("database entry"); + fs::copy(entry.path(), restore.path().join(entry.file_name())) + .expect("stopped-writer backup"); + } + let mut restored_command = command("initialized", &roots, addr); + restored_command.env("EDGEZERO__ADAPTER__DATA_DIR", restore.path()); + let mut restored = Server::spawn(restored_command, addr); + restored.ready(); + assert!( + restored + .request("/read") + .expect("restored value") + .ends_with("durable-value") + ); + restored.signal("TERM"); + assert!(restored.wait().success()); + } + + #[test] + fn explicit_embedding_ignores_ambient_settings_and_uses_local_rc_stop() { + let roots = Roots::new(); + let addr = address(); + let mut cmd = command("embedding", &roots, addr); + cmd.env("EDGEZERO__ADAPTER__HOST", "IGNORED_HOST_SENTINEL") + .env("EDGEZERO__ADAPTER__PORT", "0") + .env("EDGEZERO__ADAPTER__CONFIG_DIR", "IGNORED_ROOT_SENTINEL") + .env( + "EDGEZERO__STORES__CONFIG__SETTINGS__KEY", + "IGNORED_KEY_SENTINEL", + ); + let mut server = Server::spawn(cmd, addr); + server.ready(); + assert!( + server + .request("/state") + .expect("state") + .ends_with("configured-greeting") + ); + assert!( + server + .request("/stop") + .expect("caller stop") + .ends_with("stopping") + ); + assert!(server.wait().success(), "{}", server.log()); + assert_eq!(server.log().matches("INITIALIZED").count(), 1); + } + + #[test] + fn embedding_does_not_install_process_signal_handlers() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("embedding-bare", &roots, addr), addr); + server.ready(); + server.signal("TERM"); + assert_eq!(server.wait().signal(), Some(15_i32)); + } + + #[test] + fn projected_readonly_config_and_writable_data_preserve_uid_and_permissions() { + let roots = Roots::new(); + let file = roots.config.path().join("local-config-settings.json"); + let target = roots.config.path().join("projected-value"); + fs::rename(&file, &target).expect("projected target"); + symlink(&target, &file).expect("projected symlink"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o444)).expect("read-only config"); + fs::set_permissions(roots.config.path(), fs::Permissions::from_mode(0o555)) + .expect("read-only root"); + let config_before = fs::metadata(roots.config.path()).expect("config metadata"); + let data_before = fs::metadata(roots.data.path()).expect("data metadata"); + let addr = address(); + let mut server = Server::spawn(command("initialized", &roots, addr), addr); + server.ready(); + assert!( + server + .request("/write") + .expect("write") + .ends_with("written") + ); + server.signal("TERM"); + assert!(server.wait().success()); + let config_after = fs::metadata(roots.config.path()).expect("config metadata"); + let data_after = fs::metadata(roots.data.path()).expect("data metadata"); + assert_eq!( + (config_before.uid(), config_before.mode()), + (config_after.uid(), config_after.mode()) + ); + assert_eq!( + (data_before.uid(), data_before.mode()), + (data_after.uid(), data_after.mode()) + ); + fs::set_permissions(roots.config.path(), fs::Permissions::from_mode(0o700)) + .expect("fixture cleanup"); + } +} diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 4fe54f60d..981fb61e9 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -22,7 +22,7 @@ required-features = ["nested-app-config-check"] [dependencies] edgezero-core = { workspace = true } edgezero-adapter = { path = "../edgezero-adapter" } -edgezero-adapter-axum = { workspace = true, features = ["cli", "axum"], optional = true } +edgezero-adapter-axum = { workspace = true, features = ["cli", "native-bindings"], optional = true } edgezero-adapter-cloudflare = { workspace = true, features = ["cli"], optional = true } edgezero-adapter-fastly = { workspace = true, features = ["cli"], optional = true } edgezero-adapter-spin = { workspace = true, features = ["cli"], optional = true } @@ -58,6 +58,6 @@ default = [ "edgezero-adapter-cloudflare", "edgezero-adapter-spin", ] -cli = ["dep:clap", "dep:handlebars"] -demo-example = ["dep:app-demo-core", "edgezero-adapter-axum"] +cli = ["dep:clap", "dep:handlebars", "edgezero-adapter/cli"] +demo-example = ["dep:app-demo-core", "edgezero-adapter-axum", "edgezero-adapter-axum/axum"] nested-app-config-check = ["dep:proc-macro2", "dep:syn", "dep:walkdir"] diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index fd557d934..9fb9d9fe6 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,5 +1,7 @@ use edgezero_adapter::cli_support::validate_spin_outbound_host_contract; use edgezero_adapter::registry::{self as adapter_registry, AdapterAction}; +#[cfg(feature = "edgezero-adapter-axum")] +use edgezero_adapter_axum::native_build::{NativeCargoSelection, generated_axum_cargo_action}; use edgezero_core::manifest::{ CapabilitySupport, Manifest, ManifestContract, ManifestLoader, ResolvedEnvironment, }; @@ -117,6 +119,7 @@ pub fn execute( let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); let env = loader.manifest().environment_for(adapter_name); let adapter_bind = adapter_bind_from_manifest(loader.manifest(), adapter_name); + let build_features = adapter_build_features(loader.manifest(), adapter_name); return run_shell( command, root, @@ -125,6 +128,7 @@ pub fn execute( Some(env), adapter_bind, adapter_args, + &build_features, ); } @@ -177,6 +181,7 @@ pub fn execute_capture( let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); let env = loader.manifest().environment_for(adapter_name); let adapter_bind = adapter_bind_from_manifest(loader.manifest(), adapter_name); + let build_features = adapter_build_features(loader.manifest(), adapter_name); return run_shell_tee( command, root, @@ -185,6 +190,7 @@ pub fn execute_capture( Some(env), adapter_bind, adapter_args, + &build_features, ) .map(Some); } @@ -208,6 +214,9 @@ pub(crate) fn execute_capture_runtime( ) -> Result, String> { ensure_action_capabilities(runtime)?; if let ResolvedAdapterTarget::Shell(shell) = runtime.target() { + let build_features = runtime.manifest().map_or_else(Vec::new, |manifest| { + adapter_build_features(manifest, runtime.adapter_name()) + }); return run_shell_tee( shell.command(), shell.root(), @@ -216,6 +225,7 @@ pub(crate) fn execute_capture_runtime( Some(shell.environment().clone()), (shell.bind_host().map(str::to_owned), shell.bind_port()), adapter_args, + &build_features, ) .map(Some); } @@ -264,15 +274,21 @@ fn execute_after_gate(runtime: &ResolvedRuntime, adapter_args: &[String]) -> Res ResolvedAdapterTarget::Registered(_target) => { execute_registered_after_gate(runtime, adapter_args) } - ResolvedAdapterTarget::Shell(shell) => run_shell( - shell.command(), - shell.root(), - runtime.adapter_name(), - runtime.action(), - Some(shell.environment().clone()), - (shell.bind_host().map(str::to_owned), shell.bind_port()), - adapter_args, - ), + ResolvedAdapterTarget::Shell(shell) => { + let build_features = runtime.manifest().map_or_else(Vec::new, |manifest| { + adapter_build_features(manifest, runtime.adapter_name()) + }); + run_shell( + shell.command(), + shell.root(), + runtime.adapter_name(), + runtime.action(), + Some(shell.environment().clone()), + (shell.bind_host().map(str::to_owned), shell.bind_port()), + adapter_args, + &build_features, + ) + } } } @@ -302,13 +318,56 @@ fn execute_registered_after_gate( resolve_environment_defaults(&manifest.environment_for(runtime.adapter_name())) }); let selected = target.clone().with_environment_defaults(defaults); + let normalized_args = normalize_registered_axum_args(runtime, adapter_args)?; adapter.execute_target( AdapterAction::from(runtime.action()), &selected, - adapter_args, + &normalized_args, ) } +#[cfg(feature = "edgezero-adapter-axum")] +fn normalize_registered_axum_args( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + if !runtime.adapter_name().eq_ignore_ascii_case("axum") + || !matches!(runtime.action(), Action::Build | Action::Serve) + { + return Ok(adapter_args.to_vec()); + } + let features = runtime + .manifest() + .and_then(|manifest| { + manifest + .adapter_entry(runtime.adapter_name()) + .map(|(_name, adapter)| adapter.build.features.clone()) + }) + .unwrap_or_default(); + let selection = NativeCargoSelection::normalize_args(&features, adapter_args)?; + if matches!(runtime.action(), Action::Build) && !selection.run_args.is_empty() { + return Err("Axum build does not accept runtime arguments after `--`".to_owned()); + } + let mut normalized = selection.cargo_args(); + if matches!(runtime.action(), Action::Serve) && !selection.run_args.is_empty() { + normalized.push("--".to_owned()); + normalized.extend(selection.run_args); + } + Ok(normalized) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +#[expect( + clippy::unnecessary_wraps, + reason = "retain the common fallible dispatch boundary without the optional native adapter" +)] +fn normalize_registered_axum_args( + _runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + Ok(adapter_args.to_vec()) +} + fn resolve_environment_defaults(environment: &ResolvedEnvironment) -> Vec<(String, String)> { let mut defaults = Vec::new(); for binding in &environment.variables { @@ -465,19 +524,26 @@ fn adapter_bind_from_manifest( /// command, with the manifest environment / bind hints applied. Shared /// by [`run_shell`] (inherited stdio) and [`run_shell_tee`] (piped + /// echoed stdio) so both dispatch paths apply identical env precedence. +fn adapter_build_features(manifest: &Manifest, adapter_name: &str) -> Vec { + manifest + .adapter_entry(adapter_name) + .map_or_else(Vec::new, |(_name, config)| config.build.features.clone()) +} + +#[expect( + clippy::too_many_arguments, + reason = "keeps command, action, environment, bind, passthrough, and selected features explicit at the shared shell boundary" +)] fn build_shell_command( command: &str, cwd: &Path, adapter_name: &str, + action: Action, environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], + manifest_features: &[String], ) -> Result { - let full_command = if adapter_args.is_empty() { - command.to_owned() - } else { - format!("{} {}", command, shell_join(adapter_args)) - }; // Log only the manifest-defined `command`, never the trailing // `adapter_args` — passthrough args from `edgezero build/deploy // -- --token …` can carry deploy tokens, API keys, or other secrets that @@ -489,8 +555,17 @@ fn build_shell_command( cwd.display() ); - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg(&full_command).current_dir(cwd); + let mut cmd = match native_axum_cargo_command( + adapter_name, + action, + command, + cwd, + adapter_args, + manifest_features, + )? { + Some(native_command) => native_command, + None => shell_command(command, adapter_args, cwd), + }; // Precedence (high to low) for `EDGEZERO__ADAPTER__HOST/PORT` on the // subprocess: @@ -523,6 +598,73 @@ fn build_shell_command( Ok(cmd) } +#[cfg(feature = "edgezero-adapter-axum")] +fn native_axum_cargo_command( + adapter_name: &str, + action: Action, + command: &str, + cwd: &Path, + adapter_args: &[String], + manifest_features: &[String], +) -> Result, String> { + if !adapter_name.eq_ignore_ascii_case("axum") + || !matches!(action, Action::Build | Action::Serve) + { + return Ok(None); + } + let Some((subcommand, package)) = generated_axum_cargo_action(command) else { + return Ok(None); + }; + let selection = NativeCargoSelection::normalize_args(manifest_features, adapter_args)?; + if subcommand == "build" && !selection.run_args.is_empty() { + return Err( + "generated `cargo build` does not accept runtime arguments after `--`".to_owned(), + ); + } + let mut cargo = Command::new("cargo"); + cargo + .arg(subcommand) + .arg("-p") + .arg(package) + .args(selection.cargo_args()) + .current_dir(cwd); + if subcommand == "run" && !selection.run_args.is_empty() { + cargo.arg("--").args(&selection.run_args); + } + Ok(Some(cargo)) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +#[expect( + clippy::unnecessary_wraps, + reason = "retain the common fallible command boundary without the optional native adapter" +)] +fn native_axum_cargo_command( + _adapter_name: &str, + _action: Action, + _command: &str, + _cwd: &Path, + _adapter_args: &[String], + _manifest_features: &[String], +) -> Result, String> { + Ok(None) +} + +fn shell_command(command: &str, adapter_args: &[String], cwd: &Path) -> Command { + let full_command = if adapter_args.is_empty() { + command.to_owned() + } else { + format!("{} {}", command, shell_join(adapter_args)) + }; + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(full_command).current_dir(cwd); + cmd +} + +#[expect( + clippy::too_many_arguments, + reason = "keeps command, action, environment, bind, passthrough, and selected features explicit for inherited stdio" +)] fn run_shell( command: &str, cwd: &Path, @@ -531,14 +673,17 @@ fn run_shell( environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], + manifest_features: &[String], ) -> Result<(), String> { let mut cmd = build_shell_command( command, cwd, adapter_name, + action, environment, adapter_bind, adapter_args, + manifest_features, )?; let status = cmd @@ -588,6 +733,10 @@ fn tee_stream(reader: R, mut writer: W) -> String { /// captured. Returns the captured `stdout + stderr` text so the caller /// can parse machine-readable lines (e.g. Fastly's activated /// `version=`) out of a command it does not otherwise control. +#[expect( + clippy::too_many_arguments, + reason = "keeps command, action, environment, bind, passthrough, and selected features explicit for captured stdio" +)] fn run_shell_tee( command: &str, cwd: &Path, @@ -596,14 +745,17 @@ fn run_shell_tee( environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], + manifest_features: &[String], ) -> Result { let mut cmd = build_shell_command( command, cwd, adapter_name, + action, environment, adapter_bind, adapter_args, + manifest_features, )?; cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -660,6 +812,8 @@ fn shell_join(args: &[String]) -> String { #[cfg(test)] mod tests { + #[cfg(feature = "edgezero-adapter-axum")] + use super::native_axum_cargo_command; use super::{ResolvedEnvironment, apply_environment, ensure_capabilities, execute_runtime}; use crate::adapter::Action; use crate::manifest_source::resolve_runtime_from; @@ -753,6 +907,39 @@ mod tests { ManifestLoader::load_from_str(&format!("[capabilities]\n{section}\n[adapters.gate]\n")) } + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn generated_axum_cargo_intercept_is_build_and_serve_only() { + use std::path::Path; + + for action in [Action::Deploy, Action::AuthLogin] { + assert!( + native_axum_cargo_command( + "axum", + action, + "cargo build -p app", + Path::new("."), + &[], + &[], + ) + .unwrap() + .is_none() + ); + } + assert!( + native_axum_cargo_command( + "axum", + Action::Build, + "cargo build -p app", + Path::new("."), + &[], + &[], + ) + .unwrap() + .is_some() + ); + } + #[test] fn registered_dispatch_carries_only_selected_manifest_defaults_in_order() { const KEY: &str = "EDGEZERO_TEST_REGISTERED_INGRESS"; diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index cc86e79c3..9789d92a6 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -575,6 +575,9 @@ pub struct ConfigPushArgs { /// `[stores.config].ids` has length 1). #[arg(long)] pub store: Option, + /// Explicit native bindings file. Used only to reject unsupported AWS-bound publication. + #[arg(long)] + pub store_bindings: Option, /// Skip the inline diff prompt and write unconditionally. #[arg(long, short)] pub yes: bool, @@ -596,6 +599,7 @@ impl Default for ConfigPushArgs { runtime_config: None, staging: false, store: None, + store_bindings: None, yes: false, } } @@ -604,7 +608,14 @@ impl Default for ConfigPushArgs { /// Arguments for the `config validate` command. #[derive(clap::Args, Debug)] #[non_exhaustive] +#[expect( + clippy::struct_excessive_bools, + reason = "each bool is an independent Clap control; target toggles mirror Cargo feature flags" +)] pub struct ConfigValidateArgs { + /// Select bindings-only native validation; must be `axum`. + #[arg(long)] + pub adapter: Option, /// Path to the typed app-config file (default: `.toml` /// resolved from the manifest's `[app].name`, next to the manifest). #[arg(long)] @@ -614,13 +625,28 @@ pub struct ConfigValidateArgs { pub manifest: PathBuf, /// Skip the `__…__` env-var overlay when loading the /// typed app-config. The default loads the overlay so validation - /// sees the same values the runtime would. + /// sees the values the runtime would. #[arg(long)] pub no_env: bool, + /// Explicit absolute native bindings file for bindings-only validation. + #[arg(long)] + pub store_bindings: Option, /// Strict mode: additionally check capability-aware completeness /// for the declared adapter set and well-formed handler paths. #[arg(long)] pub strict: bool, + /// Native Cargo target triple used for provider capability resolution. + #[arg(long, requires = "store_bindings")] + pub target: Option, + /// Resolve with all target features enabled. + #[arg(long)] + pub target_all_features: bool, + /// Add native Cargo features for target capability resolution. + #[arg(long, value_delimiter = ',')] + pub target_features: Vec, + /// Suppress target default features. + #[arg(long)] + pub target_no_default_features: bool, } impl Default for ConfigValidateArgs { @@ -628,10 +654,16 @@ impl Default for ConfigValidateArgs { #[inline] fn default() -> Self { Self { + adapter: None, app_config: None, manifest: default_manifest_path(), no_env: false, + store_bindings: None, strict: false, + target: None, + target_all_features: false, + target_features: Vec::new(), + target_no_default_features: false, } } } @@ -745,6 +777,7 @@ mod tests { assert_eq!(args.manifest, PathBuf::from("edgezero.toml")); assert!(args.adapter.is_empty()); assert!(args.app_config.is_none()); + assert!(args.store_bindings.is_none()); assert!(!args.dry_run); assert!(args.key.is_none()); assert!(!args.local); @@ -760,6 +793,12 @@ mod tests { let args = ConfigValidateArgs::default(); assert_eq!(args.manifest, PathBuf::from("edgezero.toml")); assert!(args.app_config.is_none()); + assert!(args.adapter.is_none()); + assert!(args.store_bindings.is_none()); + assert!(args.target.is_none()); + assert!(args.target_features.is_empty()); + assert!(!args.target_all_features); + assert!(!args.target_no_default_features); assert!(!args.no_env); assert!(!args.strict); } @@ -848,6 +887,39 @@ mod tests { assert!(!validate.strict); } + #[test] + fn config_validate_parses_native_bindings_target_controls() { + let args = Args::try_parse_from([ + "edgezero", + "config", + "validate", + "--adapter", + "axum", + "--store-bindings", + "/tmp/native.toml", + "--target", + "x86_64-unknown-linux-gnu", + "--target-features", + "aws-appconfig-agent,aws-secrets-manager", + "--target-no-default-features", + ]) + .expect("parse native bindings validation"); + let Command::Config(ConfigCmd::Validate(validate)) = args.cmd else { + panic!("expected config validate"); + }; + assert_eq!(validate.adapter.as_deref(), Some("axum")); + assert_eq!( + validate.store_bindings, + Some(PathBuf::from("/tmp/native.toml")) + ); + assert_eq!(validate.target.as_deref(), Some("x86_64-unknown-linux-gnu")); + assert_eq!( + validate.target_features, + ["aws-appconfig-agent", "aws-secrets-manager"] + ); + assert!(validate.target_no_default_features); + } + #[test] fn config_gc_parses_explicit_dry_run() { let args = Args::try_parse_from([ diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 7a820269c..c83b3ae94 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -27,6 +27,13 @@ use crate::ensure_adapter_defined; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; +#[cfg(feature = "edgezero-adapter-axum")] +use edgezero_adapter_axum::native_bindings::{ + NativeBindingsLimits, NativeConfigBinding, NativeProviderCapabilities, NativeSourceSelection, + NativeStoreBindings, NativeStoreDeclarations, NativeSuppliedIds, +}; +#[cfg(feature = "edgezero-adapter-axum")] +use edgezero_adapter_axum::native_build::NativeCargoSelection; use edgezero_core::app_config::{ self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretField, SecretKind, SecretPathSegment, @@ -217,6 +224,10 @@ struct ResolvedTomlLeaf<'raw> { /// Returns a human-readable error string on any validation failure. #[inline] pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { + if args.store_bindings.is_some() || args.adapter.is_some() { + return run_native_bindings_validate(args); + } + reject_target_controls_without_bindings(args)?; let ctx = load_validation_context(args)?; run_shared_checks(&ctx, None)?; log::info!( @@ -236,6 +247,10 @@ pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), Str where C: DeserializeOwned + Validate + AppConfigMeta, { + if args.store_bindings.is_some() || args.adapter.is_some() { + return run_native_bindings_validate(args); + } + reject_target_controls_without_bindings(args)?; let ctx = load_validation_context(args)?; run_shared_checks(&ctx, None)?; @@ -443,6 +458,7 @@ pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where C: DeserializeOwned + Serialize + Validate + AppConfigMeta, { + reject_aws_bound_config_push(args)?; // Pre-flight: load + validate. let ctx = load_push_context(args)?; run_shared_checks(&ctx.validation, Some(&args.adapter))?; @@ -606,6 +622,12 @@ where // adapter_typed_checks; no consent gate, no re-fetch). let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), + adapter: None, + store_bindings: None, + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, manifest: args.manifest.clone(), no_env: args.no_env, strict: false, @@ -1329,6 +1351,12 @@ fn load_push_context(args: &ConfigPushArgs) -> Result { // alongside the schema and selected-adapter shared checks. let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), + adapter: None, + store_bindings: None, + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, manifest: args.manifest.clone(), no_env: args.no_env, strict: true, @@ -1464,6 +1492,143 @@ fn generated_at_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } +fn reject_target_controls_without_bindings(args: &ConfigValidateArgs) -> Result<(), String> { + if args.target.is_some() + || !args.target_features.is_empty() + || args.target_all_features + || args.target_no_default_features + { + return Err( + "native target controls require `--adapter axum --store-bindings `" + .to_owned(), + ); + } + Ok(()) +} + +/// Validate only a caller-selected native bindings file. This path intentionally +/// does not load app TOML, ambient overlays, tokens, credentials, or deployment env. +#[cfg(feature = "edgezero-adapter-axum")] +fn run_native_bindings_validate(args: &ConfigValidateArgs) -> Result<(), String> { + let adapter = args + .adapter + .as_deref() + .ok_or_else(|| "bindings-only validation requires `--adapter axum`".to_owned())?; + if !adapter.eq_ignore_ascii_case("axum") { + return Err("bindings-only validation is supported only for `--adapter axum`".to_owned()); + } + let path = args.store_bindings.as_deref().ok_or_else(|| { + "bindings-only validation requires `--store-bindings `".to_owned() + })?; + if args.app_config.is_some() { + return Err("`--app-config` cannot be used with bindings-only validation".to_owned()); + } + let manifest = ManifestLoader::from_path(&args.manifest) + .map_err(|_error| "failed to load application manifest".to_owned())?; + let bindings = + NativeStoreBindings::read_with_policy(path, NativeBindingsLimits::default(), None) + .map_err(|_error| "invalid native store bindings".to_owned())?; + let mut target_args = Vec::new(); + for feature in &args.target_features { + target_args.extend(["--features".to_owned(), feature.clone()]); + } + if args.target_all_features { + target_args.push("--all-features".to_owned()); + } + if args.target_no_default_features { + target_args.push("--no-default-features".to_owned()); + } + if let Some(target) = &args.target { + target_args.extend(["--target".to_owned(), target.clone()]); + } + // Normalize and validate target controls, but this first version deliberately + // makes no provider-availability claim and runs no Cargo command. + let manifest_features = manifest + .manifest() + .adapter_entry("axum") + .map_or_else(Vec::new, |(_name, definition)| { + definition.build.features.clone() + }); + let selection = NativeCargoSelection::normalize_args(&manifest_features, &target_args)?; + let declarations = NativeStoreDeclarations::try_from(&manifest.manifest().stores) + .map_err(|_error| "invalid store declarations for native bindings".to_owned())?; + NativeSourceSelection::validate( + declarations, + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: true, + }, + NativeBindingsLimits::default(), + None, + ) + .map_err(|_error| "native store bindings do not match manifest declarations".to_owned())?; + log::info!( + "[edgezero] native store bindings structurally valid; target provider availability unknown (target={}, requested_features={}, all_features={}, no_default_features={})", + selection.target.as_deref().unwrap_or("host"), + selection.requested_features.len(), + selection.all_features, + selection.no_default_features, + ); + Ok(()) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +fn run_native_bindings_validate(_args: &ConfigValidateArgs) -> Result<(), String> { + Err("bindings-only validation requires the CLI Axum native-bindings feature".to_owned()) +} + +#[cfg(feature = "edgezero-adapter-axum")] +fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { + let Some(path) = args.store_bindings.as_deref() else { + return Ok(()); + }; + if !args.adapter.eq_ignore_ascii_case("axum") { + return Err("`--store-bindings` push selection requires `--adapter axum`".to_owned()); + } + if !path.is_absolute() { + return Err("`--store-bindings` must be an absolute path".to_owned()); + } + let manifest = ManifestLoader::from_path(&args.manifest) + .map_err(|_error| "failed to load application manifest".to_owned())?; + let logical = resolve_config_store_id(args.store.as_deref(), manifest.manifest())?; + let bindings = + NativeStoreBindings::read_with_policy(path, NativeBindingsLimits::default(), None) + .map_err(|_error| "invalid native store bindings".to_owned())?; + let declarations = NativeStoreDeclarations::try_from(&manifest.manifest().stores) + .map_err(|_error| "invalid native store declarations".to_owned())?; + NativeSourceSelection::validate( + declarations, + &bindings, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + NativeProviderCapabilities { + appconfig_agent: true, + secrets_manager: true, + }, + NativeBindingsLimits::default(), + None, + ) + .map_err(|_error| "native store bindings do not match manifest declarations".to_owned())?; + if matches!( + bindings.config().get(&logical), + Some(NativeConfigBinding::AppConfigAgent(_)) + ) { + return Err("config push does not publish to AWS-bound native config stores; update the selected binding through its deployment workflow".to_owned()); + } + Ok(()) +} + +#[cfg(not(feature = "edgezero-adapter-axum"))] +fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { + if args.store_bindings.is_some() { + return Err("`--store-bindings` requires the CLI Axum native-bindings feature".to_owned()); + } + Ok(()) +} + fn load_validation_context(args: &ConfigValidateArgs) -> Result { let manifest_loader = ManifestLoader::from_path(&args.manifest) .map_err(|err| format!("failed to load {}: {err}", args.manifest.display()))?; @@ -2436,6 +2601,12 @@ source = "target/wasm32-wasip2/release/demo.wasm" fn args_for(manifest: &Path) -> ConfigValidateArgs { ConfigValidateArgs { app_config: None, + adapter: None, + store_bindings: None, + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, manifest: manifest.to_path_buf(), no_env: true, // tests don't want env leakage strict: false, @@ -2446,6 +2617,7 @@ source = "target/wasm32-wasip2/release/demo.wasm" ConfigPushArgs { adapter: adapter.to_owned(), app_config: None, + store_bindings: None, dry_run: false, key: None, local: false, @@ -2461,6 +2633,93 @@ source = "target/wasm32-wasip2/release/demo.wasm" // ---------- raw flow ---------- + #[cfg(feature = "edgezero-adapter-axum")] + const AWS_BINDINGS_FIXTURE: &str = r#"version = 1 +[config.remote] +provider = "aws-appconfig-agent" +default_key = "main" +endpoint = "http://127.0.0.1:2772" +[config.remote.documents.main] +application = "demo-app" +environment = "production" +profile = "envelope" +"#; + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn bindings_only_validation_does_not_require_app_toml() { + let manifest_text = r#" +[app] +name = "demo-app" +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" +[stores.config] +ids = ["remote"] +default = "remote" +"#; + let (dir, manifest, app_config) = setup_project(manifest_text, "not used"); + fs::remove_file(&app_config).expect("remove local config"); + let bindings_path = dir.path().join("bindings.toml"); + fs::write(&bindings_path, AWS_BINDINGS_FIXTURE).expect("write native bindings"); + let args = ConfigValidateArgs { + app_config: None, + adapter: Some("axum".to_owned()), + store_bindings: Some(bindings_path), + target: None, + target_features: Vec::new(), + target_all_features: false, + target_no_default_features: false, + manifest, + no_env: false, + strict: false, + }; + run_config_validate(&args).expect("structural validation does not load app TOML"); + } + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn push_guard_rejects_only_the_selected_aws_config_id() { + let manifest_text = PUSH_MANIFEST.replace( + "ids = [\"app_config\"]", + "ids = [\"app_config\", \"remote\"]\ndefault = \"app_config\"", + ); + let (dir, manifest, app_config) = setup_project(&manifest_text, VALID_APP_CONFIG); + fs::remove_file(app_config).expect("guard must not require local app TOML"); + let bindings_dir = tempfile::tempdir().expect("bindings directory"); + let bindings = bindings_dir.path().join("bindings.toml"); + fs::write( + &bindings, + AWS_BINDINGS_FIXTURE.replace("remote", "app_config"), + ) + .expect("write bindings"); + let mut args = push_args(&manifest, "axum"); + args.store_bindings = Some(bindings.clone()); + let error = reject_aws_bound_config_push(&args).expect_err("AWS-bound push is refused"); + assert!(error.contains("does not publish to AWS-bound")); + for (local, dry_run) in [(false, false), (true, false), (false, true), (true, true)] { + args.local = local; + args.dry_run = dry_run; + let push_error = run_config_push_typed::(&args) + .expect_err("public typed push refuses AWS before loading app TOML"); + assert!(push_error.contains("does not publish to AWS-bound")); + assert!( + !dir.path().join(".edgezero").exists(), + "refusal performs no local write" + ); + } + + fs::write(&bindings, AWS_BINDINGS_FIXTURE).expect("write unrelated AWS binding"); + reject_aws_bound_config_push(&args).expect("unrelated declared AWS binding is allowed"); + fs::write(&bindings, AWS_BINDINGS_FIXTURE.replace("remote", "unknown")) + .expect("write typo"); + reject_aws_bound_config_push(&args) + .expect_err("undeclared bindings are not a valid push selection"); + } + #[test] fn raw_validates_a_well_formed_project() { let _lock = manifest_guard().lock().expect("manifest guard"); diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 42070284c..f021ddb95 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1440,6 +1440,19 @@ mod tests { } fn assert_generated_axum_entrypoint(project_dir: &Path) { + let axum_cargo = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/Cargo.toml")) + .expect("read generated axum Cargo.toml"); + for feature in ["aws-appconfig-agent", "aws-secrets-manager", "aws-stores"] { + assert!( + axum_cargo.contains(feature), + "missing generated native feature {feature}" + ); + } + assert!( + axum_cargo.contains("default = []"), + "AWS features must be opt-in" + ); let axum_main = fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/src/main.rs")) .expect("read axum main.rs"); @@ -1447,7 +1460,7 @@ mod tests { !axum_main.contains("process::exit") && !axum_main.contains("EdgeZeroAxumService") && !axum_main.contains("into_axum_response") - && axum_main.contains("run_app::()"), + && axum_main.contains("run_generated_app::()"), "generated Axum entrypoint must use the connection-owning runner", ); } diff --git a/crates/edgezero-cli/tests/native_store_generated_proof.rs b/crates/edgezero-cli/tests/native_store_generated_proof.rs new file mode 100644 index 000000000..ed9284ddb --- /dev/null +++ b/crates/edgezero-cli/tests/native_store_generated_proof.rs @@ -0,0 +1,941 @@ +//! Ignored end-to-end proof proposal for generated native AWS stores. +//! +//! Copy to `crates/edgezero-cli/tests/native_store_generated_proof.rs` after coordinator review. +//! It invokes the real `edgezero new` and `edgezero build --adapter axum -- --offline` commands, +//! builds the generated Axum target with default-off, Agent-only, Secrets-only and combined +//! manifest feature selections, and launches its binary. +//! +//! The untouched generated main calls `run_generated_app`, proving explicit AWS bindings fail +//! closed when services were not compiled. The success main is replaced only in the temporary +//! generated project with an application-owned public production initializer. That initializer +//! loads the real serialized envelope and resolves its nested `#[secret]` reference before +//! inserting typed state. The test holds both provider responses and the initializer on gates, +//! then confirms the generated server does not listen until they complete. +//! +//! All service traffic uses short-lived literal `127.0.0.1` fixtures. The generated child starts +//! with `env_clear`, empty profile files and HOME, dummy credentials, metadata lookup disabled, +//! and both `AWS_ENDPOINT_URL_SECRETS_MANAGER` and the global endpoint override pointing to the +//! Secrets Manager fixture. No AWS account or real credentials, DNS, external socket, image or +//! IAM action is involved. `EDGEZERO_GENERATED_TEST_TARGET_DIR` may point at a coordinator-selected target +//! directory; otherwise builds use the generated project's `target/`. Keep this test ignored; +//! the coordinator owns any Cargo command used to run it. + +#[cfg(test)] +#[cfg(unix)] +mod tests { + + use std::env::var_os; + use std::fs; + use std::io::{ErrorKind, Read as _, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Output, Stdio}; + use std::sync::mpsc::{self, Receiver, Sender}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::{Value, json}; + use tempfile::TempDir; + use toml::Value as TomlValue; + + const WAIT: Duration = Duration::from_secs(20); + const SERVICE_GRACE: Duration = Duration::from_millis(400); + const SECRET_ARN: &str = + "arn:aws:secretsmanager:us-east-1:111122223333:secret:native-probe-signing-AbCdEf"; + const APP_TOKEN: &str = "generated-proof-secret-value"; + const AGENT_TOKEN: &str = "generated-proof-agent-token"; + const FALLBACK_SENTINEL: &str = "must-not-fall-back-to-environment"; + + #[derive(Debug)] + struct HttpRequest { + body: String, + headers: String, + } + + struct HttpReply { + body: String, + content_type: &'static str, + reason: &'static str, + status: u16, + } + + impl HttpReply { + fn ok(body: String, content_type: &'static str) -> Self { + Self { + body, + content_type, + reason: "OK", + status: 200, + } + } + } + + /// A gated one-request HTTP/1 fixture. It holds the first response until the test releases it, + /// then watches for extra calls while the generated app serves requests from its snapshots. + struct GatedService { + endpoint: String, + finish_tx: Sender<()>, + reply_tx: Sender, + request_rx: Receiver, + worker: Option>>, + } + + impl GatedService { + fn bind() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback"); + listener + .set_nonblocking(true) + .expect("nonblocking fixture listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_tx, request_rx) = mpsc::channel(); + let (reply_tx, reply_rx) = mpsc::channel(); + let (finish_tx, finish_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let (mut startup_stream, _) = accept_before(&listener, deadline_after(WAIT)); + startup_stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("bounded fixture request read"); + startup_stream + .set_write_timeout(Some(Duration::from_secs(3))) + .expect("bounded fixture response write"); + let startup_request = read_request(&mut startup_stream); + request_tx + .send(startup_request) + .expect("parent receives fixture request"); + let reply = reply_rx + .recv_timeout(WAIT) + .expect("test releases fixture response"); + write_reply(&mut startup_stream, &reply); + finish_rx + .recv_timeout(WAIT) + .expect("parent completes snapshot assertions"); + + let mut extra = Vec::new(); + let deadline = deadline_after(SERVICE_GRACE); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut extra_stream, peer)) => { + assert!(peer.ip().is_loopback(), "only literal loopback peers"); + extra_stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded extra request read"); + let extra_request = read_request(&mut extra_stream); + let _sent = request_tx.send(HttpRequest { + body: extra_request.body.clone(), + headers: extra_request.headers.clone(), + }); + extra.push(extra_request); + write_reply( + &mut extra_stream, + &HttpReply { + body: "REQUEST_TIME_PROVIDER_ACCESS".to_owned(), + content_type: "text/plain", + reason: "Unexpected request", + status: 500, + }, + ); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + extra + }); + Self { + endpoint, + finish_tx, + reply_tx, + request_rx, + worker: Some(worker), + } + } + + fn endpoint(&self) -> &str { + &self.endpoint + } + + fn finish(mut self) -> Vec { + self.finish_tx + .send(()) + .expect("fixture waits until handler assertions finish"); + self.worker + .take() + .expect("fixture worker") + .join() + .expect("fixture thread") + } + + fn reply(&self, reply: HttpReply) { + self.reply_tx.send(reply).expect("service awaits response"); + } + + fn request(&self) -> HttpRequest { + self.request_rx + .recv_timeout(WAIT) + .expect("startup request reaches loopback service") + } + } + + struct RunningHost { + address: SocketAddr, + child: Child, + stderr: Option>>, + stdout: Option>>, + } + + impl RunningHost { + fn http(&mut self, path: &str) -> String { + let deadline = deadline_after(WAIT); + loop { + if let Some(status) = self.child.try_wait().expect("generated process status") { + panic!("generated server exited before {path} ({status})"); + } + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(40)) + { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded generated response"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .expect("send generated handler request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read generated response"); + if response.starts_with("HTTP/1.1 200") { + return response; + } + } + assert!( + Instant::now() < deadline, + "generated listener startup timeout" + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn spawn(mut command: Command, address: SocketAddr) -> Self { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn generated server"); + let stdout_pipe = child.stdout.take().expect("capture generated stdout"); + let stderr_pipe = child.stderr.take().expect("capture generated stderr"); + let stdout_reader = thread::spawn(move || { + let mut output = Vec::new(); + let mut pipe = stdout_pipe; + pipe.read_to_end(&mut output) + .expect("read generated stdout"); + output + }); + let stderr_reader = thread::spawn(move || { + let mut output = Vec::new(); + let mut pipe = stderr_pipe; + pipe.read_to_end(&mut output) + .expect("read generated stderr"); + output + }); + Self { + address, + child, + stderr: Some(stderr_reader), + stdout: Some(stdout_reader), + } + } + + fn stop(mut self) -> Output { + if self + .child + .try_wait() + .expect("generated process state") + .is_none() + { + let _killed = self.child.kill(); + } + let status = self.child.wait().expect("reap generated app"); + let stdout = self + .stdout + .take() + .expect("stdout reader") + .join() + .expect("stdout thread"); + let stderr = self + .stderr + .take() + .expect("stderr reader") + .join() + .expect("stderr thread"); + Output { + status, + stderr, + stdout, + } + } + } + + impl Drop for RunningHost { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _killed = self.child.kill(); + } + let _status = self.child.wait(); + if let Some(stderr) = self.stderr.take() { + let _output = stderr.join(); + } + if let Some(stdout) = self.stdout.take() { + let _output = stdout.join(); + } + } + } + + fn deadline_after(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("deadline within representable range") + } + + fn accept_before(listener: &TcpListener, deadline: Instant) -> (TcpStream, SocketAddr) { + loop { + match listener.accept() { + Ok((stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture peer must be loopback"); + return (stream, peer); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "fixture request deadline"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + } + + fn read_request(stream: &mut TcpStream) -> HttpRequest { + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 2048]; + let header_end = loop { + let read = stream.read(&mut chunk).expect("read request headers"); + assert_ne!(read, 0, "peer closed before request headers"); + bytes.extend_from_slice(&chunk[..read]); + assert!(bytes.len() <= 64 * 1024, "fixture request header bound"); + if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + break index.checked_add(4).expect("header end offset"); + } + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (_, value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length"))?; + value.trim().parse::().ok() + }) + .unwrap_or(0); + assert!(content_length <= 64 * 1024, "fixture request body bound"); + let body_end = header_end + .checked_add(content_length) + .expect("request body end offset"); + while bytes.len() < body_end { + let read = stream.read(&mut chunk).expect("read request body"); + assert_ne!(read, 0, "peer closed before request body"); + bytes.extend_from_slice(&chunk[..read]); + assert!(bytes.len() <= body_end, "bounded request body"); + } + HttpRequest { + body: String::from_utf8_lossy(&bytes[header_end..body_end]).into_owned(), + headers: headers.to_ascii_lowercase(), + } + } + + fn write_reply(stream: &mut TcpStream, reply: &HttpReply) { + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reply.reason, + reply.content_type, + reply.body.len() + ) + .expect("write fixture response headers"); + stream + .write_all(reply.body.as_bytes()) + .expect("write fixture response body"); + } + + fn toml_table(entries: impl IntoIterator) -> TomlValue { + TomlValue::Table(entries.into_iter().collect()) + } + + fn toml_strings(values: &[&str]) -> TomlValue { + TomlValue::Array( + values + .iter() + .map(|value| TomlValue::String((*value).to_owned())) + .collect(), + ) + } + + fn install_store_declarations(project: &Path) { + let manifest_path = project.join("edgezero.toml"); + let source = fs::read_to_string(&manifest_path).expect("scaffold manifest"); + let mut manifest: TomlValue = toml::from_str(&source).expect("parse scaffold TOML"); + let root = manifest.as_table_mut().expect("manifest root table"); + let stores = toml_table([ + ( + "config".to_owned(), + toml_table([ + ("ids".to_owned(), toml_strings(&["app_config"])), + ( + "default".to_owned(), + TomlValue::String("app_config".to_owned()), + ), + ]), + ), + ( + "secrets".to_owned(), + toml_table([ + ("ids".to_owned(), toml_strings(&["signing"])), + ( + "default".to_owned(), + TomlValue::String("signing".to_owned()), + ), + ]), + ), + ]); + root.insert("stores".to_owned(), stores); + fs::write( + &manifest_path, + toml::to_string(&manifest).expect("serialize manifest"), + ) + .expect("write portable store IDs"); + } + + fn set_axum_features(project: &Path, features: &[&str]) { + let manifest_path = project.join("edgezero.toml"); + let source = fs::read_to_string(&manifest_path).expect("generated manifest"); + let mut manifest: TomlValue = toml::from_str(&source).expect("parse generated manifest"); + let axum_build = manifest + .as_table_mut() + .and_then(|root| root.get_mut("adapters")) + .and_then(TomlValue::as_table_mut) + .and_then(|adapters| adapters.get_mut("axum")) + .and_then(TomlValue::as_table_mut) + .and_then(|axum| axum.get_mut("build")) + .and_then(TomlValue::as_table_mut) + .expect("generated Axum build settings"); + axum_build.insert("features".to_owned(), toml_strings(features)); + fs::write( + &manifest_path, + toml::to_string(&manifest).expect("serialize manifest"), + ) + .expect("write selected Axum build features"); + } + + fn prepare_typed_generated_app(project: &Path) { + let core = project.join("crates/native-probe-core/src"); + fs::write( + core.join("config.rs"), + concat!( + "use serde::Deserialize;\n", + "use validator::Validate;\n\n", + "#[derive(Debug, Deserialize, Validate, edgezero_core::AppConfig)]\n", + "#[serde(deny_unknown_fields)]\n", + "pub struct NativeProbeConfig {\n", + " #[validate(length(min = 4_u64))]\n", + " pub greeting: String,\n", + " #[secret]\n", + " #[validate(length(min = 8_u64))]\n", + " pub api_token: String,\n", + "}\n\n", + "#[derive(Clone)]\n", + "pub struct StartupState {\n", + " pub greeting: String,\n", + " pub api_token: String,\n", + "}\n", + ), + ) + .expect("write typed app schema"); + + let handlers_path = core.join("handlers.rs"); + let mut handlers = fs::read_to_string(&handlers_path).expect("generated handlers"); + handlers = handlers.replace( + "use edgezero_core::extractor::{Headers, Json, Path};", + "use edgezero_core::extractor::{Headers, Json, Path, State};\nuse std::sync::Arc;", + ); + let start = handlers + .find("#[action]\npub async fn root()") + .expect("generated root handler"); + let handler_tail = handlers.get(start..).expect("root handler UTF-8 boundary"); + let relative_end = handler_tail + .find("\n}\n") + .expect("generated root handler end"); + let handler_end = start + .checked_add(relative_end) + .expect("generated root handler end offset"); + let close = handler_end + .checked_add(3) + .expect("generated root handler closing offset"); + handlers.replace_range( + start..close, + r#"#[action] + pub async fn root(State(state): State>) -> Text { + Text::new(format!("{}|{}", state.greeting, state.api_token)) + } + "#, + ); + fs::write(handlers_path, handlers).expect("wire typed response state"); + fs::write( + project.join("native-probe.toml"), + "greeting = \"local-typed-config\"\napi_token = \"signing_key\"\n", + ) + .expect("write local typed validation fixture"); + } + + fn install_startup_initializer(project: &Path) { + fs::write( + project.join("crates/native-probe-adapter-axum/src/main.rs"), + r#"use std::fs; + use std::path::PathBuf; + use std::sync::Arc; + use std::thread; + use std::time::{Duration, Instant}; + + use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; + use edgezero_core::extractor::AppConfig; + use edgezero_core::EdgeError; + use native_probe_core::config::{NativeProbeConfig, StartupState}; + + fn main() -> anyhow::Result<()> { + run_production_app_with_initializer::(|app, stores| { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.named_ref("app_config")) + .ok_or_else(|| EdgeError::service_unavailable("config binding missing"))?; + let config = AppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + if let (Ok(entered), Ok(release)) = ( + std::env::var("NATIVE_PROBE_INITIALIZER_ENTERED"), + std::env::var("NATIVE_PROBE_INITIALIZER_RELEASE"), + ) { + fs::write(entered, b"typed config is valid") + .map_err(EdgeError::internal)?; + let release = PathBuf::from(release); + let deadline = Instant::now().checked_add(Duration::from_secs(10)) + .ok_or_else(|| EdgeError::service_unavailable("initializer deadline unavailable"))?; + while !release.is_file() { + if Instant::now() >= deadline { + return Err(EdgeError::internal(anyhow::anyhow!( + "test initializer gate timed out" + ))); + } + thread::sleep(Duration::from_millis(5)); + } + } + app.insert_state(Arc::new(StartupState { + greeting: config.greeting, + api_token: config.api_token, + })); + Ok(()) + }) + })?; + Ok(()) + } + "#, + ) + .expect("install app-owned typed startup check"); + } + + fn bindings_file(path: &Path, agent_endpoint: &str) { + fs::write( + path, + format!( + r#"version = 1 + + [config.app_config] + provider = "aws-appconfig-agent" + default_key = "settings" + endpoint = "{agent_endpoint}" + access_token_env = "APPCONFIG_AGENT_TOKEN" + + [config.app_config.documents.settings] + application = "native-probe" + environment = "test" + profile = "startup-proof" + + [secrets.signing] + provider = "aws-secrets-manager" + region = "us-east-1" + + [secrets.signing.entries.signing_key] + secret_id = "{SECRET_ARN}" + "# + ), + ) + .expect("write absolute fictional bindings file"); + } + + fn serialized_envelope() -> String { + serde_json::to_string(&BlobEnvelope::new( + json!({ + "greeting": "generated-startup-greeting", + "api_token": "signing_key", + }), + "2026-10-07T12:00:00Z".to_owned(), + )) + .expect("serialize actual core envelope") + } + + fn secret_reply() -> HttpReply { + HttpReply::ok( + json!({ + "ARN": SECRET_ARN, + "Name": "native-probe-signing", + "VersionId": "12345678901234567890123456789012", + "SecretString": APP_TOKEN, + }) + .to_string(), + "application/x-amz-json-1.1", + ) + } + + fn run_disabled_child(mut command: Command, address: SocketAddr) -> Output { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn default-off generated app"); + let deadline = deadline_after(WAIT); + loop { + if child + .try_wait() + .expect("default-off child status") + .is_some() + { + return child + .wait_with_output() + .expect("capture disabled-provider error"); + } + if TcpStream::connect_timeout(&address, Duration::from_millis(25)).is_ok() { + let _killed = child.kill(); + let _output = child.wait_with_output(); + panic!("default-off generated app accepted traffic for an AWS binding"); + } + if Instant::now() >= deadline { + let _killed = child.kill(); + let _output = child.wait_with_output(); + panic!("disabled provider did not fail closed"); + } + thread::sleep(Duration::from_millis(10)); + } + } + + fn assert_no_listener(address: SocketAddr) { + TcpStream::connect_timeout(&address, Duration::from_millis(60)) + .expect_err("startup must not accept before provider and app checks finish"); + } + + fn reserve_app_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve loopback app port"); + listener.local_addr().expect("app address") + } + + fn empty_endpoint() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve local closed endpoint"); + let address = listener.local_addr().expect("closed loopback endpoint"); + drop(listener); + format!("http://{address}") + } + + fn profiles(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let home = root.join("empty-home"); + let aws = root.join("empty-aws-profiles"); + fs::create_dir_all(&home).expect("empty HOME"); + fs::create_dir_all(&aws).expect("profile directory"); + let config = aws.join("config"); + let credentials = aws.join("credentials"); + fs::write(&config, "").expect("no AWS config profiles"); + fs::write(&credentials, "").expect("no AWS credential profiles"); + (home, config, credentials) + } + + fn child_command( + binary: &Path, + root: &Path, + bindings: &Path, + app_address: SocketAddr, + secrets_endpoint: &str, + initializer_gate: Option<(&Path, &Path)>, + ) -> Command { + let (home, aws_config, aws_credentials) = profiles(root); + let mut command = Command::new(binary); + command + .env_clear() + .current_dir(root) + .env("HOME", home) + .env("AWS_CONFIG_FILE", aws_config) + .env("AWS_SHARED_CREDENTIALS_FILE", aws_credentials) + .env("AWS_REGION", "us-east-1") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ACCESS_KEY_ID", "DUMMY_GENERATED_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY", "DUMMY_GENERATED_SECRET_KEY") + .env("AWS_ENDPOINT_URL", secrets_endpoint) + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", secrets_endpoint) + .env("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "false") + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", app_address.port().to_string()) + .env("EDGEZERO__LOGGING__LEVEL", "off") + .env("APPCONFIG_AGENT_TOKEN", AGENT_TOKEN) + .env("signing_key", FALLBACK_SENTINEL); + if let Some((entered, release)) = initializer_gate { + command + .env("NATIVE_PROBE_INITIALIZER_ENTERED", entered) + .env("NATIVE_PROBE_INITIALIZER_RELEASE", release); + } + command + } + + fn assert_no_sensitive_output(output: &Output) { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + for sentinel in [ + APP_TOKEN, + AGENT_TOKEN, + FALLBACK_SENTINEL, + "DUMMY_GENERATED_ACCESS_KEY", + "DUMMY_GENERATED_SECRET_KEY", + "AGENT_BODY_SENTINEL", + "SECRET_BODY_SENTINEL", + ] { + assert!(!text.contains(sentinel), "child output leaked {sentinel}"); + } + } + + fn assert_cargo_command(project: &Path, features: &[&str]) { + set_axum_features(project, features); + let output = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["build", "--adapter", "axum", "--", "--offline"]) + .current_dir(project) + .env("CARGO_TARGET_DIR", target_directory(project)) + .output() + .expect("run root CLI native build"); + assert!( + output.status.success(), + "edgezero build with selected manifest features {features:?} failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn target_directory(project: &Path) -> PathBuf { + match var_os("EDGEZERO_GENERATED_TEST_TARGET_DIR") { + Some(target_override) => { + let target_path = PathBuf::from(target_override); + if target_path.is_absolute() { + target_path + } else { + project.join(target_path) + } + } + None => project.join("target"), + } + } + + fn generated_binary(project: &Path) -> PathBuf { + target_directory(project).join("debug/native-probe-adapter-axum") + } + + fn wait_for_file(path: &Path) { + let deadline = deadline_after(WAIT); + while !path.is_file() { + assert!( + Instant::now() < deadline, + "generated initializer gate timeout" + ); + thread::sleep(Duration::from_millis(5)); + } + } + + fn http_body(response: &str) -> &str { + response + .split_once("\r\n\r\n") + .expect("HTTP response boundary") + .1 + } + + fn prepare_workspace(temp: &TempDir) -> PathBuf { + let scaffold = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["new", "native-probe", "--dir"]) + .arg(temp.path()) + .output() + .expect("run real edgezero new command"); + assert!( + scaffold.status.success(), + "edgezero new failed:\n{}\n{}", + String::from_utf8_lossy(&scaffold.stdout), + String::from_utf8_lossy(&scaffold.stderr) + ); + let project = temp.path().join("native-probe"); + assert!( + project + .join("crates/native-probe-adapter-axum/src/main.rs") + .is_file() + ); + install_store_declarations(&project); + prepare_typed_generated_app(&project); + project + } + + #[test] + #[ignore = "runs the real scaffold and five offline generated native builds; coordinator-owned Cargo check"] + #[expect( + clippy::too_many_lines, + reason = "Keep this generated CLI lifecycle as one readable end-to-end proof." + )] + fn generated_cli_build_features_and_native_aws_startup_are_proven_together() { + let temp = tempfile::tempdir().expect("generated project root"); + let project = prepare_workspace(&temp); + let bindings = temp.path().join("native-stores.toml"); + let no_service_endpoint = empty_endpoint(); + bindings_file(&bindings, &no_service_endpoint); + + // Default generated Cargo features stay empty. With an explicit AWS binding selected, + // run_generated_app must use strict startup and reject the disabled provider. Port 1 is + // just a closed loopback port, so a broken feature guard still cannot reach AWS. + assert_cargo_command(&project, &[]); + let disabled_address = reserve_app_address(); + let disabled = run_disabled_child( + child_command( + &generated_binary(&project), + temp.path(), + &bindings, + disabled_address, + &no_service_endpoint, + None, + ), + disabled_address, + ); + assert!( + !disabled.status.success(), + "disabled provider must fail startup" + ); + assert_no_listener(disabled_address); + let disabled_stderr = String::from_utf8_lossy(&disabled.stderr); + assert!( + disabled_stderr.contains("provider not compiled"), + "disabled provider must report a closed capability error: {disabled_stderr}" + ); + assert_no_sensitive_output(&disabled); + + // The build command under test reads `[adapters.axum.build].features`, not features on + // this CLI binary or arbitrary dependency unification in the root workspace. + assert_cargo_command(&project, &["aws-appconfig-agent"]); + assert_cargo_command(&project, &["aws-secrets-manager"]); + assert_cargo_command(&project, &["aws-appconfig-agent", "aws-secrets-manager"]); + + // This app-owned check is deliberately in the generated consumer. The generated default + // main remains run_generated_app; replace it only for the real typed-startup scenario. + install_startup_initializer(&project); + assert_cargo_command(&project, &["aws-appconfig-agent", "aws-secrets-manager"]); + + let agent = GatedService::bind(); + let secrets = GatedService::bind(); + let document = serialized_envelope(); + let agent_endpoint = agent.endpoint().to_owned(); + let secrets_endpoint = secrets.endpoint().to_owned(); + bindings_file(&bindings, &agent_endpoint); + let app_address = reserve_app_address(); + let initializer_entered = temp.path().join("initializer-entered"); + let initializer_release = temp.path().join("initializer-release"); + let mut host = RunningHost::spawn( + child_command( + &generated_binary(&project), + temp.path(), + &bindings, + app_address, + &secrets_endpoint, + Some((&initializer_entered, &initializer_release)), + ), + app_address, + ); + + // Hold each provider response. Neither listener may accept while provider setup or the + // typed app initializer is still pending. + let agent_request = agent.request(); + assert!(agent_request.headers.starts_with( + "get /applications/native-probe/environments/test/configurations/startup-proof " + )); + assert!( + agent_request + .headers + .contains(&format!("authorization: bearer {AGENT_TOKEN}")) + ); + assert!(agent_request.headers.contains("accept-encoding: identity")); + assert_no_listener(app_address); + agent.reply(HttpReply::ok(document, "application/json")); + + let secret_request = secrets.request(); + assert!( + secret_request + .headers + .contains("x-amz-target: secretsmanager.getsecretvalue") + ); + assert!( + secret_request + .headers + .contains("credential=dummy_generated_access_key/") + ); + let secret_input: Value = + serde_json::from_str(&secret_request.body).expect("actual SDK request JSON"); + assert_eq!(secret_input["SecretId"], SECRET_ARN); + assert!(secret_input.get("VersionStage").is_none()); + assert!(secret_input.get("VersionId").is_none()); + assert_no_listener(app_address); + secrets.reply(secret_reply()); + + // The generated initializer has now parsed and validated the actual envelope and resolved + // the nested signing_key value. It pauses before returning; the generated app must still + // keep its listener closed until the application-owned check completes. + wait_for_file(&initializer_entered); + assert_no_listener(app_address); + fs::write(&initializer_release, "continue").expect("release generated initializer"); + + // AppConfig::from_binding validated the envelope, resolved `api_token = "signing_key"` + // from the AWS secret snapshot, and inserted the typed state before the listener appeared. + let first = host.http("/"); + assert_eq!( + http_body(&first), + "generated-startup-greeting|generated-proof-secret-value" + ); + let second = host.http("/"); + assert_eq!( + http_body(&second), + "generated-startup-greeting|generated-proof-secret-value" + ); + + // Both loopback providers received one startup request. Request-time reads came entirely + // from the process snapshots; an accidental remote read would be captured as an extra. + assert!( + agent.finish().is_empty(), + "no Agent request after preparation" + ); + assert!( + secrets.finish().is_empty(), + "no Secrets Manager request after preparation" + ); + let output = host.stop(); + assert_no_sensitive_output(&output); + } +} diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 74c86b998..530e990c7 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -289,6 +289,19 @@ impl App { self.ingress_head_limits } + /// Registers typed handler state during startup, before ingress admission or serving. + /// + /// Same-type insertion replaces previous state for this App only. Shared routers + /// fork on write, preserving routes and peer Apps. Previously resolved or admitted + /// dispatch tokens are invalid for this App after insertion. This is not live reload. + #[inline] + pub fn insert_state(&mut self, value: T) + where + T: Clone + Send + Sync + 'static, + { + self.router.insert_state(value); + } + /// Consume the app and return the contained router service. #[must_use] #[inline] @@ -2570,6 +2583,60 @@ mod tests { assert_eq!(complete_envelope(response).status(), StatusCode::OK); } + #[test] + fn startup_state_forks_router_and_invalidates_old_admission() { + use crate::extractor::State; + + #[crate::action] + async fn state(State(value): State>) -> Result { + Ok(value.to_string()) + } + + let router = RouterService::builder() + .get("/state/{id}", state) + .with_state(Arc::::from("builder")) + .build(); + let peer = App::new(router.clone()); + let mut app = App::new(router); + let head = || { + IngressHeadParts::new( + Method::GET, + "/state/42".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ) + }; + let IngressBeginOutcome::Admitted(old) = app + .begin_ingress(head(), MonotonicInstant::now()) + .expect("admission") + else { + panic!("expected admission") + }; + let request = || { + request_builder() + .uri("/state/42") + .body(Body::empty()) + .expect("request") + }; + app.insert_state(Arc::::from("first")); + app.insert_state(Arc::::from("startup")); + assert_eq!(app.router().routes(), peer.router().routes()); + let rejected = complete_envelope(block_on(app.dispatch_admitted(old, request()))); + assert_eq!(rejected.status(), StatusCode::INTERNAL_SERVER_ERROR); + for _ in 0..2_u8 { + let IngressBeginOutcome::Admitted(new) = app + .begin_ingress(head(), MonotonicInstant::now()) + .expect("admission") + else { + panic!("expected admission") + }; + let response = complete_envelope(block_on(app.dispatch_admitted(new, request()))); + assert_eq!(response.body().as_bytes().expect("body"), b"startup"); + } + let response = block_on(peer.router().oneshot(request())).expect("peer response"); + assert_eq!(response.body().as_bytes().expect("body"), b"builder"); + } + #[test] fn default_hooks_do_not_own_logging() { assert!(!DefaultHooks::owns_logging()); diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 0d5de5bcd..ddf5fb9ec 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -844,10 +844,15 @@ where None, ) })?; - let key = binding.default_key.clone(); - extract_from_handle::(ctx, &binding.handle, &key) - .await - .map(AppConfig) + Self::from_binding( + binding, + None, + ctx.extensions().get::(), + ctx.config_extraction_limits(), + ctx.monotonic_clock(), + ) + .await + .map(AppConfig) } } @@ -855,6 +860,33 @@ impl AppConfig where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { + /// Loads one application-selected binding without a request or route dispatch. + /// + /// Uses the same limits, envelope checks, secret resolution, validation and + /// error categories as request extraction. `None` selects the binding's default + /// key. This does not cache config or infer required application schemas. + /// + /// # Errors + /// Returns a typed store-extraction error for backend, budget, envelope, + /// secret or schema failures. Native startup must classify errors before logging. + #[inline] + pub async fn from_binding( + binding: &ConfigStoreBinding, + key: Option<&str>, + secrets: Option<&SecretRegistry>, + limits: ConfigExtractionLimits, + clock: MonotonicClock, + ) -> Result { + extract_from_handle::( + &binding.handle, + key.unwrap_or(&binding.default_key), + secrets, + limits, + clock, + ) + .await + } + /// Read the typed config from a NON-default config store. /// `key = None` falls back to that store's `binding.default_key`. /// Returns the inner `C` directly per spec 6.2.1. @@ -874,8 +906,14 @@ where None, ) })?; - let resolved_key = key.unwrap_or(&binding.default_key).to_owned(); - extract_from_handle::(ctx, &binding.handle, &resolved_key).await + Self::from_binding( + binding, + key, + ctx.extensions().get::(), + ctx.config_extraction_limits(), + ctx.monotonic_clock(), + ) + .await } /// Read the typed config from the default store under an @@ -896,7 +934,14 @@ where None, ) })?; - extract_from_handle::(ctx, &binding.handle, key).await + Self::from_binding( + binding, + Some(key), + ctx.extensions().get::(), + ctx.config_extraction_limits(), + ctx.monotonic_clock(), + ) + .await } } @@ -1201,15 +1246,16 @@ fn future_format_reason(raw: &str) -> Option { /// Returns [`EdgeError::StoreExtraction`] with the stable reason corresponding /// to store, envelope, secret, or schema failure. async fn extract_from_handle( - ctx: &RequestContext, handle: &ConfigStoreHandle, key: &str, + secrets: Option<&SecretRegistry>, + limits: ConfigExtractionLimits, + clock: MonotonicClock, ) -> Result where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - let mut budget = - ConfigExtractionBudget::start(ctx.config_extraction_limits(), ctx.monotonic_clock())?; + let mut budget = ConfigExtractionBudget::start(limits, clock)?; let read = handle .get_bounded( key, @@ -1274,7 +1320,7 @@ where let mut data = parsed?; drop(raw); - let resolved = secret_walk::(ctx, &mut budget, &mut data).await; + let resolved = secret_walk::(secrets, &mut budget, &mut data).await; budget.check_deadline()?; resolved?; @@ -1335,7 +1381,7 @@ fn map_config_store_error(err: &ConfigStoreError) -> EdgeError { /// /// `StoreRef` fields are skipped — their value is a store id, not a key. async fn secret_walk( - ctx: &RequestContext, + secrets: Option<&SecretRegistry>, budget: &mut ConfigExtractionBudget, data: &mut serde_json::Value, ) -> Result<(), EdgeError> @@ -1349,7 +1395,7 @@ where if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_secret_field(ctx, budget, data, &field, &field.path, String::new()).await?; + resolve_secret_field(secrets, budget, data, &field, &field.path, String::new()).await?; } Ok(()) } @@ -1358,7 +1404,7 @@ where /// secret leaf(s). `rendered` is the dotted path so far (with concrete `[n]` /// indices) for error hints. fn resolve_secret_field<'walk>( - ctx: &'walk RequestContext, + secrets: Option<&'walk SecretRegistry>, budget: &'walk mut ConfigExtractionBudget, node: &'walk mut serde_json::Value, field: &'walk SecretField, @@ -1369,7 +1415,7 @@ fn resolve_secret_field<'walk>( match remaining.split_first() { // Leaf reached: `node` is the PARENT object; the last field is the key. Some((SecretPathSegment::Field(name), [])) => { - resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await + resolve_leaf(secrets, budget, node, field, name.as_ref(), &rendered).await } Some((SecretPathSegment::OptionalField(name), [])) => { if node.as_object().is_some_and(|parent| { @@ -1380,7 +1426,7 @@ fn resolve_secret_field<'walk>( }) { return Ok(()); } - resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await + resolve_leaf(secrets, budget, node, field, name.as_ref(), &rendered).await } // Required intermediates still reject stale blobs. Optional // intermediates are represented explicitly below rather than by the @@ -1394,7 +1440,8 @@ fn resolve_secret_field<'walk>( Some(next_rendered), )), Some(child) => { - resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await + resolve_secret_field(secrets, budget, child, field, rest, next_rendered) + .await } } } @@ -1410,7 +1457,8 @@ fn resolve_secret_field<'walk>( match parent.get_mut(name.as_ref()) { None | Some(serde_json::Value::Null) => Ok(()), Some(child) => { - resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await + resolve_secret_field(secrets, budget, child, field, rest, next_rendered) + .await } } } @@ -1426,7 +1474,7 @@ fn resolve_secret_field<'walk>( }; for (idx, item) in items.iter_mut().enumerate() { let indexed = format!("{rendered}[{idx}]"); - resolve_secret_field(ctx, budget, item, field, rest, indexed).await?; + resolve_secret_field(secrets, budget, item, field, rest, indexed).await?; } Ok(()) } @@ -1447,7 +1495,7 @@ fn join_field(prefix: &str, name: &str) -> String { /// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling /// within `parent`. async fn resolve_leaf( - ctx: &RequestContext, + secrets: Option<&SecretRegistry>, budget: &mut ConfigExtractionBudget, parent: &mut serde_json::Value, field: &SecretField, @@ -1486,7 +1534,7 @@ async fn resolve_leaf( let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { - let bound = ctx.secret_store_default().ok_or_else(|| { + let bound = secrets.and_then(SecretRegistry::default).ok_or_else(|| { store_extraction_error( StoreExtractionReason::MissingRegistry, format!( @@ -1514,19 +1562,21 @@ async fn resolve_leaf( ) })? .to_owned(); - let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { - // `store_id_str` is the blob's store_ref VALUE — config data that - // may be sensitive — and this message reaches the HTTP body. Name - // the field, not the stored id. - store_extraction_error( - StoreExtractionReason::UnknownStore, - format!( - "secret field `{leaf_path}` names a store_ref that is not declared in \ + let bound = secrets + .and_then(|registry| registry.named(&store_id_str)) + .ok_or_else(|| { + // `store_id_str` is the blob's store_ref VALUE — config data that + // may be sensitive — and this message reaches the HTTP body. Name + // the field, not the stored id. + store_extraction_error( + StoreExtractionReason::UnknownStore, + format!( + "secret field `{leaf_path}` names a store_ref that is not declared in \ [stores.secrets] (id redacted)" - ), - Some(leaf_path.clone()), - ) - })?; + ), + Some(leaf_path.clone()), + ) + })?; (bound, store_id_str) } }; @@ -2785,7 +2835,7 @@ mod tests { let mut budget = ConfigExtractionBudget::start(ConfigExtractionLimits::default(), ctx.monotonic_clock()) .expect("test budget"); - secret_walk::(ctx, &mut budget, data).await + secret_walk::(ctx.extensions().get::(), &mut budget, data).await } #[test] diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index cc1fa9e83..407e9cb2b 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod middleware; pub mod outbound; pub mod params; pub mod platform; +pub mod probe; pub mod responder; pub mod response; pub mod response_egress; diff --git a/crates/edgezero-core/src/probe.rs b/crates/edgezero-core/src/probe.rs new file mode 100644 index 000000000..7f8ec783b --- /dev/null +++ b/crates/edgezero-core/src/probe.rs @@ -0,0 +1,163 @@ +//! App-owned probe routes with read-only live lifecycle observation. +//! +//! Adapters may publish a reader in request extensions. Absence does not imply +//! health or lifecycle support. Applications choose paths and middleware; these +//! helpers register no routes. Do not override framework probe/store extensions +//! through application state or middleware. Native dispatch uses a private gate. + +use std::fmt; +use std::sync::Arc; + +use crate::action; +use crate::body::Body; +use crate::context::RequestContext; +use crate::error::EdgeError; +use crate::http::{Response, StatusCode, response_builder}; + +/// Observed native runner phase. Once draining starts, a run never becomes ready again. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LifecyclePhase { + Draining, + Ready, + Starting, + Stopped, +} + +/// Cloneable read-only observation, sampled when a handler executes. +/// +/// Readiness means the selected entrypoint's startup contract succeeded, not that +/// undisclosed application requirements or ongoing dependencies were checked. +#[derive(Clone)] +pub struct LifecycleReader { + read: Arc Option + Send + Sync>, +} + +impl LifecycleReader { + #[inline] + #[must_use] + pub fn new(read: Read) -> Self + where + Read: Fn() -> Option + Send + Sync + 'static, + { + Self { + read: Arc::new(read), + } + } + + #[inline] + #[must_use] + pub fn phase(&self) -> Option { + (self.read)() + } +} + +impl fmt::Debug for LifecycleReader { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LifecycleReader").finish_non_exhaustive() + } +} + +/// Returns 200 while starting, ready or draining, otherwise 503. +/// +/// This table applies only if the handler executes. Before acceptance and after +/// listener withdrawal, supervisors must also handle refusal, reset or EOF. +/// +/// # Errors +/// Returns an internal error only if the static response cannot be constructed. +#[action] +pub async fn liveness(ctx: RequestContext) -> Result { + match phase(&ctx) { + Some(LifecyclePhase::Starting | LifecyclePhase::Ready | LifecyclePhase::Draining) => { + response(StatusCode::OK, "live") + } + Some(LifecyclePhase::Stopped) => response(StatusCode::SERVICE_UNAVAILABLE, "stopped"), + None => response(StatusCode::SERVICE_UNAVAILABLE, "probe state unavailable"), + } +} + +fn phase(ctx: &RequestContext) -> Option { + ctx.extensions() + .get::() + .and_then(LifecycleReader::phase) +} + +/// Returns 200 only in Ready, otherwise 503. Does not poll dependencies. +/// +/// # Errors +/// Returns an internal error only if the static response cannot be constructed. +#[action] +pub async fn readiness(ctx: RequestContext) -> Result { + match phase(&ctx) { + Some(LifecyclePhase::Ready) => response(StatusCode::OK, "ready"), + Some(_) => response(StatusCode::SERVICE_UNAVAILABLE, "not ready"), + None => response(StatusCode::SERVICE_UNAVAILABLE, "probe state unavailable"), + } +} + +fn response(status: StatusCode, text: &'static str) -> Result { + response_builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .header("cache-control", "no-store") + .body(Body::from(text)) + .map_err(EdgeError::internal) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use futures::executor::block_on; + + use super::*; + use crate::http::request_builder; + use crate::router::RouterService; + + #[test] + fn ordinary_routes_observe_live_phase_and_missing_state() { + #[action] + async fn health() -> Result<&'static str, EdgeError> { + Ok("app health") + } + let current = Arc::new(Mutex::new(None)); + let observed = Arc::clone(¤t); + let reader = LifecycleReader::new(move || *observed.lock().expect("phase")); + let router = RouterService::builder() + .get("/app-ready", readiness) + .get("/app-live", liveness) + .get("/health", health) + .build(); + for (value, ready, live) in [ + (None, 503, 503), + (Some(LifecyclePhase::Starting), 503, 200), + (Some(LifecyclePhase::Ready), 200, 200), + (Some(LifecyclePhase::Draining), 503, 200), + (Some(LifecyclePhase::Stopped), 503, 503), + ] { + *current.lock().expect("phase") = value; + for (path, expected) in [("/app-ready", ready), ("/app-live", live)] { + let mut request = request_builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(reader.clone()); + let result = block_on(router.oneshot(request)).expect("probe"); + assert_eq!(result.status().as_u16(), expected); + assert_eq!(result.headers()["cache-control"], "no-store"); + assert_eq!( + result.headers()["content-type"], + "text/plain; charset=utf-8" + ); + } + } + for (path, expected) in [("/app-ready", 503), ("/health", 200), ("/ready", 404)] { + let request = request_builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + let result = block_on(router.oneshot(request)).expect("response"); + assert_eq!(result.status().as_u16(), expected); + } + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 08d92e024..ea1fd52c4 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -345,6 +345,7 @@ impl RouterBuilder { } } +#[derive(Clone)] struct RouterInner { manifest_json: Option>, middlewares: Vec, @@ -528,6 +529,15 @@ impl RouterService { } } + pub(crate) fn insert_state(&mut self, value: T) + where + T: Clone + Send + Sync + 'static, + { + Arc::make_mut(&mut self.inner) + .state_extensions + .insert(value); + } + fn new( routes: HashMap>, middlewares: Vec, diff --git a/crates/edgezero-core/tests/fixtures/production_handlers.rs b/crates/edgezero-core/tests/fixtures/production_handlers.rs new file mode 100644 index 000000000..5a3a1e323 --- /dev/null +++ b/crates/edgezero-core/tests/fixtures/production_handlers.rs @@ -0,0 +1,32 @@ +//! Shared native/WASM hosting fixture. No native runtime dependency belongs here. + +use std::sync::Arc; + +use edgezero_core::EdgeError; +use edgezero_core::action; +use edgezero_core::extractor::{AppConfig, State}; +use serde::Deserialize; +use validator::Validate; + +#[derive(Deserialize, Validate, edgezero_core::AppConfig)] +pub struct FixtureConfig { + #[validate(length(min = 4_u64))] + pub greeting: String, + #[secret] + #[validate(length(min = 8_u64))] + pub token: String, +} + +pub struct HostState { + pub greeting: String, +} + +#[action] +pub async fn state(State(state): State>) -> Result { + Ok(state.greeting.clone()) +} + +#[action] +pub async fn typed(AppConfig(config): AppConfig) -> Result { + Ok(config.greeting) +} diff --git a/crates/edgezero-core/tests/production_handlers.rs b/crates/edgezero-core/tests/production_handlers.rs new file mode 100644 index 000000000..afa95fe74 --- /dev/null +++ b/crates/edgezero-core/tests/production_handlers.rs @@ -0,0 +1,41 @@ +//! Compile this same handler fixture for native and every supported WASM target. + +#[path = "fixtures/production_handlers.rs"] +mod fixture; + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use edgezero_core::Body; + use edgezero_core::http::{StatusCode, request_builder}; + use edgezero_core::probe; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + use super::fixture; + + #[test] + fn shared_state_config_and_probe_actions_use_portable_routes() { + let router = RouterService::builder() + .get("/state", fixture::state) + .get("/typed", fixture::typed) + .get("/ready", probe::readiness) + .with_state(Arc::new(fixture::HostState { + greeting: "retained".to_owned(), + })) + .build(); + for (path, status) in [ + ("/state", StatusCode::OK), + ("/ready", StatusCode::SERVICE_UNAVAILABLE), + ("/typed", StatusCode::INTERNAL_SERVER_ERROR), + ] { + let request = request_builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + let response = block_on(router.oneshot(request)).expect("response"); + assert_eq!(response.status(), status); + } + } +} diff --git a/crates/edgezero-store-aws/Cargo.toml b/crates/edgezero-store-aws/Cargo.toml new file mode 100644 index 000000000..42fef05cb --- /dev/null +++ b/crates/edgezero-store-aws/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "edgezero-store-aws" +description = "Optional native AWS configuration and secret snapshots for EdgeZero" +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[features] +default = [] +snapshot-stores = ["dep:edgezero-core", "dep:bytes", "dep:async-trait", "dep:tokio"] +appconfig-agent = ["snapshot-stores", "dep:reqwest", "dep:futures-util"] +secrets-manager = ["snapshot-stores", "dep:aws-config", "dep:aws-sdk-secretsmanager"] + +[dependencies] +percent-encoding = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +async-trait = { workspace = true, optional = true } +aws-config = { workspace = true, optional = true } +aws-sdk-secretsmanager = { workspace = true, optional = true } +bytes = { workspace = true, optional = true } +edgezero-core = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true, features = ["stream"] } +tokio = { workspace = true, optional = true } + +[dev-dependencies] +futures = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } diff --git a/crates/edgezero-store-aws/src/appconfig_tests.rs b/crates/edgezero-store-aws/src/appconfig_tests.rs new file mode 100644 index 000000000..22452b99e --- /dev/null +++ b/crates/edgezero-store-aws/src/appconfig_tests.rs @@ -0,0 +1,166 @@ +//! Literal-loopback HTTP tests for startup-only `AppConfig` snapshots. + +use std::collections::BTreeMap; +use std::time::Duration; + +use edgezero_core::time::MonotonicClock; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use crate::settings::{ + AgentAccessToken, AgentDocument, AppConfigAgentSettings, AwsPreparationLimits, +}; +use crate::{AwsPreparation, PreparationError}; + +async fn fixture(headers: &str, body: Vec) -> (AppConfigAgentSettings, JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("literal loopback"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + let response_headers = headers.to_owned(); + let server = tokio::spawn(async move { + let (mut socket, _) = timeout(Duration::from_secs(2), listener.accept()) + .await + .expect("finite accept") + .expect("accept"); + let mut request = Vec::new(); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let mut chunk = [0_u8; 1024]; + let count = socket.read(&mut chunk).await.expect("request"); + assert_ne!(count, 0, "request did not close before its headers"); + request.extend_from_slice(chunk.get(..count).expect("read buffer bound")); + } + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n{response_headers}\r\n", + body.len() + ) + .as_bytes(), + ) + .await + .expect("response head"); + let _written = socket.write_all(&body).await; + String::from_utf8(request).expect("HTTP request") + }); + let settings = AppConfigAgentSettings { + endpoint, + access_token_env: None, + default_key: "main".to_owned(), + documents: BTreeMap::from([( + "main".to_owned(), + AgentDocument { + application: "app".to_owned(), + environment: "test".to_owned(), + profile: "freeform".to_owned(), + }, + )]), + }; + (settings, server) +} + +#[tokio::test] +async fn raw_empty_and_quoted_utf8_are_preserved_and_successes_are_deduplicated() { + for body in ["", "raw free-form\n", "\"quoted\""] { + let (mut settings, server) = fixture("", body.as_bytes().to_vec()).await; + settings + .documents + .insert("alias".to_owned(), settings.documents["main"].clone()); + let mut session = + AwsPreparation::new(MonotonicClock::default(), AwsPreparationLimits::default()) + .expect("session"); + let handle = session + .prepare_appconfig(&settings, None) + .await + .expect("snapshot"); + let _request = server.await.expect("server"); + for key in ["main", "alias"] { + let value = handle + .get(key) + .await + .expect("lookup") + .expect("mapped value"); + assert_eq!(value.as_ref(), body); + } + // The listener is gone. A second store preparation must reuse the successful target. + let cached = session + .prepare_appconfig(&settings, None) + .await + .expect("cached snapshot"); + assert_eq!( + cached + .get("main") + .await + .expect("read") + .expect("value") + .as_ref(), + body + ); + assert_eq!(session.stats().mappings, 4_u64); + assert_eq!( + session.stats().unique_retained_payload_bytes, + u64::try_from(body.len()).expect("length") + ); + assert!(session.stats().metadata_bytes > 0); + } +} + +#[tokio::test] +async fn bearer_header_and_identity_encoding_are_explicit() { + let (mut settings, server) = fixture("", b"opaque".to_vec()).await; + settings.access_token_env = Some("DUMMY_AGENT_TOKEN".to_owned()); + let token = AgentAccessToken::new("dummy-bearer-token".to_owned()).expect("token"); + let mut session = + AwsPreparation::new(MonotonicClock::default(), AwsPreparationLimits::default()) + .expect("session"); + let _handle = session + .prepare_appconfig(&settings, Some(&token)) + .await + .expect("snapshot"); + let request = server.await.expect("server").to_ascii_lowercase(); + assert!(request.contains("authorization: bearer dummy-bearer-token\r\n")); + assert!(request.contains("accept-encoding: identity\r\n")); +} + +#[tokio::test] +async fn nonidentity_encoding_invalid_utf8_and_payload_caps_fail_closed() { + for (headers, body, cap, expected) in [ + ( + "Content-Encoding: gzip\r\n", + b"raw".to_vec(), + 4_u64, + PreparationError::Malformed, + ), + ( + "Content-Encoding: identity\r\nContent-Encoding: identity\r\n", + b"raw".to_vec(), + 4_u64, + PreparationError::Malformed, + ), + ( + "Content-Encoding: identity, identity\r\n", + b"raw".to_vec(), + 4_u64, + PreparationError::Malformed, + ), + ("", vec![0xff_u8], 4_u64, PreparationError::Malformed), + ("", b"five!".to_vec(), 4_u64, PreparationError::SizeLimit), + ] { + let (settings, server) = fixture(headers, body).await; + let limits = AwsPreparationLimits { + max_config_document_bytes: cap, + ..AwsPreparationLimits::default() + }; + let mut session = AwsPreparation::new(MonotonicClock::default(), limits).expect("session"); + let error = session + .prepare_appconfig(&settings, None) + .await + .map(|_handle| ()) + .expect_err("closed failure"); + assert_eq!(error, expected); + assert_eq!(session.stats().unique_retained_payload_bytes, 0); + let _request = server.await.expect("server"); + } +} diff --git a/crates/edgezero-store-aws/src/lib.rs b/crates/edgezero-store-aws/src/lib.rs new file mode 100644 index 000000000..d1c92e71e --- /dev/null +++ b/crates/edgezero-store-aws/src/lib.rs @@ -0,0 +1,73 @@ +//! Native AWS store preparation, independent of the HTTP adapter. +//! +//! Settings remain available without either service feature. Service failures +//! use closed categories; no SDK, HTTP, settings or payload source chain survives. + +#![cfg_attr( + all( + not(target_arch = "wasm32"), + any(feature = "appconfig-agent", feature = "secrets-manager") + ), + expect( + clippy::pub_use, + reason = "keep the provider's supported preparation API at the crate root" + ) +)] + +pub mod settings; + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "appconfig-agent")] +mod appconfig_tests; + +#[cfg(all( + not(target_arch = "wasm32"), + any(feature = "appconfig-agent", feature = "secrets-manager") +))] +mod preparation; +#[cfg(all(not(target_arch = "wasm32"), feature = "secrets-manager"))] +mod secrets_manager; +#[cfg(test)] +#[cfg(all(not(target_arch = "wasm32"), feature = "secrets-manager"))] +mod snapshot_tests; +#[cfg(all( + not(target_arch = "wasm32"), + any(feature = "appconfig-agent", feature = "secrets-manager") +))] +pub use preparation::{AwsPreparation, PreparationStats}; + +/// Sanitized preparation failures. Never attach provider response details. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PreparationError { + #[error("access denied")] + AccessDenied, + #[error("credential failure")] + Credentials, + #[error("preparation deadline")] + Deadline, + #[error("decryption failure")] + Decryption, + #[error("invalid binding")] + InvalidBinding, + #[error("invalid agent endpoint")] + InvalidEndpoint, + #[error("invalid preparation limits")] + InvalidLimits, + #[error("invalid resource reference")] + InvalidResource, + #[error("invalid version selector")] + InvalidSelector, + #[error("malformed value")] + Malformed, + #[error("missing resource or version")] + Missing, + #[error("size limit")] + SizeLimit, + #[error("throttled")] + Throttled, + #[error("provider unavailable")] + Unavailable, + #[error("provider not compiled")] + UnsupportedProvider, +} diff --git a/crates/edgezero-store-aws/src/preparation.rs b/crates/edgezero-store-aws/src/preparation.rs new file mode 100644 index 000000000..fa1be8bfe --- /dev/null +++ b/crates/edgezero-store-aws/src/preparation.rs @@ -0,0 +1,740 @@ +//! Native service preparation session and immutable `AppConfig` snapshots. +//! +//! This module is enabled only when at least one native AWS store service is +//! selected. The session's counters and caches are private to one startup. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +#[cfg(feature = "appconfig-agent")] +use std::sync::Arc; +use std::time::Duration; + +use edgezero_core::config_store::BoundedStoreRead; +#[cfg(feature = "appconfig-agent")] +use edgezero_core::config_store::ConfigStoreHandle; +#[cfg(feature = "appconfig-agent")] +use edgezero_core::config_store::{ + ConfigStore, ConfigStoreError, ConfigValue, finish_bounded_config_read, +}; +#[cfg(test)] +#[cfg(feature = "appconfig-agent")] +use edgezero_core::config_store_contract_tests; +#[cfg(feature = "secrets-manager")] +use edgezero_core::secret_store::{SecretError, SecretStore, finish_bounded_secret_read}; +use edgezero_core::time::{Deadline, MonotonicClock}; +#[cfg(feature = "appconfig-agent")] +use futures_util::StreamExt as _; +#[cfg(feature = "appconfig-agent")] +use reqwest::header::{ACCEPT_ENCODING, AUTHORIZATION, CONTENT_ENCODING, HeaderValue}; +#[cfg(feature = "appconfig-agent")] +use reqwest::{Client, ClientBuilder, redirect::Policy}; +#[cfg(feature = "appconfig-agent")] +use tokio::time::timeout; + +use crate::PreparationError; +use crate::settings::AwsPreparationLimits; +#[cfg(feature = "appconfig-agent")] +use crate::settings::{AgentAccessToken, AppConfigAgentSettings}; + +/// Aggregate startup preparation. Never share this object across startup runs. +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the session shares selected state with its feature-gated Secrets Manager sibling module" +)] +pub struct AwsPreparation { + accounting: Accounting, + #[cfg(feature = "appconfig-agent")] + appconfig_cache: HashMap, + #[cfg(feature = "appconfig-agent")] + appconfig_client: Option, + pub(crate) clock: MonotonicClock, + pub(crate) deadline: Deadline, + pub(crate) limits: AwsPreparationLimits, + #[cfg(feature = "secrets-manager")] + pub(crate) secret_cache: HashMap, + #[cfg(feature = "secrets-manager")] + pub(crate) secret_clients: HashMap, +} + +impl fmt::Debug for AwsPreparation { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AwsPreparation") + .field("stats", &self.stats()) + .finish_non_exhaustive() + } +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "keep startup entry points and feature-gated service operations grouped ahead of private helpers" +)] +impl AwsPreparation { + /// Start the aggregate deadline before creating any service client. + /// + /// A timeout bounds observation of async work, not arbitrary blocking provider + /// work or subprocesses: dropping the future is not a hard wall-clock guarantee. + /// + /// # Errors + /// + /// Returns [`PreparationError::InvalidLimits`] when limits are invalid or the + /// aggregate deadline cannot be represented. + #[inline] + pub fn new( + clock: MonotonicClock, + limits: AwsPreparationLimits, + ) -> Result { + limits.validate()?; + let duration = Duration::from_millis(limits.preparation_timeout_ms); + let deadline = clock + .now() + .checked_add(duration) + .map(Deadline::at_instant) + .ok_or(PreparationError::InvalidLimits)?; + Ok(Self { + clock, + deadline, + limits, + accounting: Accounting::default(), + #[cfg(feature = "appconfig-agent")] + appconfig_client: None, + #[cfg(feature = "appconfig-agent")] + appconfig_cache: HashMap::new(), + #[cfg(feature = "secrets-manager")] + secret_cache: HashMap::new(), + #[cfg(feature = "secrets-manager")] + secret_clients: HashMap::new(), + }) + } + + /// Fetch and freeze all mapped `AppConfig` Agent documents into a core handle. + /// + /// # Errors + /// + /// Returns a sanitized [`PreparationError`] when settings, authorization, the + /// deadline, a response, or a configured size limit prevents preparation. + #[inline] + #[cfg(feature = "appconfig-agent")] + pub async fn prepare_appconfig( + &mut self, + settings: &AppConfigAgentSettings, + token: Option<&AgentAccessToken>, + ) -> Result { + settings.validate()?; + if settings.access_token_env.is_some() != token.is_some() { + return Err(PreparationError::InvalidBinding); + } + let mut targets = Vec::new(); + for (key, document) in &settings.documents { + self.check_deadline()?; + let url = settings.document_url(document)?; + let auth = token.map_or_else( + || AuthorizationIdentity::NoBearer { + env_name: settings.access_token_env.clone(), + }, + |value| AuthorizationIdentity::Bearer { + env_name: settings.access_token_env.clone(), + token: value.expose().to_owned(), + }, + ); + let target = AppConfigTargetKey { + url: url.as_str().to_owned(), + auth, + }; + let auth_metadata = match &target.auth { + AuthorizationIdentity::NoBearer { env_name } => { + env_name.as_ref().map_or(0, String::len) + } + AuthorizationIdentity::Bearer { + env_name, + token: bearer_token, + } => env_name + .as_ref() + .map_or(Some(0), |name| Some(name.len())) + .and_then(|bytes| bytes.checked_add(bearer_token.len())) + .ok_or(PreparationError::SizeLimit)?, + }; + let target_metadata = target + .url + .len() + .checked_add(auth_metadata) + .ok_or(PreparationError::SizeLimit)?; + self.accounting + .charge_mapping(&self.limits, key.len(), 0, target_metadata)?; + targets.push((key, url, target)); + } + let mut entries = BTreeMap::new(); + for (key, url, target) in targets { + self.check_deadline()?; + let value = if let Some(value) = self.appconfig_cache.get(&target) { + value.clone() + } else { + let fetched = self.fetch_agent_document(url, token).await?; + self.accounting + .charge_retained(&self.limits, fetched.as_ref().len())?; + self.appconfig_cache.insert(target, fetched.clone()); + fetched + }; + entries.insert(key.clone(), value); + } + self.check_deadline()?; + Ok(ConfigStoreHandle::new(Arc::new(ConfigSnapshot { entries }))) + } + + /// Safe counters only; no identifiers, lengths of individual values, or auth data. + #[must_use] + #[inline] + pub fn stats(&self) -> PreparationStats { + PreparationStats { + mappings: self.accounting.mapping_count, + unique_retained_payload_bytes: self.accounting.unique_retained_payload_bytes, + metadata_bytes: self.accounting.metadata_bytes, + peak_transient_staging_bytes: self.accounting.peak_transient_staging_bytes, + } + } + + pub(crate) fn check_deadline(&self) -> Result { + self.deadline + .remaining_at(self.clock.now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(PreparationError::Deadline) + } + + /// Current operation allowance, clipped to the shared deadline. + pub(crate) fn operation_budget(&self) -> Result { + Ok(self + .check_deadline()? + .min(Duration::from_millis(self.limits.operation_timeout_ms))) + } + + /// Charge one logical mapping before cache lookup/deduplication. + #[cfg(feature = "secrets-manager")] + pub(crate) fn charge_mapping( + &mut self, + key_bytes: usize, + namespace_bytes: usize, + target_metadata_bytes: usize, + ) -> Result<(), PreparationError> { + self.accounting.charge_mapping( + &self.limits, + key_bytes, + namespace_bytes, + target_metadata_bytes, + ) + } + + /// Retain one successful unique payload. Call only after all value caps pass. + #[cfg(feature = "secrets-manager")] + pub(crate) fn charge_retained(&mut self, bytes: usize) -> Result<(), PreparationError> { + self.accounting.charge_retained(&self.limits, bytes) + } + + #[cfg(feature = "appconfig-agent")] + async fn fetch_agent_document( + &mut self, + url: url::Url, + token: Option<&AgentAccessToken>, + ) -> Result { + self.check_deadline()?; + let client = if let Some(client) = &self.appconfig_client { + client.clone() + } else { + let client = ClientBuilder::new() + .no_proxy() + .connection_verbose(false) + .redirect(Policy::none()) + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .connect_timeout( + self.operation_budget()? + .min(Duration::from_millis(self.limits.connect_timeout_ms)), + ) + .build() + .map_err(|_error| PreparationError::Unavailable)?; + self.check_deadline()?; + self.appconfig_client = Some(client.clone()); + client + }; + let budget = self + .operation_budget()? + .min(Duration::from_millis(self.limits.attempt_timeout_ms)); + let mut request = client + .get(url) + .header(ACCEPT_ENCODING, "identity") + .timeout(budget); + if let Some(access_token) = token { + let mut value = HeaderValue::from_str(&format!("Bearer {}", access_token.expose())) + .map_err(|_error| PreparationError::InvalidBinding)?; + value.set_sensitive(true); + request = request.header(AUTHORIZATION, value); + } + let remaining_payload = self + .limits + .max_snapshot_bytes + .checked_sub(self.accounting.unique_retained_payload_bytes) + .ok_or(PreparationError::SizeLimit)?; + let cap = usize::try_from(self.limits.max_config_document_bytes.min(remaining_payload)) + .map_err(|_error| PreparationError::InvalidLimits)?; + let fetched = timeout(budget, async { + let response = request.send().await.map_err(map_http_error)?; + if !response.status().is_success() { + return Err(match response.status().as_u16() { + 401 | 403 => PreparationError::AccessDenied, + 404 => PreparationError::Missing, + 429 => PreparationError::Throttled, + _ => PreparationError::Unavailable, + }); + } + if response.headers().get_all(CONTENT_ENCODING).iter().count() > 1 + || response + .headers() + .get_all(CONTENT_ENCODING) + .iter() + .any(|value| { + value.to_str().map_or(true, |encoding| { + !encoding.trim().eq_ignore_ascii_case("identity") + }) + }) + { + return Err(PreparationError::Malformed); + } + if response + .content_length() + .is_some_and(|length| length > u64::try_from(cap).unwrap_or(u64::MAX)) + { + return Err(PreparationError::SizeLimit); + } + let mut stream = response.bytes_stream(); + let mut body = Vec::with_capacity(cap.min(16 * 1024)); + while let Some(chunk_result) = stream.next().await { + let chunk_bytes = chunk_result.map_err(map_http_error)?; + if chunk_bytes.len() > cap.saturating_sub(body.len()) { + return Err(PreparationError::SizeLimit); + } + self.check_deadline()?; + self.accounting.observe_staging( + body.len() + .checked_add(chunk_bytes.len()) + .ok_or(PreparationError::SizeLimit)?, + )?; + body.extend_from_slice(&chunk_bytes); + } + String::from_utf8(body).map_err(|_error| PreparationError::Malformed) + }) + .await + .map_err(|_elapsed| PreparationError::Deadline)?; + self.check_deadline()?; + let text = fetched?; + // Preserve every UTF-8 byte, including empty/non-JSON free-form documents. + self.accounting.observe_staging(text.len())?; + let value = ConfigValue::from(text); + self.check_deadline()?; + Ok(value) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PreparationStats { + pub mappings: u64, + pub metadata_bytes: u64, + pub peak_transient_staging_bytes: u64, + pub unique_retained_payload_bytes: u64, +} + +#[derive(Default)] +struct Accounting { + mapping_count: u64, + metadata_bytes: u64, + peak_transient_staging_bytes: u64, + unique_retained_payload_bytes: u64, +} + +impl Accounting { + fn charge_mapping( + &mut self, + limits: &AwsPreparationLimits, + key_bytes: usize, + namespace_bytes: usize, + target_metadata_bytes: usize, + ) -> Result<(), PreparationError> { + let mapping_count = self + .mapping_count + .checked_add(1) + .filter(|count| *count <= limits.max_remote_values) + .ok_or(PreparationError::SizeLimit)?; + let metadata = key_bytes + .checked_add(namespace_bytes) + .and_then(|value| value.checked_add(target_metadata_bytes)) + .and_then(|value| u64::try_from(value).ok()) + .and_then(|value| self.metadata_bytes.checked_add(value)) + .ok_or(PreparationError::SizeLimit)?; + // Metadata has no independent configured ceiling. Checked arithmetic keeps + // the diagnostic statistic representable without pretending it is an RSS cap. + self.mapping_count = mapping_count; + self.metadata_bytes = metadata; + Ok(()) + } + + fn charge_retained( + &mut self, + limits: &AwsPreparationLimits, + bytes: usize, + ) -> Result<(), PreparationError> { + let byte_count = u64::try_from(bytes).map_err(|_error| PreparationError::SizeLimit)?; + let total = self + .unique_retained_payload_bytes + .checked_add(byte_count) + .filter(|total| *total <= limits.max_snapshot_bytes) + .ok_or(PreparationError::SizeLimit)?; + self.unique_retained_payload_bytes = total; + Ok(()) + } + + #[cfg(feature = "appconfig-agent")] + fn observe_staging(&mut self, bytes: usize) -> Result<(), PreparationError> { + let byte_count = u64::try_from(bytes).map_err(|_error| PreparationError::SizeLimit)?; + self.peak_transient_staging_bytes = self.peak_transient_staging_bytes.max(byte_count); + Ok(()) + } +} + +#[cfg(feature = "appconfig-agent")] +#[derive(Clone, Eq, Hash, PartialEq)] +struct AppConfigTargetKey { + auth: AuthorizationIdentity, + url: String, +} + +#[cfg(feature = "appconfig-agent")] +#[derive(Clone, Eq, Hash, PartialEq)] +enum AuthorizationIdentity { + Bearer { + env_name: Option, + token: String, + }, + NoBearer { + env_name: Option, + }, +} + +#[cfg(feature = "appconfig-agent")] +impl fmt::Debug for AuthorizationIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AuthorizationIdentity([redacted])") + } +} + +#[cfg(feature = "appconfig-agent")] +impl fmt::Debug for AppConfigTargetKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AppConfigTargetKey([redacted])") + } +} + +#[cfg(feature = "secrets-manager")] +#[derive(Clone, Eq, Hash, PartialEq)] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the client cache key is shared only with the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretClientKey { + pub(crate) policy: String, + pub(crate) region: String, +} + +#[cfg(feature = "secrets-manager")] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the SDK client is shared only with the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretClient { + pub(crate) client: aws_sdk_secretsmanager::Client, +} + +#[cfg(feature = "secrets-manager")] +#[derive(Clone, Eq, Hash, PartialEq)] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the target cache key is shared only with the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretTargetKey { + pub(crate) client_policy: String, + pub(crate) region: String, + pub(crate) secret_id: String, + /// Exact private selector identity; never include in diagnostics. + pub(crate) selector: String, +} + +#[cfg(feature = "secrets-manager")] +impl fmt::Debug for SecretTargetKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretTargetKey([redacted])") + } +} + +#[cfg(feature = "appconfig-agent")] +struct ConfigSnapshot { + entries: BTreeMap, +} + +#[cfg(feature = "appconfig-agent")] +impl fmt::Debug for ConfigSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigSnapshot") + .field("entry_count", &self.entries.len()) + .finish() + } +} + +#[cfg(feature = "appconfig-agent")] +#[async_trait::async_trait(?Send)] +impl ConfigStore for ConfigSnapshot { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.entries.get(key).cloned()) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let result = finish_bounded_config_read( + Ok(self.entries.get(key).cloned()), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + result + } +} + +#[cfg(feature = "secrets-manager")] +#[expect( + clippy::field_scoped_visibility_modifiers, + reason = "the immutable snapshot is constructed by the feature-gated Secrets Manager sibling module" +)] +pub(crate) struct SecretSnapshot { + pub(crate) entries: BTreeMap, + pub(crate) namespace: String, +} + +#[cfg(feature = "secrets-manager")] +impl fmt::Debug for SecretSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretSnapshot") + .field("entry_count", &self.entries.len()) + .finish_non_exhaustive() + } +} + +#[cfg(feature = "secrets-manager")] +#[async_trait::async_trait(?Send)] +impl SecretStore for SecretSnapshot { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + Ok((store_name == self.namespace) + .then(|| self.entries.get(key).cloned()) + .flatten()) + } + + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + let value = (store_name == self.namespace) + .then(|| self.entries.get(key).cloned()) + .flatten(); + let result = finish_bounded_secret_read( + Ok(value), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + result + } +} + +#[cfg(feature = "appconfig-agent")] +fn map_http_error(_error: reqwest::Error) -> PreparationError { + PreparationError::Unavailable +} + +#[cfg(test)] +#[cfg(feature = "appconfig-agent")] +mod tests { + use super::*; + use edgezero_core::time::MonotonicInstant; + use futures::executor::block_on; + use std::sync::Mutex; + + fn agent_settings(endpoint: &str) -> AppConfigAgentSettings { + use crate::settings::AgentDocument; + AppConfigAgentSettings { + default_key: "main".to_owned(), + endpoint: endpoint.to_owned(), + access_token_env: Some("EDGE_TOKEN".to_owned()), + documents: BTreeMap::from([( + "main".to_owned(), + AgentDocument { + application: "app".to_owned(), + environment: "test".to_owned(), + profile: "profile".to_owned(), + }, + )]), + } + } + + fn test_clock() -> MonotonicClock { + MonotonicClock::default() + } + + #[test] + fn absolute_deadline_is_checked_and_snapshot_contract_is_ordinary() { + let clock = test_clock(); + let _session = AwsPreparation::new(clock, AwsPreparationLimits::default()) + .expect("default limits produce a valid preparation session"); + let accounting = Accounting::default(); + assert_eq!(accounting.mapping_count, 0); + } + + #[test] + fn accounting_charges_aliases_before_dedup_and_checks_overflow() { + let limits = AwsPreparationLimits { + max_remote_values: 1, + ..AwsPreparationLimits::default() + }; + let mut accounting = Accounting::default(); + accounting + .charge_mapping(&limits, 3, 0, 4) + .expect("first mapping is within the configured limit"); + assert_eq!( + accounting.charge_mapping(&limits, 3, 0, 4), + Err(PreparationError::SizeLimit) + ); + accounting.unique_retained_payload_bytes = u64::MAX; + assert_eq!( + accounting.charge_retained(&limits, 1), + Err(PreparationError::SizeLimit) + ); + } + + #[tokio::test] + async fn appconfig_real_loopback_fetch_and_exact_body_snapshot() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind loopback"); + let address = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut request_bytes = vec![0_u8; 4096]; + let count = socket.read(&mut request_bytes).await.expect("read request"); + let request_text = String::from_utf8_lossy(&request_bytes[..count]); + assert!( + request_text + .to_ascii_lowercase() + .contains("accept-encoding: identity") + ); + assert!( + request_text.contains("/applications/app/environments/test/configurations/profile") + ); + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 12\r\nConnection: close\r\n\r\n{\"x\":\"\u{fc}\"} "; + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + let mut settings = agent_settings(&format!("http://{address}")); + settings.access_token_env = None; + let mut session = + AwsPreparation::new(test_clock(), AwsPreparationLimits::default()).expect("session"); + let handle = session + .prepare_appconfig(&settings, None) + .await + .expect("prepare"); + let value = handle + .get("main") + .await + .expect("read snapshot") + .expect("entry"); + assert_eq!(value.as_ref(), "{\"x\":\"\u{fc}\"} "); + server.await.expect("server"); + } + + #[test] + fn token_debug_is_redacted_even_when_target_identity_is_debugged() { + let token = AgentAccessToken::new("very-secret-token".to_owned()).expect("token"); + assert!(!format!("{token:?}").contains("very-secret-token")); + let key = AppConfigTargetKey { + url: "http://127.0.0.1".to_owned(), + auth: AuthorizationIdentity::Bearer { + env_name: Some("EDGE_TOKEN".to_owned()), + token: token.expose().to_owned(), + }, + }; + assert!(!format!("{key:?}").contains("very-secret-token")); + } + + #[test] + fn bounded_snapshot_deadline_wins_over_size_error_at_final_check() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || { + let mut instant = clock_now.lock().expect("clock mutex"); + let sampled = *instant; + *instant = instant + .checked_add(Duration::from_millis(1)) + .expect("test instant advances"); + sampled + }); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_millis(2)) + .expect("deadline"), + ); + let snapshot = ConfigSnapshot { + entries: BTreeMap::from([("large".to_owned(), ConfigValue::from("payload"))]), + }; + let result = block_on(snapshot.get_bounded("large", &clock, deadline, 1, 1)); + assert!(matches!(result, Err(ConfigStoreError::DeadlineExceeded))); + } + + config_store_contract_tests!(appconfig_snapshot_contract, { + ConfigSnapshot { + entries: BTreeMap::from([ + ("contract.key.a".to_owned(), ConfigValue::from("value_a")), + ("contract.key.b".to_owned(), ConfigValue::from("value_b")), + ]), + } + }); +} diff --git a/crates/edgezero-store-aws/src/secrets_manager.rs b/crates/edgezero-store-aws/src/secrets_manager.rs new file mode 100644 index 000000000..b215bc10c --- /dev/null +++ b/crates/edgezero-store-aws/src/secrets_manager.rs @@ -0,0 +1,809 @@ +//! Pinned AWS SDK transport and immutable Secrets Manager snapshots. +//! +//! This module is intentionally private to the native provider crate. Public +//! callers receive only a core `SecretHandle`; neither the SDK client nor the +//! remote resource map survives preparation. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use aws_config::BehaviorVersion; +use aws_sdk_secretsmanager::Client; +use aws_sdk_secretsmanager::config::timeout::TimeoutConfig; +use aws_sdk_secretsmanager::config::{Region, retry::RetryConfig}; +use aws_sdk_secretsmanager::error::SdkError; +#[cfg(test)] +use aws_sdk_secretsmanager::primitives::Blob; +use bytes::Bytes; +use edgezero_core::secret_store::SecretHandle; +use tokio::time::timeout; + +use crate::PreparationError; +use crate::preparation::{ + AwsPreparation, SecretClient, SecretClientKey, SecretSnapshot, SecretTargetKey, +}; +use crate::settings::{SecretTarget, SecretVersion, SecretsManagerSettings}; + +const MAX_NAMESPACE_BYTES: usize = 512; +const DEFAULT_CLIENT_POLICY: &str = "fixed-default"; + +#[expect( + clippy::multiple_inherent_impl, + reason = "keep the feature-gated Secrets Manager transport in its provider-specific module" +)] +impl AwsPreparation { + /// Load every explicitly mapped secret and return an immutable namespace-bound snapshot. + /// + /// The aggregate budget begins in `AwsPreparation::new`, before credential or SDK + /// initialization. This method adds no outer retry loop; the pinned SDK owns at most + /// three total attempts for each `GetSecretValue` operation. + /// + /// # Errors + /// + /// Returns a sanitized [`PreparationError`] if a binding is invalid, the + /// aggregate deadline expires, the provider fails, or a configured size limit is exceeded. + #[inline] + #[expect( + clippy::too_many_lines, + reason = "the validate-fetch-check-charge-publish sequence is easier to audit as one operation" + )] + pub async fn prepare_secrets( + &mut self, + settings: &SecretsManagerSettings, + namespace: &str, + ) -> Result { + // Validate all portable handle names before constructing a client or doing IO. + if !valid_secret_name(namespace) || namespace.len() > MAX_NAMESPACE_BYTES { + return Err(PreparationError::InvalidBinding); + } + settings.validate()?; + if settings + .entries + .keys() + .any(|key| !valid_secret_name(key) || key.len() > MAX_NAMESPACE_BYTES) + { + return Err(PreparationError::InvalidBinding); + } + + self.check_deadline()?; + for (key, target) in &settings.entries { + self.charge_mapping(key.len(), namespace.len(), target_metadata_bytes(target)?)?; + } + let region = settings.region.clone(); + let client_key = SecretClientKey { + region: region.clone(), + policy: DEFAULT_CLIENT_POLICY.to_owned(), + }; + if !self.secret_clients.contains_key(&client_key) { + let remaining = self.operation_budget()?; + let connect = remaining.min(Duration::from_millis(self.limits.connect_timeout_ms)); + let attempt = remaining.min(Duration::from_millis(self.limits.attempt_timeout_ms)); + let client = timeout(remaining, async { + let shared = aws_config::defaults(BehaviorVersion::v2026_01_12()) + .region(Region::new(region.clone())) + .load() + .await; + self.check_deadline()?; + let config = Client::from_conf( + aws_sdk_secretsmanager::Config::from(&shared) + .to_builder() + .region(Region::new(region.clone())) + .retry_config(RetryConfig::standard().with_max_attempts(3)) + .timeout_config( + TimeoutConfig::builder() + .connect_timeout(connect) + .operation_timeout(remaining) + .operation_attempt_timeout(attempt) + .build(), + ) + .build(), + ); + self.check_deadline()?; + Ok::(config) + }) + .await + .map_err(|_elapsed| PreparationError::Deadline)??; + self.check_deadline()?; + self.secret_clients + .insert(client_key.clone(), SecretClient { client }); + } + + let client = self + .secret_clients + .get(&client_key) + .map(|entry| entry.client.clone()) + .ok_or(PreparationError::Unavailable)?; + let mut entries = BTreeMap::new(); + for (key, target) in &settings.entries { + self.check_deadline()?; + // Charge every logical mapping before target-cache lookup: aliases do not + // bypass the session's configured mapping bound. + let target_key = SecretTargetKey { + region: region.clone(), + client_policy: DEFAULT_CLIENT_POLICY.to_owned(), + secret_id: target.secret_id().to_owned(), + selector: target_selector_identity(target), + }; + let value = if let Some(value) = self.secret_cache.get(&target_key) { + value.clone() + } else { + let operation_budget = self.operation_budget()?; + let attempt_budget = + operation_budget.min(Duration::from_millis(self.limits.attempt_timeout_ms)); + let connect_budget = + operation_budget.min(Duration::from_millis(self.limits.connect_timeout_ms)); + let mut request = client.get_secret_value().secret_id(target.secret_id()); + match target.version() { + SecretVersion::Current => { + // Omission has the service-defined AWSCURRENT meaning. + } + SecretVersion::Stage(stage) => request = request.version_stage(stage), + SecretVersion::VersionId(version) => request = request.version_id(version), + } + let per_operation = TimeoutConfig::builder() + .connect_timeout(connect_budget) + .operation_timeout(operation_budget) + .operation_attempt_timeout(attempt_budget) + .build(); + let result = timeout( + operation_budget, + request + .customize() + .config_override( + aws_sdk_secretsmanager::Config::builder().timeout_config(per_operation), + ) + .send(), + ) + .await + .map_err(|_elapsed| PreparationError::Deadline)?; + self.check_deadline()?; + let output = result.map_err(|error| { + if matches!(&error, SdkError::TimeoutError(_)) { + return PreparationError::Deadline; + } + let service = error.as_service_error().map(|service| { + ( + service.is_decryption_failure(), + service.is_resource_not_found_exception(), + service.is_invalid_request_exception(), + service.is_invalid_parameter_exception(), + service.meta().code().unwrap_or_default().to_owned(), + ) + }); + let status = error + .raw_response() + .map(|response| response.status().as_u16()); + classify_sdk_error(service, status) + })?; + let cap = usize::try_from(self.limits.max_secret_value_bytes) + .map_err(|_error| PreparationError::InvalidLimits)?; + let size = match (output.secret_string(), output.secret_binary()) { + (Some(value), None) => value.len(), + (None, Some(value)) => value.as_ref().len(), + (None, None) | (Some(_), Some(_)) => return Err(PreparationError::Malformed), + }; + self.check_deadline()?; + if size > cap { + return Err(PreparationError::SizeLimit); + } + // SDK response parsing/materialization happens before this check. This + // is a retained-value cap, not an SDK RSS or transfer-allocation bound. + self.check_deadline()?; + self.charge_retained(size)?; + let value = match (output.secret_string, output.secret_binary) { + (Some(value), None) => Bytes::from(value.into_bytes()), + (None, Some(value)) => Bytes::from(value.into_inner()), + (None, None) | (Some(_), Some(_)) => return Err(PreparationError::Malformed), + }; + self.check_deadline()?; + self.secret_cache.insert(target_key, value.clone()); + value + }; + entries.insert(key.clone(), value); + } + + self.check_deadline()?; + Ok(SecretHandle::new(Arc::new(SecretSnapshot { + namespace: namespace.to_owned(), + entries, + }))) + } +} + +fn valid_secret_name(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_NAMESPACE_BYTES && !value.chars().any(char::is_control) +} + +fn target_selector_identity(target: &SecretTarget) -> String { + match target.version() { + SecretVersion::Current => "stage:AWSCURRENT".to_owned(), + SecretVersion::Stage(value) => format!("stage:{value}"), + SecretVersion::VersionId(value) => format!("version:{value}"), + } +} + +fn target_metadata_bytes(target: &SecretTarget) -> Result { + let selector_bytes = match target.version() { + SecretVersion::Current => "AWSCURRENT".len(), + SecretVersion::Stage(value) | SecretVersion::VersionId(value) => value.len(), + }; + target + .secret_id() + .len() + .checked_add(selector_bytes) + .ok_or(PreparationError::SizeLimit) +} + +#[cfg(test)] +fn materialize_payload( + secret_string: Option<&str>, + secret_binary: Option<&Blob>, +) -> Result { + match (secret_string, secret_binary) { + (Some(value), None) => Ok(Bytes::copy_from_slice(value.as_bytes())), + (None, Some(value)) => Ok(Bytes::copy_from_slice(value.as_ref())), + // Empty strings/blobs are Some(empty) and remain valid. Neither and both + // fields are malformed service responses; never choose a precedence. + (None, None) | (Some(_), Some(_)) => Err(PreparationError::Malformed), + } +} + +fn classify_sdk_error( + service: Option<(bool, bool, bool, bool, String)>, + status: Option, +) -> PreparationError { + if let Some((decryption, not_found, invalid_request, invalid_parameter, code)) = service { + if decryption { + return PreparationError::Decryption; + } + if not_found || invalid_request { + return PreparationError::Missing; + } + if invalid_parameter { + return PreparationError::InvalidResource; + } + match code.as_str() { + "AccessDeniedException" | "AccessDenied" => { + return PreparationError::AccessDenied; + } + "ExpiredTokenException" | "InvalidClientTokenId" | "UnrecognizedClientException" => { + return PreparationError::Credentials; + } + "ThrottlingException" | "Throttling" | "TooManyRequestsException" => { + return PreparationError::Throttled; + } + _ => {} + } + } + if status == Some(403) { + return PreparationError::AccessDenied; + } + if status == Some(429) { + return PreparationError::Throttled; + } + // SDK error/source chains are deliberately consumed and discarded here. + // Credential-process failures are not consistently exposed as a stable + // typed operation error by this SDK version; without an unredacted chain + // they conservatively collapse to the closed unavailable category. + PreparationError::Unavailable +} + +#[cfg(test)] +#[expect( + clippy::absolute_paths, + reason = "explicit std and crate paths keep the isolated child-process fixture auditable" +)] +mod tests { + use super::materialize_payload; + use crate::PreparationError; + use aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueOutput; + use aws_sdk_secretsmanager::primitives::Blob; + use bytes::Bytes; + use std::io::{Read as _, Write as _}; + use std::net::{TcpListener, TcpStream}; + use std::process::{Command, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + const TEST_ARN: &str = "arn:aws:secretsmanager:us-east-1:111122223333:secret:fixture-AbCdEf"; + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + + #[derive(Clone)] + enum FixtureResponse { + Decryption, + Delay(Duration), + Denied, + InternalError, + Json(String), + Missing, + Oversize, + Success, + } + + fn assert_sdk_fixture_scope() { + let endpoint = + std::env::var("AWS_ENDPOINT_URL_SECRETS_MANAGER").expect("fixture endpoint required"); + let endpoint_url = url::Url::parse(&endpoint).expect("fixture URL"); + assert!( + endpoint_url.scheme() == "http" + && endpoint_url.host_str() == Some("127.0.0.1") + && endpoint_url.port().is_some(), + "SDK child accepts only the literal-loopback fixture" + ); + assert!( + std::env::var("AWS_ACCESS_KEY_ID").is_ok_and(|value| value.starts_with("DUMMY")), + "SDK child requires dummy credentials" + ); + } + + fn respond(mut stream: TcpStream, response: FixtureResponse) -> (String, String) { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("request read timeout"); + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut chunk).expect("read request headers"); + assert_ne!(read, 0, "SDK closed before request headers"); + request.extend_from_slice(&chunk[..read]); + if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break end + .checked_add(4) + .expect("header terminator offset fits request"); + } + assert!(request.len() < 64 * 1024, "bounded fixture request headers"); + }; + let headers = String::from_utf8_lossy(&request[..header_end]).to_ascii_lowercase(); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length:").map(str::trim)) + .and_then(|value| value.parse::().ok()) + .expect("SDK content length"); + let body_end = header_end + .checked_add(content_length) + .expect("bounded request body end fits usize"); + while request.len() < body_end { + let read = stream.read(&mut chunk).expect("read request body"); + assert_ne!(read, 0, "SDK closed before request body"); + request.extend_from_slice(&chunk[..read]); + } + let body = String::from_utf8_lossy(&request[header_end..body_end]).into_owned(); + assert!(headers.contains("x-amz-target: secretsmanager.getsecretvalue")); + assert!( + body.contains(TEST_ARN), + "request must contain only the fictional fixture ARN" + ); + if let FixtureResponse::Delay(delay) = response { + thread::sleep(delay); + let payload = b"{\"SecretString\":\"VALUE_SENTINEL\"}"; + let _header_write = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/x-amz-json-1.1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + let _body_write = stream.write_all(payload); + return (headers, body); + } + let (status, code, payload) = match response { + FixtureResponse::Decryption => (400_u16, "Bad Request", r#"{"__type":"DecryptionFailure","message":"VALUE_SENTINEL"}"#.to_owned()), + FixtureResponse::Denied => (400_u16, "Bad Request", r#"{"__type":"AccessDeniedException","message":"ARN_SENTINEL CREDENTIAL_SENTINEL"}"#.to_owned()), + FixtureResponse::Delay(_) => return (headers, body), + FixtureResponse::InternalError => (500_u16, "Internal Server Error", r#"{"__type":"InternalServiceError","message":"SDK_ERROR_SENTINEL"}"#.to_owned()), + FixtureResponse::Json(payload) => (200_u16, "OK", payload), + FixtureResponse::Missing => (400_u16, "Bad Request", r#"{"__type":"ResourceNotFoundException","message":"ARN_SENTINEL"}"#.to_owned()), + FixtureResponse::Oversize => (200_u16, "OK", format!(r#"{{"ARN":"{TEST_ARN}","Name":"fixture","VersionId":"12345678901234567890123456789012","SecretString":"{}"}}"#, "x".repeat(0x0001_0001))), + FixtureResponse::Success => (200_u16, "OK", format!(r#"{{"ARN":"{TEST_ARN}","Name":"fixture","VersionId":"12345678901234567890123456789012","SecretString":"VALUE_SENTINEL"}}"#)), + }; + let response_body = payload.as_bytes(); + write!(stream, "HTTP/1.1 {status} {code}\r\nContent-Type: application/x-amz-json-1.1\r\nx-amzn-RequestId: fixture-request-id\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", response_body.len()).expect("write response headers"); + stream + .write_all(response_body) + .expect("write response body"); + (headers, body) + } + + fn loopback_fixture( + responses: Vec, + ) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind literal loopback only"); + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + let address = listener.local_addr().expect("loopback address"); + let endpoint = format!("http://{address}"); + let worker = thread::spawn(move || { + let deadline = Instant::now() + .checked_add(Duration::from_secs(12)) + .expect("fixture deadline fits the monotonic clock"); + let mut bodies = Vec::new(); + for response in responses { + let stream = loop { + match listener.accept() { + Ok((stream, peer)) => { + assert!(peer.ip().is_loopback(), "fixture peer must be loopback"); + break stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "SDK did not contact fixture"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + }; + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .expect("response write timeout"); + let (_, body) = respond(stream, response); + bodies.push(body); + } + bodies + }); + (endpoint, worker) + } + + fn isolated_sdk_child(endpoint: &str, scenario: &str) -> std::process::Output { + let directory = std::env::temp_dir().join(format!( + "edgezero-secrets-fixture-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), + )); + std::fs::create_dir_all(&directory).expect("create isolated profile directory"); + let config = directory.join("config"); + let credentials = directory.join("credentials"); + std::fs::write(&config, "[profile fixture]\nregion = us-east-1\n") + .expect("write isolated config"); + std::fs::write( + &credentials, + "[fixture]\naws_access_key_id = DUMMY_ACCESS_KEY\naws_secret_access_key = DUMMY_SECRET_KEY\n", + ) + .expect("write dummy credentials"); + let output = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "secrets_manager::tests::sdk_child_entry", + "--nocapture", + ]) + .env_clear() + .env("HOME", &directory) + .env("AWS_CONFIG_FILE", config) + .env("AWS_SHARED_CREDENTIALS_FILE", credentials) + .env("AWS_PROFILE", "fixture") + .env("AWS_ACCESS_KEY_ID", "DUMMY_ACCESS_KEY") + .env("AWS_SECRET_ACCESS_KEY", "DUMMY_SECRET_KEY") + .env("AWS_REGION", "us-east-1") + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ENDPOINT_URL_SECRETS_MANAGER", endpoint) + .env("AWS_RETRY_MODE", "standard") + .env("AWS_MAX_ATTEMPTS", "3") + .env("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "false") + .env("EDGEZERO_SECRETS_SCENARIO", scenario) + .stdin(Stdio::null()) + .output() + .expect("run isolated SDK child"); + std::fs::remove_dir_all(directory).expect("remove isolated profile directory"); + output + } + + #[test] + fn sdk_blob_bytes_are_preserved_without_a_second_base64_decode() { + let binary = Blob::new(b"YWJjZA==".to_vec()); + assert_eq!( + materialize_payload(None, Some(&binary)), + Ok(Bytes::from_static(b"YWJjZA==")) + ); + assert_eq!(materialize_payload(Some(""), None), Ok(Bytes::new())); + assert_eq!( + materialize_payload(None, Some(&Blob::new(Vec::new()))), + Ok(Bytes::new()) + ); + assert_eq!( + materialize_payload(None, None), + Err(PreparationError::Malformed) + ); + assert_eq!( + materialize_payload(Some("x"), Some(&binary)), + Err(PreparationError::Malformed) + ); + } + + #[test] + fn real_sdk_uses_child_only_service_endpoint_and_capped_retries() { + let (endpoint, fixture) = loopback_fixture(vec![ + FixtureResponse::InternalError, + FixtureResponse::InternalError, + FixtureResponse::Success, + ]); + let child = isolated_sdk_child(&endpoint, "retry"); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "isolated SDK child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 3, + "standard SDK performs at most three total attempts" + ); + assert!(requests.iter().all(|body| body.contains(TEST_ARN))); + } + + #[test] + fn real_sdk_serializes_exact_current_stage_and_version_selectors() { + for (scenario, selector) in [ + ("current", ""), + ("stage", r#""VersionStage":"BLUE""#), + ( + "version", + r#""VersionId":"12345678901234567890123456789012""#, + ), + ] { + let (endpoint, fixture) = loopback_fixture(vec![FixtureResponse::Success]); + let child = isolated_sdk_child(&endpoint, scenario); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "isolated SDK child failed for {scenario}: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains(TEST_ARN)); + if !selector.is_empty() { + assert!( + requests[0].contains(selector), + "selector serialized exactly" + ); + } + if scenario == "current" { + assert!( + !requests[0].contains("VersionStage") && !requests[0].contains("VersionId") + ); + } + } + } + + #[test] + fn real_sdk_service_failures_are_closed_and_non_retryable() { + for (response, scenario, category) in [ + (FixtureResponse::Denied, "denied", "access denied"), + ( + FixtureResponse::Decryption, + "decryption", + "decryption failure", + ), + ( + FixtureResponse::Missing, + "missing", + "missing resource or version", + ), + ] { + let (endpoint, fixture) = loopback_fixture(vec![response]); + let child = isolated_sdk_child(&endpoint, scenario); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "isolated child rejected expected category: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 1, + "non-retryable service failures use one SDK attempt" + ); + let output = format!( + "{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + assert!( + output.contains(category), + "child reports only its closed category" + ); + for sentinel in [ + "ARN_SENTINEL", + "VALUE_SENTINEL", + "CREDENTIAL_SENTINEL", + "SDK_ERROR_SENTINEL", + ] { + assert!( + !output.contains(sentinel), + "public child output excludes source details" + ); + } + } + } + + #[test] + fn real_sdk_binary_empty_and_exactly_one_payload_fields() { + for (scenario, payload) in [ + ( + "binary", + serde_json::json!({"SecretBinary": "WW1sdVlYSjU="}), + ), + ("empty", serde_json::json!({"SecretString": ""})), + ( + "both", + serde_json::json!({"SecretString": "opaque", "SecretBinary": ""}), + ), + ("neither", serde_json::json!({})), + ( + "dedup", + serde_json::json!({"SecretString": "VALUE_SENTINEL"}), + ), + ] { + let (endpoint, fixture) = + loopback_fixture(vec![FixtureResponse::Json(payload.to_string())]); + let child = isolated_sdk_child(&endpoint, scenario); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "payload child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 1, + "exactly one service request despite aliases/repeated snapshot reads" + ); + } + } + + #[test] + fn real_sdk_payload_cap_rejects_materialized_oversize_value() { + let (endpoint, fixture) = loopback_fixture(vec![FixtureResponse::Oversize]); + let child = isolated_sdk_child(&endpoint, "oversize"); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "oversize child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!(requests.len(), 1); + let output = format!( + "{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + assert!(output.contains("size limit")); + assert!( + !output.contains(&"x".repeat(128)), + "payload is not included in public output" + ); + } + + #[test] + fn real_sdk_attempt_timeout_is_observed_without_an_outer_retry_loop() { + let (endpoint, fixture) = + loopback_fixture(vec![FixtureResponse::Delay(Duration::from_millis(350))]); + let child = isolated_sdk_child(&endpoint, "timeout"); + let requests = fixture.join().expect("fixture thread"); + assert!( + child.status.success(), + "timeout child failed: {}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + requests.len(), + 1, + "SDK timeout behavior is inside the one provider operation" + ); + } + + /// Invoked only by an env-cleared child process launched by the parent fixture. + #[expect( + clippy::err_expect, + reason = "the opaque SecretHandle success value intentionally has no Debug API" + )] + #[expect( + clippy::print_stdout, + reason = "the child reports its closed error category to the parent fixture" + )] + #[tokio::test] + async fn sdk_child_entry() { + let Ok(scenario) = std::env::var("EDGEZERO_SECRETS_SCENARIO") else { + return; + }; + assert_sdk_fixture_scope(); + let version = match scenario.as_str() { + "stage" => crate::settings::SecretVersion::Stage("BLUE".to_owned()), + "version" => crate::settings::SecretVersion::VersionId( + "12345678901234567890123456789012".to_owned(), + ), + _ => crate::settings::SecretVersion::Current, + }; + let target = crate::settings::SecretTarget::new(TEST_ARN.to_owned(), version) + .expect("valid fictional ARN"); + let settings = crate::settings::SecretsManagerSettings { + region: "us-east-1".to_owned(), + entries: std::collections::BTreeMap::from([("active".to_owned(), target)]), + }; + let limits = if scenario == "timeout" { + crate::settings::AwsPreparationLimits { + connect_timeout_ms: 50, + attempt_timeout_ms: 100, + operation_timeout_ms: 500, + preparation_timeout_ms: 2_000, + ..crate::settings::AwsPreparationLimits::default() + } + } else { + crate::settings::AwsPreparationLimits::default() + }; + let mut session = crate::preparation::AwsPreparation::new( + edgezero_core::time::MonotonicClock::default(), + limits, + ) + .expect("valid session"); + let result = session.prepare_secrets(&settings, "runtime").await; + match scenario.as_str() { + "retry" | "current" | "stage" | "version" | "dedup" => { + let handle = result.expect("fixture-backed mapped secret prepares"); + let bytes = handle + .get_bytes("runtime", "active") + .await + .expect("snapshot read") + .expect("mapped value"); + assert_eq!(bytes.as_ref(), b"VALUE_SENTINEL"); + if scenario == "dedup" { + let cached = session + .prepare_secrets(&settings, "second") + .await + .expect("success cache shared across bound namespaces"); + assert_eq!( + cached + .get_bytes("second", "active") + .await + .expect("read") + .expect("mapped") + .as_ref(), + b"VALUE_SENTINEL" + ); + assert_eq!(session.stats().mappings, 2_u64); + assert_eq!(session.stats().unique_retained_payload_bytes, 14_u64); + } + } + "binary" | "empty" => { + let handle = result.expect("payload snapshot"); + let bytes = handle + .get_bytes("runtime", "active") + .await + .expect("snapshot") + .expect("mapped empty value is not a miss"); + let expected: &[u8] = if scenario == "binary" { + b"YmluYXJ5" + } else { + b"" + }; + assert_eq!(bytes.as_ref(), expected); + } + "both" | "neither" => { + assert_eq!( + result.err().expect("exactly one payload field required"), + crate::PreparationError::Malformed + ); + } + "denied" | "decryption" | "missing" | "timeout" | "oversize" => { + let expected = match scenario.as_str() { + "denied" => crate::PreparationError::AccessDenied, + "decryption" => crate::PreparationError::Decryption, + "missing" => crate::PreparationError::Missing, + "timeout" => crate::PreparationError::Deadline, + "oversize" => crate::PreparationError::SizeLimit, + _ => panic!("unknown failure scenario"), + }; + let error = result.err().expect("failed preparation must not publish"); + assert_eq!(error, expected); + println!("closed-category: {error}"); + } + _ => panic!("unknown child scenario"), + } + } + + #[test] + fn response_builder_exposes_the_pinned_sdk_fields_used_by_transport() { + let output = GetSecretValueOutput::builder().secret_string("").build(); + assert_eq!(output.secret_string(), Some("")); + assert!(output.secret_binary().is_none()); + } +} diff --git a/crates/edgezero-store-aws/src/settings.rs b/crates/edgezero-store-aws/src/settings.rs new file mode 100644 index 000000000..dd28e8ce1 --- /dev/null +++ b/crates/edgezero-store-aws/src/settings.rs @@ -0,0 +1,879 @@ +//! SDK-free typed settings and validation. Resource values never enter diagnostics. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "settings, validators and their tests are grouped by provider" +)] + +use std::collections::BTreeMap; +use std::fmt; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use percent_encoding::percent_decode_str; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer}; +use url::Url; + +use crate::PreparationError; + +// Portable SecretHandle limits names to 512 UTF-8 bytes. Keep SDK-free settings +// independent of core; service implementations must use the same core bound. +const MAX_SECRET_KEY_BYTES: usize = 512; + +/// One configuration key maps to one complete free-form Agent document. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentDocument { + pub application: String, + pub environment: String, + pub profile: String, +} + +impl fmt::Debug for AgentDocument { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AgentDocument").finish_non_exhaustive() + } +} + +/// `AppConfig` Agent settings. The token field names a bootstrap variable, not a value. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AppConfigAgentSettings { + pub default_key: String, + pub endpoint: String, + pub access_token_env: Option, + pub documents: BTreeMap, +} + +impl fmt::Debug for AppConfigAgentSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AppConfigAgentSettings") + .finish_non_exhaustive() + } +} + +impl AppConfigAgentSettings { + /// Builds an exact Agent request URL using checked path segments. + /// + /// # Errors + /// Rejects ambiguous/non-loopback endpoints and unsafe document identifiers. + #[inline] + pub fn document_url(&self, document: &AgentDocument) -> Result { + let mut url = agent_endpoint(&self.endpoint)?; + let expected = [ + "applications", + document.application.as_str(), + "environments", + document.environment.as_str(), + "configurations", + document.profile.as_str(), + ]; + for segment in [ + &document.application, + &document.environment, + &document.profile, + ] { + if !valid_agent_segment(segment) { + return Err(PreparationError::InvalidResource); + } + } + url.path_segments_mut() + .map_err(|_error| PreparationError::InvalidEndpoint)? + .clear() + .extend(expected); + let reparsed = + Url::parse(url.as_str()).map_err(|_error| PreparationError::InvalidEndpoint)?; + if !reparsed.path_segments().is_some_and(|segments| { + segments + .map(|segment| percent_decode_str(segment).decode_utf8()) + .eq(expected.into_iter().map(|segment| Ok(segment.into()))) + }) { + return Err(PreparationError::InvalidResource); + } + Ok(reparsed) + } + + /// Validates every mapping without HTTP or bootstrap environment access. + /// + /// # Errors + /// Returns a closed category for invalid endpoint, mapping, default or token name. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + agent_endpoint(&self.endpoint)?; + if self.documents.is_empty() + || !self.documents.contains_key(&self.default_key) + || self.documents.keys().any(|key| !valid_lookup_key(key)) + || self + .access_token_env + .as_deref() + .is_some_and(|name| !valid_variable_name(name)) + { + return Err(PreparationError::InvalidBinding); + } + for document in self.documents.values() { + self.document_url(document)?; + } + Ok(()) + } +} + +/// A bootstrap token. Debug hides both the token and its length. +#[derive(Clone)] +pub struct AgentAccessToken(String); + +impl AgentAccessToken { + /// Captures an explicit nonempty bearer token without reading the environment. + /// + /// # Errors + /// Rejects missing/empty tokens and bytes that cannot be sent as a bearer value. + #[inline] + pub fn new(value: String) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(PreparationError::InvalidBinding); + } + Ok(Self(value)) + } + + /// Exposes the token only to the provider transport. + #[inline] + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AgentAccessToken { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AgentAccessToken([redacted])") + } +} + +/// Exactly one version selector. Omission means AWSCURRENT at startup. +/// API lengths count characters; non-control Unicode and embedded spaces retain +/// their exact meaning. Validation does not assert that a version/stage exists. +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretVersion { + Current, + Stage(String), + VersionId(String), +} + +impl SecretVersion { + fn validate(&self) -> Result<(), PreparationError> { + let (value, minimum, maximum) = match self { + Self::Current => return Ok(()), + Self::Stage(value) => (value, 1, 256), + Self::VersionId(value) => (value, 32, 64), + }; + let length = value.chars().count(); + if !(minimum..=maximum).contains(&length) + || value.chars().all(char::is_whitespace) + || value.chars().any(char::is_control) + { + return Err(PreparationError::InvalidSelector); + } + Ok(()) + } +} + +impl fmt::Debug for SecretVersion { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretVersion([redacted])") + } +} + +/// A complete ARN and a validated selector. Construction never discovers credentials. +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +pub struct SecretTarget { + secret_id: String, + version: SecretVersion, +} + +impl SecretTarget { + /// Validates a complete ARN and mutually exclusive typed selector. + /// + /// # Errors + /// Rejects incomplete/unsupported resources and invalid selector lengths. + #[inline] + pub fn new(secret_id: String, version: SecretVersion) -> Result { + secret_region(&secret_id)?; + version.validate()?; + let selected = match version { + SecretVersion::Stage(stage) if stage == "AWSCURRENT" => SecretVersion::Current, + other @ (SecretVersion::Current + | SecretVersion::Stage(_) + | SecretVersion::VersionId(_)) => other, + }; + Ok(Self { + secret_id, + version: selected, + }) + } + + /// Complete resource identifier for the service request, never diagnostics. + #[inline] + #[must_use] + pub fn secret_id(&self) -> &str { + &self.secret_id + } + + /// Exact version selection for the service request. + #[inline] + #[must_use] + pub fn version(&self) -> &SecretVersion { + &self.version + } +} + +impl fmt::Debug for SecretTarget { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretTarget").finish_non_exhaustive() + } +} + +impl<'de> Deserialize<'de> for SecretTarget { + #[inline] + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Input { + secret_id: String, + version_id: Option, + version_stage: Option, + } + let input = Input::deserialize(deserializer)?; + let version = match (input.version_id, input.version_stage) { + (None, None) => SecretVersion::Current, + (None, Some(stage)) => SecretVersion::Stage(stage), + (Some(id), None) => SecretVersion::VersionId(id), + (Some(_), Some(_)) => { + return Err(D::Error::custom(PreparationError::InvalidSelector)); + } + }; + Self::new(input.secret_id, version).map_err(D::Error::custom) + } + + #[inline] + fn deserialize_in_place>( + deserializer: D, + place: &mut Self, + ) -> Result<(), D::Error> { + *place = Self::deserialize(deserializer)?; + Ok(()) + } +} + +/// Explicit region and finite secret key mappings. Namespaces are supplied by the host. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretsManagerSettings { + pub region: String, + pub entries: BTreeMap, +} + +impl SecretsManagerSettings { + /// Validates explicit region, keys and every ARN's region/partition relationship. + /// + /// # Errors + /// Rejects empty mappings, invalid keys, missing regions and mismatched ARNs. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + // Match the portable SecretHandle byte limit without adding core to the + // settings-only graph. Provider code must also enforce core's bound. + if self.entries.is_empty() + || self + .entries + .keys() + .any(|key| !valid_lookup_key(key) || key.len() > MAX_SECRET_KEY_BYTES) + { + return Err(PreparationError::InvalidBinding); + } + for target in self.entries.values() { + if secret_region(target.secret_id())? != self.region { + return Err(PreparationError::InvalidResource); + } + } + Ok(()) + } +} + +impl fmt::Debug for SecretsManagerSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretsManagerSettings") + .finish_non_exhaustive() + } +} + +/// Remote preparation policy for one explicitly shared session, not a process/RSS cap. +/// Omitted fields use documented defaults from this single policy source. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct AwsPreparationLimits { + pub max_remote_values: u64, + pub max_config_document_bytes: u64, + pub max_secret_value_bytes: u64, + pub max_snapshot_bytes: u64, + pub preparation_timeout_ms: u64, + pub operation_timeout_ms: u64, + pub connect_timeout_ms: u64, + pub attempt_timeout_ms: u64, +} + +impl Default for AwsPreparationLimits { + #[inline] + fn default() -> Self { + Self { + max_remote_values: 1_024, + max_config_document_bytes: 1_024 * 1_024, + max_secret_value_bytes: 64 * 1_024, + max_snapshot_bytes: 8 * 1_024 * 1_024, + preparation_timeout_ms: 30_000, + operation_timeout_ms: 5_000, + connect_timeout_ms: 1_000, + attempt_timeout_ms: 2_000, + } + } +} + +impl AwsPreparationLimits { + /// Validates finite representable bounds and consistency, without hardcoding the defaults as maxima. + /// + /// # Errors + /// Rejects zero, unusable timer/counter values and inconsistent limits. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + for value in [ + self.max_remote_values, + self.max_config_document_bytes, + self.max_secret_value_bytes, + self.max_snapshot_bytes, + ] { + if value == 0 || usize::try_from(value).is_err() { + return Err(PreparationError::InvalidLimits); + } + } + for millis in [ + self.preparation_timeout_ms, + self.operation_timeout_ms, + self.connect_timeout_ms, + self.attempt_timeout_ms, + ] { + if millis == 0 + || Instant::now() + .checked_add(Duration::from_millis(millis)) + .is_none() + { + return Err(PreparationError::InvalidLimits); + } + } + if self.max_config_document_bytes > self.max_snapshot_bytes + || self.max_secret_value_bytes > self.max_snapshot_bytes + || self.connect_timeout_ms > self.attempt_timeout_ms + || self.attempt_timeout_ms > self.operation_timeout_ms + || self.operation_timeout_ms > self.preparation_timeout_ms + { + return Err(PreparationError::InvalidLimits); + } + Ok(()) + } +} + +fn valid_lookup_key(key: &str) -> bool { + !key.is_empty() && !key.chars().all(char::is_whitespace) && !key.chars().any(char::is_control) +} + +fn valid_variable_name(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn valid_agent_segment(segment: &str) -> bool { + !segment.is_empty() + && segment.len() <= 128 + && !matches!(segment, "." | "..") + && !segment.chars().any(|character| { + character.is_control() || matches!(character, '/' | '\\' | '%' | '?' | '#') + }) +} + +fn agent_endpoint(endpoint: &str) -> Result { + // Check the original authority too: URL parsing otherwise accepts integer/octal + // IPv4, backslashes and dot paths while changing their spelling/meaning. + let rest = endpoint + .strip_prefix("http://") + .ok_or(PreparationError::InvalidEndpoint)?; + let authority = rest.strip_suffix('/').unwrap_or(rest); + if authority.contains(['/', '\\', '@', '?', '#', '%']) + || endpoint + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) + { + return Err(PreparationError::InvalidEndpoint); + } + let host = if let Some(ipv6) = authority.strip_prefix('[') { + ipv6.split_once(']').map(|(host, _port)| host) + } else { + Some( + authority + .split_once(':') + .map_or(authority, |(host, _port)| host), + ) + } + .ok_or(PreparationError::InvalidEndpoint)?; + let literal = host + .parse::() + .map_err(|_error| PreparationError::InvalidEndpoint)?; + let url = Url::parse(endpoint).map_err(|_error| PreparationError::InvalidEndpoint)?; + if !literal.is_loopback() + || url.scheme() != "http" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + || url.port_or_known_default().is_none_or(|port| port == 0) + || url.host_str().is_none() + { + return Err(PreparationError::InvalidEndpoint); + } + Ok(url) +} + +fn secret_region(arn: &str) -> Result<&str, PreparationError> { + if arn.len() > 2_048 { + return Err(PreparationError::InvalidResource); + } + let parts: Vec<&str> = arn.split(':').collect(); + let [prefix, partition, service, region, account, resource, name] = parts.as_slice() else { + return Err(PreparationError::InvalidResource); + }; + let Some((secret_name, suffix)) = name.rsplit_once('-') else { + return Err(PreparationError::InvalidResource); + }; + if *prefix != "arn" + || *service != "secretsmanager" + || *resource != "secret" + || account.len() != 12 + || !account.bytes().all(|byte| byte.is_ascii_digit()) + || secret_name.is_empty() + || secret_name.len() > 512 + || !secret_name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'/' | b'_' | b'+' | b'=' | b'.' | b'@' | b'-') + }) + || suffix.len() != 6 + || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric()) + || arn.len() > 2_048 + || !valid_region(partition, region) + { + return Err(PreparationError::InvalidResource); + } + Ok(region) +} + +fn valid_region(partition: &str, region: &str) -> bool { + let Some((family, number)) = region.rsplit_once('-') else { + return false; + }; + if number.is_empty() + || !number.bytes().all(|byte| byte.is_ascii_digit()) + || number.starts_with('0') + || !family + .split('-') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase())) + { + return false; + } + // Supported partition families, not an assertion that an arbitrary matching + // region is currently served. Isolated/sovereign partitions are not v1 support. + match partition { + "aws-cn" => family.starts_with("cn-"), + "aws-us-gov" => family.starts_with("us-gov-"), + "aws" => { + [ + "af-", "ap-", "ca-", "eu-", "il-", "me-", "mx-", "sa-", "us-", + ] + .iter() + .any(|prefix| family.starts_with(prefix)) + && !family.starts_with("us-gov-") + && !family.starts_with("us-iso") + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::panic_in_result_fn, + reason = "test functions combine checked setup with behavioral assertions" + )] + #![expect( + clippy::assertions_on_result_states, + reason = "malformed input matrix observes parser acceptance without unwrapping" + )] + + use std::borrow::Cow; + use std::collections::BTreeMap; + + use percent_encoding::percent_decode_str; + use url::Url; + + use super::{ + AgentAccessToken, AgentDocument, AppConfigAgentSettings, AwsPreparationLimits, + SecretTarget, SecretVersion, SecretsManagerSettings, + }; + use crate::PreparationError; + + fn agent(endpoint: &str) -> AppConfigAgentSettings { + AppConfigAgentSettings { + default_key: "settings".to_owned(), + endpoint: endpoint.to_owned(), + access_token_env: None, + documents: BTreeMap::from([( + "settings".to_owned(), + AgentDocument { + application: "example-app".to_owned(), + environment: "production".to_owned(), + profile: "application-envelope".to_owned(), + }, + )]), + } + } + + #[test] + fn literal_loopback_and_exact_request_path() -> Result<(), PreparationError> { + for endpoint in [ + "http://127.0.0.1:2772", + "http://127.1.2.3:2772/", + "http://[::1]:2772", + ] { + let settings = agent(endpoint); + settings.validate()?; + let document = settings + .documents + .get("settings") + .ok_or(PreparationError::InvalidBinding)?; + assert_eq!( + settings.document_url(document)?.path(), + "/applications/example-app/environments/production/configurations/application-envelope" + ); + } + let mut settings = agent("http://127.0.0.1:2772"); + if let Some(document) = settings.documents.get_mut("settings") { + document.profile = "Profile name \u{fc}".to_owned(); + } + let document = settings + .documents + .get("settings") + .ok_or(PreparationError::InvalidBinding)?; + assert_eq!( + settings.document_url(document)?.path(), + "/applications/example-app/environments/production/configurations/Profile%20name%20%C3%BC" + ); + Ok(()) + } + + #[test] + fn endpoints_and_segments_fail_without_echoing_inputs() { + for endpoint in [ + "http://localhost:2772", + "https://127.0.0.1", + "http://192.0.2.1", + "http://user:sentinel@127.0.0.1", + "http://127.0.0.1?sentinel", + "http://127.0.0.1#sentinel", + "http://127.0.0.1/x/..", + "http://127.0.0.1/%2e", + "http://2130706433", + "http://0177.0.0.1", + "http://127.0.0.1\\sentinel", + "http://[::1%25sentinel]", + "http://127.0.0.1:0", + "http://127.0.0.1:65536", + " http://127.0.0.1", + "http://127.0.0.1//", + ] { + let settings = agent(endpoint); + assert!(settings.validate().is_err(), "accepted unsafe endpoint"); + assert!(!format!("{settings:?}").contains("sentinel")); + } + for profile in ["", ".", "..", "a/b", "a\\b", "a%2Fb", "a?b", "a#b", "a\nb"] { + let mut settings = agent("http://127.0.0.1:2772"); + if let Some(document) = settings.documents.get_mut("settings") { + document.profile = profile.to_owned(); + } + assert!(settings.validate().is_err()); + } + } + + #[test] + fn defaults_tokens_and_mapping_keys_are_strict() { + let mut settings = agent("http://127.0.0.1:2772"); + settings.default_key = "Settings".to_owned(); + assert_eq!(settings.validate(), Err(PreparationError::InvalidBinding)); + settings.default_key = "settings".to_owned(); + settings.access_token_env = Some("TOKEN=sentinel".to_owned()); + assert_eq!(settings.validate(), Err(PreparationError::InvalidBinding)); + assert!(!format!("{settings:?}").contains("sentinel")); + for value in ["", "\n", "a b", "\u{fc}"] { + assert!(AgentAccessToken::new(value.to_owned()).is_err()); + } + let token = AgentAccessToken::new("sentinel-secret".to_owned()); + assert!(token.is_ok()); + assert!(!format!("{token:?}").contains("sentinel")); + } + + #[test] + fn complete_arns_regions_and_cross_account_references() -> Result<(), PreparationError> { + for (partition, region) in [ + ("aws", "us-east-1"), + ("aws-cn", "cn-north-1"), + ("aws-us-gov", "us-gov-west-1"), + ] { + let target = SecretTarget::new( + format!( + "arn:{partition}:secretsmanager:{region}:999988887777:secret:example/key-AbCdEf" + ), + SecretVersion::Current, + )?; + let mut settings = SecretsManagerSettings { + region: region.to_owned(), + entries: BTreeMap::from([("active".to_owned(), target)]), + }; + settings.validate()?; + settings.region = "us-west-2".to_owned(); + assert_eq!(settings.validate(), Err(PreparationError::InvalidResource)); + } + for arn in [ + "name", + "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel", + "arn:aws:s3:us-east-1:111122223333:secret:sentinel-AbCdEf", + "arn:aws-cn:secretsmanager:us-east-1:111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-gov-west-1:111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-0:111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager::111122223333:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-1:123:secret:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-1:111122223333:other:sentinel-AbCdEf", + "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel*-AbCdEf", + "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel-AbCdEf:extra", + ] { + let error = SecretTarget::new(arn.to_owned(), SecretVersion::Current).err(); + assert_eq!(error, Some(PreparationError::InvalidResource)); + assert!(!format!("{error:?}").contains("sentinel")); + } + Ok(()) + } + + #[test] + fn selectors_are_exclusive_and_length_checked() { + let arn = "arn:aws:secretsmanager:us-east-1:111122223333:secret:sentinel-AbCdEf"; + for version in [ + SecretVersion::Stage(String::new()), + SecretVersion::Stage("x".repeat(257)), + SecretVersion::VersionId("x".repeat(31)), + SecretVersion::VersionId("x".repeat(65)), + SecretVersion::Stage("sentinel\n".to_owned()), + ] { + assert_eq!( + SecretTarget::new(arn.to_owned(), version).err(), + Some(PreparationError::InvalidSelector) + ); + } + for selector in [ + "", + "version_stage = 'AWSCURRENT'", + "version_id = '12345678901234567890123456789012'", + ] { + assert!( + toml::from_str::(&format!("secret_id = '{arn}'\n{selector}")).is_ok() + ); + } + for suffix in [ + "version_stage = 'AWSCURRENT'\nversion_id = '12345678901234567890123456789012'", + "unknown = 'sentinel'", + ] { + assert!( + toml::from_str::(&format!("secret_id = '{arn}'\n{suffix}")).is_err() + ); + } + } + + #[test] + fn secret_keys_fit_portable_handle_and_current_selectors_normalize() + -> Result<(), PreparationError> { + let arn = "arn:aws:secretsmanager:us-east-1:111122223333:secret:example-AbCdEf"; + let current = SecretTarget::new(arn.to_owned(), SecretVersion::Current)?; + let explicit = SecretTarget::new( + arn.to_owned(), + SecretVersion::Stage("AWSCURRENT".to_owned()), + )?; + assert_eq!(current, explicit); + let mut settings = SecretsManagerSettings { + region: "us-east-1".to_owned(), + entries: BTreeMap::from([("\u{fc}".repeat(256), current)]), + }; + settings.validate()?; + let target = settings + .entries + .values() + .next() + .cloned() + .ok_or(PreparationError::InvalidBinding)?; + settings.entries = BTreeMap::from([("\u{fc}".repeat(257), target)]); + assert_eq!(settings.validate(), Err(PreparationError::InvalidBinding)); + Ok(()) + } + + #[test] + fn allowed_segments_round_trip_after_serialization_and_decoding() -> Result<(), PreparationError> + { + let mut settings = agent("http://127.0.0.1:2772"); + settings.documents.insert( + "settings".to_owned(), + AgentDocument { + application: "App +@=".to_owned(), + environment: "Env:;name".to_owned(), + profile: "Profile \u{fc}".to_owned(), + }, + ); + let target = settings + .documents + .get("settings") + .ok_or(PreparationError::InvalidBinding)?; + let serialized = settings.document_url(target)?.to_string(); + let reparsed = + Url::parse(&serialized).map_err(|_error| PreparationError::InvalidEndpoint)?; + let decoded: Vec = reparsed + .path_segments() + .ok_or(PreparationError::InvalidEndpoint)? + .map(|segment| { + percent_decode_str(segment) + .decode_utf8() + .map(Cow::into_owned) + .map_err(|_error| PreparationError::InvalidResource) + }) + .collect::>()?; + assert_eq!( + decoded, + [ + "applications", + "App +@=", + "environments", + "Env:;name", + "configurations", + "Profile \u{fc}" + ] + ); + Ok(()) + } + + #[test] + fn selector_boundaries_preserve_unicode_and_embedded_spaces() -> Result<(), PreparationError> { + let arn = "arn:aws:secretsmanager:us-east-1:111122223333:secret:example-AbCdEf"; + for selector in [ + SecretVersion::Stage("x".to_owned()), + SecretVersion::Stage("\u{fc}".repeat(256)), + SecretVersion::Stage("a b".to_owned()), + SecretVersion::VersionId("\u{fc}".repeat(32)), + SecretVersion::VersionId("x".repeat(64)), + ] { + let target = SecretTarget::new(arn.to_owned(), selector.clone())?; + assert_eq!(target.version(), &selector); + } + assert_eq!( + SecretTarget::new(arn.to_owned(), SecretVersion::Stage(" ".repeat(256))).err(), + Some(PreparationError::InvalidSelector) + ); + Ok(()) + } + + #[test] + fn arn_size_name_and_region_shape_boundaries() -> Result<(), PreparationError> { + let valid = format!( + "arn:aws:secretsmanager:us-east-1:111122223333:secret:{}-AbCdEf", + "x".repeat(512) + ); + SecretTarget::new(valid, SecretVersion::Current)?; + let oversized_name = format!( + "arn:aws:secretsmanager:us-east-1:111122223333:secret:{}-AbCdEf", + "x".repeat(513) + ); + assert_eq!( + SecretTarget::new(oversized_name, SecretVersion::Current).err(), + Some(PreparationError::InvalidResource) + ); + // Family shape does not promise an existing/served region. + SecretTarget::new( + "arn:aws:secretsmanager:us-fictional-99:111122223333:secret:x-AbCdEf".to_owned(), + SecretVersion::Current, + )?; + let base = "arn:aws:secretsmanager::111122223333:secret:x-AbCdEf"; + let maximum_region = format!("us-{}-1", "a".repeat(2_048 - base.len() - 5)); + let maximum = + format!("arn:aws:secretsmanager:{maximum_region}:111122223333:secret:x-AbCdEf"); + assert_eq!(maximum.len(), 2_048); + SecretTarget::new(maximum.clone(), SecretVersion::Current)?; + assert_eq!( + SecretTarget::new(format!("{maximum}x"), SecretVersion::Current).err(), + Some(PreparationError::InvalidResource) + ); + for region in [ + "zz-east-1", + "us--east-1", + "us-East-1", + "us-east-01", + "us-east-x", + ] { + assert_eq!( + SecretTarget::new( + format!("arn:aws:secretsmanager:{region}:111122223333:secret:x-AbCdEf"), + SecretVersion::Current + ) + .err(), + Some(PreparationError::InvalidResource) + ); + } + Ok(()) + } + + #[test] + fn configurable_limits_defaults_consistency_and_unknown_fields() -> Result<(), PreparationError> + { + let defaults = AwsPreparationLimits::default(); + defaults.validate()?; + assert_eq!(defaults.max_snapshot_bytes, 8 * 1_024 * 1_024); + let raised: AwsPreparationLimits = toml::from_str("max_snapshot_bytes = 16777216") + .map_err(|_error| PreparationError::InvalidLimits)?; + raised.validate()?; + assert_eq!( + raised.max_config_document_bytes, + defaults.max_config_document_bytes + ); + for input in [ + "max_snapshot_bytes = 0", + "max_snapshot_bytes = 1", + "max_remote_values = -1", + "operation_timeout_ms = 0", + "connect_timeout_ms = 2001", + "attempt_timeout_ms = 5001", + "operation_timeout_ms = 30001", + "unknown = 1", + "max_remote_values = 18446744073709551616", + ] { + let parsed = toml::from_str::(input); + assert!(parsed.is_err() || parsed.is_ok_and(|limits| limits.validate().is_err())); + } + Ok(()) + } +} diff --git a/crates/edgezero-store-aws/src/snapshot_tests.rs b/crates/edgezero-store-aws/src/snapshot_tests.rs new file mode 100644 index 000000000..912e18be8 --- /dev/null +++ b/crates/edgezero-store-aws/src/snapshot_tests.rs @@ -0,0 +1,80 @@ +//! Private snapshot read contracts, with no service client or credentials. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::secret_store::{SecretError, SecretStore as _}; +use edgezero_core::secret_store_contract_tests; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +use futures::executor::block_on; + +use crate::preparation::SecretSnapshot; + +fn snapshot() -> SecretSnapshot { + SecretSnapshot { + namespace: "mystore".to_owned(), + entries: BTreeMap::from([ + ( + "contract_key".to_owned(), + Bytes::from_static(b"contract_value"), + ), + ( + "contract_key_2".to_owned(), + Bytes::from_static(b"another_value"), + ), + ]), + } +} + +#[test] +fn final_deadline_check_precedes_size_error() { + let start = MonotonicInstant::now(); + let sampled = Arc::new(Mutex::new(start)); + let clock = MonotonicClock::new(move || { + let mut now = sampled.lock().expect("clock mutex"); + let result = *now; + *now = now + .checked_add(Duration::from_millis(1)) + .expect("clock advance"); + result + }); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_millis(2)) + .expect("deadline"), + ); + let result = + block_on(snapshot().get_bytes_bounded("mystore", "contract_key", &clock, deadline, 1, 1)); + assert!(matches!(result, Err(SecretError::DeadlineExceeded))); +} + +#[test] +fn namespace_miss_and_backend_and_value_caps_are_enforced() { + let clock = MonotonicClock::default(); + let deadline = Deadline::at_instant( + clock + .now() + .checked_add(Duration::from_secs(1)) + .expect("deadline"), + ); + let provider = snapshot(); + let miss = + block_on(provider.get_bytes_bounded("other", "contract_key", &clock, deadline, 0, 0)) + .expect("namespace miss"); + assert!(miss.value.is_none()); + for (backend_cap, value_cap) in [(1_u64, 100_u64), (100_u64, 1_u64)] { + let result = block_on(provider.get_bytes_bounded( + "mystore", + "contract_key", + &clock, + deadline, + backend_cap, + value_cap, + )); + assert!(matches!(result, Err(SecretError::ValueTooLarge))); + } +} + +secret_store_contract_tests!(secret_snapshot_contract, snapshot()); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 46f21e821..728dd20bc 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -50,6 +50,7 @@ export default defineConfig({ { text: 'Cloudflare Workers', link: '/guide/adapters/cloudflare' }, { text: 'Fermyon Spin', link: '/guide/adapters/spin' }, { text: 'Axum (Native)', link: '/guide/adapters/axum' }, + { text: 'Native AWS stores', link: '/guide/native-aws-stores' }, ], }, { diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 45addea2e..0d76f0a77 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -315,27 +315,37 @@ The database file grows over time and does not shrink after deletions. To reclai space, delete the file in `.edgezero/`; the data is lost. See [KV Storage](/guide/kv) for the portable API. -## Secret Store - -A declared `[stores.secrets]` id resolves to an `EnvSecretStore`, which looks up each -secret name verbatim in the process environment. Axum lists `secrets` in its -`single_store_kinds`, so only one secrets id may be declared: - -```bash -API_KEY=mysecret edgezero serve --adapter axum -``` - -## Config Store - -For local development, each declared `[stores.config]` id resolves to a -local-file config store backed by `.edgezero/local-config-.json`. -The standard runner loads immutable snapshots before binding its listener. -Missing files create empty declared stores. Malformed files retain typed failing -bindings, while allocation-limit violations fail startup. Changes made by `config -push` take effect after restart, not on the next request. -The portable manifest carries no inline defaults — the -pre-rewrite `[stores.config.defaults]` table is gone (see -[the migration guide](../manifest-store-migration.md)). +## Native store providers + +Axum keeps the existing local defaults, but native applications can select a provider +per logical store ID. The portable manifest still declares IDs and defaults only. A +separate, strict native bindings file selects supported providers; it does not change +store metadata or add AWS dependencies to Fastly, Cloudflare, or Spin builds. + +Without a bindings file, config uses local `.edgezero/local-config-.json` +snapshots, secrets use environment variables, and KV uses the existing local backend. +With explicit AWS bindings, the runner fetches mapped values before its initializer +and before it accepts requests. It does not fall back to a local provider when an AWS +lookup or preparation fails. See the [native AWS stores guide](../native-aws-stores.md) +for the schema, feature selection, permissions, and lifecycle limits. + +The native binding file path must be absolute. `EDGEZERO__STORE_BINDINGS_FILE` is +read only by the environment-loading runner. Explicit `AxumRunOptions` selection +uses only values supplied in code. Programmatic applications may also inject existing +handles or deferred setup through `run_app_with_native_stores`; deferred setup is +polled after source validation, in the current native runtime. Its construction must +not perform provider work. + +A declared `[stores.secrets]` ID still defaults to an environment-backed store when +not explicitly bound. It is no longer limited to one ID by Axum. Existing +`EDGEZERO__STORES__SECRETS____NAME` and config/KV local overrides retain their +meaning for implicit local bindings; they cannot override an explicitly bound ID. + +`config push --adapter axum` remains a local-file writer. It does not publish to +AppConfig. If passed an explicit `--store-bindings` file that binds the selected ID +to AWS, the proposed guard rejects the operation before writing anything. A push +without that explicit file still only updates the local snapshot file; it does not +update an AWS-bound deployment. ```toml [stores.config] @@ -424,6 +434,22 @@ not guaranteed. Local file reads are synchronous; no finite startup I/O interrup claimed. The broad `config-read-allocation-bounds` capability remains `Unsupported`; this bounded local config-snapshot path does not certify every secret/provider allocation. +## Optional AWS features + +Generated native targets opt into `aws-appconfig-agent` and +`aws-secrets-manager` independently, or use the `aws-stores` alias for both. These +features are separate from the CLI binary's own dependencies. A bindings file cannot +enable a provider that was not compiled into the application. The AWS SDK and service +clients stay out of default native and WASM target graphs. + +The native AWS SDK can emit diagnostic fields at dependency `DEBUG`/`TRACE` levels. +Those events may include secret ARNs, credential-source metadata, or response details +when sensitive-body logging is enabled. EdgeZero redacts its own errors and diagnostics, +but the first version does not suppress dependency events because scoped suppression +can break an application's tracing-to-log fallback. Do not enable dependency +`DEBUG`/`TRACE` in production unless the process logging policy has been reviewed. +This limitation must be disclosed in release and PR review notes. + ## Container Deployment Build and deploy as a standard container: diff --git a/docs/guide/native-aws-stores.md b/docs/guide/native-aws-stores.md new file mode 100644 index 000000000..d0a4f9ede --- /dev/null +++ b/docs/guide/native-aws-stores.md @@ -0,0 +1,241 @@ +# Native AWS stores + +This guide is a draft for the first native implementation. It covers two opt-in providers: +AppConfig Agent for complete configuration documents and Secrets Manager for complete secret +values. They can be selected independently per logical store ID. They do not apply to Fastly, +Cloudflare, or Spin builds, and they do not add write, publish, provision, or rotation commands. + +The provider reads at process startup and keeps immutable in-memory snapshots. Restart the +application to adopt a new config document, secret value, or moved version stage. A running +process keeps secrets already loaded even after remote deletion or access revocation. + +## Build the providers you use + +Generated native targets can opt into either service: + +```toml +[adapters.axum.build] +features = ["aws-appconfig-agent", "aws-secrets-manager"] +``` + +`aws-stores` is a convenience alias for both. Config-only apps need not include the Secrets +Manager SDK. Secret-only apps need not include the AppConfig Agent HTTP client. All features +are off by default. A bindings file cannot enable a service that the application binary did +not compile. + +CLI bindings validation is structural and offline: + +```sh +edgezero config validate \ + --adapter axum \ + --store-bindings /etc/my-app/native-stores.toml \ + --target-features aws-appconfig-agent,aws-secrets-manager +``` + +The bindings path must be absolute. Target controls include +`--target-all-features`, `--target-no-default-features`, and `--target `. If Cargo's +resolved target feature graph is unavailable or ambiguous offline, the command reports +structural validation only and says provider availability is unknown. This check does not +load credentials, contact AWS or the Agent, prove resource access, or validate remote +contents. Runtime startup validates the actual selected inputs again. Set +`EDGEZERO__STORE_BINDINGS_FILE=/etc/my-app/native-stores.toml` before starting the generated +native target. Its entrypoint selects strict startup when this locator is present. Without +it, the generated target keeps its existing local development behavior. An empty or invalid +selected locator fails startup rather than selecting local stores. + +## Declare portable stores + +Keep provider resources out of `edgezero.toml`. The application declares logical IDs and +defaults, not AWS resources: + +```toml +[stores.config] +ids = ["application"] +default = "application" + +[stores.secrets] +ids = ["runtime"] +default = "runtime" +``` + +A deployment selects providers in a separate native file. This example uses fictional +resource names and account `111122223333`: + +```toml +version = 1 + +[config.application] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "http://127.0.0.1:2772" +access_token_env = "APPCONFIG_AGENT_TOKEN" + +[config.application.documents.settings] +application = "checkout" +environment = "production" +profile = "checkout-envelope" + +[secrets.runtime] +provider = "aws-secrets-manager" +region = "us-east-1" + +[secrets.runtime.entries.signing_key] +secret_id = "arn:aws:secretsmanager:us-east-1:111122223333:secret:checkout-signing-AbCdEf" +version_stage = "AWSCURRENT" +``` + +The schema rejects unknown fields, unsupported provider tags, empty mappings, undeclared IDs, +and conflicting selectors. The names are case-sensitive. Every configured mapping is required +during preparation. A missing mapped document or secret fails startup. There is no fallback to +`.edgezero/` or an environment secret. An unmapped lookup returns a normal miss and never +triggers another AWS request. Runtime errors use closed, fixed categories; they do not retain +SDK/HTTP error chains or raw resource values in EdgeZero-owned diagnostics. + +Use exactly one selector for each secret: no selector means `AWSCURRENT`; set `version_stage` +or `version_id` to pin another version. Do not set both. Supply a complete secret ARN and an +explicit region. The file contains identifiers and environment variable names, never +credentials, tokens, or secret values. Protect it as deployment configuration nonetheless. + +## Start the AppConfig Agent + +EdgeZero talks to a local Agent HTTP endpoint. It does not start or supervise the Agent, poll +AWS AppConfig, or run AppConfig Data session-token logic. The Agent owns its AWS role, region, +AWS polling, and cache. The application makes one GET per distinct mapped profile at startup. + +Run the Agent in the same network namespace as the application and configure it to bind to +`127.0.0.1`. The provider accepts only literal loopback IP endpoints, so endpoint hostnames do +not trigger DNS. It rejects redirects, proxy use, and non-identity content encoding. It sends +`Accept-Encoding: identity`. When the binding names a token variable, startup reads it once and +sends `Authorization: Bearer `. A missing or empty configured token fails startup. +Configure the Agent's `ACCESS_TOKEN` to the same value. Never put the token in a URL or the +bindings file. + +Pin the operator-reviewed Agent image by digest in deployment configuration, for example +`public.ecr.aws/aws-appconfig/aws-appconfig-agent@sha256:`. The +placeholder is not a usable image reference. Before publishing this guide, the operator must +select the exact Agent release, verify its image digest through the approved artifact process, +and record both in deployment documentation. Do not replace the placeholder with an +unverified or invented digest. Disable Agent disk backup/preload for this setup; this does not +make its in-memory cache fresh or prove that AWS was reachable when the application starts. A +successful read proves only that the Agent supplied acceptable bytes. + +Do not expose the Agent endpoint to untrusted processes. If processes share its network +namespace, require token authentication. A token does not isolate the endpoint from a +compromised application process. + +## Preserve complete values + +An AppConfig mapping returns the profile's entire UTF-8 document. It does not select a JSON +field, parse or reserialize a document, unquote a JSON string, or resolve secret references. +A raw profile containing `hello` returns those exact characters. A profile containing a +serialized EdgeZero `BlobEnvelope` returns that serialization unchanged; core validates the +envelope when the app loads typed config. + +For example, a free-form profile can contain one serialized envelope: + +```json +{ + "version": 1, + "generated_at": "2026-10-07T12:00:00Z", + "sha256": "", + "data": { "message": "hello", "api_token": "signing_key" } +} +``` + +The `api_token` field in this example is a secret reference name, not a token value. The +application's typed config must declare the appropriate `#[secret]` field and must explicitly +check required config and secret references during its startup initializer before readiness. +The provider does not know which values the application requires. It does not claim that +startup checks or application-specific validation have run merely because snapshots loaded. + +Secrets Manager returns each configured value in full. `SecretString` is kept as its exact +UTF-8 bytes; the SDK's `SecretBinary` is already decoded bytes and is not base64-decoded a +second time. JSON secrets remain whole JSON byte values. Typed string references still need +UTF-8; byte-oriented consumers can retain binary values. + +## Startup flow + +```mermaid +flowchart TD + Bindings[Capture explicit bindings and named token values] --> Validate[Validate every source and actual compiled providers] + Validate --> Prepare[Prepare config and secret snapshots sequentially in one session] + Prepare --> Registries[Publish the complete registries] + Registries --> Initialize[Run the application-owned startup initializer] + Initialize --> Listen[Bind listener and report readiness] + Listen --> Requests[Serve from immutable snapshots without provider requests] +``` + +The generated entrypoint prepares provider snapshots but does not invent application-specific +startup checks. To require typed config and nested secrets before listening, replace its native +main with `run_production_app_with_initializer::(...)`. The callback receives +`&mut App` and `&PreparedStores`; use core `AppConfig::::from_binding` with the selected +config binding, secret registry, extraction limits and app clock. Insert validated state with +`app.insert_state(...)`. The callback may borrow both arguments and need not be `Send`. The +framework waits for it before binding. The generated-consumer integration test exercises this +public callback, a complete envelope and nested secret resolution against loopback fixtures. + +## Limits and lifecycle + +The shared AWS preparation session defaults to an 8 MiB cap over unique retained config and +secret payload bytes. The limit is per standard runner preparation session, not process-wide +and not a bound on RSS. Separate sessions, caller-supplied handles, local providers, SDK +response allocations, credential providers, TLS buffers, typed application state, and allocator +overhead are outside this cap. Config documents and secret entries also have per-value and +mapping-count limits. Treat the 8 MiB value as a configurable provisional default, not an AWS +service limit or a measured workload requirement. + +Preparation is sequential in the initial implementation. It has finite operation and aggregate +budgets, but dropping a future does not prove that credential discovery, a subprocess, or every +socket operation stopped. A startup failure drops the runner's staged snapshot and stops before +readiness. Use the process supervisor for restart policy and backoff. + +Config values are snapshots by mapped document. The snapshots do not promise a coordinated +revision across profiles or across config and secrets. If multiple documents must change +atomically, place them in one envelope or have application-owned release validation reject +mismatched revisions. Restart to adopt changes. + +A running process retains loaded secrets until it exits. Remote deletion, rotation, or revoked +access does not erase those bytes. Restart to adopt the selected version or remove a secret. +`Bytes`/`Arc` ownership does not promise memory zeroization. Do not log values, ARNs, tokens, +credential sources, or response bodies. + +**Dependency logging limitation.** EdgeZero-owned startup errors and diagnostics use redacted +categories, but the first version does not suppress DEBUG/TRACE events emitted by the AWS SDK +and its dependencies. Those events can expose secret ARNs, credential-source metadata, and, +when `LOG_SENSITIVE_BODIES=true`, response details. Scoped suppression was not adopted because +it can permanently disable an application's tracing-to-log fallback. Keep AWS dependency +`DEBUG`/`TRACE` off in production unless operators have reviewed their logger, sinks, and +sensitive-body settings. This limitation must be called out in reviews of the first version. + +## Credentials and permissions + +For the application, use the AWS SDK's supported credential chain. In production, prefer the +workload role attached to the service. For local development, use a named authenticated AWS +profile or another supported developer credential source. The bindings file and container +image must not contain static access keys. Secret-only applications initialize the Secrets +Manager client; AppConfig-only applications leave AWS credentials to the Agent. + +Grant the Agent role only the AppConfig Data read actions it needs: + +- `appconfig:StartConfigurationSession` for the selected application, environment, and profile. +- `appconfig:GetLatestConfiguration` for that configuration session/resource scope. + +Grant the application role `secretsmanager:GetSecretValue` for the explicitly mapped secret +ARNs, and `kms:Decrypt` only when a selected customer-managed key requires it. Runtime roles +do not need resource creation, AppConfig deployment, secret mutation, staging-label changes, +or rotation-Lambda management. Review the exact IAM resource scoping for the deployed AWS +service and account before deployment. This guide is not an IAM acceptance result. + +AWS charges and service quotas depend on the selected AWS resources, Agent polling, region, and +traffic. Estimate them from the deployment configuration. Keep a named operator responsible +for stopping the Agent and removing disposable AppConfig/Secrets Manager resources after +experiments; the runtime and CLI do not clean them up. + +## Push remains local + +`edgezero config push --adapter axum` writes local config snapshot files. It does not publish +AppConfig data. The `--store-bindings /absolute/path` push guard rejects a selection +that binds the chosen logical ID to AWS, before diffing, prompting, or writing. Without that +explicit file, a local push is still only a local write; it makes no claim about a separate +AWS deployment. There are no AWS publish, provision, secret-mutation, or cleanup commands in +this feature. diff --git a/docs/superpowers/plans/2026-10-07-native-store-providers.md b/docs/superpowers/plans/2026-10-07-native-store-providers.md new file mode 100644 index 000000000..f323eb3de --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-native-store-providers.md @@ -0,0 +1,618 @@ +# Native store providers implementation plan + +- Status: Implementation complete through offline Stages 1-8. Native provider combinations, generated-consumer startup, workspace/native/WASM gates and documentation checks passed. Draft [PR #414](https://github.com/stackpop/edgezero/pull/414) was published via `gh stack` after rebasing and rerunning gates. Provider availability validation deliberately remains structural-only/unknown. Real AWS acceptance is declined and excluded. +- Date: 2026-10-07. +- Owning issue: [EdgeZero #410](https://github.com/stackpop/edgezero/issues/410). +- Governing design: [Native store providers independent of HTTP adapters](../specs/2026-10-07-native-store-providers-design.md), including the R1 Cargo feature-resolution correction. +- Inspected main: `683202c66948146ec360f490126f1d60f920288b`. +- Prerequisite evidence: PR #275 at `d1a81579afb96f200e7fe8fd294600f0b446b080`; PR #406 at `3ab22096fd117f7506cf437b5ba128e0789e059c`. Both remain open at planning time. Their inspected APIs are not APIs available on main. +- Evidence level: Executed combined-prerequisite tests, native service feature combinations, real SDK literal-loopback fixtures and actual CLI-generated consumer startup. No real AWS, IAM, image or deployment acceptance. + +## 1. Goal and scope + +Allow native applications to bind KV, configuration, and secrets independently of their HTTP runtime and independently for each logical store ID. Ship AppConfig Agent whole-document configuration and Secrets Manager whole-value secrets as optional native providers. Keep ordinary local providers, portable declarations, typed envelopes, nested secret references, and per-ID registries. + +Success means a generated native application can start with mixed local, custom, and AWS bindings, validate required typed application inputs before readiness, serve requests from the same completed registries, and adopt remote changes only on restart. A failed mapped remote fetch must stop startup without falling back to files or environment values. + +Do not implement DynamoDB, live reload, secret refresh, publishing, provisioning, a provider plugin registry, a new HTTP runtime, or application-specific schemas. Do not extract existing local providers into new crates. Do not change portable trait future bounds, store declarations, macro metadata, or core errors to expose AWS details. + +The user's subsequent instruction to implement authorizes execution of this plan and rebasing onto the open prerequisites. Stage proof gates remain; do not skip verification between stages. Real AWS acceptance is excluded. Subsequent approval authorizes #410 commits and a draft via `gh stack`, but not merging, deployment, or changes to existing prerequisite PRs. Rewriting the prerequisite commit as part of the expressly authorized rebase is recorded below. + +## 2. Decisions and approval status + +The configurable 8 MiB default, shared-session quota scope, and caller-owned preparation boundary were approved in conversation on 2026-10-07. After the initial Stage 0 audit, the user explicitly instructed: "Those pull requests are not blockers. Go ahead and implement. If you need changes from those pull requests, rebase on top of them." This authorizes implementation of the proposed design and stages, including use of open prerequisite heads. Remaining policy proposals are the implementation contract unless the user changes them. This original instruction did not authorize AWS service access or publishing. Later approval authorizes draft publication; real AWS acceptance remains excluded. + +The implementation follows these choices: + +1. Use a move-only `NativeStoreOverrides` argument separate from cloneable `AxumRunOptions`. Each supplied ID has either an existing handle or a deferred, owned, non-`Send` future. Validate the complete source plan before polling futures. Require deferred future construction to be side-effect-free, with provider work inside its async body. The runner guarantees no polling before validation; it cannot undo caller-side work during future construction. +2. Keep a cloneable `NativeStoreBindingsInput` selection inside `AxumRunOptions`: local defaults, an absolute bindings-file path, or an inline typed `NativeStoreBindings` value. File and inline settings are mutually exclusive; selection methods reject a second explicit source rather than overwriting it. Both normalize into the same validator and preparation path. Noncolliding supplied handles/futures may coexist as the separate overrides argument; same-ID collisions are errors. +3. Use one explicit `AwsPreparation` session for all built-in AWS bindings prepared by a runner invocation. It owns budgets, successful target deduplication, and client reuse. Do not provide convenience constructors that silently create an independent budget per binding. +4. Apply declared native build features to both standard native build and serve commands. Normalize manifest and explicit feature inputs once; use the same selection when offline validation can resolve the actual target. +5. Add a bindings-only mode to `config validate` when `--adapter axum --store-bindings ` is supplied. It does not require a local app-config TOML file or claim to validate remote contents. Preserve the existing validation mode without these arguments. +6. Use a Cargo-resolution parity spike before shipping provider-availability verdicts. A failed or ambiguous offline resolution produces an explicit structural-only result, never an inferred enabled/disabled result. +7. Keep provider failures as closed, sanitized native categories. The coordinator attaches only the baked store kind and logical ID. Do not retain or format SDK, HTTP, TOML, credential, or arbitrary custom-provider error chains. + +Approved accounting and ordering policy: + +- The standard runner uses one shared session to enforce combined quotas across all its built-in AWS bindings. Prebuilt opaque handles, local providers, and separately created sessions remain caller-owned and are not included. Independent sessions may together exceed 8 MiB; this is not a process-wide limit. +- Validate sources before opening runner-created providers or polling deferred supplied futures. Require side-effect-free deferred construction. A caller can already have opened a `Ready` handle or done other work before supplying it; the runner cannot undo that work. + +Do not introduce process-global accounting or mandatory introspection of portable handles. The former accepted-only prerequisite gate is superseded by the user's authorization to use open PRs. Re-audit and test the exact combined stack; never claim those contracts are accepted on main. Runtime proof, feature isolation, and separate AWS acceptance gates remain. + +## 3. Delivery order + +```mermaid +flowchart TD + Audit[Stage 0: approve policies and re-audit prerequisites] --> Compose[Stage 1: generic native composition] + Compose --> Settings[Stage 2: strict bootstrap and AWS settings] + Settings --> Session[Stage 3: shared preparation and snapshot contracts] + Session --> Agent[Stage 4: AppConfig Agent] + Session --> Secrets[Stage 5: Secrets Manager] + Agent --> Tooling[Stage 6: generated features and offline CLI] + Secrets --> Tooling + Tooling --> Runtime[Stage 7: generated production startup proof] + Runtime --> Gates[Stage 8: docs and native/WASM CI] + Gates --> Acceptance[Stage 9: separately authorized AWS acceptance] +``` + +Stages 4 and 5 may be investigated in parallel. Keep edits to shared settings, session state, Cargo manifests, and lockfiles under one owner. Serialize Cargo commands sharing a target directory. Stage 6's Cargo-resolution spike can be investigated earlier, but do not publish a capability verdict until it passes. + +## 4. File map and ownership + +Existing files to change narrowly: + +| Area | Files | Intended change | +| --------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Workspace | `Cargo.toml`, `Cargo.lock` | Add optional native provider member/dependencies and coherent SDK pins | +| Native runtime | `crates/edgezero-adapter-axum/src/dev_server.rs` | Source planning, sequential setup, completed registry publication, additive runner API | +| Accepted production options | `crates/edgezero-adapter-axum/src/run_options.rs` | Bootstrap selection and local-root validation after source selection; exists at inspected #406, not main | +| Axum features | `crates/edgezero-adapter-axum/{Cargo.toml,src/lib.rs}` | Native-bindings/settings support and independent AWS feature forwarding | +| Axum CLI/scaffolding | `crates/edgezero-adapter-axum/src/cli.rs`, `src/templates/Cargo.toml.hbs` | Standard native Cargo invocation and generated service features | +| CLI inputs | `crates/edgezero-cli/src/args.rs` | Bindings path, selected adapter, target feature/default/all controls, push guard input | +| CLI validation/push | `crates/edgezero-cli/src/config.rs` | Bindings-only validation and explicit remote publication refusal | +| CLI build routing | `crates/edgezero-cli/src/{lib,adapter}.rs` | Normalize standard Axum feature selection before both generated-command and fallback dispatch | +| Generator | `crates/edgezero-cli/src/generator.rs` | Structural assertions and any needed dependency/feature template context | +| Generated integration | `crates/edgezero-cli/tests/generated_project_builds.rs` | Native provider combinations and preserved generated WASM builds | +| CI | `.github/workflows/{test,format}.yml` | Enabled-native tests, isolated feature lint, generated proof, dependency-graph assertions | +| Guides | `docs/guide/adapters/{overview,axum}.md`, `docs/guide/{configuration,cli-reference}.md` | Setup, support matrix, CLI modes, bounds, restart and stale-value policies | + +New files, names proposed: + +```text +crates/edgezero-adapter-axum/src/ + native_bindings.rs # SDK-free parsing, source validation, local-root needs + native_build.rs # SDK-free standard native Cargo selection, if extraction is needed + +crates/edgezero-store-aws/ + Cargo.toml + src/ + lib.rs + settings.rs # Strict types, selectors, endpoint/ARN validation + preparation.rs # Shared session, budgets, dedup, sanitized errors + appconfig_agent.rs # Restricted HTTP reader and immutable config provider + secrets_manager.rs # SDK boundary and immutable secret provider + +crates/edgezero-cli/tests/ + native_target_features.rs # Cargo-resolution versus compiled-fixture parity + +scripts/ + check_native_store_dependency_graph.sh + smoke_test_native_stores.sh + accept_native_aws_stores.sh # Only if an opt-in harness is useful; never default CI +``` + +Keep unit tests colocated. Add standalone fixture files only when the real generated/runtime path needs them. Prefer generating a temporary application through the existing scaffold over maintaining a second hand-written application. Keep fixture lockfiles current where fixtures are committed separately. + +Core and macro production changes are not expected after prerequisites are accepted. If new work appears necessary there, stop and explain the missing contract instead of expanding this plan silently. + +## 5. Stage 0: prerequisites, policy approval, and baseline + +### Work + +- [x] Read current `CLAUDE.md`, governing spec, #410/#324, and exact selected #275/#406 revisions. +- [x] Record selected stack commit IDs and integration differences. Open-PR use is explicitly authorized; do not label them accepted on main or implement an alternate bounded-read API. +- [x] Confirm `ConfigValue`, `BoundedStoreRead`, required bounded config/secret methods, application monotonic clock, fallible `App::build`, `PreparedStores`, initializer state insertion, and readiness/shutdown behavior on the combined stack. +- [x] Record the approved configurable 8 MiB session quota and caller-owned preparation boundary in both spec and plan. +- [x] Record the user's authorization to implement the plan and use open prerequisites, superseding the original accepted-only gate. Separate AWS acceptance remains unapproved. +- [x] Inventory and reuse the selected prerequisites' tests. Add only missing native composition/provider behavior. +- [x] Record a baseline for relevant native tests, existing workspace gates, and installed WASM targets. Classify pre-existing failures separately. All eight Stage 0 main checks passed; see the execution record below. +- [ ] Select exact compatible `aws-config` and `aws-sdk-secretsmanager` versions for the repository Rust toolchain. Add direct Smithy dependencies only when a shipped API or test connector needs them. Record the SDK behavior version, TLS/runtime feature choices, and Agent image/version to use later. + +### Exit gate + +The implementation plan is authorized. Use the exact user-approved prerequisite stack, source/test inventory, and passing combined-contract tests. Both PRs remain open; they are not a blocker under the user's instruction. Do not modify main or claim these contracts have landed. SDK graph/image selection still needs evidence before the corresponding provider stages. + +## 6. Stage 1: generic per-ID native composition + +### API shape + +Use the existing `KvHandle`, `ConfigStoreBinding`, and `BoundSecretStore` output types. A proposed native-only builder owns maps keyed by logical ID: + +```rust,ignore +// Exact exported names are settled after the accepted prerequisite re-audit. +// No Send bound on this future. Existing provider objects remain Send + Sync. +pub type NativeStoreFuture = Pin> + 'static +>>; + +enum NativeStoreSource { + Ready(H), + Prepare(NativeStoreFuture), +} + +pub struct NativeStoreOverrides { + // private keyed maps for KV, config, and secrets +} +``` + +Expose small typed insertion methods for each kind and for ready/deferred inputs. Insertions reject duplicates instead of overwriting a map entry. Keep map/source internals private. Require all deferred provider opening to happen inside the future body when polled, not during construction; this is a caller contract, not something the type system can prove. Tests establish zero polls and zero runner-owned opens on validation failure, not absence of earlier caller-side side effects. Do not require another public provider-factory trait. + +Add one runner entrypoint accepting options, overrides, stop, and the accepted app initializer. The options carry `NativeStoreBindingsInput`; Stage 1 starts with its local-default case, and Stage 2 adds file/inline normalization. The validator compares the selected settings IDs with the separate override maps before any preparation. Existing public entrypoints delegate with empty overrides. Keep `AxumRunOptions` cloneable; do not put futures into it. Owned deferred futures can capture `Rc` and bootstrap data. They cannot borrow options or the app initializer's higher-ranked inputs. + +The validated source plan has separate local, supplied-ready, supplied-deferred, and built-in-settings variants. `NativeStoreFuture` is only for opaque custom providers. File or inline settings become built-in variants, not stored futures: the runner matches each typed settings variant and directly awaits the matching method on its one mutable `AwsPreparation` session. This avoids trying to borrow that session into a stored `'static` future. For secrets, pass the baked logical namespace into snapshot construction and wrap the returned `SecretHandle` in `BoundSecretStore` with the same namespace. Config construction returns a `ConfigStoreHandle` paired with the validated settings default key. File and inline inputs cannot coexist; either selected input can coexist with noncolliding custom overrides. Custom-provider quota enforcement belongs to that provider, not to introspection of its eventual handle. + +### Work + +- [x] Compile-probe the proposed API with an `Rc`-capturing setup future that returns a valid handle on the existing current-thread `LocalSet`. +- [x] Preserve the initializer's accepted higher-ranked borrowing signature. Prove it still permits borrowing stores across await and capturing non-`Send` application state during setup. +- [x] Separate options/scalar validation from local-root requirements. Determine required roots from the resolved per-ID sources, not just declared store kinds. +- [x] Build one source plan for all kinds. Validate defaults, declared IDs, duplicate supplied inputs, explicit scalar-overlay conflicts, and local-root needs before preparing any runner-owned provider. Stage 2 adds file/inline settings and cross-source collision coverage. +- [x] Preserve config default keys, secret bound namespaces, and local KV NAME-derived paths. Explicit sources retain supplied semantics; implicit local sources keep existing env behavior. +- [x] Assemble the app in the selected prerequisite order, then sequentially prepare sources under the existing runtime. No additional runtime or initializer-side provider selection. +- [x] Stage every handle, finalize registries once after all sources succeed, preserve declared defaults, and share the completed `PreparedStores` between initializer and request service. +- [x] Drop staged handles and preparation futures on failure or startup stop. No initializer, readiness, or listener after incomplete preparation. + +### Proof + +Add colocated tests for: + +- ready and deferred custom providers, mixed same-kind bindings, and the default ID; +- config default-key preservation and wrong-secret-namespace isolation; +- undeclared IDs, duplicate supplied IDs, and explicit NAME/KEY conflicts; supplied/file and supplied/inline collision tests follow in Stage 2; +- zero deferred polls and zero runner-owned provider opens on every source-validation failure; +- remote/custom-only config without CONFIG_DIR, custom-only KV without DATA_DIR, and roots required only for remaining local IDs; +- sequential preparation, failure of a later binding, partial-state drop, and no initializer/readiness on failure; +- initializer and request service observing the same prepared handles; +- existing local development and strict production defaults with empty overrides; +- stop during a pending preparation without claiming all external work is forcibly canceled. + +Run scoped Axum tests after each code increment. Exit when fake providers prove generic composition without importing AWS. + +## 7. Stage 2: strict bootstrap parsing and settings-only AWS crate + +### Ownership and feature shape + +Native `native_bindings.rs` owns the top-level versioned schema, kinds/IDs, tagged dispatch, source collisions, overlays, local-root needs, and file limits. `edgezero-store-aws::settings` owns AWS settings and resource-shape validation. Never put provider fields into portable declarations or generated metadata. + +Add a settings-only dependency on the provider crate to the native bindings feature. This does not enable either service. The runtime and CLI can both use native-bindings without enabling each other's runtime/registry features. + +Provider features: + +```text +edgezero-store-aws + default = [] + appconfig-agent -> HTTP client, streaming support, native timers + secrets-manager -> AWS config/service SDK and native runtime support + +edgezero-adapter-axum + native-bindings -> native parser + SDK-free AWS settings + axum -> existing runtime + native-bindings + cli -> existing CLI + native-bindings + aws-appconfig-agent -> provider appconfig-agent + aws-secrets-manager -> provider secrets-manager + aws-stores -> both explicit service features +``` + +Use native target dependency declarations and module gates. Do not add another Tokio dependency to an adapter or any AWS/native dependency to portable core or WASM adapters. Service-disabled settings parsing must work without the service clients. All-feature builds must not conceal missing single-feature cfgs. + +### Work + +- [x] Add the provider workspace member, `lib.rs`, strict settings structs, version selector enum, limits, and closed redacted preparation error categories. +- [x] Add native-only version 1 tagged parsing with unknown/irrelevant fields, unsupported KV/provider tags, duplicate tables, empty mappings and undeclared IDs rejected. +- [x] Require mapped config defaults and case-sensitive IDs/lookup keys, without parsing/projecting remote values. +- [x] Validate complete Secrets Manager ARNs, supported partition/region-family relationships, explicit matching region, exclusive selectors and API length bounds. Full cross-account ARNs remain explicit. +- [x] Validate literal-loopback HTTP endpoints and original authority/path spelling; construct escaped segments and independently test decoded round-trip equality. +- [x] Add mutually exclusive absolute file and typed inline selection. Explicit APIs use only supplied bootstrap values; `from_env` alone resolves ambient locator/token variables. +- [x] Read selected regular files once with cap+1 probing, redacted fixed errors and Unix nonblocking FIFO rejection. +- [x] Capture locator/scalar inputs once, reject relevant normalized duplicates, capture named tokens once per name, and redact Debug/errors. Preserve unrelated EnvConfig duplicate semantics; ambiguous explicitly supplied token values fail closed. +- [x] Reject disabled providers before deferred polling/local provider construction. Services are not implemented/enabled at this settings-only checkpoint; actual compiled capabilities are added with Stages 3-5. + +### Limits schema + +Use a trusted bootstrap `NativeBindingsLimits` policy for the file byte cap and total explicit-ID cap. Defaults are 256 KiB and 128 IDs. A bindings file cannot raise its own pre-parse byte limit. Programmatic overrides of bootstrap limits are explicit; offline validation reports the policy it used. + +Propose an optional `[limits.aws]` table for remote preparation settings: + +| Field | Default | +| --------------------------- | ------------------------------------------------------------- | +| `max_remote_values` | 1,024 mapped config documents plus secret entries | +| `max_config_document_bytes` | 1 MiB | +| `max_secret_value_bytes` | 64 KiB | +| `max_snapshot_bytes` | 8 MiB combined unique retained AWS payload per shared session | +| `preparation_timeout_ms` | 30,000 | +| `operation_timeout_ms` | 5,000 | +| `connect_timeout_ms` | 1,000 | +| `attempt_timeout_ms` | 2,000 | + +The approved 8 MiB default is provisional and not derived from AWS limits or workload measurements. Keep an aggregate cap because many values can each satisfy per-value limits while collectively retaining excessive payload. Document its rationale and scope; revisit the default with representative application payload sizes plus headroom. Explicitly raising or lowering it must remain possible. + +These are configurable defaults, not immutable hard ceilings. Explicit file/typed policies may change service limits subject to finite/nonzero checks, checked conversions/arithmetic, and consistency of per-operation/aggregate and per-value/combined limits. The file-byte/explicit-ID limits change only through trusted native bootstrap options. Keep retry count and concurrency fixed initially. Count mappings before deduplication; deduplication must not let unlimited aliases bypass entry limits. Reject simultaneous explicit bootstrap-supplied and file-supplied AWS limit policies. Otherwise use the one supplied policy, filling omitted fields from documented defaults, or use all defaults when both policies are omitted. Do not fieldwise merge two supplied policies. The selected policy applies to built-in settings prepared through the shared session, not opaque custom handles/futures or separately prepared AWS handles. + +### Proof + +Settings-only tests cover every rejection rule, blank/non-Unicode bootstrap inputs, token absence/emptiness, env versus explicit options, file overflow, unsupported features, selectors, limits, and source conflicts. Verify parser errors do not contain sentinel ARNs, tokens, credentials, or raw TOML values. + +Run settings-only provider tests and Axum parser tests in runtime-only and CLI-only configurations. Inspect reachable normal dependencies to confirm the SDK and HTTP client are absent from settings-only builds. + +## 8. Stage 3: shared AWS preparation and bounded snapshot reads + +### Work + +- [ ] Implement an explicit, per-startup `AwsPreparation` session with sequential `&mut self` preparation methods. No process-global client/cache/session. +- [ ] Start one aggregate deadline before credential/client initialization. Derive operation, attempt, and connect budgets from remaining aggregate time and configured caps. Credential/setup elapsed time consumes this budget; check expiry before and after awaits and discard results completed after expiry. Classify credential/blocking-work cancellation as best-effort or unsupported under the accepted capability contract. Do not promise a 30-second wall-clock completion bound or that dropping a future stops a credential process or socket. Audit whether the pinned SDK operation timeout includes each supported credential provider; do not assume it does. +- [ ] Track mapping counts, unique retained payload bytes, metadata, and transient staging separately. Use checked accounting and reject overflow before retaining the next payload. +- [ ] Cache only successful targets. Reuse immutable payload allocations between identical targets while retaining separate logical-key and namespace mappings. +- [ ] Include normalized endpoint, application/environment/profile, and authorization policy identity in AppConfig dedup keys. Different token policies must not merge. Keep token identity internal and redacted. +- [ ] Include region/client-policy identity, full ARN, and exact normalized selector in secret dedup keys. Normalize omitted/current selection consistently; never collapse pinned versions or distinct stages. +- [ ] Reuse one Secrets Manager client per region/policy in the shared session. Config-only sessions must not initialize credentials or an SDK client. +- [ ] Implement immutable config and secret snapshot providers under accepted #275 methods. They do not keep cloud clients or issue request-time cloud calls. +- [ ] Ordinary config reads return shared `ConfigValue`; secret reads return exact `Bytes`. Unknown keys or wrong secret namespaces return successful misses without probing a service. +- [ ] Bounded reads reject an expired deadline before lookup, honor independent backend/value caps, report hit payload bytes and zero bytes on misses, and recheck deadline precedence before returning. Reuse accepted finishing helpers where suitable. +- [ ] Preserve accepted conservative deadline/allocation classification. Document that SDK parsing, credential work, allocator overhead, and the Agent are outside retained-payload accounting. + +### Proof + +Use a deterministic injected monotonic clock and production accounting paths. Test exact limits and limit+1, explicit higher/lower quota settings, aggregate bytes across both services and IDs, mapping-count limits before dedup, checked overflow, auth/selector-sensitive dedup, preparation deadline exhaustion, sanitized Debug/display, and independent sessions without global state. Prove one runner shares the same quota across all built-in AWS bindings and that a quota breach drops staged state before readiness without eviction or local fallback. Independently prepared opaque handles are not included in that accounting. Add a delayed credential-provider fixture that completes after the aggregate deadline and prove no value is retained or published. Separately test a pending credential future under supported timers. Neither fixture proves forcible cancellation of a real credential process or blocking provider; keep that fact unproven with best-effort wording. + +Run existing core config/secret contract macros against preseeded snapshot providers. Add bounded-read tests for both ordinary hits and misses, empty values, namespace isolation, backend/value allowances, and deadline precedence. These tests are separate from startup transfer tests. + +Atomic publication is the native coordinator's responsibility. A programmatic caller that retains a handle returned by one successful preparation cannot expect the session to erase that handle after a later failure. The standard runner drops all staged handles on failure; prove that path in Stages 1 and 7. + +## 9. Stage 4: AppConfig Agent whole-document preparation + +### Work + +- [ ] Build a dedicated native HTTP client. Disable ambient proxies, redirects, and transparent decompression. Do not reuse the generic outbound proxy client. +- [ ] Construct GET `/applications/{application}/environments/{environment}/configurations/{profile}` using validated, escaped segments. +- [ ] Send `Accept-Encoding: identity`. Send `Authorization: Bearer ` only when explicitly configured. Do not enable request/header/body diagnostics that reveal bootstrap data. +- [ ] Perform one bounded request per distinct complete target, including streamed body consumption. Reject non-success/protocol responses and non-identity content encoding. +- [ ] Reject oversize declared content lengths early when available; always enforce actual streamed bytes before accumulation exceeds the allowed limit. Detect truncated/erroring streams. +- [ ] Validate UTF-8 and retain the exact text, including whitespace, JSON quoting, empty documents, and serialized envelopes. Do not parse, unquote, reserialize, or resolve secret references. +- [ ] Make every mapped profile mandatory during preparation. A missing/unavailable mapped profile is a startup error, never a snapshot miss or local fallback. +- [ ] Drop response bodies and source errors before producing categorized diagnostics. Publish no partial binding. + +### Proof + +Use authorized loopback-only fixtures with bounded lifetimes and isolated ports. Cover IPv4/IPv6 validation, exact escaped paths, auth/no-auth, absent/empty tokens, proxy/redirect refusal, encoding rejection, status errors, malformed UTF-8, empty/raw/envelope fidelity, content-length and streamed cap edges, truncation, connect/body deadline behavior, dedup across IDs, auth-policy separation, and immutable reads after the fixture changes. + +Test generated envelope extraction and nested secret resolution later through the app initializer. A fake Agent proves reader behavior, not AWS reachability, deployment freshness, or warm-cache policy. + +Run AppConfig-only provider tests. Exit with no Secrets Manager SDK in the config-only normal dependency graph. + +## 10. Stage 5: Secrets Manager preparation and real SDK behavior + +### Work + +- [ ] Load the pinned SDK using explicit region, fixed behavior defaults, supported credential discovery, capped standard retries, and explicit operation/attempt/connect budgets. +- [ ] Count credential/client initialization against the shared aggregate budget. Use workload roles in documented production setup and a documented authenticated developer path. Do not require static keys in bindings or images. +- [ ] Make exact `GetSecretValue` requests for mapped ARNs/selectors only. Load every mapped entry before completing that binding. +- [ ] Accept exactly one value field. Return `SecretString` UTF-8 bytes or the Rust SDK's already-decoded `SecretBinary` bytes. Preserve empty/binary values where allowed. Do not extract JSON fields or base64-decode a Blob again. +- [ ] Check payload size after SDK materialization and before retaining it. Describe this as a retained-value cap, not a bound on every SDK allocation. +- [ ] Classify service/credential/transport failures before dropping source details. Keep missing resource/version, denied, decryption, throttled, deadline, malformed, and size categories where exposed. +- [ ] Let the SDK own transient retries, at most three total attempts and bounded by remaining budgets. Add no outer retry loop. Verify denied/decryption/validation errors are not repeatedly retried under the pinned policy. +- [ ] Spike the real generated-child test path against the pinned SDK now: child-only dummy credentials, metadata disabled, and supported service endpoint override to a loopback scripted HTTP fixture. Verify client configuration honors that override without a production bindings field or public mock API before Stage 7 depends on it. +- [ ] Drop clients/session staging after preparation; snapshot handles contain only successful mapped payloads. Never persist snapshots to disk or claim zeroization. + +### Test layers + +1. A narrow private service-call seam exercises production snapshot building, mappings, selectors, payload fidelity, errors, and cleanup with deterministic responses. Do not expose a public mock/factory hierarchy. +2. The pinned SDK with a scripted connector exercises real serialization, error classification, retry attempts, and timeout settings. Supply fixture credentials explicitly to avoid ambient discovery or IMDS. A fake service-call method cannot prove SDK retry behavior. +3. Separately authorized AWS acceptance proves deployed credentials, IAM, and service version behavior. + +Cover binary data that resembles base64 text, neither/both fields, empty values, default/current versus pinned versions, selectors, wrong namespaces, unknown keys without calls, ARN/policy-sensitive dedup, missing version, expired credentials, denied/decryption/throttle/transport errors, capped attempts, exhausted budgets, and partial-startup cleanup. + +Capture EdgeZero-owned logs and returned errors with sentinel values. Assert they contain no secrets, ARNs, token/credential sources, response bodies, or raw SDK messages. Dependency DEBUG/TRACE output is outside that guarantee; the executed diagnostic spike documents its disclosure risk. For the approved first version, retain redacted EdgeZero-owned diagnostics and explicitly disclose dependency DEBUG/TRACE output to operators and draft reviewers. Do not install global or scoped subscribers to silence dependency events. The user approved this limitation after the diagnostic spike; hardening dependency diagnostics remains a review item. The separately operated Agent's own logging policy remains operator-owned. + +Run Secrets Manager-only and combined provider tests. Exit with actual SDK retry/classification evidence, not just fake-seam assertions. + +## 11. Stage 6: generated features, Cargo selection, offline validation, and push guard + +### Generated/native feature enablement + +- [ ] Add generated native `[features]` entries for `aws-appconfig-agent`, `aws-secrets-manager`, and the proposed `aws-stores` alias. Use `default = []` for these target service choices and forward each to the matching adapter feature. +- [ ] Keep default native and generated Fastly/Cloudflare/Spin targets AWS-free. Keep CLI service capabilities independent from the binary being validated. +- [ ] Introduce one standard native Cargo selection helper shared by build/serve and offline resolution. Requested names combine declared build features and explicit additions; provider availability comes from resolved capabilities, not those names. +- [ ] Handle both real dispatch paths. Generated Axum manifests currently use shell `cargo build -p ` / `cargo run -p ` commands through `edgezero-cli::adapter::execute`; the registered Axum `run_cargo` fallback is not the sole build path. +- [ ] Route the exact standard generated Cargo command forms and registered fallback through the shared normalization. Do not parse or rewrite arbitrary operator shell programs as Cargo commands. Preserve custom-command ownership and mark capability selection unknown when its build behavior cannot be represented. +- [ ] Normalize repeated/comma/space `--features`, `--features=...`, `--all-features`, and `--no-default-features` before the Cargo/run `--` separator. Runtime args after that separator must not affect compile features. Preserve unrelated passthrough arguments. +- [ ] Identify the exact native package/manifest and target. Treat manifest target `native` as host selection, not literal `cargo --target native`. Do not silently override operator package/target selections. +- [ ] Recognize equivalent supported target flags in bindings validation, proposed `--target-features`, `--target-all-features`, `--target-no-default-features`, and `--target`. Document defaults and reject target-control flags outside bindings validation. + +### Cargo-resolution correctness spike + +Candidate resolution is `cargo tree --offline --locked` for the exact native package, normal/build edges, the same target and feature controls, and narrowly parsed package/feature records. Use metadata for package identity/topology only when it can be obtained offline without changing the lockfile. Do not assume metadata's workspace-wide graph or tree's "pretty close" build view is exact. + +- [ ] Build local-only resolver-2 fixtures and compare predicted provider availability with the same-selection compiled fixture's cfg result. +- [ ] Cover default/no-default/all/explicit/alias selection and dependency feature propagation. +- [ ] Cover sibling workspace members, own/sibling dev dependencies, build dependencies, target-specific edges, renamed/duplicate package identities, and host versus explicitly selected native targets. +- [ ] Prove an unrelated member or dev dependency cannot falsely enable a normal-runtime provider. Check the Axum adapter service feature and reachable provider feature, not just a same-named feature on another crate. +- [ ] Classify unsupported package overrides, custom shell commands, `--all-targets`, `--workspace`, `--config`, ambiguous contexts, unexpected Cargo output, missing lock/cache, or failed offline resolution as unknown unless a parity test proves support. +- [ ] Emit structural-only when resolution is unknown. Never edit the lockfile, fetch dependencies, build application code, execute build scripts, or discover credentials during the user-facing offline check. Fixture compilation belongs to tests, not validation. + +Exit this spike with a documented supported invocation subset and tests. If the candidate cannot prove a case, keep that case structural-only rather than building a second approximate feature resolver. + +### Bindings-only validation + +- [ ] Add `--adapter` and `--store-bindings` inputs plus target controls to `ConfigValidateArgs` and its defaults/parser tests. +- [ ] Branch before `load_validation_context` for the new bindings-only mode. Load portable manifest declarations and explicit native bindings; do not require `.toml` or run typed remote-content checks. +- [ ] Require Axum selection and an absolute explicit bindings path. Reject irrelevant adapter/target combinations. Preserve existing raw/typed validation paths without the new mode. +- [ ] Use the SDK-free native source validator with only supplied deployment inputs. Never read ambient NAME/KEY overrides, the ambient bindings locator, or Agent token/credential values here. Startup validates actual deployment inputs again. +- [ ] Report either structural validation plus known target-provider compatibility, or structural validation only with provider availability unknown. Neither result proves resource existence, token availability, local-root availability, IAM, typed config validity, or build success. +- [ ] Permit programmatic validation to supply explicit overlay/override information through the same pure validator; CLI file validation does not invent future supplied handles. + +### Local publication guard + +- [ ] Add explicit `--store-bindings` to downstream typed `ConfigPushArgs` and its parser/default construction sites. +- [ ] Resolve the selected logical config ID, then parse and check the explicit bindings file before remote read-back/diff, prompts, adapter publication, or local writes. +- [ ] Reject an AWS-bound selected ID with a fixed unsupported-publication message, including dry-run and `--local`. Unrelated AWS IDs do not block a local selected ID. +- [ ] Do not consult the ambient deployment locator or claim a bindings-free local push updates an AWS-bound deployment. Keep the adapter trait unchanged and the local writer intact. +- [ ] Preserve ordinary local push, sibling-key retention, dry-run no-write, and all other adapters' behavior. Do not add AWS diff/gc/publication/mutation workflows. + +### Proof + +Extend generator structural tests and the real generated-project integration test. Test CLI/target feature mismatches in both directions, structural-only degradation without local app TOML, flags/aliases/default suppression, generated-command versus fallback feature forwarding, custom-command preservation, no lockfile mutation/network, and explicit push refusal with no file writes or provider calls. + +## 12. Stage 7: production startup and generated consumer proof + +### Work + +- [ ] Generate a native fixture through the actual CLI. Configure portable IDs, native binding settings, provider features, and an app-owned initializer under accepted lifecycle contracts. +- [ ] Use `#[action]` handlers and `edgezero_core` HTTP types. Load a real serialized envelope through accepted `AppConfig::from_binding` or its accepted equivalent, resolve nested secret references, validate typed configuration, and insert typed handler state before readiness. +- [ ] Exercise these combinations: AWS config plus environment secrets; local config plus AWS secrets; both AWS providers plus redb; same-kind local/custom/AWS bindings; remote-only config and injected KV without local roots. +- [ ] Use loopback Agent and scripted Secrets Manager HTTP fixtures through the real pinned SDK in the generated child process. Stage 5 must prove a supported SDK endpoint override plus explicit fixture credentials can select that loopback fixture in child-only bootstrap without ambient discovery or external calls. Clear inherited child bootstrap, supply only required fixture settings and dummy credentials/region, disable metadata credential lookup, and select only the loopback Secrets Manager endpoint. Keep this endpoint out of the production bindings schema. Assert request targets, bodies, selectors, and attempts without logging Authorization/signatures or payload values; unexpected destinations fail the test. A downstream binary cannot use a dependency's private `cfg(test)` service/connector seam; reserve that seam for provider unit tests. If the pinned SDK cannot support the generated fixture this way, stop and approve another test strategy rather than adding a public mock registry. Run real AWS through the same generated runtime only in Stage 9. +- [ ] Make a downstream fake KV use only generic handle injection. It must not import AWS or depend on a built-in tag. +- [ ] Change fixture versions while the process runs. Prove snapshot reads and retained typed state do not change; restart and prove new values are adopted. Test pinned/current secret selection separately. +- [ ] Fail a mapped profile, secret fetch, envelope validation, nested required secret lookup, and app initializer. Prove no readiness/listener acceptance and no fallback in each case. +- [ ] Stop while AWS preparation is pending. Reuse accepted shutdown semantics and observe staged handle drop without overstating credential/socket cancellation. +- [ ] Capture child process output and returned startup diagnostics. Assert sentinel secrets, tokens, ARNs, raw bindings, config documents, response bodies, and SDK chains never appear. + +### Exit gate + +A generated native application proves the preparation -> shared registries -> app initializer -> typed handler path. Record commit, feature set, fixture policy, commands, and results. Mock evidence is not IAM/Agent freshness evidence. + +## 13. Stage 8: docs, feature isolation, and CI + +### Documentation + +- [ ] Update existing local-only claims in the Axum guide. Document native binding selection, local defaults, strict production roots, explicit options versus env loading, generic supplied handles/deferred setup, and SDK-free CLI validation. +- [ ] Document Agent process/network ownership, literal loopback setup, explicit loopback bind, bearer token policy, pinned Agent version, backup/preload disabled, and warm-cache staleness. +- [ ] Document exact raw/envelope and string/binary semantics, required mappings, startup-only adoption, non-atomic cross-document snapshots, secret retention after remote deletion/revocation, and no zeroization guarantee. +- [ ] Document bounds and their scope, sequential startup costs, best-effort cancellation, service/credential failure categories, and supervisor-owned restart backoff. +- [ ] Document generated opt-in features, supported Cargo-resolution subset, structural-only results, and local-only `config push`. No publication/provisioning claims. +- [ ] Include fictional bindings, role permissions, developer authentication, native-only support, costs, and named cleanup ownership. Keep application schemas/required secret names out of framework examples. + +### Required repository checks + +Run these against a stable combined state after scoped tests have passed: + +```sh +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly +cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Add isolated native-provider gates, proposed commands: + +```sh +cargo test -p edgezero-store-aws --no-default-features +cargo test -p edgezero-store-aws --no-default-features --features appconfig-agent +cargo test -p edgezero-store-aws --no-default-features --features secrets-manager +cargo test -p edgezero-store-aws --no-default-features --features "appconfig-agent secrets-manager" +cargo test -p edgezero-adapter-axum --no-default-features --features cli +cargo test -p edgezero-adapter-axum --no-default-features --features axum +cargo test -p edgezero-adapter-axum --no-default-features --features "axum aws-stores" +cargo test -p edgezero-cli --test native_target_features +cargo test -p edgezero-cli --test generated_project_builds -- --ignored +``` + +Also lint settings-only, AppConfig-only, Secrets-only, Axum runtime-only, and CLI-only modes so all-feature unification cannot hide cfg mistakes. Tests needing the native runtime stay in native crates. Keep portable contract suites on `futures::executor::block_on`. + +- [ ] Wire enabled native provider tests and resolver/generated fixture checks into CI. Default provider tests require no credentials and no external network. +- [ ] Add target-filtered reachable normal/build graph assertions for all three generated WASM adapters and the existing Fastly `fastly cli` combination. Assert no `edgezero-store-aws`, AWS service SDK/config/credential crates, or AWS Smithy runtime dependencies are reachable there. Match actual SDK package identities, not a blanket `aws-*` prefix that would also ban unrelated TLS cryptography such as `aws-lc-rs`. +- [ ] Assert default native has no service clients; config-only has no Secrets Manager SDK; secret-only has no Agent HTTP-client dependency introduced by the provider feature. Compare dependency causes rather than banning unrelated existing HTTP dependencies. +- [ ] Inspect the selected graph, not every package listed in Cargo metadata, the lockfile, or host workspace all-feature builds. Optional AWS packages in Cargo.lock are not evidence of WASM reachability. +- [ ] Preserve existing adapter WASM contract/runtime tests, generated target checks, and docs gates. Run excluded app-demo/fixture checks when their manifests or lockfiles change. +- [ ] Run `npm run format`, `npm run lint`, and `npm run build` in `docs` after approved guide changes. Internal `docs/superpowers` documents are excluded from Prettier/site inputs; do not reformat them globally. + +### Exit gate + +All applicable host, generated-native, isolated-feature, docs, and WASM gates pass, or each unavailable/pre-existing failure has explicit evidence and a named blocker. Do not call unrun targets passing. + +## 14. Stage 9: disposable AWS acceptance, separately authorized + +Before any AWS action, obtain approval naming account/region, temporary workload/developer identity, maximum cost/request count, finite runtime, resources, and cleanup owner. Do not put this into ordinary CI or launch it merely because local tests passed. + +### Work + +- [ ] Pin and record commit, Rust/SDK behavior version, Agent version/image, native feature set, deployment environment, and bindings policy. +- [ ] Have the authorized operator create/review disposable AppConfig application/environment/free-form profiles and Secrets Manager string/binary versions. Runtime code creates nothing. +- [ ] Use an Agent role limited to AppConfig Data `StartConfigurationSession` and `GetLatestConfiguration`, and an application role limited to exact secret `GetSecretValue` plus required customer-managed-key `kms:Decrypt`. +- [ ] Start a cold Agent without disk backups. Retrieve exact raw/envelope documents through the generated application and complete required typed/secret checks before readiness. +- [ ] Verify denied cold-Agent access fails startup; separately observe and record documented warm-cache behavior without presenting it as fail-on-revocation or latest-deployment proof. +- [ ] Verify current/pinned secret versions, binary fidelity, restart adoption, missing resources/versions, denied secret access, and denial/no-fallback startup diagnostics. +- [ ] Change config/secret versions and stage labels. Prove the running process retains its snapshots and a new process adopts the selected new values. +- [ ] Stop the application and Agent, remove disposable resources through operator tooling, and verify cleanup. Record incomplete cleanup and owner explicitly. + +Record resource identifiers only in an authorized private acceptance record. Public/runtime logs and checked-in examples remain redacted. Acceptance failure blocks claims about the deployed behavior it exercised; local fixtures cannot substitute for it. + +## 15. Evidence, review, rollout, and rollback + +For each stage record `check | exact revision or relevant file hashes | environment/features | result | evidence location`. Run scoped tests after every code change as required by the repository. Run broad checks at stable integration checkpoints. Reuse only evidence whose relevant inputs still match. + +Review two high-risk facts before release: + +1. Provider/bootstrap/service errors cannot expose values through EdgeZero-controlled output. Push from source/path inspection to captured generated-process output in Stage 7, including failure and diagnostic levels. +2. Required AWS values and app-owned typed checks finish before listener acceptance/readiness, with no local fallback. Push from source order to failing and successful generated startup tests, then authorized AWS acceptance. + +Source-only claims remain unproven. Timeout observation does not prove credential-process/socket cancellation; payload accounting does not certify RSS; warmed Agent reads do not certify AWS freshness. + +Roll out additively. Existing applications without bindings inputs keep current defaults and AWS-free builds. Compile provider features before selecting a remote binding. Migrate one ID at a time with operator-reviewed resource mappings and app-owned startup validation. No framework data migration or remote write is included. + +Rollback means select a reviewed local/custom binding and restart with the needed local inputs, or deploy the previous reviewed binary/bindings pair. Never automate fallback after a selected AWS binding fails. Restart-based rollback may still require app-owned config/secret version coordination. + +## 16. Planning investigation and chosen approach + +Three read-only workers investigated native composition, AWS providers, and CLI/build/CI. Each then proposed a minimal approach; no worker edited code or ran Cargo/AWS checks. + +- Native composition confirmed existing handles and registries suffice. Choose keyed ready/deferred inputs instead of a general factory registry or an async initializer that selects stores too late. +- AWS investigation confirmed an explicit shared preparation session can own quotas, dedup, and clients without globals. Choose it over independent hidden per-binding sessions; retain caller responsibility for opaque prebuilt handles. +- CLI investigation found generated manifest commands bypass the Axum fallback and that metadata alone cannot be assumed equivalent to selected-package compilation. Cover both standard dispatch paths and gate offline verdicts on Cargo parity fixtures, with explicit structural-only degradation. + +The chosen plan limits production changes to native composition, one optional provider crate, and necessary generated/CLI integration. Existing local implementations remain in place; core/macros and unrelated adapter behavior stay unchanged. + +## 17. Stage 0 execution record + +### Gate result + +Historical initial audit on 2026-10-07, before the user's subsequent implementation/rebase authorization. At that point the original accepted-only exit gate was not satisfied and no runtime implementation had started. The later authorization and execution record below supersede that gate result. + +Read `CLAUDE.md`, the handoff completely, this worktree's complete spec and plan, the referenced reusable lifecycle design, and current #410/#324 issue bodies. Both issues remain open. Read-only GitHub queries confirmed: + +| Prerequisite | Exact current head | Status | Accepted revision | +| ------------ | ------------------------------------------ | ------------------------------------ | --------------------- | +| #275 | `d1a81579afb96f200e7fe8fd294600f0b446b080` | OPEN, draft; unchanged from planning | None; no merge commit | +| #406 | `3ab22096fd117f7506cf437b5ba128e0789e059c` | OPEN, draft; unchanged from planning | None; no merge commit | + +#406's current PR body also records outstanding integration with newer #275 contracts. The #406 head is not a descendant of this worktree's main revision. Therefore there are no accepted revisions to re-audit or accepted-head API differences to record. Do not transplant either draft or implement against main's old contracts. Repeat the audit against the exact accepted commits after prerequisite acceptance and an approved worktree integration step. + +The remaining approvals are still pending: Agent staleness, startup-only secret retention after deletion/revocation, whole-document mappings, initial service set, external bindings and generic injection choices, remaining limits, generated feature enablement, and the Stage 1 implementation plan. The approved configurable 8 MiB shared-session quota and caller-owned boundary remain settled. AWS acceptance still requires its separate authorization. + +### Contract and test inventory + +These are source observations, not executed draft-contract proof: + +- Main `crates/edgezero-core/src/config_store.rs:214-220` still returns `Option`. Main has ordinary secret reads, no core `time.rs`, and infallible `Hooks::build_app`. Main Axum `dev_server.rs:333-383` has development startup only, binds before building registries, and has no production `PreparedStores` initializer. +- Draft #275 adds shared `ConfigValue`, `BoundedStoreRead`, mandatory `get_bounded` and `get_bytes_bounded` methods with injected `MonotonicClock`, absolute `Deadline`, and independent backend/value caps. See draft core `config_store.rs:295-412`, `secret_store.rs:385-402`, `time.rs:27-130`, and fallible `App::build` in `app.rs:143-168`. +- Draft #406 exposes borrowed registries through `PreparedStores`, with the initializer `for<'startup> FnOnce(&'startup mut App, &'startup PreparedStores) -> LocalStartupFuture<'startup>`. The boxed future has no `Send` bound. See draft Axum `dev_server.rs:63-94, 782-834`; `initializer_borrows_inputs_across_await_and_captures_rc` at `1111-1145`. +- Draft #406 `App::insert_state` retains `Clone + Send + Sync + 'static` handler state and uses router copy-on-write. See draft core `app.rs:285-297`, `router.rs:532-538`, and `startup_state_forks_router_and_invalidates_old_admission` at `app.rs:2459-2505`. Non-`Send` setup does not imply non-`Send` retained handler state. +- Draft #406 prepares stores and runs the initializer before binding/readiness. Its stop/drain path gates dispatch and releases service/registry ownership. Existing `production_hosting.rs` tests cover signals, finite/forced drain, startup stop without readiness, redacted failures, typed startup validation, embedding, persistence, aliases and restart snapshots. Reuse them after acceptance; they were not run in this worktree. +- Draft root validation remains kind-wide at `run_options.rs:144-189`. Stage 1 must derive required roots from resolved per-ID local sources, without replacing accepted lifecycle behavior. +- Exported `config_store_contract_tests!` and `secret_store_contract_tests!` exercise ordinary reads and are reusable downstream. The separate `ready_*_bounded_read!` helpers are private `cfg(test)` fixtures, not exported bounded contracts. Snapshot providers need their own bounded-read tests in addition to the exported ordinary suites. + +Full exact-head source/test inventories and conditional proposals are local artifacts at `/tmp/native-store-stage0-core/{report,proposal}.md` and `/tmp/native-store-stage0-startup/{report,proposal}.md`. Draft #275 text was retrieved through the read-only contents API because its Git object is absent locally; draft #406 text came from the existing Git object. All extracts remained under `/tmp`; no branches were copied. + +### Executed main baseline + +Tested code revision: `683202c66948146ec360f490126f1d60f920288b`, with no tracked code changes. Environment: Rust/Cargo 1.95.0, native `x86_64-unknown-linux-gnu`, `CARGO_NET_OFFLINE=true`, `CARGO_BUILD_JOBS=2`, worktree-local target directory. All three WASM targets were already installed. No dependencies or toolchains were installed. Cargo manifest and lockfile SHA-256 values were identical before and after: + +```text +Cargo.toml acbdefe5e403b9faa1c09b095811927cb4c16faa549ec287a43f2e307d9df844 +Cargo.lock ccc5cbeda40fab3f2334b9846c565ea6699753162c508998a891f6037a4cee6d +``` + +All Cargo commands sharing the target directory ran sequentially. Evidence directory: `/tmp/native-store-stage0-baseline/`, with exact `.command` files, full `.log` files, `results.txt`, environment, revision, and before/after hashes. + +| Check | Result | Seconds | Log | +| --------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------- | ------------------------ | +| `cargo test --offline --locked -p edgezero-adapter-axum --all-targets --all-features` | PASS, 149 tests | 64 | `axum-tests.log` | +| `cargo test --offline --locked --workspace --all-targets` | PASS, 1,440 tests; 1 ignored | 118 | `workspace-tests.log` | +| `cargo fmt --all -- --check` | PASS | 1 | `format.log` | +| `cargo clippy --offline --locked --workspace --all-targets --all-features -- -D warnings` | PASS | 90 | `clippy.log` | +| `cargo check --offline --locked --workspace --all-targets --features "fastly cloudflare spin"` | PASS | 8 | `workspace-features.log` | +| `cargo check --offline --locked -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly` | PASS | 28 | `fastly-wasm.log` | +| `cargo check --offline --locked -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare` | PASS | 19 | `cloudflare-wasm.log` | +| `cargo check --offline --locked -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` | PASS | 19 | `spin-wasm.log` | + +No baseline failures were observed. The existing ignored `generated_workspace_compiles` test was not forced because it may fetch dependencies. WASM results are compilation evidence, not hosted execution. These baseline checks prove no new AWS provider behavior and do not validate the draft PR combination. + +### Dependency investigation and pending work + +Read-only registry evidence identifies candidate direct pins `aws-config = "=1.12.0"` and `aws-sdk-secretsmanager = "=1.120.0"`; both declare Rust 1.94.1 and are non-yanked. Their declared direct MSRVs fit installed Rust 1.95.0, but no complete transitive graph was resolved or compiled. Candidate fixed behavior is `BehaviorVersion::v2026_01_12()`, with explicit native runtime/current HTTPS-client features and reviewed developer credential features rather than `behavior-version-latest`. The documented default connect timeout is 3.1 seconds, so the provider must configure its selected budget explicitly. + +Final coherent SDK pins/features, transitive MSRV proof, diagnostic/timeout/retry audits, and the exact Agent image/version pin remain unfinished Stage 0 work. No container was pulled. Do not treat a floating Agent `2.x` tag or a Lambda-extension release as an exact acceptance pin. Research sources and limitations are recorded in `/tmp/native-store-stage0-baseline/sdk-research.md`. + +### Worker decisions and next step + +Two read-only repository explorers audited core contracts and production startup separately. Two read-only architecture workers then compared minimal conditional Stage 1 approaches. A read-only build validator checked the eight command/log/results artifacts, revision, environment, and unchanged hashes; its report is `/tmp/native-store-stage0-baseline/verification.md`. The coordinator inspected the source excerpts and actual logs directly. The chosen approach remains the existing plan: private per-ID ready/deferred overrides, full source validation, sequential temporary staging, and one complete registry publication. Complete-prebuilt-registry injection would shift mixed-source preparation onto callers; direct mutation of published registries would complicate failure cleanup. No production edits were needed or authorized for this audit. + +The initial audit recommended waiting for acceptance/approval. The user's later instruction superseded that recommendation; the current execution record follows. + +## 18. Authorized prerequisite integration and Stage 1 + +The user explicitly authorized implementation and rebasing onto the open PRs. No GitHub state changed. A local backup branch `backup/issue-410-main-stage0` retains the original main base. + +Selected #275 head: `d1a81579afb96f200e7fe8fd294600f0b446b080`. Selected #406 head: `3ab22096fd117f7506cf437b5ba128e0789e059c`, one unique commit after shared ancestor `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a`. The #406 commit was rebased onto #275, producing combined base `1499062`. Both source PRs remain open; this is a local implementation stack, not an accepted main revision. + +Two read-only workers investigated and proposed integration choices, recorded at `/tmp/native-store-integration/{native,contracts}-proposal.md`. Central resolution preserved #275's required serde dependency, bounded local config snapshot parser/residency, HTTP head/deadline limits, connection capacity and panic isolation, logging/preflight ordering, and asynchronous egress. It added #406's HRTB initializer, state insertion, readiness/stop/signals and bounded production drain. The production fixture was migrated from String to shared ConfigValue. A bounded required-file loader replaces #406's older unbounded loader. + +Scoped integration tests passed: core all-targets, Axum all-targets/all-features, including 13 production-hosting subprocess tests. Initial integration runs exposed fixture value-type and category mismatches, plus regressions in connection panic/cancellation tests; these were fixed before the rebase completed. The combined owner keeps #275's bounded FuturesUnordered connection ownership and adds phase-aware graceful drain, rather than using #406's older parser or spawning unbounded tasks. + +Stage 1 adds `native_stores.rs`, private per-kind source maps, closed preparation categories, move-only ready/deferred overrides, and `run_app_with_native_stores`. Hosting options remain cloneable with local-default bootstrap selection. Complete source validation precedes local provider construction and deferred polling. Roots are required only for IDs still resolved locally. Sources are prepared sequentially, then all registries are finalized once before the initializer and request service. + +New colocated tests exercise non-Send Rc setup on the real runner, default/key preservation, mixed local/custom roots, ready/deferred KV and secrets, namespace misses, duplicates and redacted Debug, undeclared sources/overlay/root/metadata errors with zero deferred polls and no database files, later failure dropping staged handles and unpolled futures, startup stop without binding, and the initializer/request path using the same provider and inserted state. Existing dev and strict-production regression suites remain passing. + +Stage 1 exit gate passed. Evidence is `/tmp/native-store-integration/stage1-tests.log` and `stage1-clippy.log`, plus all twelve serialized workspace/native/WASM/isolation checks passing in `check-results.txt`. Runtime-only non-test `cargo check --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib` also passed, closing the build review's dev-dependency-unification proof gap. Exact commands/logs and before/after hashes are retained there; current relevant source hashes still match `stage1-inputs.sha256`. + +Read-only runtime and build reviewers reported no confirmed findings in `stage1-{runtime,build}-review.md`. Their source conclusions were checked against executed logs and unchanged input fingerprints. The reviewer noted that cross-kind behavior was tested in separate fixtures rather than one combined invocation; later production/provider tests will exercise the combined path. Review workers and the broader check runner have finished and were released. Baseline main evidence is kept separate from this combined-stack proof. + +Stage 2 read-only investigations/proposals are `stage2-{bootstrap,aws-settings}-proposal.md`. Chosen approach is the shared native parser feature plus SDK-free AWS settings crate, with typed source decisions rather than unvalidated option bags. AWS resource/URL validation belongs in the provider crate; schema/collisions/bootstrap ownership stay native-side. Public API docs confirm Secrets Manager selector lengths and complete-ARN/name constraints. No AWS resource or credential access is needed for this unit. + +No #410 commit was created. No deployment, publishing, image pull, credential discovery, or AWS service call was performed. Further implementation is authorized. The user has since explicitly declined real AWS acceptance, so Stage 9 is excluded from the currently authorized task and will not be run or requested. + +## 19. Stage 2 execution and proof + +Added `edgezero-store-aws` as a default-off, settings-only member. Its normal graph has only serde, URL/percent encoding, and closed error formatting. The shared Axum `native-bindings` feature supports parser use without either runtime or CLI registration. AWS service feature bodies are intentionally empty until their provider stages; the native runner currently rejects all built-in AWS selections before setup. Do not describe this checkpoint as operational AWS support. + +The pure source validator now owns declarations/defaults, supplied/file/inline collisions, raw NAME/KEY conflicts and target capability checks. The runtime translates those decisions to its existing sources, checks local-root needs and retains Stage 1 failure/drop ordering. File contents are captured during selection into cloneable options, not reopened during preparation. Explicit constructors retain only supplied bootstrap values. `from_env` captures the locator and scalar inputs and resolves each selected token name once. Relevant normalized duplicate settings fail; unrelated core environment semantics remain unchanged. Ambiguous explicitly supplied token values cannot be used by a provider. + +ARN validation supports `aws`, `aws-cn`, and `aws-us-gov` region families, matching explicit region, full account/resource shape, names of 1-512 documented CreateSecret characters and the six-character ARN suffix. This is shape validation, not a served-region/resource existence claim. Selectors retain non-control Unicode and embedded spaces under the API's character-count lengths, with all-whitespace/control values rejected. Agent identifiers support escaped spaces/Unicode and other safe segment characters up to 128 bytes, while slash/backslash/percent/query/fragment/control and standalone dot segments fail. Endpoint validation rejects normalized integer/octal IPv4 and other path/authority ambiguities. + +Both Stage 2 reviewers found no confirmed defect. Their concrete test gaps were addressed: malformed UTF-8 and duplicate tables, independent decoded path assertions, selector/ARN boundaries, exact file/explicit policy selection, plus a real isolated subprocess through `AxumRunOptions::from_env` for named token capture and missing/empty/non-Unicode token/locator errors. File and inline failures run through the real runner and assert zero future polls, no initializer/database files, dropped supplied state and redacted output. + +Evidence is `/tmp/native-store-integration/stage2-check-results.txt`, exact command files and logs, and before/after hashes. Provider settings: 9 passed. Axum all features: 260 unit, 13 outbound, 13 production-hosting passed. CLI-only: 55 passed; runtime-only: 219 passed. Workspace tests, fmt, native feature check, three WASM builds, normal non-test CLI/runtime builds and SDK/HTTP-free settings/parser dependency assertions passed. One initial workspace Clippy run found two new test-only style errors; these were fixed and scoped settings tests plus full workspace strict Clippy and formatting passed. Final hashes differ from the broad-check hashes only in those test-only changes to `settings.rs`; `stage2-final-inputs.sha256` records the final state. Runtime/CLI sources and production settings validation remained unchanged. + +The shared-session and SDK diagnostic audits are the next read-only proposals. No AWS resource access, credential discovery, image pull, deployment, mutation or publishing occurred. Real AWS acceptance remains excluded by the user's instruction. + +## 20. Follow-up validation and diagnostic approval gate + +Stage 2 follow-up made source decisions borrow declaration IDs. The same validator now consumes macro-baked static metadata or strings owned by a CLI manifest, without leaking strings or changing core. Manifest adapters reject legacy fields and an omitted multi-ID default. Secret lookup keys and selected non-opaque namespaces are checked against core's 512-byte bound before preparation; supplied opaque namespace semantics remain caller-owned. Omitted/current and explicit `AWSCURRENT` selectors normalize to the same target identity. + +Executed evidence is `/tmp/native-store-integration/stage2-boundaries-*.log`: settings 10 passed, Axum all-features library 262 passed, workspace tests, strict workspace Clippy, formatting and combined native feature checks passed. Initial Clippy issues in the follow-up were corrected before the final successful run. The only subsequent source delta is comments, followed by settings tests and formatting again. No #410 commit was created. + +The isolated SDK spike is outside the repository. It resolves/builds Secrets Manager 1.120.0 with runtime 1.16.0 / HTTP client 1.5.0 on Rust 1.95.0; it does **not** establish the complete aws-config developer-chain graph. Explicit fictional credentials and literal-loopback fixtures were used with cleared process environments. Scoped full client construction/send suppresses sentinel ARN/value/provider metadata under a process-global TRACE collector, even with `LOG_SENSITIVE_BODIES=true`. Two non-sensitive throughput events escape on HTTP success. Source-only pooled-driver findings must be qualified by the selected path/features; that driver path was not active in the tested configuration. + +A separate executed tracing/log fixture establishes a policy blocker. With no global tracing subscriber, `WithSubscriber(NoSubscriber)` suppresses its inner event but permanently disables unrelated application tracing-to-log fallback after the scope (`before=true hidden=false after=false facade_after=true`). With additive `tracing/log-always`, it does not suppress the mirrored inner event (`hidden=true`). A scoped mute is therefore not a logging-preserving, feature-independent SDK diagnostic strategy. + +**Historical pause, superseded by explicit first-version approval.** The user authorized continuing with redacted/dropped EdgeZero diagnostics and a disclosed dependency DEBUG/TRACE limitation before reviews. Do not use scoped NoSubscriber or change global application logging. Keep EdgeZero-owned errors closed and payload-free; dependency diagnostic output remains caller/operator-owned and must be called out in docs and the draft PR. The user also authorized commits/publication to open a draft with `gh stack` after implementation and checks. Stages 3-8 are now in progress; Stage 9 remains declined/excluded and is not requested. Detailed sources, fixture boundaries and logs: `/tmp/native-store-integration/sdk-diagnostics-{proposal,execution}.md`. + +## 21. Offline implementation and verification checkpoint + +The coordinator integrated the common session, private snapshots, literal-loopback Agent transport and pinned Secrets Manager SDK. Config remains arbitrary whole UTF-8, including empty and quoted values. Secret payloads preserve SDK-decoded bytes, require exactly one payload field and deduplicate successful targets across namespaces. The native runner uses one shared session and completes all registries before the application initializer/listener. + +CLI build/serve dispatch shares SDK-free Cargo argument normalization. Only exact generated Axum commands are intercepted as direct argv; custom shell commands remain unchanged. Pinned dispatch reads selected manifest features, not a rediscovered manifest. Bindings-only validation normalizes manifest/explicit target controls and reports structural-only validity with provider availability unknown. The unproven Cargo feature resolver is deliberately not shipped. The public typed push API rejects selected AWS config bindings before app TOML, prompts or writes, including local/dry-run flags. + +Generated Axum entrypoints now use `run_generated_app`: no locator preserves existing local development behavior; any selected locator enters strict native startup and compiled-out providers fail closed. The ignored generated-consumer test invokes real `edgezero new` and `edgezero build`, compiles default-off/Agent-only/Secrets-only/both, rejects a compiled-out selection, then exercises a generated app's public HRTB initializer with gated provider responses, a serialized envelope, nested secret resolution and repeated snapshot-backed requests. This is separate from the five static production-consumer tests. + +Executed coordinator gates: `/tmp/native-store-integration/final-checks.sh`, exact commands/results/logs and stable before/after source fingerprint under `/tmp/native-store-integration/final-checks/`. All 24 gates passed. Workspace tests: 1,988 passed, two ignored generated tests; both ignored tests were separately executed successfully. Agent-only: 25; Secrets-only: 25; both providers: 40. Axum all-feature tests including subprocesses/contracts: 299. Demo tests: 54; lifecycle fixture tests: 31. Strict workspace and isolated Clippy, combined native features, all three WASM adapters, SDK-free normal graphs and strict rustdoc passed. The existing generated workspace test also passed its host/strict-Clippy/core-tests/typed-CLI/three-WASM checks (`generated-full-final.log`). Docs lint, full formatting and site build passed. + +A machine-local `/tmp` quota caused one interrupted test run. Checks use a disk-backed `TMPDIR=/home/pav/.cache/native-store-checks/tmp`; no unrelated temporary files were removed. CLI unit tests with every adapter disabled assume registered adapters and are not a supported test scope; CLI-only compilation/strict Clippy passes, and the default registered CLI suite passes 227 tests. + +Read-only final provider and CLI reviews found no confirmed boundary defects (`/tmp/native-store-final-review-{provider,cli}.md`). These reviews did not execute Cargo and do not replace coordinator gates. Generated `serve` CLI end-to-end behavior is not separately demonstrated; shared argument selection/action gating is source/unit-tested, and generated build plus production startup is executed. Remaining review items are dependency DEBUG/TRACE disclosure, operator-reviewed Agent release/digest and explicitly excluded real AWS acceptance. + +Publication completed with one draft stack member, [PR #414](https://github.com/stackpop/edgezero/pull/414), assigned to ChristianPavilonis. Trunk is `docs/outbound-http-spec` / #275 at `ea66eff045512cccc60646d6ec339e83ff44fed7`. The compatible lifecycle bridge is `a08f54c`; tested implementation checkpoint is `ab402f4fbdaa5c7c244ec16c11cfacec65123064`, tree `d6097c9c8947e8828338b3340287d6df0650380e`. Existing #275/#406 were not modified. The clean rebase required one lockfile dependency disambiguation (`wasm-streams 0.6.0`), followed by all 24 gates passing again and a successful existing generated workspace/WASM test. Exact commands, fingerprints and logs remain under `final-checks/`; `generated-full-rebased.log` records the additional consumer gate. The subsequent publication-record commit changes only documentation. No merge or deployment was performed. + +## Links + +- [Governing design](../specs/2026-10-07-native-store-providers-design.md). +- [Owning issue #410](https://github.com/stackpop/edgezero/issues/410). +- [Bounded config and lifecycle prerequisite PR #275](https://github.com/stackpop/edgezero/pull/275). +- [Production startup prerequisite PR #406](https://github.com/stackpop/edgezero/pull/406). +- [Raw configuration semantics #324](https://github.com/stackpop/edgezero/issues/324). +- [AWS Agent configuration](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-containers-agent-configuring.html). +- [Secrets Manager GetSecretValue](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). +- [Rust SDK timeouts](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/timeouts.html) and [retries](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/retries.html). +- [Cargo features](https://doc.rust-lang.org/cargo/reference/features.html), [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html), and [tree](https://doc.rust-lang.org/cargo/commands/cargo-tree.html). diff --git a/docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md b/docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md new file mode 100644 index 000000000..77f43ee48 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-axum-production-startup-lifecycle-design.md @@ -0,0 +1,427 @@ +# Axum startup, storage, and bounded shutdown + +Date: 2026-10-05 +Status: initial implementation for review. The worktree implements the combined lifecycle and production-startup APIs below. Deployment guidance and the remaining acceptance checks are still open. + +Issues: [#392 lifecycle](https://github.com/stackpop/edgezero/issues/392) and [#396 configuration/storage](https://github.com/stackpop/edgezero/issues/396), under [Epic #391](https://github.com/stackpop/edgezero/issues/391). + +Base: [PR #275](https://github.com/stackpop/edgezero/pull/275), inspected at `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a`. + +Worktree: `/home/pav/worktrees/issue-392-396-production-hosting-spec`. + +## Goal + +A production Axum application validates its runtime settings and initializes required stores before accepting traffic. Applications with additional configuration/bootstrap requirements validate them through the initializer variant before readiness. Its handlers can reuse startup-loaded state. On shutdown, readiness is revoked, new work stops, and dispatched work gets one finite drain budget. + +Preserve the existing permissive development entrypoint and local defaults. Strict production startup is an explicit API choice over the same runtime owner, service, and connection implementation. Do not infer production policy from release builds, containers, or the presence of path settings. + +Keep #275's fallible app assembly, owned HTTP/1 connections, local non-Send execution, and response-egress semantics. This work extends that runner rather than introducing another HTTP server or portable lifecycle framework. + +## Scope and ownership + +| Owner | Responsibility | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #392 | Native runtime ownership, signals/caller shutdown, lifecycle observation, dispatch gating, tracked draining, exit outcomes, and portable probe helpers | +| #396 | Explicit strict production settings/store preparation, development compatibility, app-owned initializer, shared typed-config loading, startup-only typed state, persistence/permissions/backup evidence, and production adoption | +| Application | Required schemas, config keys, extra secrets, signing/bootstrap policy, registered handler state, probe paths, and optional ongoing dependency checks | +| #395 | Residual outbound transport/wire acceptance; coordinate fallible transport startup here without building another client | +| #398/#399 | Container packaging and native architecture validation; consume this runtime contract for later integration checks | +| #397/#400 | Wider diagnostics/trusted-proxy work and deployment recipes, respectively | + +No live reload, filesystem watcher, online backup service, new backup CLI, distributed storage, automatic migrations, retry framework, tracing vendor, health configuration section, management listener, or public EdgeZero application image is included. No blanket Send bounds are added to portable futures or bodies. + +Trusted Server supplies concrete consumer requirements, but this spec does not implement its migration or certify its application image. + +## Current implementation and evidence + +All findings below are source inspection at the base above, not executed proof. Paths are repository-relative unless noted. + +| Area | Current behavior and source | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App assembly | `crates/edgezero-core/src/app.rs`, `App::build` and `Hooks::configure`: install platform metadata and configure once. Configure receives no prepared native stores. | +| Standard entrypoint | `crates/edgezero-adapter-axum/src/dev_server.rs`, `run_app`: build app, resolve settings, bind, initialize stores, then serve. Generated main calls its no-argument form. | +| Local serving | The same file, `serve_local`: current-thread runtime/LocalSet, detached local connection tasks, Ctrl-C or internal oneshot exit. No SIGTERM or tracked graceful-drain phase. | +| Connections | `crates/edgezero-adapter-axum/src/connection.rs`, `serve_http1`: owned Hyper HTTP/1 keep-alive and response deadline enforcement. Extend this owner for draining. | +| Config files | `crates/edgezero-adapter-axum/src/config_store.rs`, `from_path` and `local_path`: flat string-map snapshots, missing-file empty stores, ancestor/cwd discovery. The runner drops malformed bindings with warnings. | +| KV identity | `dev_server.rs`, `kv_store_path` and `build_kv_registry`: name-derived slug/hash basenames under `.edgezero`. Required declared KV failures are already fatal. Each id currently attempts its own open. | +| Documented aliases | `docs/guide/kv.md:122`: ids with the same resolved platform name share a file. Per-id opens must be reconciled with this documented behavior. | +| Typed config | `crates/edgezero-core/src/extractor.rs`, `AppConfig` and `extract_from_handle`: bounded reads, envelope/integrity checks, secret resolution, schema validation, and redaction. This algorithm currently takes RequestContext. | +| Handler state | `crates/edgezero-core/src/router.rs`, `RouterBuilder::with_state`: typed state copied into requests, same-type last-write-wins. `App` currently has no post-build state setter. | +| Outbound startup | `crates/edgezero-adapter-axum/src/service.rs`, `AxumServiceState::from_app`: `try_transport().ok()` hides initialization failure; admitted requests subsequently fail. | +| Completion | `response.rs` and `crates/edgezero-core/src/response_egress.rs`: HostHandoff is not client receipt. Pre-egress cancellation can abandon callbacks while releasing captured resources. | +| Existing proof gap | `key_value_store.rs`, `data_persists_across_reopens`, tests reopen in one process, not subprocess restart, competing-writer rejection, or backup/restore. Existing server tests do not prove signal-driven draining. | + +Trusted Server at `666953a0de0dbec514b0f0bd5a8b03074f13e62b` was also inspected. Its Fastly adapter loads settings into Arc and captures that state in handlers, but its current entrypoint builds the app for each request. Native startup should retain loaded state for the native run, not assume Fastly cross-request retention. + +## Agreed runtime settings + +Use the existing `EDGEZERO__ADAPTER__*` convention. The table defines strict production options, not replacement defaults for the development runner. + +| Setting | Contract | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HOST` | Omitted defaults to `127.0.0.1`. Supplied invalid values fail, rather than warn/fallback. Preserve current IP-literal support. | +| `PORT` | Omitted defaults to `8787`. Supplied malformed/out-of-range/zero values fail. Containers supply explicit binding values. | +| `CONFIG_DIR` | Explicit absolute, existing directory when local config stores are declared in production. | +| `DATA_DIR` | Explicit absolute, existing writable directory when embedded KV stores are declared in production. | +| `SHUTDOWN_GRACE_SECONDS` | Omitted defaults to 10. Supplied values are positive integer seconds; invalid, zero, overflow, or unusable timer values fail startup. No silent clamp/fallback. | + +Directory values are not required for undeclared store kinds. Validate supplied recognized settings instead of using invalid input as omission. Preserve native defaults for genuinely omitted optional settings. Do not add a global scanner for unrelated provider variables. + +The production standalone entrypoint reads environment settings once into native options. Embedding accepts explicit options and does not silently merge ambient hosting settings. An embedding caller can deliberately use the environment-loading constructor. Store-name/key overrides belong to that selected configuration snapshot too; this does not remove the environment-backed secret backend. + +Strictness includes recognized supplied native settings that previously fell back, such as logging level or declared-store name/key overrides. Keep shared EnvConfig fallback semantics unchanged for other adapters; perform strict native validation before using normalized values. Preserve `Hooks::owns_logging` and avoid installing a competing logger. + +There is no new CLI flag or runtime configuration file for these settings. Preserve development bind warnings/fallbacks, local config discovery, missing-file empty stores, malformed-binding warnings/omission, and cwd-relative KV defaults. Declared KV initialization failure remains fatal. Explicit production options never use those fallbacks. The new shutdown duration has no legacy permissive behavior to preserve; a supplied invalid duration fails in either entrypoint. + +The Axum CLI's existing development serve path keeps its permissive behavior. A wrapper launching a production entrypoint must pass supplied hosting settings through without replacing invalid input with defaults. Keep existing development wrapper normalization out of that production path; do not add a second CLI configuration policy. + +## Startup and initialization + +Proposed production ordering for the agreed invariant. Development uses the same lifecycle owner with its existing store/default policy and no additional initializer requirement: + +```mermaid +flowchart TD + A[Parse and validate native settings] --> B[Create owned local runtime and stop observation] + B --> C[App::build installs platform and configures once] + C --> D[Prepare required registries and shared transport] + D --> E[Await app-owned initializer] + E --> F[Register validated typed handler state] + F --> G[Bind and adopt listener] + G --> H[Ready and accepting] + C -->|Failure| X[Release resources and return non-success] + D -->|Failure| X + E -->|Failure| X + G -->|Failure| X +``` + +No acceptance or Ready transition can precede successful initialization. Binding after validation avoids a misleading open port during production standalone startup. An existing embedding path with a prebound listener can preserve that listener, but still cannot accept or become Ready early. + +Register standalone signals early enough to observe stop during initialization. Embedding supplies its stop source without installing process-wide handlers. A stop before Ready cancels cooperative initialization, releases prepared resources, and never enters Ready. Completed intentional stop exits zero. Initialization failure remains non-success. Cleanup uses the same shutdown budget; expiry or a second signal is forced stop. + +Run startup and connection serving on the same owned current-thread runtime/LocalSet. The initializer and its future need not be Send. Do not capture them in the existing spawn_blocking bridge. This design is a blocking native runner; it does not add another public async hosting framework. + +A supplied production initializer runs exactly once, after `App::build`/`Hooks::configure` and strict store preparation, before App is shared with connections. It receives mutable App and borrowed access to the same registry handles/bindings that requests will receive. It cannot turn failed declarations into absent bindings or replace prepared registries. Borrowed registry access does not make the underlying KV backend read-only; any app-owned bootstrap writes remain application policy. + +The application explicitly selects every required typed blob/schema and additional required secret or startup invariant. Store metadata contains ids/defaults, not schemas or required-key lists. A successful callback is evidence that the app's selected checks passed, not proof that the framework discovered every application requirement. + +Apps without application-specific startup requirements can use a production convenience entrypoint without an initializer argument. It still validates framework settings, declared stores, transport, and fallible app assembly before Ready. An explicit initializer variant adds app-selected blob/schema, secret, and bootstrap checks. A convenience entrypoint must not claim those application-specific checks occurred. + +Preserve the existing no-argument development entrypoint. Newly generated no-store apps remain runnable without a source migration or explicit no-op callback; a generated config Rust type alone does not declare a mandatory stored blob. Ready means the selected entrypoint's startup contract completed, not that the framework discovered every application requirement. Production applications with additional requirements must select the initializer variant. + +### Proposed native API shape + +These sketches describe the boundary, not compiled APIs or final Rust lifetime ergonomics. Public names are proposed. Declarations are API outline notation, not implementation code. + +```text +pub struct AxumRunOptions { + // Validated bind, roots, shutdown duration and selected store overrides. +} + +impl AxumRunOptions { + pub fn from_env() -> anyhow::Result; + // Explicit construction validates supplied native settings as well. +} + +pub struct PreparedStores { + // Private registry fields, None only for an undeclared/unattached kind. +} + +impl PreparedStores { + pub fn config(&self) -> Option<&ConfigRegistry>; + pub fn kv(&self) -> Option<&KvRegistry>; + pub fn secrets(&self) -> Option<&SecretRegistry>; +} + +pub type LocalStartupFuture<'a> = + Pin> + 'a>>; + +// Existing development convenience API and permissive local defaults. +pub fn run_app() -> anyhow::Result<()>; + +// Production convenience: strict environment options and SIGINT/Unix SIGTERM. +// Completes framework startup checks without claiming app-specific validation. +pub fn run_production_app() -> anyhow::Result<()>; + +// Production standalone with additional app-owned initialization. +pub fn run_production_app_with_initializer( + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Initialize: for<'a> FnOnce(&'a mut App, &'a PreparedStores) + -> LocalStartupFuture<'a>; + +// Production embedding convenience: explicit options and caller-owned stop. +// Neither embedding variant installs global signal handlers. +pub fn run_app_with_options( + options: AxumRunOptions, + stop: Stop, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future; + +// Production embedding with additional app-owned initialization. +pub fn run_app_with_options_and_initializer( + options: AxumRunOptions, + stop: Stop, + initialize: Initialize, +) -> anyhow::Result<()> +where + A: Hooks, + Stop: Future, + Initialize: for<'a> FnOnce(&'a mut App, &'a PreparedStores) + -> LocalStartupFuture<'a>; +``` + +Caller-owned stop can use an existing oneshot/watch/notification future. No new portable shutdown-token framework is needed. The native owner handles stop idempotently; repeat caller requests do not shorten the budget. The standalone signal source separately observes the second signal for forced teardown. + +These entrypoints are thin policy/initializer selections over one native runtime owner, not separate servers. Convenience variants supply an internal successful initializer; callers need not write a no-op. Retain the existing module location and existing development signatures unless an accepted prerequisite changes them. + +Router-only AxumDevServer callers retain their configuration and handle-attachment conveniences over that same owner; do not require an unrelated initializer migration. Normalize bare handles into the existing registry representation before any selected initialization. Tests may keep a private prebound-listener entrypoint through that same owner. + +## Shared typed-config loading and state retention + +Expose a context-free entrypoint to the existing typed loader, proposed as: + +```text +AppConfig::from_binding( + binding: &ConfigStoreBinding, + key: Option<&str>, + secrets: Option<&SecretRegistry>, + limits: ConfigExtractionLimits, + clock: MonotonicClock, +) -> Result; +``` + +This is signature notation, not literal Rust syntax. Implement it as an async inherent method with the existing `C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static` bounds. A missing explicit key uses the binding's resolved default key. The app chooses default or named bindings; do not apply one guessed type to all declared stores. + +Factor only the RequestContext dependencies out of `extract_from_handle` and secret traversal. Request extraction and startup then share one parser, budget, envelope/version/hash checks, secret walk, optional-secret behavior, validation, and redacted error mapping. No fake request, route dispatch, second parser, operator TOML loader, or implicit cache is involved. + +Existing extraction limits apply per selected blob and its referenced secrets. They are not a deadline for the entire initializer or arbitrary signing/bootstrap work. They also do not bound the initial allocation of the outer local-file map. Do not add a separate startup-timeout setting or new numerical file-size policy in this slice. Operator-supplied files are trusted deployment inputs, and the supervisor owns the ultimate startup/stop bound. + +Add an explicit post-build method, proposed as: + +```text +App::insert_state(&mut self, value: T) +where + T: Clone + Send + Sync + 'static; +``` + +It uses the existing router state-extension mechanism. Same-type insertion replaces earlier state, including builder-registered state, for this App. Typical usage registers Arc and handlers extract State>. + +A router shared with another App must not be mutated globally. Use copy-on-write/forked router internals for state insertion; clone existing handler/middleware references and retain route metadata and matching behavior. Do not rebuild the app/routes, require Arc uniqueness, introduce a second state overlay with competing precedence, or use a global/deferred cell. Prove shared-router isolation in a test. + +State insertion is a startup operation and must finish before ingress admission or serving begins. Existing resolved-dispatch/admission tokens retain the prior router owner; copy-on-write insertion invalidates those tokens for the modified App. Do not promise that outstanding tokens survive insertion or introduce a new route-owner identity model. Document this boundary and test that a pre-insertion token is rejected while requests admitted after insertion dispatch normally. This API is not live state replacement. + +A non-Send initializer does not relax the existing Clone/Send/Sync bounds on registered handler state. Applications own state contents and any intentional interior mutability. Retention is explicit; ordinary AppConfig request extraction does not become a cache automatically. + +## Store and filesystem contract + +The requirements below define strict production preparation. Development retains its current local config discovery, empty/malformed binding policy, and cwd-relative data paths. File identities, alias handling, ownership limitations, snapshots, and safe recovery guidance apply in both paths. + +### Config + +- Read `/local-config-.json`, retaining the existing flat string-to-string format and logical-id identity. +- Every declared file must exist, be readable, and parse successfully. No empty fallback or dropped binding. +- Permit symlinks used by projected ConfigMap/Secret mounts. The opened target must be a readable regular file. Operator-supplied roots are trusted; no generic filesystem sandbox or source-tree containment restriction is added. +- A valid outer map is not proof of a valid typed blob or required secrets. Those checks belong to the initializer. +- Config stores remain startup snapshots. Updates require process restart; no watcher/reload path. + +### KV + +- Read/create `/kv--.redb`, preserving the existing basename derived from the resolved platform NAME. Config filenames still use logical ids. +- The root must already exist and be writable. The runner may create a new database file inside it, but must not fabricate a missing mount/root directory. +- Resolve aliases before opening: two logical ids with the same resolved NAME share one database handle and keyspace in this process. Different names select different files. Do not change file identity or silently move existing data. +- The same key through either alias refers to the same value. Alias ids are not isolated namespaces. Shared handles do not change operation guarantees or make the documented read-modify-write helper atomic. +- redb remains one owning process per database. A second process fails rather than silently sharing. Shared volumes do not establish multi-writer or multi-replica safety. +- Keep runner-owned handles alive until connection tasks settle, then relinquish them on completed startup failure, orderly stop, or cooperative forced cleanup. A database lock is released only when the last handle owner drops it. Embedding callers may retain an attached handle, and initializers may clone one outside the App; those owners must release their handles before reopening or backing up the database. The runner does not revoke caller-owned handles. + +### Permissions, updates, and recovery + +Operators provision roots, ownership, and permissions for the runtime UID, including the Docker recipe's numeric UID. Config may be read-only; data must support real database writes. No startup chown, chmod 777, automatic lock repair, database deletion, or corruption recovery is added. + +Externally supplied secret updates use process restart. The existing environment-backed secret backend remains unchanged; this is not a new secret reload/rotation system. + +Initial backup support is a documented stopped-writer procedure: stop the writer, release every database handle including caller-owned clones, copy the closed database, restore into a prepared directory with correct permissions, reopen, and read stored values back. Returning from an embedded runner alone does not prove the database is closed. Online copying of an active database is not a supported backup claim. Remove ordinary recovery advice that recommends deleting production databases for lock errors or capacity maintenance; do not invent a maintenance subsystem. + +The storage guide must include these operational limits and procedures: + +- TTL expiry is lazy. Reads and paginated listing clean up encountered expired entries; unvisited expired entries can remain. An application-owned maintenance pass can traverse the existing paginated listing API through the owning process, following cursors to exhaustion even when a page is empty. There is no automatic sweeper or new maintenance CLI. +- Deletes and expiry cleanup do not automatically shrink the database file. Monitor database size and available mount capacity, provision headroom for writes and backups, and do not treat TTL as a disk quota. This slice does not expose or certify compaction; unsupported file shrinking must be stated explicitly. +- For lock conflicts, identify and stop the other owning process or release retained embedding handles before retrying. Do not remove the database or bypass its lock. Prevent overlapping writers during restart/rollout. +- For capacity exhaustion, stop the writer if needed, preserve the database, and restore usable capacity by enlarging the mount or removing unrelated disposable files. Reopen and verify stored values once capacity is available; backup/restore copying alone is not a compaction procedure. +- For suspected corruption, stop all owners, preserve the damaged file for investigation, and restore a known-good stopped-writer backup into a prepared directory. Verify stored values before resuming traffic. Automated repair and untested salvage procedures remain unsupported. + +## Lifecycle, shutdown, and results + +One native owner controls Starting, Ready, Draining, and Stopped. Acceptance and phase observation derive from that owner, not separately maintained booleans or response reports. A run never returns to Ready after draining begins. + +```mermaid +flowchart TD + A[Starting] -->|Validated service and listener| B[Ready] + A -->|Stop before ready| C[Cleanup without readiness] + A -->|Initialization failure| G[Stopped with non-success] + B -->|First stop request| D[Draining: revoke ready and block new work] + D --> E[Graceful connection completion] + E -->|All tasks settled| F[Stopped with zero exit] + C -->|Cleanup completes| F + D -->|Budget expires or second signal| H[Force cancellation and settle tasks] + C -->|Budget expires or second signal| H + H --> G +``` + +Standalone SIGINT and Unix SIGTERM feed the same first-stop transition. Registration failure is a startup error. A second termination signal of either kind requests immediate forced teardown. Embedding has no implicit global handlers. + +On first stop: + +1. Revoke readiness and stop accepting new connections. +2. Gate new request dispatch on existing keep-alive connections too. Define existing work at the service-dispatch boundary, not merely an accepted socket or queued pipelined bytes. +3. Ask owned Hyper connections to stop keep-alive/new work and finish dispatched requests. Close idle connections promptly. +4. Keep polling tracked local tasks and request/response work until completion or the single process drain deadline. +5. On expiry or second signal, cancel remaining work and settle its task ownership before returning. + +Use native monotonic runtime time for the process budget. Do not depend on an application's injected request clock for supervisor termination. Earlier accepted request/response deadlines still win. Long-lived/SSE responses share the shutdown budget; this does not impose a new ordinary stream-lifetime or handler deadline. + +Collect connection task results instead of detaching them. Preserve ordinary connection-scoped failure/disconnect behavior. Terminal listener/runtime/runner failures revoke readiness and return non-success; do not add a general restart/retry policy here. + +Completed graceful drain or completed intentional stop during startup exits zero. Startup failure, runner failure, grace exhaustion, and signal-requested forced teardown report non-success. Retain enough internal outcome distinction for focused tests and safe diagnostics, without introducing a detailed numeric exit-code taxonomy. + +The budget is cooperative. Synchronous startup code, blocking handlers, callbacks, destructors, or a stalled runtime can prevent timer/cancellation progress. The process supervisor supplies the final hard-kill boundary. Deployment examples must set a longer supervisor interval, such as 30 seconds for a 20-second drain, rather than rely on Docker's default timeout. + +Preserve #275 completion semantics. HostHandoff does not prove client receipt. Dropping unfinished egress uses its existing terminal outcome. Pre-egress cancellation may release captured resources without a completion callback/report. Do not promise a terminal report for every accepted request, SIGKILL, or process abort; connection ownership determines drain completion. + +## App-owned probe routes + +Add a small portable `edgezero_core::probe` module with: + +- A Starting/Ready/Draining/Stopped observation enum. +- A cloneable read-only live reader, following the existing MonotonicClock Arc/callback pattern. +- Ordinary `#[action]` handlers for readiness and liveness. + +The native runner publishes its reader through existing request extensions. Core contains no lifecycle writer, shutdown token, listener, task, timer, Axum/Tokio dependency, new action capability, or portable async Hooks method. Other adapters initially publish no native lifecycle observation. + +Handlers read the current phase when executed, not a copied admission-time snapshot. + +| Observed phase | Readiness | Liveness | +| --------------------- | ---------------------------- | ---------------------------- | +| Starting | 503 | 200 | +| Ready | 200 | 200 | +| Draining | 503 | 200 | +| Stopped | 503 | 503 | +| Missing/unknown state | 503, probe state unavailable | 503, probe state unavailable | + +Use short non-sensitive text, plain-text content type, and Cache-Control: no-store. Missing state must not invent healthy behavior or lifecycle support on a provider. Apps can retain an independent portable health handler when that is their desired policy. Ready reflects the selected runner's contract: development readiness is not strict production validation, and production convenience readiness does not imply application-specific initialization. Native readiness does not periodically revalidate dependencies. Applications can add ongoing checks; the framework does not poll upstreams or trigger automatic outage restarts. + +Applications bind helpers through ordinary Rust or manifest routes. No automatic paths, reserved namespace, health flag, health config section, path environment variable, management listener, or admission exemption. Existing middleware/auth remains application-owned. Do not replace framework-owned probe/store metadata through app state or middleware; the native dispatch gate must use its private owner rather than an overrideable request extension. + +There is no reachable-probe guarantee before startup acceptance or after listener withdrawal. The response table describes a helper that executes. During drain, a previously dispatched probe can observe Draining; a newly attempted request may receive refusal/reset/EOF instead of 503. No successful readiness response means not ready. Supervisor liveness policy must account for intentional listener shutdown. + +Trusted Server already owns `/health`. Its Axum handler returns 200 on a successfully built router, while startup fallback returns errors even for `/health`. Fastly answers `/health` before app construction, so that route is liveness rather than readiness. Keep those downstream routes application-owned. Generated EdgeZero root/introspection paths are editable trigger rows, not reserved paths; the old automatic `/__edgezero/routes` mechanism was removed. + +## Failure diagnostics + +Startup failures identify the stage, recognized setting/logical store id, and safe failure category. Never print config content, resolved secrets, raw invalid settings, parser input fragments, or callback/provider source strings. A malformed config must not leak through a serde error wrapped in an ordinary log or anyhow termination chain. + +Return safe classified errors at the standard runner boundary. Do not retain an unredacted source chain where ordinary main termination/debug formatting will print it. Broader permitted internal diagnostics belong to #397; this spec does not add a logging framework or debug disclosure switch. + +Use the existing public HTTP error contract for request failures. Probe bodies contain no app configuration or infrastructure details. Preserve custom application logger ownership. These guarantees apply to framework-owned error conversion/logging; arbitrary app logging and panic payloads remain application responsibilities. + +## Compatibility and entrypoint migration + +Preserve #396's permissive development requirement and the existing no-argument `run_app` signature. This review decision supersedes the earlier interview's strict-everywhere and mandatory-initializer policies. No GitHub issue amendment is needed. + +Inventory Axum main templates, app-demo's native entrypoint, CLI demo runner, AxumDevServer/test embedding callers, and downstream consumers. Do not migrate development callers merely to make them pass a no-op initializer. Add production adoption examples that select the strict convenience entrypoint or, for application-required checks, its initializer variant. Both use the existing server implementation. Document their different readiness guarantees and any prerequisite-driven source migration. + +Generated no-store apps retain their runnable development entrypoint. Show the explicit production entrypoint choice and how required stored config adds typed loading and state registration. Container recipes under #398 must select that production contract rather than certify a permissive development binary from a passing HTTP probe. Do not add schema inference, a new app! keyword, optional-store policy machinery, or a native dependency in the shared app crate. + +The native CLI's serve/push/provision recipes retain local preparation and path conventions. Production adoption documentation supplies explicit absolute roots; development does not acquire a mandatory-root requirement. `config push` retains its current project-local output convention. No automatic production push/deployment mechanism is added. + +Post-build state registration remains portable and preserves router state semantics. The context-free config helper preserves all existing request-extraction outcomes on native/WASM targets. Coordinate #379's retained-provider-app work where APIs overlap, without making it a required native hosting dependency or assuming retained provider apps imply process readiness. + +## Originally proposed PR split + +The original plan below separated the issue boundaries. This worktree delivers the shared runtime and startup/storage changes together in one PR stacked on #275, not old main. The issue ownership and acceptance boundaries still apply. + +1. **#392 lifecycle PR, first on #275.** Establish the single local runtime owner, explicit initialization barrier, caller/signal stop handling, tracked connections, dispatch gating, drain/result behavior, and core probe observation/helpers. Its initialization barrier can accept a local future without knowing application schemas. Do not claim strict config/value certification before #396 integrates. +2. **#396 startup/storage PR, stacked on #392.** Supply explicit strict production preparation and optional app initialization to that barrier; implement root/settings validation, alias handle reuse, shared typed loader, startup-only post-build state, production adoption examples, development compatibility, and persistence/permissions/backup checks. +3. **Integrated acceptance on the second PR.** Exercise failed initialization, phase transitions, signals, pending handlers/streams, durable restart, mounted permissions, and probe wiring against the combined series. Close overlapping issue acceptance only with that evidence. + +These are behavioral review boundaries, not permission for two independent agents to rewrite the same runner concurrently. Preserve existing development signatures and keep any prerequisite-required caller changes at a coherent boundary. Add production entrypoints and real required-config initialization under #396 without forcing a development migration. Do not leave a broken intermediate commit or invent a compatibility server. + +#393/#394/#395/#397 then add only residual work/evidence against the accepted foundation. #398/#399 can prepare packaging independently; final container production checks consume the integrated result. #400 documents proven behavior. + +## Likely affected files + +- `crates/edgezero-adapter-axum/src/dev_server.rs`, `connection.rs`, `service.rs`, `config_store.rs`, and `key_value_store.rs`, with colocated tests. +- `crates/edgezero-adapter-axum/src/cli.rs` and its native main template where needed to preserve development behavior and document explicit production adoption. Strict launch wrappers must not normalize invalid production inputs. +- `crates/edgezero-core/src/app.rs`, `router.rs`, and `extractor.rs` for state registration and shared typed loading; new `probe.rs` plus exports in `lib.rs`. +- CLI demo runner, root README/core-config templates where preparation guidance is stale, generated-project tests, and app-demo's Axum main. +- Axum/config/KV guides and small native/portable fixtures needed for the acceptance checks. Do not pull Trusted Server sources or operator configs into framework fixtures. + +Keep this list bounded. No general generator/dependency resolver, provider lifecycle, portable body, or distributed store rewrite is implied. + +## Acceptance and planned validation + +The table below is the full acceptance plan, not a claim that every item is complete. The initial implementation has passed workspace tests, strict workspace Clippy, formatting, the Fastly/Cloudflare/Spin feature check, and the Spin WASM compilation check. The Axum all-features suite also passed all 13 subprocess hosting tests, including signals, finite drain, forced cancellation, startup failures, embedding, persistence, aliasing, stopped-writer backup/restore, and read-only config permissions. + +Before choosing implementation changes, re-audit the accepted #275 integration revision and record its exact commit, existing contracts/tests reused, checks already covered, and residual implementation/evidence needed for each issue. Repeat the inventory if prerequisite contracts change. The draft-head source inspection above is not this acceptance gate or executed production proof. + +| Owner | Focused evidence required | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Both | Accepted-#275 revision and covered/residual inventory are recorded; add only residual changes and missing acceptance evidence. | +| #392 | Subprocess SIGINT and Unix SIGTERM each cause graceful stop; second signal forces non-success. Embedding stop installs no global handlers. | +| #392 | In a running subprocess, dispatch a delayed finite response before sending process-wide SIGTERM; observe readiness withdrawal, full response completion, and zero exit. Repeat SIGTERM with a never-ending handler and open-ended stream to prove forced non-zero exit within the configured cooperative budget. Reuse these scenarios for final-image integration; separate signal and unit drain tests do not replace them. | +| Both | Production subprocess failures cover an occupied bind port, explicit initializer failure, invalid recognized settings, missing roots/declared config files, unreadable/non-regular/malformed config, and unusable/locked KV data. Each exits non-zero without Ready or request acceptance and identifies a safe stage/setting or logical id/category; sentinel values and unredacted source chains never appear in ordinary stdout/stderr diagnostics. | +| #392 | Never Ready during startup/failure; startup stop never accepts; Ready only after successful barrier and listener setup; drain revokes it immediately. | +| #392 | Delayed dispatched work finishes; idle keep-alive closes; new keep-alive/pipelined work does not dispatch after drain. | +| #392 | Never-ending handler/stream and zero-read peer reach controlled forced teardown within the configured cooperative budget. No detached work remains when the runner returns. | +| #392 | One budget is not restarted by repeat programmatic stop. Existing earlier response deadlines/outcomes remain intact. | +| #392 | Probe helpers cover every phase and missing state through ordinary router dispatch, preserve app route ownership, and observe a live transition rather than a stale snapshot. New drain probes need not reach HTTP. | +| #396 | Production entrypoints reject invalid supplied host/port/logging/known overrides/duration without normalization into defaults. Omitted bind/duration values use documented defaults. Production launch wrappers preserve this behavior. | +| #396 | Existing development entrypoints remain runnable without new root settings or initializer arguments. Preserve missing-config empty stores, malformed-binding warning/omission, local path discovery/cwd defaults, existing bind fallbacks, and fatal declared KV failures. Diagnostics remain redacted. Selecting production explicitly enforces strict checks regardless of cwd or build profile. | +| #396 | A standalone release subprocess using the production entrypoint starts from unrelated cwd with only explicit roots; missing roots, unreadable/non-regular/malformed config, and unusable/locked data fail before acceptance. | +| #396 | Valid flat map containing malformed/missing required typed blob, integrity failure, invalid required fields, or missing referenced secret fails the initializer before Ready. Sentinel values never appear in ordinary logs/returned diagnostics. | +| #396 | A supplied initializer runs once with exactly the bindings attached to requests. Production convenience entrypoints require no callback but still enforce framework checks and never claim app-specific validation. Native production option construction does not silently merge environment settings. | +| #396 | Shared typed loader and request extraction agree on default KEY overrides, named stores/secrets, optional secrets, redaction, byte/structure/deadline limits, and existing error categories. | +| #396 | Startup-registered Arc state reaches multiple requests; last same-type insertion wins; another App sharing the original router retains its own state. Pre-insertion resolved/admission tokens are rejected for the changed App, while tokens created after insertion dispatch normally. | +| #396 | Distinct names stay distinct; same-name aliases share one handle/keyspace; fresh database in prepared root works; second process cannot own the same file. | +| #396 | Value survives actual process stop/restart; stopped-writer backup restores readable values; runtime UID works with read-only config and writable data; startup never changes root ownership or deletes locked files. An embedding owner retaining a handle keeps the file locked after runner return; dropping the last owner permits reopening. | +| #396 | Storage guidance covers lazy expiry, paginated cleanup, non-shrinking files, capacity monitoring/headroom, safe lock/capacity/corruption recovery, and unsupported compaction/repair. Backup instructions require all handle owners to release the database. | +| #396 | Config snapshot stays unchanged after file update until restart. Document restart-based secret deployment updates without unsafe process-environment mutation tests. | +| Both | Non-Send local initializer fixture compiles/runs; same shared probe/typed-state handlers compile for supported WASM targets without native dependencies. | + +Tests use disposable data, bounded waits, explicit cleanup, and no platform credentials. Ordinary unit/compile tests do not require Docker or external services. Signal/filesystem subprocess tests use local fixtures. UID/container evidence can reuse #398's opt-in runner after integration with its template PR; unavailable Docker/architecture evidence remains a reported gap. + +At implementation checkpoints, use the repository's required checks: + +```sh +cargo test -p edgezero-core --all-targets +cargo test -p edgezero-adapter-axum --all-targets --all-features +cargo test -p edgezero-cli --all-targets +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +cargo test -p edgezero-cli --test generated_project_builds -- --ignored +``` + +Also run docs lint/format/build and the existing example/provider gates relevant to changed callers. Installed-target or container gaps must be disclosed, not presented as passes. Existing Fastly/Cloudflare/Spin gates must stay green. Container UID, standalone release, unreadable/unusable mount, retained-handle lock, and production-guide acceptance remain open. The accepted-prerequisite re-audit must also be repeated after #275 merges. + +## Risks and review checks + +- Compile the proposed higher-ranked initializer signature with borrowed inputs and Rc-capturing non-Send futures before committing to its ergonomics. No runtime ownership workaround may add blanket Send bounds. +- Verify copy-on-write router state insertion against the locked matchit/http types and existing Clone/Send/Sync state bounds. No uniqueness assumption or cross-App mutation is acceptable. +- Production strictness is opt-in. Preserve existing development signatures, fallbacks, and data filenames. Make production adoption and readiness guarantees explicit; do not silently certify a development binary or force unrelated callers to migrate. +- App-owned initialization/state can execute arbitrary code. Cooperative cancellation does not bound blocking code/destructors or prevent unsafe app overrides of request metadata. The supervisor and trusted app-composition contract remain explicit. +- Existing typed limits do not prove startup-file or whole-process memory bounds. This spec adds no concurrency/handler-deadline policy and does not relabel BestEffort/Unsupported guarantees from #275. +- A successfully sampled readiness response does not promise the server remains ready through transmission or prove client receipt. + +Read-only audits and alternative proposals were used for lifecycle, configuration, native startup API, and probe portability. Their useful findings are incorporated above. Rejected alternatives were a second server, inferred environment modes, native dependencies in shared probe handlers, a portable async startup hook, synthetic request validation, implicit settings caches, per-id path machinery, and immediate-stop-only hosting. This review preserves permissive development and makes strict production an explicit entrypoint choice, superseding the earlier strict-everywhere and mandatory-no-op proposals. + +## Review boundary + +The interview's policy decisions and subsequent review revisions are recorded here. The initial source and test implementation covers development compatibility, optional application initialization, startup-only state insertion, explicit embedding options, restart-based config updates, and the native lifecycle owner. API and behavioral review remain necessary before merge. + +The implementation is based on #275 at `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a`. Its branch has since advanced to `dfd439c613af4f6ed295e71c032881b15bf7247c`, including overlapping Axum preflight and logging changes. Integration with that newer prerequisite and its accepted-revision re-audit remain open. This work does not certify a deployment or published container image, and it does not complete all acceptance criteria for #392 or #396. diff --git a/docs/superpowers/specs/2026-10-07-native-store-providers-design.md b/docs/superpowers/specs/2026-10-07-native-store-providers-design.md new file mode 100644 index 000000000..93aad53c0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-native-store-providers-design.md @@ -0,0 +1,423 @@ +# Native store providers independent of HTTP adapters + +- Status: Offline implementation verified and published as draft [PR #414](https://github.com/stackpop/edgezero/pull/414) via `gh stack`, based on open #275 with the compatible #406 lifecycle bridge. Real AWS service acceptance is declined and excluded. +- Date: 2026-10-07 +- Owning issue: [EdgeZero #410](https://github.com/stackpop/edgezero/issues/410). +- Audited main: `683202c66948146ec360f490126f1d60f920288b`. +- Initial host: Axum. The provider implementations must not depend on Axum. + +## 1. Outcome and invariants + +A native application chooses its HTTP runtime and its KV, configuration, and secret providers independently. It can also choose different providers for different logical IDs of the same store kind. + +For example, Axum can serve an application with redb application state, AppConfig configuration, and environment secrets. Another deployment of the same application can use local configuration and Secrets Manager. A future DynamoDB KV provider replaces a KV binding, not the HTTP runtime or configuration provider. + +The application continues to use `KvStore`, `ConfigStore`, `SecretStore`, their handles, and the existing per-ID registries. AWS resource names, credential discovery, protocols, and service errors belong behind that boundary. + +Required invariants: + +- Provider selection never changes the meaning of a logical store ID or its default. +- Selecting an AWS binding never silently falls back to local files or environment secrets. +- Raw configuration, serialized `BlobEnvelope` values, and nested secret references retain their meanings. +- AWS and native runtime dependencies stay outside portable core and provider-WASM dependency graphs. +- Preparing a provider does not claim application hot reload, safe signing-key rotation, or distributed KV guarantees. +- Provider failure and an absent lookup key remain distinguishable. + +## 2. Audited baseline and integration prerequisites + +These are current main behaviors, not proposed contracts. + +| Area | Current behavior | Source | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Store declarations | Only logical IDs and defaults are portable. Provider fields in `[stores]` and per-adapter store tuning are rejected. | `crates/edgezero-core/src/manifest.rs:350-372`, `:469-505` | +| Macro metadata | `StoreMetadata` carries IDs/default only. No provider initialization is generated. | `crates/edgezero-core/src/app.rs:77-102`; `crates/edgezero-macros/src/app.rs:91-124` | +| Config | Read-only async key lookup returning `Option`. | `crates/edgezero-core/src/config_store.rs:206-214` | +| Secrets | Read-only named-store/key lookup returning bytes. | `crates/edgezero-core/src/secret_store.rs`, `SecretStore` | +| KV | Mutable bytes, TTL, existence, deletion, and prefix pagination. | `crates/edgezero-core/src/key_value_store.rs:880` | +| Composition | Registries already bind a provider handle per ID. Config adds a default key; secrets add a bound namespace. | `crates/edgezero-core/src/store_registry.rs:35-84`, `:115-230` | +| Native injection | Service and dev-server builders accept existing registries. Generated `run_app` hard-wires redb, local JSON, and environment secrets. | `crates/edgezero-adapter-axum/src/service.rs:49-123`; `src/dev_server.rs:142-192`, `:333-509` | +| Typed configuration | Request extraction reads and verifies an envelope, resolves nested secret references, then deserializes and validates. It is not automatically a startup check. | `crates/edgezero-core/src/extractor.rs:748-813`, `:970-1195` | + +Two open PRs supply the prerequisite contracts. After the initial audit, the user explicitly authorized implementation and rebasing onto them. The worktree now uses #275 at `d1a81579afb96f200e7fe8fd294600f0b446b080` with #406 rebased onto it as combined base `1499062`. Neither source PR is accepted on main. References below to accepted prerequisite contracts mean the exact selected and tested stack for this implementation, not a claim about main or GitHub approval. + +Initially inspected heads: + +- [PR #275](https://github.com/stackpop/edgezero/pull/275), inspected at `d1a81579afb96f200e7fe8fd294600f0b446b080`, introduces fallible `App::build::(platform)`, shared `ConfigValue`, and required `ConfigStore::get_bounded` and `SecretStore::get_bytes_bounded` methods with a clock, absolute deadline, and separate backend/value allowances. Do not implement against main's old `String`-only contract or invent a parallel bounded-read API. +- [PR #406](https://github.com/stackpop/edgezero/pull/406), inspected at `3ab22096fd117f7506cf437b5ba128e0789e059c`, implements #392/#396 production startup and an app initializer over `PreparedStores`. At that head, `PreparedStores` exposes read access, not provider override construction. `AxumRunOptions::validate_stores` requires config/data roots for every declared config/KV kind. `build_production_stores` constructs local providers directly. + +Re-audit and test the exact selected stack before runtime integration. Open status is not a blocker under the user's subsequent instruction. In particular, remote configuration must not require `CONFIG_DIR`, and an injected remote KV must not require `DATA_DIR`. Mixed deployments require roots only for bindings still using local defaults. Reuse the accepted production initializer, readiness, shutdown, and configuration extraction contracts. + +Coordinate raw-value semantics with [#324](https://github.com/stackpop/edgezero/issues/324). Do not copy Fastly's current envelope-only raw lookup behavior into a new provider. + +## 3. Proposed decisions + +| Decision | Proposal | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Application API | Keep the existing store traits, handles, and registries. No unified `Store` trait. | +| Provider unit | A binding for one store kind and logical ID. Same-kind local and AWS bindings can coexist. | +| AWS packaging | One optional native `edgezero-store-aws` crate, independent of any HTTP adapter. | +| General extension | Application-supplied per-ID handles at native startup. No dynamic plugin registry or provider discovery. | +| Bootstrap | An explicitly located native bindings file, or programmatic bindings. Do not bake AWS references into portable declarations. | +| Initial config provider | AppConfig Agent, free-form whole-document reads. No direct AppConfig Data API implementation initially. | +| Initial secret provider | Secrets Manager through the AWS SDK for Rust. Whole-value reads with current or pinned version selection. | +| Config and secret adoption | Load all explicitly mapped AWS values at startup and publish immutable process snapshots. Restart to adopt updates. | +| DynamoDB config | Evaluated and deferred. AppConfig plus Secrets Manager is the initial #410 service set. | +| DynamoDB KV | A separately approved provider implementing the ordinary KV contract. The composition boundary supports it; #410 does not implement it. | +| Publication/provisioning | Runtime readers only. Existing operator tooling owns AWS resource creation and publication. | + +The user's implementation instruction authorizes these choices. Direct polling, live secrets, or DynamoDB configuration would change the approved protocol and scope. + +## 4. Architecture and ownership + +```mermaid +flowchart TD + App[Portable application and logical store IDs] --> Traits[Core store traits and handles] + Traits --> Registries[Per-ID KV, config, and secret registries] + Bootstrap[Native bootstrap settings and supplied handles] --> Prepare[Native startup composition] + Prepare --> Local[Existing redb, JSON, and environment providers] + Prepare --> AWS[Optional edgezero-store-aws factories] + Prepare --> Custom[Other providers implementing core traits] + AWS --> Agent[AppConfig Agent HTTP endpoint] + AWS --> SM[AWS Secrets Manager SDK] + Local --> Registries + AWS --> Registries + Custom --> Registries + Registries --> Startup[App-owned typed configuration and secret checks] + Startup --> Runtime[Axum initially, other native HTTP runtimes later] +``` + +### Core + +Own store contracts, opaque handles, logical metadata, registries, envelope validation, and typed extraction. Do not add AWS enums, credentials, resource references, remote scheduling, or native executor requirements. + +Traits retain `#[async_trait(?Send)]`. Store implementations remain `Send + Sync`; changing the future bound to `Send` would break the portable contract. + +AWS snapshot implementations must implement both ordinary and bounded reads under the accepted #275 contracts. Bounded reads reject an already-expired deadline, apply independent backend/value allowances, report the snapshot payload bytes exposed to the caller, and recheck deadline precedence before returning. A successful missing lookup reports zero payload bytes. No bounded method may delegate to an unbounded cloud operation; cloud access happens only during preparation. + +### AWS provider crate + +Expose typed settings and async preparation methods producing config/secret handles through an explicit shared `AwsPreparation` session. The standard runner owns one session for all its built-in AWS bindings; programmatic callers explicitly create and reuse a session. It owns aggregate budgets, successful-target deduplication, and client reuse. Do not hide independent per-binding sessions behind convenience constructors. Preparation methods own AWS access, strict resource mapping, sanitized error classification, and immutable snapshot construction. They do not serve HTTP requests, discover application secret names, create another runtime, or initialize global logging. + +Suggested independent features are `appconfig-agent` and `secrets-manager`, with no default features. Use target-specific native dependencies. AWS SDK and any Tokio timer dependency belong in this native provider crate, never core or the WASM adapters. Reuse the existing native runtime; do not add another Tokio dependency to an adapter. + +The AWS crate must not depend on Axum, the CLI adapter registry, or downstream application crates. Future provider crates can implement the same contracts without depending on it. + +### Native composition + +Extend the accepted production store-preparation step with per-kind, per-ID override inputs using existing `KvHandle`, `ConfigStoreBinding`, and `BoundSecretStore` types. Permit async setup inside the native runtime before registries are finalized. The application initializer consumes the completed registries; it is too late to select providers there. + +For each declared ID, choose exactly one source: + +1. An application-supplied binding. +2. An explicit bindings-file entry. +3. The existing local default for that kind. + +Reject duplicate supplied/file bindings for the same ID rather than silently choosing precedence. Reject undeclared IDs. Build each registry once and retain its declared default. Validate local root requirements after determining which bindings need those roots. Complete source validation before opening runner-owned providers or polling supplied deferred futures. Constructing a deferred future must be side-effect-free, with provider work inside its async body. This ordering guarantee cannot undo caller-side work or prevent a supplied ready handle from already being open. + +The standard Axum runner can dispatch the small set of built-in binding tags to constructors. A custom provider needs only a supplied handle, not a new tag in a global registry. Share these handles between startup checks and requests. + +Keep existing redb/file/env implementations in their current crate initially. Extracting all local providers into new crates would add unrelated migration work. A later native runtime can adopt or move them separately. + +## 5. Bootstrap and binding shape + +Keep `edgezero.toml` portable: + +```toml +[stores.kv] +ids = ["state"] + +[stores.config] +ids = ["app", "routing"] +default = "app" + +[stores.secrets] +ids = ["signing", "notifications"] +default = "signing" +``` + +The proposed native input is an absolute `EDGEZERO__STORE_BINDINGS_FILE` path, or an explicit options method. Read it once during startup. With no input, preserve existing development defaults and the accepted production defaults. A missing explicitly selected file is an error, not an empty binding set. + +Proposed file, with fictional resources: + +```toml +version = 1 + +[config.app] +provider = "aws-appconfig-agent" +default_key = "settings" +endpoint = "http://127.0.0.1:2772" +# Optional local agent authorization, read from the bootstrap environment. +access_token_env = "APPCONFIG_AGENT_TOKEN" + +[config.app.documents.settings] +application = "example-app" +environment = "production" +profile = "application-envelope" + +[config.routing] +provider = "aws-appconfig-agent" +default_key = "allowlist" +endpoint = "http://127.0.0.1:2772" + +[config.routing.documents.allowlist] +application = "example-app" +environment = "production" +profile = "routing-allowlist" + +[secrets.signing] +provider = "aws-secrets-manager" +region = "us-east-1" + +[secrets.signing.entries.active_key] +secret_id = "arn:aws:secretsmanager:us-east-1:111122223333:secret:example-signing-AbCdEf" +version_stage = "AWSCURRENT" +``` + +`state` remains redb and `notifications` remains environment-backed. Replacing either is an independent operation. The schema may later add a `dynamodb` KV tag, but v1 must reject it as unsupported rather than accepting a nonfunctional placeholder. + +Validation rules: + +- Require schema version 1. Reject unknown kinds, fields, provider tags, duplicate tables, empty mappings, and undeclared IDs. IDs and lookup keys are case-sensitive. +- Parse provider settings into tagged types. Reject irrelevant provider fields instead of retaining an unvalidated option bag. +- Require the config `default_key` to name a document mapping. Multiple lookup keys require explicit entries. +- Require an explicit Secrets Manager region and complete ARN for each secret. Validate ARN service, region, and resource shape. Cross-account reads are allowed only with explicit full ARNs and operator-managed permissions. +- Allow one version selector per secret: omitted/current stage, an explicit stage, or a pinned version ID. Reject simultaneous `version_id` and `version_stage`. Although AWS can accept a matching pair, v1 does not need that extra state. +- Existing `EDGEZERO__STORES______NAME` and `__KEY` behavior remains unchanged for implicit local bindings. Reject those supplied overrides for an explicitly bound ID. Do not repurpose a platform name as an AWS ARN. +- Explicit options and supplied handles do not silently merge ambient environment settings. The env-loading entry point is the only path that reads the file locator from the environment. +- The file contains resource references and environment variable names, never credential or secret values. No remote configuration can supply its own bootstrap credentials, endpoint, region, or binding file. + +Keep parsing native-side. The native bindings module owns the versioned top-level file, store kinds/IDs, source collisions, provider-tag dispatch, local defaults, scalar-overlay conflicts, and root requirements. The AWS crate owns strict AWS settings types and resource-shape validation. It must not require changes to `StoreDeclaration`, `StoreMetadata`, or macro-generated resource metadata. Programmatic callers use typed constructors and existing handles without a bindings file. + +The env-loading entry point captures the file locator and scalar overlays once. Explicit options use only supplied bootstrap settings. Both use the same source validator. Offline CLI validation does not treat its own ambient environment as the future process's deployment configuration; conflicts with actual deployment overlays are always checked again at startup. + +## 6. AppConfig configuration + +### Access path + +Use AWS AppConfig Agent initially. AWS recommends it for local caching, polling interval handling, and configuration token management. EdgeZero uses its local HTTP GET endpoint; it does not implement session tokens or a polling loop. + +The operator owns the agent process/container, its AWS role and region, its pinned version, and its network policy. EdgeZero neither launches nor supervises it. The binding identifies application, environment, and profile; the agent's AWS bootstrap is separate from the application binding. + +V1 permits only an HTTP endpoint with a literal loopback IP address. Reject URL userinfo, query strings, fragments, unexpected base paths, redirects, and non-loopback destinations. Construct path segments with proper escaping. Disable ambient HTTP proxies for this client. A token, when configured, comes from the named bootstrap environment variable and never enters diagnostics. Separate-pod or remote-agent access requires a later explicit transport/security design. + +When token authentication is configured, the operator sets Agent `ACCESS_TOKEN` to the same value as the provider's named bootstrap environment variable. The provider sends `Authorization: Bearer ` on its GET request. When unset, it sends no authorization header. A configured but absent/empty token variable fails startup. Never put the token in a URL or emit request-header diagnostics. + +Container deployments must share an appropriate network namespace. Configure the agent to bind loopback explicitly; AWS documents different binding defaults for ECS/EKS and EC2/on-premises. Require token authentication when untrusted processes share that namespace. This does not isolate the endpoint from a compromised application process. + +### Key mapping + +Each configured EdgeZero key maps to the entire deployed free-form document in one profile. The key selects a document, never a field inside it. Multiple independent values require explicit profile mappings: + +- `get("settings")` returns the profile's exact UTF-8 document bytes as `ConfigValue`. +- A profile containing a serialized `BlobEnvelope` returns that serialization unchanged. Core extraction validates it. +- A profile containing a raw string returns the raw string, including whitespace or an empty payload. Do not parse or flatten arbitrary JSON, unquote it, reserialize it, or resolve secret references inside the provider. +- Unknown lookup keys return `Ok(None)`. An unavailable explicitly mapped profile fails startup. +- Invalid UTF-8, response/protocol errors, and oversized documents are errors. + +Request identity content encoding and disable transparent HTTP decompression. V1 rejects responses with a non-identity `Content-Encoding`; compressed responses require a later explicit wire/decoded-limit contract. Bound streamed body bytes before retaining them. The retained UTF-8 body is the original profile text, not compressed transport bytes. + +V1 implements neither key/value document projection nor feature-flag evaluation. They need explicit contracts if later required. + +### Snapshot and freshness + +Fetch each distinct mapped profile once during startup, validate its bytes/limits, and retain an immutable snapshot. The agent can continue polling, but EdgeZero does not reread it after startup. There is no hot reload or app-state mutation. + +The snapshot is coherent per document, not an atomic revision across profiles, secret resources, or providers. Applications needing a coordinated release must use a single configuration envelope with explicit pinned secret versions or their own release validation. + +An agent can successfully return cached data while AWS is unavailable or access has changed. Therefore startup proves that the configured agent supplied an acceptable document, not that AWS was reachable at that instant or that the document is the latest deployment. Do not call this a freshness guarantee. + +Disable agent disk backup/preload for the initial documented setup. Even without disk backup, its in-memory cache can be stale. This proposal explicitly accepts that upstream agent policy; EdgeZero never adds another stale fallback after a failed fetch. If #410 consumers require a maximum config age or fail-on-revocation startup, this access-path choice needs revision before approval. + +Direct Data API polling is deferred. Choosing it later requires single-owner session tokens, one-use tokens, server polling intervals, unchanged empty-response handling, session recovery, ambiguous failure handling, and separate tests. Those requirements are not implemented by this spec's agent reader. + +## 7. Secrets Manager + +Bind the exact logical namespace and key to an explicit secret ARN/version selector. The resulting `BoundSecretStore` must preserve namespace isolation; a wrong store or unmapped key returns `Ok(None)` and never probes arbitrary AWS names. + +At startup, retrieve every explicitly mapped entry. All such entries are required to exist. This keeps a finite, reviewable access set and avoids per-request cloud calls. If an application does not need a secret in this deployment, omit its entry. Required app references to an omitted entry fail in the app-owned startup check. + +Return complete values: + +- `SecretString` becomes its exact UTF-8 bytes. +- The Rust SDK's `SecretBinary` blob is already decoded bytes. Do not base64-decode it again. +- Reject malformed responses with neither or both value fields. Preserve valid empty values where the service/client permits them; app validation decides whether empty is acceptable. +- Do not extract JSON fields. A JSON secret is one complete byte value. +- Typed string secret references still require UTF-8. Direct byte consumers retain binary fidelity. + +Omitted version selection means `AWSCURRENT` at startup. A pinned version ID remains pinned. Restart reads the selector again and constructs a new process snapshot; moving a staging label does not alter the running snapshot. + +There is no TTL cache, negative cache, or serve-stale-on-refresh path. Snapshot memory contains only configured successful values and remains bounded until shutdown. Deleting a secret or revoking AWS access does not erase an already loaded secret from a running process. Operators must stop/restart that process when immediate removal is required. This policy needs explicit approval for rotation-sensitive consumers. + +A failed subsequent restart does not reuse a prior process snapshot. Do not persist secret snapshots to disk. Avoid unnecessary byte/string copies and exclude values from `Debug`, logs, traces, and errors. Standard `Bytes`/`Arc` ownership does not promise memory zeroization; any future such guarantee requires a separate API review. + +## 8. Startup, budgets, and failures + +```mermaid +flowchart TD + Start[Start native process] --> Resolve[Validate bootstrap settings and per-ID sources] + Resolve --> Assemble[Accepted fallible framework assembly] + Assemble --> Load[Prepare local bindings and bounded AWS snapshots] + Load --> Registries[Finalize shared registries] + Registries --> Checks[App initializer loads typed config and required secrets] + Checks --> Ready[Start accepting traffic and expose readiness] + Resolve -->|Failure| Stop[Redacted startup failure, non-zero exit] + Assemble -->|Failure| Stop + Load -->|Failure| Stop + Checks -->|Failure| Stop +``` + +Reuse #392/#396 rather than introducing another readiness or mandatory-secret API. The application owns schema validation, required secret references, signing initialization, and publication of typed handler state. Framework assembly keeps its accepted order; provider preparation precedes the app initializer and listener acceptance. + +At the inspected #275 head, `AppConfig::from_binding` provides context-free typed loading with the shared envelope/secret algorithm. Use its accepted form in the production initializer, then retain validated typed state using #406's startup state insertion. Request extraction remains available and can repeat parsing against immutable snapshots; snapshot providers alone do not cache typed application objects. + +### Proposed v1 defaults + +All limits are validated finite, nonzero settings. Reuse accepted core snapshot/extraction limits where they apply; provider transfer and bootstrap limits are additional checks, not replacements. + +| Limit | Default | +| -------------------------------------------- | --------------------------------------------------------------------- | +| Bindings file bytes | 256 KiB | +| Explicit bindings | 128 total IDs | +| Remote mapped values | 1,024 config document and secret entry mappings per shared session | +| AppConfig document bytes | 1 MiB | +| Secret payload bytes | 64 KiB | +| Combined retained AWS snapshot payload bytes | 8 MiB unique retained payload per shared preparation session | +| Aggregate AWS preparation budget | 30 seconds per session, including credential/client setup and fetches | +| Individual service operation budget | At most 5 seconds, also bounded by remaining aggregate budget | +| Connect / attempt budgets | At most 1 second / 2 seconds, clipped to remaining operation budget | +| Concurrent remote preparation | One operation at a time initially | + +The 8 MiB limit is an approved provisional, configurable default, not an AWS service limit or a number justified by workload measurements. An aggregate cap prevents many individually valid documents and secrets from accumulating an unexpectedly large snapshot. Applications with reviewed larger inputs may explicitly raise it. Revisit the default using representative deployment payload sizes and headroom, rather than presenting it as a validated requirement. + +The standard runner's one shared session accounts for all built-in AWS bindings it prepares, across config/secrets and logical IDs. Programmatic callers use the same accounting by reusing an explicit session. Opaque supplied handles, local providers, and separately created sessions are outside that session's quota. Independent sessions can together exceed 8 MiB; no process-wide cap or global accounting is promised. Callers own the budget for those additional resources. + +Count metadata, mapping entries, retained payload, and transient staging separately. Count every mapping before deduplication, but count a shared immutable payload allocation only once against retained bytes. Bound agent body reads before accumulating the whole document. Reject duplicates before constructing snapshots. Deduplicate identical profile or ARN/version requests only when their complete normalized target and authorization/client policy match; do not accidentally merge namespaces. Check aggregate residency before retaining each payload. A quota breach fails preparation and the runner drops all staged state before readiness; it does not evict values or fall back to local sources. + +The SDK may materialize secret responses and allocate before EdgeZero checks payload size. Metadata, transient buffers, credential providers, TLS, SDK parsing, allocator overhead, local-provider payloads, typed application state, and the external agent are not covered by the retained-payload cap. Do not describe these limits as a certified RSS bound. Preserve #275's conservative allocation/deadline capability classification. + +### Retry ownership + +Reuse one session-owned Secrets Manager client per region/client policy during preparation, not one client per key or request. Configure supported temporary-credential discovery and pin SDK versions and behavior defaults. Production uses workload roles; local developers use a named authenticated profile or supported developer chain. Never require static keys in the bindings file or image. + +The SDK owns Secrets Manager retries, capped at three total attempts within the operation and aggregate budgets. EdgeZero adds no outer retry loop. Retry transient transport/service/throttling failures only; do not repeatedly retry validation, access denial, or decryption failures. Test the pinned SDK's actual classifications. + +The agent HTTP reader makes one bounded request per distinct profile. Agent AWS polling/retry policy is upstream. A failed preparation exits; an operator/supervisor owns any restart backoff. No retry across partially published snapshots is possible because publication occurs only after success. + +Use the accepted application monotonic clock for caller deadlines. Configure provider timers and SDK operation budgets, but do not claim forcible cancellation of credential-process discovery, blocking provider work, or all socket activity. Timeout observation is not proof that every underlying operation stopped. Retain best-effort wording until runtime evidence proves a stronger guarantee. + +### Error boundary + +Classify failures before dropping provider response/source details. Use a native sanitized preparation error with store kind, baked logical ID, and a fixed category. Suggested categories include invalid binding, missing resource/version, credential failure, access denied, decryption failure, throttled, deadline, transport unavailable, malformed value, and size limit. Keep these distinctions where the access path exposes them; the Agent may expose only an upstream-unavailable response, not AWS's original cause. + +Do not extend core errors merely to publish AWS service codes. Snapshot reads use the accepted portable errors for invalid keys, deadlines, and bounds. A missing local lookup is `Ok(None)`; a failed mapped AWS fetch is a startup error, never a successful miss. Agent HTTP authorization denial and missing resource responses remain failures even if detailed cloud causes are unavailable. + +Log only fixed categories and validated application logical IDs. Drop SDK/HTTP error chains before startup formatting. Never log credential sources, ARNs, endpoint tokens, response bodies, raw bootstrap values, config contents, or secrets. Keep service request IDs only if a separately reviewed safe diagnostic policy allows them. + +## 9. Why DynamoDB is separate + +DynamoDB is a good candidate for both config and ordinary KV, but those are separate implementations and permissions. + +For configuration it fits `get(key)` directly. A later config provider must specify table/key/value attributes, namespace encoding, string versus binary handling, version selection, consistency, and missing-item behavior. A DynamoDB item is limited to 400 KiB including attribute names and values, which can exclude complete application envelopes. AppConfig better matches the initial managed configuration deployment requirement; supporting both now would multiply acceptance work without a confirmed second requirement. + +A later DynamoDB KV adapter can implement `KvStore` and return a `KvHandle` to the same native override path. Its owning spec must settle: + +- Table and namespace/key schema, collision-free encoding, and per-binding isolation. +- Byte fidelity, item overhead, and backend value limits. Core's current maximum is not a promise that every provider accepts that size. +- TTL visibility and expiration during reads and listing, rather than assuming background deletion is immediate. +- Prefix pagination, ordering, cursors, consistency, and behavior under concurrent updates. +- Operation budgets, retry policy, permissions, and disposable AWS acceptance. + +Passing ordinary KV contracts would not certify CAS, atomic multi-key operations, strongly consistent prefix snapshots, EC identity-graph behavior, or safe distributed rate limiting. Do not advertise those capabilities without their owning contract and tests. + +Parameter Store remains a documented alternative, not an initial implementation. Valkey, PostgreSQL, S3, and non-AWS provider implementations are also separate tasks. Secrets stored as ordinary KV bytes do not acquire Secrets Manager's lifecycle. + +## 10. Generated applications, CLI, and permissions + +Offer the standard native runner independent opt-in `aws-appconfig-agent` and `aws-secrets-manager` features forwarding to the corresponding `edgezero-store-aws` service features. An optional `aws-stores` alias may enable both, but must not be the only path. Config-only builds need no Secrets Manager SDK; secret-only builds need no Agent HTTP client. The CLI and generated Axum Cargo target must make those dependency choices explicit. A bindings file does not add dependencies to an existing binary. A selected provider that was not compiled in fails safely at startup. + +Make forwarding `[adapters.axum.build].features` an explicit build deliverable. Main's Axum `run_cargo` forwards explicit extra args, while its blueprint has no provider features. Declared native build features and explicit Cargo `--features` additions form the requested feature names, not the resolved provider capabilities. Determine provider availability from Cargo's resolved feature graph for the selected native package and target, including dependency declarations, defaults, `--no-default-features`, `--all-features`, and alias/dependency feature expansion. Build and offline validation must use the same target-selection and feature-resolution inputs. Map generated target features to adapter service features. Add generated-native compile tests for config-only, secret-only, both, and disabled providers. Keep default-generated native and WASM targets AWS-free. + +Put the bindings-file parser and per-ID source validation in a native bindings module in `edgezero-adapter-axum`, available to its `axum` and `cli` features. Share TOML/settings dependencies through a native-bindings feature, rather than enabling `cli` from the runtime or `axum` from CLI-only validation. Main currently enables the TOML dependency only under `cli`. + +The AWS crate owns strict deserialization/validation of its settings types; those types must be available without linking the service SDK or HTTP client. This allows the existing CLI dependency on the Axum adapter to reuse the parser without another crate or portable-core changes. + +Propose `config validate --adapter axum --store-bindings /absolute/native-stores.toml`, with optional `--target-features` additions and target controls equivalent to Cargo's `--all-features` and `--no-default-features`, for offline structural validation against declared IDs, explicitly supplied source conflicts, and limit settings. Target-provider compatibility uses Cargo-resolved features under the same inputs as native build, not a union of feature names or the running CLI binary's compiled services. If target build information is incomplete or its feature graph cannot be resolved offline, explicitly report structural validation only, without a provider-availability verdict. Runtime validation remains authoritative for a prebuilt binary. Test both directions of a CLI/target feature mismatch, default-enabled providers, suppression through `--no-default-features`, `--all-features`, the `aws-stores` alias when offered, and dependency feature propagation. Compare each verdict with the corresponding compiled native target. Never resolve AWS credentials, probe resources, or fetch configuration/secret values during this check. + +No new config publishing, secret mutation, or AWS provisioning behavior is included. Existing `config push --adapter axum` remains a local-file writer and does not update AppConfig. Document that it does not affect an explicitly AWS-bound ID. If the command is given an explicit native bindings file selecting that ID, reject unsupported remote publication rather than report a successful AWS update. Secret-management commands remain under #284. + +Read permissions are independent of operator writes: + +- Agent role needs AppConfig Data `StartConfigurationSession` and `GetLatestConfiguration` for reviewed configuration resources. +- Application role needs Secrets Manager `GetSecretValue` for explicit ARNs and `kms:Decrypt` when the selected customer-managed key requires it. +- Runtime roles do not need resource creation, config deployment, secret mutation, staging-label mutation, or rotation-Lambda management. +- Config-only applications need no Secrets Manager client or application AWS credentials. The agent owns its role. Secret-only applications need no agent. + +Document native-only support, exact setup, restart adoption, upstream agent staleness, secret snapshot retention, least privilege, costs, and cleanup. Downstream apps own binding adoption and app-specific requirements; no Trusted Server or Axon schema belongs in these crates. + +## 11. Delivery sequence and proof + +The user's subsequent instruction to go ahead authorizes execution of the staged implementation plan. Stage proof gates remain; AWS acceptance still requires separate explicit authorization. + +1. Re-audit and integrate accepted #275/#406 contracts. Record existing coverage and add only missing native composition behavior. Test injected fake providers, mixed bindings, defaults, duplicate rejection, and remote-only startup without local roots. +2. Add typed bootstrap parsing and the optional native AWS crate skeleton. Test strict parsing, source conflicts, feature-disabled selection, namespace mappings, and budget validation without network access. +3. Add AppConfig Agent whole-document snapshot preparation. Use a deterministic local HTTP fixture for exact paths, bearer authorization and token redaction, redirects/proxy rejection, content-encoding rejection, raw UTF-8, envelope preservation, empty values, invalid UTF-8, response errors, truncation, deadlines, size limits, and snapshot immutability. Test bounded snapshot reads independently of startup transfer limits. +4. Add Secrets Manager snapshot preparation. Put a narrow test seam at the service-call boundary, not a public provider factory hierarchy. Test string/binary fidelity, no double base64 decoding, namespace isolation, selectors, deduplication, missing resource/version, expired credentials, denied/decryption/throttling/transport errors, and partial-startup cleanup. Test bounded snapshot reads for expired deadlines, separate backend/value allowances, hit byte accounting, zero-byte missing results, and deadline precedence before return. Test startup fetch limits separately; bounded lookup does not bound SDK response allocation during construction. +5. Wire generated native feature enablement and offline validation. Test AWS config plus env secrets, local config plus AWS secrets, both AWS providers plus redb, and same-kind local/custom/AWS bindings. Prove a downstream fake KV can use the generic injection path without importing AWS. +6. Exercise production startup through a generated native application. Required envelope validation and nested secret resolution must finish before readiness. Capture logs/public errors and assert fixture values, tokens, ARNs, and provider bodies never appear. Version changes affect a new process, not the running one. +7. Run an explicitly authorized disposable AWS acceptance using pinned Agent/SDK versions, least-privilege roles, finite cost/request limits, and named cleanup ownership. Record commit, environment, resources, result, and cleanup. Verify cold-agent retrieval, denied cold-agent access, documented warm-cache behavior, current/pinned secret versions, restart adoption, and denied secret access. + +Default tests require neither AWS credentials nor network access. Provider fixtures prove EdgeZero behavior, not deployed IAM, credential discovery, or Agent behavior. Local HTTP fixtures are authorized loopback tests, not external-network tests. + +Implementation gates include: + +```sh +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Also add enabled native-provider tests/builds, generated application compilation, and existing Fastly/Cloudflare/Spin WASM target checks with AWS absent from their reachable dependency graph. Deterministic provider tests use `futures::executor::block_on` where possible. Native SDK/HTTP fixture tests may use the provider crate's native runtime; they must not move that requirement into portable contract suites. + +Stages 1 and 2 now have executed generic composition, strict bootstrap/settings, isolated-feature and native/WASM build proof in the maintained plan's execution records. Cloud providers and shared-session accounting remain unimplemented at this checkpoint. No deployed AWS behavior or forcible cancellation is proven. The user has explicitly declined real AWS acceptance; it is excluded from the currently authorized task. + +## 12. Alternatives and review questions + +Rejected for the initial design: + +- AWS services inside Axum implementation modules. Smaller at first, but prevents independent provider use by another native runtime. +- Provider settings embedded in portable `[stores]` metadata. Mixes application declarations with deployment references and changes macros unnecessarily. +- A general plugin registry. Existing traits and handle injection already solve extension; no dynamic discovery is required. +- Environment-only nested resource mappings. Scalar NAME/KEY overrides are useful, but finite key-to-profile/ARN maps are easier to validate in a typed file. +- A JSON string-map inside each AppConfig profile. It could pack several lookup keys into one document, but adds projection and envelope-string wrapping. Whole-document bindings preserve raw bytes with fewer decoding rules, at the cost of more profiles for independently deployed keys. +- Direct AppConfig Data API polling. Removes a sidecar dependency but makes EdgeZero own the token protocol, polling, cache, and recovery. Agent plus startup snapshots is the smaller initial implementation. +- Live Secrets Manager reads with a TTL cache. Requires refresh ownership, stale-on-error policy, and rotation adoption semantics. Immutable startup snapshots are simpler and avoid per-request SDK work. + +The approved implementation follows these policies: + +- AppConfig Agent deployment and its lack of a provider-visible maximum-staleness guarantee. +- Startup-only secrets, including retention after remote deletion/revocation until process shutdown. +- Whole-document key mappings rather than JSON projection. +- AppConfig and Secrets Manager as the initial service set, with DynamoDB config/KV deferred to separately scoped work. +- The external bindings file, generic per-ID injection, remaining proposed bounds, and generated feature-enable path. The configurable 8 MiB shared-session quota and caller-owned preparation boundary were approved on 2026-10-07. + +If the application requires live rotation or enforced config freshness, obtain approval to change these contracts. Do not add them implicitly during implementation. The user's implementation/rebase instruction supersedes the original remaining-design and accepted-only prerequisite gates; later approval authorizes draft publication via `gh stack`. Real AWS acceptance remains excluded. + +### SDK diagnostic policy: approved first-version limitation + +An isolated native spike resolves/builds the Secrets Manager-only candidate graph and verifies direct scoped suppression against fictional loopback data; it does not prove the complete credential-chain graph. A separate executed tracing/log probe finds that scoped `NoSubscriber` permanently disables unrelated application tracing-to-log fallback when no global tracing subscriber is installed. Additive `tracing/log-always` instead bypasses that suppression. This violates the logging-preservation/diagnostic contracts; do not silently install a global logger/subscriber or impose new caller requirements. + +The user has explicitly approved continuing the first version with redacted/dropped EdgeZero-owned diagnostics and a dependency DEBUG/TRACE caveat disclosed before reviews. Do not scope NoSubscriber or alter application logging. Dependency diagnostic hardening is a review item, not an implementation blocker. Offline Stages 1-8 are implemented and verified; provider availability validation remains explicitly structural-only/unknown. Real AWS acceptance remains declined and is not requested. Evidence and exact boundaries are recorded in the maintained plan's section 20 and `/tmp/native-store-integration/sdk-diagnostics-execution.md`. + +## References + +- [Issue #410 and ownership boundaries](https://github.com/stackpop/edgezero/issues/410). +- [Production local stores #396](https://github.com/stackpop/edgezero/issues/396) and [startup/readiness #392](https://github.com/stackpop/edgezero/issues/392). +- [Reusable application lifecycle](2026-09-15-reusable-app-lifecycle-design.md). Retaining an app does not refresh its typed state. +- [AWS AppConfig Agent](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-agent.html). +- [Agent container settings, network binding, and backups](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-integration-containers-agent-configuring.html). +- [AppConfig Data polling protocol](https://docs.aws.amazon.com/appconfig/2019-10-09/APIReference/API_appconfigdata_GetLatestConfiguration.html). +- [Secrets Manager GetSecretValue](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). +- [AWS SDK credential providers](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/credproviders.html), [timeouts](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/timeouts.html), and [retry configuration](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/retries.html). +- [DynamoDB constraints](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html). diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 738880a5d..eabfb4306 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", "futures", "futures-util", "http", @@ -961,6 +962,16 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.21", + "url", +] + [[package]] name = "either" version = "1.18.0" diff --git a/scripts/check_native_store_dependency_contract.sh b/scripts/check_native_store_dependency_contract.sh new file mode 100755 index 000000000..c75502c9a --- /dev/null +++ b/scripts/check_native_store_dependency_contract.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Independent normal dependency graphs; workspace --all-features is not isolation proof. +set -euo pipefail + +assert_no_sdk() { + local graph + graph="$(cargo tree --offline --locked --edges normal --prefix none "$@")" + if grep -Eq '^aws-(config|sdk-[^ ]+) v' <<< "$graph"; then + printf 'AWS SDK leaked into isolated graph: %s\n' "$*" >&2 + exit 1 + fi +} + +assert_no_sdk -p edgezero-cli --no-default-features --features cli,edgezero-adapter-axum +assert_no_sdk -p edgezero-store-aws --no-default-features +assert_no_sdk -p edgezero-store-aws --no-default-features --features appconfig-agent +assert_no_sdk -p edgezero-adapter-axum --no-default-features --features axum +assert_no_sdk -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +assert_no_sdk -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly +assert_no_sdk -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +printf 'Native store SDK isolation graphs passed.\n' diff --git a/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock index d395b42ea..1058143d3 100644 --- a/tests/fixtures/reusable-app/Cargo.lock +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -523,6 +523,7 @@ dependencies = [ "axum", "bytes", "edgezero-core", + "edgezero-store-aws", "futures", "futures-util", "http", @@ -538,6 +539,7 @@ dependencies = [ "simple_logger", "thiserror 2.0.20", "tokio", + "toml", "tower", "tracing", ] @@ -663,6 +665,16 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.20", + "url", +] + [[package]] name = "either" version = "1.16.0"