diff --git a/Cargo.lock b/Cargo.lock index 7e86336..f3847fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -665,9 +665,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -878,9 +878,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1047,9 +1047,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dynamic-config" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0245845d160b0a450130722105a94dc488893a4ff862f275d38576ad353bbb75" +checksum = "5db0b6b9af6670ab2ccfd9cf30659be255ff0e9eda3da1ad086adf9fe3bc3e51" dependencies = [ "arc-swap", "config", @@ -1060,15 +1060,16 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "shuttle", "toml 1.1.4+spec-1.1.0", ] [[package]] name = "dynamic-config-consul" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "801c1050cbe1f8a988ee9aaeda7a33b88044aa22fb4ca06982593fb77ed3b7dd" +checksum = "e5146af2db2fdbaaac2761660a89d156bc55544a2b85a572085baba56ae2f646" dependencies = [ "base64 0.23.1", "dynamic-config", @@ -1079,9 +1080,9 @@ dependencies = [ [[package]] name = "dynamic-config-etcd" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e80518443bb152a61fcbb62d352dfb5c7378b04a9430fbafc77fb925db05e16" +checksum = "47bf2503a82c5b03e5c60b9b936f06ef74e7194713a69a0d02b07c4619838cd4" dependencies = [ "dynamic-config", "dynamic-config-store-core", @@ -1091,9 +1092,9 @@ dependencies = [ [[package]] name = "dynamic-config-firestore" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4265fb13e820fc218f38e1da12dc155c66c368420177062afa5c8b6393cfe67c" +checksum = "a7d6c2721e3fac01c3ea70df0ba50e902c9517764368cfc80c0d6b52465450e7" dependencies = [ "dynamic-config", "dynamic-config-store-core", @@ -1103,9 +1104,9 @@ dependencies = [ [[package]] name = "dynamic-config-git" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d187064eb8b207e09c4e06bf58b91210c178fd3f1eface6319204fd8032dfe9b" +checksum = "788f922b39ddf9c75c8920368d17bdc5d2c3d1cedef0d3bbfbcda9692eb6e2ae" dependencies = [ "dynamic-config", "dynamic-config-store-core", @@ -1115,9 +1116,9 @@ dependencies = [ [[package]] name = "dynamic-config-macros" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bc24ebed394ef05aa92e1f7b8f46b801b0ccf41890440091364be07e4abe40" +checksum = "8d46acd9699b544599c36cdc53d91088d7325119f0b15b530546ba0451fea630" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1127,9 +1128,9 @@ dependencies = [ [[package]] name = "dynamic-config-nats" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1a5e673a9a09ef9d0a5dc9f367389b6464a518a78a055f950f8237b5b6cc74" +checksum = "ab90fbf0d8ec95265a48927a49c823993450e1f1a7fccd60f2f92e69e474ab00" dependencies = [ "async-nats", "dynamic-config", @@ -1140,7 +1141,7 @@ dependencies = [ [[package]] name = "dynamic-config-python" -version = "0.3.1" +version = "0.3.2" dependencies = [ "dynamic-config", "pyo3", @@ -1149,7 +1150,7 @@ dependencies = [ [[package]] name = "dynamic-config-python-remote" -version = "0.3.1" +version = "0.3.2" dependencies = [ "aws-config", "aws-credential-types", @@ -1169,9 +1170,9 @@ dependencies = [ [[package]] name = "dynamic-config-redis" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e5ad538e0d1184df9f59453d5b8146127dd8c73c6660ae2fb1d36391e628e2" +checksum = "90e325d00c3c22b5108191adffc775c0b1139e39a2b423424463f3052a98c6c0" dependencies = [ "dynamic-config", "dynamic-config-store-core", @@ -1180,9 +1181,9 @@ dependencies = [ [[package]] name = "dynamic-config-s3" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "834331efa9fa8f4879b0ecff405e698034490c287e39f17e7c9fef459058ab7a" +checksum = "32eac0eb7ffe1b75b588fd89d364837a3cd83a7bc203e60bd259397ac0404b52" dependencies = [ "aws-config", "aws-sdk-s3", @@ -1195,18 +1196,18 @@ dependencies = [ [[package]] name = "dynamic-config-store-core" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2639ec07e7ac66431063c3c15ddf6b23da517992d039e7c1eb7341029c27a746" +checksum = "676a1b14e018906663dafc3c759c88a7475b6272a67e30d44991bb061a8a688e" dependencies = [ "dynamic-config", ] [[package]] name = "dynamic-config-vault" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0237c79b8100a3025ca812dd323ec001aff2b1ede53521451806e8a0cb7e6a75" +checksum = "e76758fc775619deb5c1f331e1efe5d6e09d1080192296cc645819f87a033834" dependencies = [ "dynamic-config", "dynamic-config-store-core", @@ -1238,9 +1239,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" @@ -1723,9 +1724,9 @@ dependencies = [ [[package]] name = "gix-features" -version = "0.49.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" dependencies = [ "bytes", "crc32fast", @@ -1763,9 +1764,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" dependencies = [ "bstr", "gix-features", @@ -1776,9 +1777,9 @@ dependencies = [ [[package]] name = "gix-glob" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" dependencies = [ "bitflags 2.13.1", "bstr", @@ -1788,9 +1789,9 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.26.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +checksum = "caf125eae66b7d6e4395511a06c0d43a3c34eac96c8641fb98b22078faee65b8" dependencies = [ "faster-hex", "gix-features", @@ -1811,9 +1812,9 @@ dependencies = [ [[package]] name = "gix-ignore" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +checksum = "65859a2f7de5e159d4344a5486ebf53b6bac22d5ec6afa836e47c10ae88a7f0f" dependencies = [ "bstr", "gix-glob", @@ -1952,9 +1953,9 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" +checksum = "2b075e730586bba7341304d6fc1b4efc1d10cf64532622521c0e07f30e661046" dependencies = [ "bstr", "gix-trace", @@ -2206,9 +2207,9 @@ dependencies = [ [[package]] name = "gix-utils" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", @@ -2218,9 +2219,9 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" dependencies = [ "bstr", ] @@ -2274,9 +2275,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.17" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -2588,9 +2589,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2863,9 +2864,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loom" @@ -3793,9 +3794,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -4023,9 +4024,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "shuttle" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34d00c32c85e9dafcb7948cabbb601509e9568ef4e636dafeecc847c7c43c3b" +checksum = "786792d8bc94c770b53a938f0ae68726387a3a0a3762f9cc2c38d5e1bb40f0c0" dependencies = [ "bitvec", "cfg-if", @@ -4045,9 +4046,9 @@ dependencies = [ [[package]] name = "shuttle-engine" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523ba98b8f098f0a18123fdfbc10d689e4c94d1b4ac6f819d1b05560fddea92" +checksum = "bb03d6daa3cf319f53bef382ee4431203c770912209703452033d19fb71e813b" dependencies = [ "assoc", "bitvec", @@ -4066,9 +4067,9 @@ dependencies = [ [[package]] name = "shuttle-schedulers" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d23f8e974ad0772f4b5303c3d5060cede31225e985405e3b7917e75d4d7e602e" +checksum = "d7673a579e1660404067f5aa1344d6530f15b59f53b0fe98767d7ab117d14067" dependencies = [ "rand 0.8.7", "rand_pcg 0.3.1", @@ -4079,9 +4080,9 @@ dependencies = [ [[package]] name = "shuttle-std" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba1922b51cc6234d6488d5fb73dc3142020aab2e65c3e5b38bc8827b6dcdf74" +checksum = "bf4a3088fd2d19e5ebc24bc3779bc85a68228fafde2d87d138e475b8591dc721" dependencies = [ "assoc", "owo-colors", @@ -4824,9 +4825,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.24.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "js-sys", "wasm-bindgen", @@ -5311,9 +5312,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -5322,9 +5323,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 1563526..6035537 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,21 +23,21 @@ authors = ["Cevat Batuhan Tolon "] # The engine. `default-features = false` because each crate names what it # needs: the base wheel carries every feature a Python caller can reach, # the remote wheel only what a document needs to travel. -dynamic-config = { version = "0.9", default-features = false } +dynamic-config = { version = "0.10", default-features = false } # The store crates' shared internals: the redaction that keeps a password # out of a store's description, by the same rule the store crates use # rather than by a second copy of it. -dynamic-config-store-core = "0.9" +dynamic-config-store-core = "0.10" # The eight stores. Caret, because a wheel built today should be able to # pick up a store's patch release without this repository being re-cut. -dynamic-config-consul = "0.9" -dynamic-config-etcd = { version = "0.9", features = ["tls"] } -dynamic-config-firestore = "0.9" -dynamic-config-git = "0.9" -dynamic-config-nats = "0.9" -dynamic-config-redis = { version = "0.9", features = ["tls"] } -dynamic-config-s3 = "0.9" -dynamic-config-vault = "0.9" +dynamic-config-consul = "0.10" +dynamic-config-etcd = { version = "0.10", features = ["tls"] } +dynamic-config-firestore = "0.10" +dynamic-config-git = "0.10" +dynamic-config-nats = "0.10" +dynamic-config-redis = { version = "0.10", features = ["tls"] } +dynamic-config-s3 = "0.10" +dynamic-config-vault = "0.10" # A wheel is downloaded, not compiled by whoever installs it, so the # release profile is the one that matters. diff --git a/book/src/reference.md b/book/src/reference.md index 9b4497a..76b0cc2 100644 --- a/book/src/reference.md +++ b/book/src/reference.md @@ -543,6 +543,7 @@ instance carries `kind`, `path`, `origin_kind` and `origin`. | `InvalidError` | The configuration as a whole was rejected — Pydantic's report is on `.errors`, scrubbed of input values, and `[]` for a schema that raises a message rather than a report (a dataclass, a `msgspec.Struct`) | | `RemoteError` | A remote store could not be read — unreachable, refusing, malformed | | `AuthError` | A credential was rejected, or could not be obtained. Distinct from `RemoteError` on purpose: waiting fixes one and not the other | +| `AbsentError` | The store answered, and the document is not there — a key deleted, a path that never existed. Distinct from `RemoteError`, which is an outage waiting cures, and from `MissingError`, which is a key absent from a document that *was* read | | `DecryptError` | An encrypted source could not be decrypted | | `BackendError` | The engine refused — a source added after loading, for instance | | `NotInitialisedError` | `current()` before the first successful load | diff --git a/book/src/telemetry.md b/book/src/telemetry.md index 7364a32..5128335 100644 --- a/book/src/telemetry.md +++ b/book/src/telemetry.md @@ -56,7 +56,7 @@ does not fix its sources. | Field | Type | Means | |---|---|---| -| `kind` | `str` | The category: `io`, `parse`, `missing`, `type`, `env`, `invalid`, `remote`, `auth`, `decrypt`, `backend` — the names the [exception classes](reference.md#exceptions) carry | +| `kind` | `str` | The category: `io`, `parse`, `missing`, `type`, `env`, `invalid`, `remote`, `auth`, `absent`, `decrypt`, `backend` — the names the [exception classes](reference.md#exceptions) carry | | `path` | `str` | The dotted key path, empty when the failure belongs to the load as a whole | | `seconds_ago` | `float` | How long before the status was taken it was recorded | diff --git a/dynamic-config-python-remote/CHANGELOG.md b/dynamic-config-python-remote/CHANGELOG.md index 3d94173..376951f 100644 --- a/dynamic-config-python-remote/CHANGELOG.md +++ b/dynamic-config-python-remote/CHANGELOG.md @@ -30,6 +30,8 @@ is the door into this one. ## [Unreleased] +## 0.3.3 — 2026-08-23 + ## 0.3.2 — 2026-08-21 ### Changed diff --git a/dynamic-config-python-remote/Cargo.toml b/dynamic-config-python-remote/Cargo.toml index e990c70..f2a2b93 100644 --- a/dynamic-config-python-remote/Cargo.toml +++ b/dynamic-config-python-remote/Cargo.toml @@ -9,7 +9,7 @@ name = "dynamic-config-python-remote" # release that moves the base wheel and not this one is fine (the extra keeps # working); the reverse is not, which is why the compatibility floor is # declared in `pyproject.toml` rather than assumed. -version = "0.3.2" +version = "0.3.3" edition.workspace = true # The organisation's one 1.88 floor — originally forced here anyway by # `aws-sdk-sts`, `async-nats` and `redis`, measured rather than declared. diff --git a/dynamic-config-python-remote/pyproject.toml b/dynamic-config-python-remote/pyproject.toml index fc7de35..4be5268 100644 --- a/dynamic-config-python-remote/pyproject.toml +++ b/dynamic-config-python-remote/pyproject.toml @@ -55,7 +55,7 @@ classifiers = [ # remote path at all. It is not `==` for the reason the Rust crates are: a # wheel that pinned its sibling exactly would make every base-wheel patch a # forced upgrade of this one. -dependencies = ["dynamic-config-py>=0.3.2"] +dependencies = ["dynamic-config-py>=0.3.3"] dynamic = ["version"] [project.urls] diff --git a/dynamic-config-python/CHANGELOG.md b/dynamic-config-python/CHANGELOG.md index 5b9af78..a76cd96 100644 --- a/dynamic-config-python/CHANGELOG.md +++ b/dynamic-config-python/CHANGELOG.md @@ -26,6 +26,31 @@ breaking. ## [Unreleased] +## 0.3.3 — 2026-08-23 + +### Added + +- **`AbsentError`.** A store that answers "that path holds nothing" raises + its own class rather than the base one. Distinct from `RemoteError`, which + is an outage waiting cures, and from `MissingError`, which is a key absent + from a document that *was* read — the three deserve different answers, and + a program that cannot tell them apart serves a deleted secret forever. +- **`fingerprint()`** — a stable digest of the configuration installed, or + `None` before the first load. `sha256:…`, over the resolved tree rather + than any rendering of it, so two processes agree whether their files were + written as TOML or as YAML. + + **Safe to log**: every field named to `secrets=[..]` is masked by position + before hashing, so it moves when a secret appears or disappears and stays + put when one merely rotates — a digest that moved on rotation would be an + oracle for the value that moved it. It answers what + `status().generation` cannot: two processes on generation 4 have nothing + in particular in common. + +### Changed + +- The engine and the nine store crates move to **0.10**. + ## 0.3.2 — 2026-08-21 ### Changed diff --git a/dynamic-config-python/Cargo.toml b/dynamic-config-python/Cargo.toml index 5b21491..3841b91 100644 --- a/dynamic-config-python/Cargo.toml +++ b/dynamic-config-python/Cargo.toml @@ -13,7 +13,7 @@ name = "dynamic-config-python" # So this moves when *this* changes — a new Python API, a behaviour # change, or an engine bump worth shipping — and `maturin upload # --skip-existing` makes a release that changed nothing here a no-op. -version = "0.3.2" +version = "0.3.3" edition.workspace = true # Its own floor, not the workspace's 1.71: PyO3 needs more than the core does. rust-version = "1.88" diff --git a/dynamic-config-python/python/dynamic_config/__init__.py b/dynamic-config-python/python/dynamic_config/__init__.py index 325ffce..8007238 100644 --- a/dynamic-config-python/python/dynamic_config/__init__.py +++ b/dynamic-config-python/python/dynamic_config/__init__.py @@ -62,6 +62,7 @@ class Database: from . import _core from ._config import DynamicConfig from ._core import ( + AbsentError, AuthError, BackendError, DecryptError, @@ -108,6 +109,7 @@ class Database: __engine_version__: str = _core.__engine_version__ __all__ = [ + "AbsentError", "AsyncRemoteSource", "AuthError", "BackendError", diff --git a/dynamic-config-python/python/dynamic_config/_config.py b/dynamic-config-python/python/dynamic_config/_config.py index d0f03ce..52fbf8d 100644 --- a/dynamic-config-python/python/dynamic_config/_config.py +++ b/dynamic-config-python/python/dynamic_config/_config.py @@ -1552,6 +1552,25 @@ def snapshot(self) -> Snapshot: """The resolved section, without deserializing it into the model.""" return Snapshot(self._core.snapshot()) + def fingerprint(self) -> str | None: + """A stable digest of the configuration installed, or None. + + ``sha256:…``, computed over the resolved tree rather than any + rendering of it, so two processes agree whether their files were + written as TOML or as YAML. + + **Safe to log.** Every field named to ``secrets=[..]`` is masked by + position before hashing, so the digest moves when a secret appears + or disappears and stays put when one merely rotates — a digest that + moved on rotation would be an oracle for the value that moved it. + + What it is for is comparing two processes without comparing two + documents. ``status().generation`` counts *this* process's installs + and cannot answer that: two pods on generation 4 have nothing in + particular in common. + """ + return self._core.fingerprint() + # ── Telemetry ────────────────────────────────────────────────────── def status(self) -> ConfigStatus: diff --git a/dynamic-config-python/python/dynamic_config/_core.pyi b/dynamic-config-python/python/dynamic_config/_core.pyi index 79d5540..04c60cb 100644 --- a/dynamic-config-python/python/dynamic_config/_core.pyi +++ b/dynamic-config-python/python/dynamic_config/_core.pyi @@ -86,6 +86,7 @@ class Config: def explain(self, path: str) -> dict[str, Any]: ... def check(self) -> dict[str, Any]: ... def snapshot(self) -> Snapshot: ... + def fingerprint(self) -> str | None: ... def release(self) -> None: ... # Telemetry def status(self) -> dict[str, Any]: ... @@ -131,6 +132,7 @@ class InvalidError(DynamicConfigError): class RemoteError(DynamicConfigError): ... class AuthError(DynamicConfigError): ... +class AbsentError(DynamicConfigError): ... class DecryptError(DynamicConfigError): ... class BackendError(DynamicConfigError): ... diff --git a/dynamic-config-python/src/config/mod.rs b/dynamic-config-python/src/config/mod.rs index 609993b..dab50d4 100644 --- a/dynamic-config-python/src/config/mod.rs +++ b/dynamic-config-python/src/config/mod.rs @@ -1249,6 +1249,30 @@ impl Config { .unbind()) } + /// A stable digest of the configuration installed, or `None` before + /// the first load. No parameters. + /// + /// `sha256:…`, over the resolved tree rather than any rendering of it, + /// so two processes agree whether their files were written as TOML or + /// as YAML. **Safe to log**: every field named to `secrets=[..]` is + /// masked by position before hashing, so the digest moves when a secret + /// appears or disappears and stays put when one merely rotates — a + /// digest that moved on rotation would be an oracle for the value that + /// moved it. + /// + /// What it is for is comparing two processes without comparing two + /// documents. `status().generation` counts *this* process's installs + /// and cannot answer that: two pods on generation 4 have nothing in + /// particular in common. + fn fingerprint(&self, py: Python<'_>) -> PyResult> { + let inner = Arc::clone(&self.inner); + let dynamic = self.inner.dynamic(py, &inner)?; + + Ok(dynamic + .fingerprint() + .map(|fingerprint| (*fingerprint).clone())) + } + /// The resolved section as data, without the model. No parameters. fn snapshot(&self, py: Python<'_>) -> PyResult { let inner = Arc::clone(&self.inner); diff --git a/dynamic-config-python/src/errors.rs b/dynamic-config-python/src/errors.rs index d81734c..ed0dc97 100644 --- a/dynamic-config-python/src/errors.rs +++ b/dynamic-config-python/src/errors.rs @@ -74,6 +74,16 @@ create_exception!( Distinct from `RemoteError`, which is the store being unreachable: \ waiting will not fix this one." ); +create_exception!( + _core, + AbsentError, + DynamicConfigError, + "The store answered, and the document is not there.\n\n\ + Distinct from `RemoteError`, which is the store being unreachable, and \ + from `MissingError`, which is a key absent from a document that was \ + read. Waiting fixes an unreachable store; waiting does not bring back a \ + deleted secret." +); create_exception!( _core, DecryptError, @@ -98,6 +108,7 @@ fn class_for(py: Python<'_>, kind: ErrorKind) -> Bound<'_, PyType> { ErrorKind::Invalid => py.get_type::(), ErrorKind::Remote => py.get_type::(), ErrorKind::Auth => py.get_type::(), + ErrorKind::Absent => py.get_type::(), ErrorKind::Decrypt => py.get_type::(), ErrorKind::Backend => py.get_type::(), // `ErrorKind` is `#[non_exhaustive]`: a kind this binding predates @@ -182,6 +193,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add("InvalidError", module.py().get_type::())?; module.add("RemoteError", module.py().get_type::())?; module.add("AuthError", module.py().get_type::())?; + module.add("AbsentError", module.py().get_type::())?; module.add("DecryptError", module.py().get_type::())?; module.add("BackendError", module.py().get_type::())?; diff --git a/dynamic-config-python/tests/test_fingerprint.py b/dynamic-config-python/tests/test_fingerprint.py new file mode 100644 index 0000000..beb9472 --- /dev/null +++ b/dynamic-config-python/tests/test_fingerprint.py @@ -0,0 +1,122 @@ +"""`fingerprint()`, and the two properties that make it printable. + +The Rust suite's `tests/fingerprint.rs` pins these on that side. Asked +again here because the binding is where a fleet actually reads it: a pod +prints a string, another pod prints a string, and either they match or +one of them is running a configuration nobody meant to ship. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dynamic_config import DynamicConfig, Values + +SECRET = "hunter2-planted-secret" + + +def built(path: Path) -> DynamicConfig[Values]: + config = DynamicConfig(Values, "svc", secrets=["password"]) + config.file(str(path)) + config.init() + + return config + + +@pytest.fixture +def written(tmp_path: Path): + def write(name: str, body: str) -> Path: + path = tmp_path / name + path.write_text(body, encoding="utf-8") + + return path + + return write + + +def test_the_same_configuration_fingerprints_the_same_in_any_format(written) -> None: + """Two hosts, one configuration, two formats. + + A digest that disagreed here would report drift between hosts that are + configured identically and merely chose different formats. + """ + as_json = written( + "config.json", + json.dumps({"svc": {"host": "db.internal", "port": 5432, "password": SECRET}}), + ) + # Different syntax, different key order, the same configuration. + as_toml = written( + "config.toml", + f'[svc]\nport = 5432\npassword = "{SECRET}"\nhost = "db.internal"\n', + ) + + assert built(as_json).fingerprint() == built(as_toml).fingerprint() + + +def test_rotating_a_secret_does_not_move_it(written) -> None: + """The property that makes it printable. + + A digest that changed when a password changed would let anyone holding a + candidate password confirm it against a logged fingerprint. + """ + before = written( + "before.json", + json.dumps({"svc": {"host": "db.internal", "password": SECRET}}), + ) + after = written( + "after.json", + json.dumps({"svc": {"host": "db.internal", "password": "correct-horse"}}), + ) + + assert built(before).fingerprint() == built(after).fingerprint() + + +def test_changing_anything_else_moves_it(written) -> None: + """And the property that makes it useful.""" + before = written( + "before.json", + json.dumps({"svc": {"host": "db.internal", "port": 5432, "password": SECRET}}), + ) + after = written( + "after.json", + json.dumps({"svc": {"host": "db.internal", "port": 6432, "password": SECRET}}), + ) + + assert built(before).fingerprint() != built(after).fingerprint() + + +def test_it_names_its_algorithm_and_carries_no_value(written) -> None: + path = written( + "config.json", + json.dumps({"svc": {"host": "db.internal", "password": SECRET}}), + ) + + fingerprint = built(path).fingerprint() + + assert fingerprint is not None + digest = fingerprint.removeprefix("sha256:") + + assert digest != fingerprint, "a fingerprint names its algorithm" + assert len(digest) == 64 + assert SECRET not in fingerprint + + +def test_there_is_no_fingerprint_before_the_first_load(written) -> None: + """Absence rather than a digest of nothing. + + The digest of an empty document would compare equal across every process + that had not started yet. + """ + path = written("config.json", json.dumps({"svc": {"host": "db.internal"}})) + + config = DynamicConfig(Values, "svc", secrets=["password"]) + config.file(str(path)) + + assert config.fingerprint() is None + + config.init() + + assert config.fingerprint() is not None diff --git a/scripts/conformance.py b/scripts/conformance.py index b280086..650323a 100644 --- a/scripts/conformance.py +++ b/scripts/conformance.py @@ -38,7 +38,9 @@ def run_case(case: pathlib.Path) -> str | None: os.environ[key] = value try: - config = DynamicConfig(Values, key=args["key"]).file(str(case / "config.toml")) + config = DynamicConfig( + Values, key=args["key"], secrets=args.get("secrets", ()) + ).file(str(case / "config.toml")) if prefix := args.get("env_prefix"): config = config.env(prefix) @@ -63,6 +65,11 @@ def run_case(case: pathlib.Path) -> str | None: config.alias(old, new) resolved = plain(config.init_and_current()) + # Only where a case pins one: every other case is a document + # comparison and asking for a digest it does not check would make + # this runner test the binding rather than the suite. + pinned = args.get("expected_fingerprint") + fingerprint = config.fingerprint() if pinned else None finally: for key in env: del os.environ[key] @@ -70,6 +77,9 @@ def run_case(case: pathlib.Path) -> str | None: if resolved != expected: return f"resolved {json.dumps(resolved, sort_keys=True)} but expected {json.dumps(expected, sort_keys=True)}" + if pinned is not None and fingerprint != pinned: + return f"fingerprinted {fingerprint} but expected {pinned}" + return None