Verify HTTP file sources by digest when the server sends no cache-validation headers - #582
Conversation
bea2b72 to
deb51b5
Compare
|
Should this be labeled |
|
If I'm reading the change here correctly, it would be backwards incompatible. The current behavior results in a file resource generating a change event every run, but also in any new file content being downloaded. If this change halts the downloads in addition to the change events, then it is a decisive break in behavior. |
|
Also, if this halts both downloads and change events, then that behavior is already accessible today by setting |
Agreed this is backwards incompatible and should be labeled accordingly. One correction on the mechanics: downloads don't stop, and new content is still applied. With no validators from the server, this branch downloads the full body on every run and hashes it as it streams by. Changed content is still detected, written, and notify still fires, exactly as today. The only thing that stops is the rewrite and notify when the downloaded bytes match what's already on disk. So network cost in the unchanged case is the same as main, one full-body download per run, and the changed case still converges. The PR description now has a before/after table covering each server-header shape, including the rows where nothing changes. |
The gap it fills is origins that send no validators at all, like the Artifactory case in #581. There is no |
Puppet::FileServing::HttpMetadata fabricated Time.now as a fake mtime whenever an HTTP(S) file source's HEAD response gave no Last-Modified, ETag, or checksum header. Since mtime is always the last resort in the checksum fallback chain, this made such a source look "changed" on every single compile, forever, regardless of checksum type requested, because the fabricated timestamp trivially compares as newer than whatever's already on disk. Any file resource pointed at an origin that sends no validators (e.g. Artifactory behind Cloudflare, per puppetlabs/puppet#9553) rewrites the file and fires notify/subscribe on every run. Fall back to :none (unverifiable, assume unchanged) instead. To avoid trading that false positive for silently never detecting a real remote change, the http file_metadata terminus now earns a real checksum when the caller asked for one: if headers give nothing usable and the requested checksum type is a real digest (the default, or any explicit type other than mtime/ctime/none), it downloads the body once and hashes it as it streams by. This does not fix a server whose Last-Modified header itself changes on every request despite unchanged content -- that still resolves to :mtime, not :none, so the verification path never triggers. There's no way to tell a lying header from a truthful one without also earning a checksum whenever any header is present, which would erase the point of HEAD-based metadata for the common case. Confirmed this boundary holds with a Docker-based puppet apply run against a hand-rolled HTTP server that can withhold or churn headers on demand. Also brings the source/checksum parameter docs in line with this and with checksum => etag (OpenVoxProject#329), which predates this fix but was never documented: they still described the old, buggy Last-Modified-or- nothing behavior as intended, and never mentioned etag at all. Three correctness issues in an earlier version of this change, caught in review: - A failed or errored verification GET returned the unverifiable :none metadata rather than surfacing the failure, so a transient network problem while trying to earn a checksum would silently look like "no changes" instead of a failed run. A non-success response now returns nil (not found, same as every other failure branch in this method); a raised error (network, TLS, etc.) is no longer rescued and propagates like the existing HEAD request already does. - The earned checksum was cached in a Tempfile on the metadata object so a subsequent rewrite could reuse it instead of downloading twice. But that Tempfile was only ever closed when a rewrite actually happened; the far more common unchanged case left it open until GC, which a long-running agent process (not the one-shot puppet apply the Docker harness exercises) could accumulate across many catalog runs. Dropped the caching: the body is hashed and discarded in place, and a rewrite -- when the (uncommon) case of real content change needs one -- downloads it again, exactly as before this change. - checksum => etag with no resolvable ETag fell back to a hardcoded :md5, breaking under FIPS the same way the existing, older fallback in Puppet::Type::File::Checksum#digest_algorithm already did (added in OpenVoxProject#329, pre-dating this fix). Both now fall back to Puppet[:digest_algorithm], which is always FIPS-safe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Michael Harp <mike@mikeharp.com>
Review feedback on the :none fallback surfaced two gaps in the explicit
checksum => mtime/ctime opt-out path for HTTP sources with no usable
validation headers:
- The opt-out was silently degrading into "this file can never be
detected as changed". The terminus now logs a warning saying exactly
that, on every run. An explicit checksum => none (or a request with no
checksum type) stays quiet, since "don't verify" is precisely what was
asked for.
- The opt-out didn't actually work: copy_source_value assigned the
content ('{none}') before updating the checksum param from the
requested type to the metadata's resolved type. The content munge does
not recognize the bare '{none}' as a checksum, so it summed it with
the stale requested type (e.g. mtime), producing a desired value of
'{mtime}' that can never match the retrieved '{none}' -- rewrite plus
notify on every run, the very bug this branch fixes elsewhere. Caught
by the end-to-end Docker repro, not the unit suites; a regression spec
now covers the ordering.
Also documents the fix boundary in the source parameter docs: a
Last-Modified header is always trusted when present, even if the server
regenerates it spuriously -- only the complete absence of validators
triggers the download-and-digest verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Michael Harp <mike@mikeharp.com>
The example "should fetch if no header specified" encoded the fabricated Time.now mtime contract: a checksum => mtime resource against a source with no usable validation headers was expected to re-download on every run. Under this branch that combination is treated as unchanged with a warning, so the example now asserts the file is left alone and the warning is logged. The VCR cassette is renamed to match the new example description. This spec kept passing on the previous push only because of the copy_source_value ordering bug fixed in the prior commit, which forced the fetch the example expected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Michael Harp <mike@mikeharp.com>
689face to
42d8aaa
Compare
Fixes #581.
AI disclosure: this contribution (investigation, code, tests, docs, and this description) was produced with substantial assistance from Claude (Anthropic), per the project's AI usage policy. I've reviewed it and take full responsibility for it.
Summary
A
fileresource with anhttp(s)sourcepointed at a server that sends noLast-Modified,ETag, or checksum header at all — or sends anETagthat's never consulted becausechecksum => etagwasn't requested — is treated as "changed" on every single run, forever.Puppet::FileServing::HttpMetadatafabricates the current wall-clock time as a fake mtime whenever headers give nothing usable, and since:mtimeis the last resort in the checksum fallback chain, the comparison is guaranteed to conclude "changed" independent of whether the remote content actually changed. This firesnotify/subscribe(e.g. restarting a service) on every Puppet run against origins like Artifactory behind a caching proxy, or plainraw.githubusercontent.comsources.See #581 for the full root-cause writeup, real-world reproduction, and a link to a self-contained Docker repro.
Behavior changes at a glance
Rows marked unchanged behave identically before and after this PR. "Before" is current
main; all runs assume the remote content did not change unless stated.checksumon the resourceETag, noLast-Modified(Artifactory behind a caching proxy)notifyfires every runETagonly, noLast-Modified(raw.githubusercontent.com)checksum => etag)notifyfires every run — theETagis never consulted without opt-inETagetagETag(#329), but the no-match fallback hardcoded md5 — broken under FIPSPuppet[:digest_algorithm], which is FIPS-safeLast-ModifiedLast-Modifiedthat churns on every request despite unchanged contentnotifyevery runX-Checksum-Sha256/-Sha1/-Md5orContent-MD5headermtimeorctimenotifyevery runnonenotifyevery run (the fabricated mtime applied even to an explicitnone)Note the change is invisible to network cost in the common cases: the unchanged-content case downloads the body once per run either way (before: as the content fetch that rewrote the file; after: as the verification stream that's hashed and discarded). Only the actually-changed case costs one extra request, and
replace => falseis not an available workaround for any of the fixed rows — it also ignores real remote changes forever, which is precisely what this fix preserves.What changed
lib/puppet/file_serving/http_metadata.rb— no more fabricatedTime.now. No usable header now resolves to:none("unverifiable, assume unchanged" — the same semanticschecksum => nonealready has elsewhere) instead of a checksum guaranteed to differ every run. Adds#verify!, called by the terminus below to override that:noneverdict with a real, earned digest.lib/puppet/indirector/file_metadata/http.rb— when metadata resolves to:noneand the resource asked for a real digest (the default, or any explicit type other thanmtime/ctime/none), downloads the body once and hashes it as it streams by, discarding the bytes. If a rewrite turns out to be needed (content actually changed), the normal content fetch downloads it again — one extra request only in the uncommon changed case, and no open file handles for a long-running agent to accumulate in the common unchanged case. A failed verification GET is a real failure, not "unchanged": a non-success response returns nil (not found, same as every other failure branch in#find— which also preserves next-source fallback forsource => [...]arrays), and a raised network error propagates, exactly like the existing HEAD request already behaves. When an explicitchecksum => mtime/ctimemeets a server with no time header, the terminus now logs a warning that a file from this source can never be detected as changed, rather than degrading silently into never-update mode.lib/puppet/type/file/source.rb— fixes a latent ordering bug incopy_source_valuethat the new:nonepath exposed: the content ('{none}') was assigned before the checksum param was updated from the requested type to the metadata's resolved type, so the content munge summed it with the stale type (e.g.mtime), producing a desired value ('{mtime}') that could never match the retrieved'{none}'— rewrite + notify every run, the very bug this PR fixes elsewhere. Found by scenario 8 of the end-to-end Docker repro (below), invisible to the unit suites; a regression spec now pins the ordering. Also brings thesourceparameter docs in line with actual behavior: they still described the old, buggyLast-Modified-or-nothing fallback as intended, never documentedchecksum => etagat all (which shipped in Feature: file etag support #329), and now state the fix boundary explicitly.lib/puppet/type/file/checksum.rb— documentschecksum => etag(same pre-existing gap), and fixes the pre-existing:etagfallback (also from Feature: file etag support #329) that hardcoded:md5when no ETag-derived type resolves — which breaks under FIPS. Both that fallback and the terminus's equivalent now usePuppet[:digest_algorithm], which is always FIPS-safe, and — since the local and remote sides of the comparison must agree on an algorithm — fixing one without the other would have made them diverge.Explicitly not fixed: a server whose
Last-Modifiedheader itself changes on every request despite unchanged content (e.g. a dynamic backend behind a caching proxy). That still resolves to:mtime, not:none, so the verification path never triggers — the gate only earns a checksum when there's no header to trust, not when there's an untrustworthy one. Distinguishing the two would mean paying for a full download on every apply for any HTTP source with aLast-Modifiedheader at all, including the well-behaved majority where it's perfectly reliable. This boundary is now documented in thesourceparameter docs, not just here.Known minor cost: a resource combining
checksum_valuewith a headerlesshttp(s)source still pays for the verification download even though thechecksum_valuecomparison doesn't use the metadata checksum — the terminus only seeschecksum_typein the request options. Rare combination; threadingchecksum_valuethrough the indirection felt like scope creep for this fix.Testing
Unit specs updated/added in
spec/unit/file_serving/http_metadata_spec.rb,spec/unit/indirector/file_metadata/http_spec.rb, andspec/unit/type/file/source_spec.rb, including coverage for: earning a checksum via GET, themtime/ctime/none/unspecified opt-outs (asserted by omitting the GET stub, so WebMock fails the example if a request sneaks through), the warning firing formtime/ctimeand staying silent fornone/unspecified, a failed verification GET returning nil, a raised network error propagating, the FIPS-safeetagfallback, and a regression spec for thecopy_source_valueordering (verified to fail against the unfixed code).For an end-to-end check beyond mocks, see miharp/openvox#1 (not for merge) — a Docker harness that runs real
puppet applyagainst a hand-rolled HTTP server able to withhold, churn, or fail cache-validation responses on demand, asserting notify behavior and exact--detailed-exitcodesexit codes across eight scenarios, with console output from both an unpatched run (bug reproduces) and this branch (resolved). Scenario 7 proves the failure-propagation property end-to-end: HEAD succeeding with no validators followed by a failing verification GET produces a failed run (exit 4), not a silently clean one. Scenario 8 covers thechecksum => mtimeopt-out (unchanged + warning) — and caught thecopy_source_valueordering bug on its first run, which the 1,000+ unit examples had missed.In draft while the fix boundary described above (only
:nonetriggers verification, not an untrustworthy-but-present header) and the backwards-compatibility labeling are under discussion.