Conversation
Add an opt-in 'earthdata' access mode to the typed virtual chunk
authorization config: an entry like
{"s3://asdc-prod-protected/": {"earthdata": true}} exchanges the
service's Earthdata Login identity for the DAAC's temporary S3
credentials via earthaccess-auth's CMR-derived bucket registry and hands
icechunk a refreshable credential that renews as the ~1h STS credentials
expire. Earthdata credentials apply to virtual chunk containers only;
icechunk stores and zarr/NetCDF sources keep using the service's ambient
credentials. Entries are validated at startup (unregistered buckets fail
the deploy; 'earthdata' is exclusive with other access options).
EDL identity sources, in precedence order: usable ambient EARTHDATA_*
variables, .netrc, or TITILER_MULTIDIM_EARTHDATA_SECRET_ARN pointing at
a Secrets Manager secret (plain token string or JSON with EARTHDATA_*
keys; plain secret names accepted as well as ARNs). The secret is
resolved lazily at first use (SnapStart-safe) and re-read every 10
minutes, rebuilding the shared credential manager when it changed, so
rotating the ~60-day EDL token needs neither a redeploy nor a restart.
A failed fetch is retried rather than latched, and a failed refresh
keeps serving on the still-valid identity. Client-facing errors never
contain the ARN, AWS errors, or EDL responses; details go to logs.
Credentials are fetched eagerly in Python before icechunk's Rust layer
runs, so failures stay typed: an unaccepted DAAC EULA surfaces as HTTP
403 with the EULA URLs, a missing EDL identity as a clean 500. The
Redis dataset cache key now fingerprints the authorization config so
authorization changes take effect immediately, and cache hits for
earthdata-authorized datasets establish the EDL identity before
unpickling (fixes fresh Lambda environments sharing a cache).
Deployment: new earthdata_secret_arn stack setting (reader role needs
secretsmanager:GetSecretValue); must run in us-west-2 (Earthdata S3
credentials are region-locked). The dev deploy authorizes ASDC as the
first production use. New dependency: earthaccess-auth 0.2.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on the earthdata feature (efda9ef), most-severe first: - Scope earthdata coupling to containers a repo actually declares: EDL identity and credential priming move out of to_credential (now pure local construction) into opener_icechunk, which intersects the configured earthdata entries with repo.config.virtual_chunk_containers via the new chunk_access.earthdata_endpoints(). A public icechunk store — or any plain zarr/NetCDF request — no longer touches Secrets Manager, EDL, or EULA state just because an earthdata entry exists in the service config. - Prime the cache-hit path: the opener records the endpoints it primed in ds.encoding (pickled with the dataset, never served in responses), and _open_cached re-primes exactly those on a hit, so a fresh worker unpickling from shared Redis surfaces typed 403/500 errors instead of opaque Rust-wrapped storage errors. Replaces the request-wide _has_earthdata_entries ensure. - Never fail requests on a bad rotation: applying a rotated secret (parse, export, EDL re-login) now restores the previous identity and backs off _RETRY_INTERVAL on failure instead of raising with no backoff after having popped the warm identity. - Swap EARTHDATA_* env vars write-before-remove (_swap_env), so a concurrent reader never observes an empty identity mid-rotation. - Skip falsy secret values: an empty EARTHDATA_TOKEN no longer shadows a working username/password with an empty bearer token, and JSON null no longer exports the literal string "None"; blank secrets raise. - Sanitize client-visible auth errors: S3CredentialsRequestFailure (403) now returns a fixed message pointing at the EDL EULA/application pages instead of echoing up to 1000 chars of the DAAC's raw response body, and LoginAttemptFailure (raw EDL response body) is mapped to a sanitized 500 instead of falling through to the str(exc) catch-all. - Drop the hand-rolled registry re-check in to_credential in favor of earthaccess-auth's own typed S3CredentialsEndpointUnresolved. - Scope the icechunk-builder parity test's 'earthdata' exemption to S3ChunkAccess, so an earthdata field added to another model without a matching model_dump exclude fails CI instead of at request time. - Repair tests/test_app.py: the merge of main (8562b7a) left test_errors_not_cacheable indented inside the previous test, an IndentationError that broke collection of the whole module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 122398b, from a second adversarial review pass: - Reject partial-identity secrets: _parse_secret now requires a usable pairing (EARTHDATA_TOKEN, or both EARTHDATA_USERNAME and EARTHDATA_PASSWORD), mirroring _usable_env_identity. Previously a username-without-password secret exported cleanly, then latched a broken identity until the secret *content* changed (the unchanged secret short-circuits _apply_secret on every refresh), failing every request with a misleading error. - Back off failed first loads: both first-load failure paths (Secrets Manager fetch failure, unusable secret) now set _RETRY_INTERVAL before raising, so a misconfigured deployment fails fast for 60s instead of issuing a fresh GetSecretValue per request serialized on the module lock. Calls inside the window return without fetching; credential use fails downstream with a typed, sanitized error. - Don't stall concurrent requests behind a refresh fetch: the deadline is pushed to now+_RETRY_INTERVAL before the network call, so requests arriving mid-refresh take the pre-lock fast path on the still-valid warm identity instead of queueing behind a possibly-hung Secrets Manager call. - Correct _swap_env's docstring: it guarantees a never-empty environment, not an atomic swap; a racing reader can briefly see a mixed identity (one transient, self-healing failure). Marked as a deliberate ceiling. - Parse the chunk-access config once per open: opener_icechunk parses and hands the models to both build_virtual_chunk_access and earthdata_endpoints (which now takes parsed entries). - Drop the dead prefix parameter from Gcs/Azure to_credential; only the S3 model resolves its prefix, and build_virtual_chunk_access branches at the single call site. - Make the Redis cache entry an explicit (endpoints, dataset) pair: the cross-process re-prime contract lives in the cache layer instead of being smuggled through ds.encoding across pickle (encoding remains only the opener->reader in-process handoff, popped at cache write). - Remove the [options.exclude-newer-package] earthaccess-auth override from uv.lock: it bypassed the package-release cooldown. The locked 0.2.0 entry is unchanged and installs via `uv sync --locked`; re-locking will admit the release once the cooldown passes. - Extract _secret_arn_unless_latched from ensure_earthdata_credentials (complexity limit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third review round found seven correctness issues surviving from the original feature commit (efda9ef), all in paths the earlier rounds never exercised, plus three cleanups. This commit, with the companion earthaccess-auth branch fix/s3credentials-status-and-lock-granularity (status-aware S3CredentialsRequestFailure; per-endpoint fetch locks in S3CredentialManager), addresses all ten: - Sanitize the Rust-side error path: icechunk stringifies exceptions raised inside its credential callbacks (the steady-state refresh at chunk-read time), chaining raw DAAC bodies into IcechunkError. A new handler logs the full text and serves fixed messages, keeping wrapped S3CredentialsRequestFailure identifiable (EULA 403; "status 401" -> service-credential 500). - Distinguish service-side credential rejection from EULA problems: a 401 from the s3credentials endpoint (expired ~60-day token, bad secret) now returns a sanitized 500 that alarms as a server error, instead of telling end users to accept EULAs. - Validate token-shaped rotations: EDL marks any non-empty token authenticated without a network call, so _rebuild_default_auth now probes one configured earthdata endpoint; a definitive 401 rejects the identity (rotation rolls back to the previous one; first load raises with backoff). EULA 403s, outages, or no earthdata entries never reject. - Make the configured secret authoritative: a stale-but-truthy ambient EARTHDATA_TOKEN no longer latches permanently and silently disables rotation; ambient credentials remain the fallback while the secret is unreachable and win outright when no ARN is configured. - Rebuild the default manager on every successful load (first load included), so an identity cached during a backoff window (netrc, ambient) can never keep shadowing the secret's identity. - Treat SecretBinary-only secrets as a typed failure with backoff (the SecretString subscript moved inside the guarded fetch). - Decode JSON string-scalar secrets to the intended token instead of exporting quote-wrapped garbage that latches. - Pop the ds.encoding endpoints marker unconditionally so it never rides datasets served with caching disabled. - build_virtual_chunk_access now takes parsed entries (config parsed exactly once per open, matching the comment), and the process-constant default authorization fingerprint is computed once, not per request. - ensure_earthdata_credentials fetch-failure handling extracted to _on_fetch_failure (complexity limit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_probe_identity validated a candidate identity with a raw, uncached fetch_s3_credentials call, then set_default_auth built a cold manager — so every cold process hit the DAAC s3credentials endpoint twice (probe, then prime). Build the S3CredentialManager first, probe through manager.get_credentials, and install that same manager via the new earthaccess-auth set_default_manager: a successful probe now leaves the credentials cached where prime_earthdata_endpoints and icechunk's refresh callable read them — one fetch per endpoint per cold process. Probe-before-install ordering is unchanged, so a 401-rejected identity still never becomes the default manager. Requires earthaccess-auth with set_default_manager (branch fix/s3credentials-status-and-lock-granularity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch depends on 0.3.0's set_default_manager and the status_code attribute on S3CredentialsRequestFailure; with 0.2.x the app fails at identity-rebuild time with an ImportError. 0.3.0 is now on PyPI, so pin it instead of shimming around the gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing smoke cases read public, from_env, or anonymous stores, so the earthdata chain this branch adds (Secrets Manager secret -> EDL login -> identity probe -> s3credentials -> virtual chunk reads) was never exercised after a deploy. One TEMPO NO2 tile from the ASDC protected bucket covers it end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers what to check by hand once a deploy-dev deployment is live: finding the endpoint, the ordered TEMPO checks (metadata needs IAM only, tiles need the full EDL chain), how to read the 403/500 failure mappings, the two cache layers behind freshness, and the map viewer. Linked from the README's deployment section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two dev-deployment failures triaged this session were both config, not code, and neither was findable from the guide: - an empty-extension NotImplementedError, which is really identify_storage_backend listing a prefix and finding nothing - LoginStrategyUnavailable, which is really a Lambda deployed without TITILER_MULTIDIM_EARTHDATA_SECRET_ARN (an unset Actions variable expands to "" and model_post_init then omits the env var) Adds both to the failure-mode table, plus a section on verifying the externally managed reader role's grants: assume-role (the only check that sees bucket and KMS key policies), simulate-principal-policy with its blind spots, and CMK detection. Records that the role needs nothing on asdc-prod-protected -- those chunk reads use DAAC temporary credentials, not ambient IAM. Also notes that /variables never uses the Redis dataset cache, so it disagrees with /tiles when a store moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TEMPO smoke case and the manual smoke-testing guide passed `sel_method=nearest` alongside an exact `sel=time=...`. That parameter was removed in the titiler 2.2 upgrade (c17dfa7); FastAPI drops unknown query parameters silently, so the requests were asking for an exact timestamp match rather than the nearest scan. Fold the method into the selector as `sel=time=nearest::<value>`, matching the syntax documented in the README and already used by the MUR SST zarr case.
main removed the Redis dataset cache and VPC (#149) and added mosaic support (#140, #138 release 0.8.0). Conflict resolutions: - reader.py: kept the earthdata priming in opener_icechunk (now also reusing main's `containers` local for its per-container logging) and main's timing/structured logs. Dropped everything that existed only to serve the Redis cache: `_open_cached`, the cache-key fingerprint helpers, and the `ds.encoding["earthdata_endpoints"]` handoff, whose sole consumer was the cache-hit re-prime path. - main.py: kept the earthdata exception handlers; dropped the now-unused `Depends` import, which only fed the removed `/clear_cache` endpoint. - tests/test_reader.py: main deleted the file (it was all cache tests); kept it with this branch's non-cache tests and dropped the cache and encoding-marker assertions with the code they covered. - deploy-dev.yml: union of both authorized-chunk-access entries (main's noaa-nws-naqfc-pds, this branch's asdc-prod-protected) plus the earthdata secret ARN env var. - README/docs: removed the Redis cache and /clear_cache paragraphs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a66aba1 raised the floor to >=0.3.0 but left uv.lock pinning 0.2.0, so `uv lock --check` failed. Regenerated with the release visible to the resolver; only the earthaccess-auth entry and its specifier move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ror bodies Switch the walkthrough to the HCHO trial store with a store-derived scan time instead of a hardcoded one, add warm-path/second-tile and credential-refresh checks, correct the empty-extension failure to the 501 it actually returns, and make test_deployment.py include the response body when a request fails (str(HTTPError) is only the status).
0.3.1 turns the bare JSONDecodeError from a TEA s3credentials endpoint serving a non-JSON body (observed: ASDC returning its EDL login page with a 200) into S3CredentialsRequestFailure carrying the status code and the first 500 bytes of the body, so the deployed service logs what the endpoint actually sent instead of an opaque 'Expecting value: line 1 column 1 (char 0)' 500.
The automated smoke test exercised the earthdata credential chain through the NO2 store only; HCHO is a separate icechunk repository with its own time axis and variable set, so a regression limited to one store could pass unnoticed. Request a tile from both.
The text-block URL invited pasting into a shell, where unquoted & is a parse error and <T> is redirection. Reuse the $TILE query from step 3 in a quoted echo/open command instead.
…ON handoff Parameterize the TEMPO walkthrough over both stores (TEMPO/VAR/CMAP blocks, run once per store) instead of HCHO-only prose. Switch rendering to rescale=0,3e16 with viridis (HCHO) / magma_r (NO2), matching the GIBS palettes Worldview uses (both run 0-3.0e16 molecules/cm2), so tiles are visually comparable to the reference. Add a Worldview sanity-check section with verified layer IDs and a permalink built from the tested scan time, and a TileJSON section with the tilejson.json request, an example response, and the caveats frontends need (baked-in sel time, store prefix lifetime).
Multidim stores carry companion variables that qualify the science
variable (TEMPO L3: main_data_quality_flag, eff_cloud_fraction next to
vertical_column), but the API rendered the raw variable with no way to
filter by them -- so tiles show data where quality-filtered reference
imagery (GIBS/Worldview) shows gaps.
Add where={variable}{op}{number} (repeatable, ANDed) to the reader
dependency: each condition variable is extracted with the same sel as
the main variable, compared against the numeric literal, and the
combined mask applied with DataArray.where, so failing pixels follow
the existing nodata path (transparent tiles, null points, excluded from
statistics). Malformed conditions, unknown variables, sel-incompatible
or extra-dimensioned condition variables all raise BadRequestError
(400). Applied in the reader so every endpoint gets it uniformly, and
tilejson.json forwards it into the tile template for map clients.
Spec: 2026-08-31-1746-where-parameter-spec (session deliverables).
hrodmn
left a comment
There was a problem hiding this comment.
@maxrjones thanks for adding this feature - it is a cool solution to a problem that I have grappled with in the past: how to properly apply a custom mask in a titiler request! I have tried a few things and the only workaround I have found with the current state of things in rio-tiler/titiler is to use the expression parameter to recode the values that I want to mask with numpy.where then provide a custom colormap that excludes the recoded mask values (example notebook). This works for some limited use-cases but is not very robust because the mask happens after warping/resampling so should really just be used with the nearest resampling method.
The where parameter is great because it applies the mask before warping/resampling. I would like to see this feature ported upstream to rio-tiler or titiler someday!
I don't love all of the dask/xarray glue code to keep everything lazy 😎 but I had to do some similar gymnastics in titiler-cmr's custom expression parser for the XarrayBackend so I get it. I don't have a great understanding of how/when to bring dask in to maintain laziness so this was helpful to see.
I will do a line-by-line review but just want to provide my initial thoughts.
| # Prune botocore's service models (~18 MB of JSON for 400+ services) down to | ||
| # the services this app uses: s3 (s3fs/aiobotocore), sts (role assumption), | ||
| # secretsmanager (Earthdata credentials). Top-level files (endpoints.json etc.) | ||
| # are kept — the loaders need them. A new boto3/aiobotocore client for another | ||
| # service raises UnknownServiceError at runtime until its model is re-added here. | ||
| find botocore/data -mindepth 1 -maxdepth 1 -type d \ | ||
| ! -name s3 ! -name sts ! -name secretsmanager -print0 | xargs -0 rm -rf | ||
| # dask[array] ships the full dask wheel; the dataframe and bag subpackages | ||
| # (~1.7 MB) are never imported by the array-only chunk manager xarray uses. | ||
| rm -rf dask/dataframe dask/bag |
There was a problem hiding this comment.
did you hit Lambda package size limitations that spurred these changes?
There was a problem hiding this comment.
yes, just adding dask put it ~5MB above the limit 😞
|
Hi @maxrjones. Sorry for the delay, but I'm reviewing now. I have some suggestions, so give me some time to construct some clear comments. |
chuckwondo
left a comment
There was a problem hiding this comment.
@maxrjones, I put a few suggestions inline, but my main suggestion is too complex to describe across inline PR comments, so I created a gist: https://gist.github.com/chuckwondo/b151f7f92d54cf01c62da2939238d62b
Co-authored-by: Chuck Daniels <cjdaniels4@gmail.com>
Rename _WHERE_OPS -> _WHERE_PREDICATE_BY_OP (it is a mapping, not a list) and _WHERE_CONDITION -> _WHERE_CONDITION_RE (it is a compiled regex, not a condition), per review. Update the leftover _FALLBACK_CHUNK references (reader and test) missed by the _FALLBACK_CHUNK_SIZE rename, and import Callable, which the new _WHERE_PREDICATE_BY_OP annotation uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts most of chuckwondo's PR #151 review gist, minus the opener-wrapper closure: - parse_where() + frozen WhereCondition dataclass: syntax errors 400 before any I/O (no store open, no EDL priming), all invalid conditions reported at once, and _apply_where works with named fields instead of a 4-tuple. - The try in __attrs_post_init__ now covers super(), which can raise after opening the dataset (bad variable/sel, missing spatial metadata) — previously those paths leaked the open dataset. - chunk_for_lazy_ops() at module level centralizes the chunking rule; it mutates in place because Dataset.copy() drops the closer, and _apply_where uses it on self.ds instead of a copy (one re-extract remains, since self.input predates the chunking). - opener_zarr's docstring points at the public chunk_for_lazy_ops instead of the private _apply_where. - tests: closed-on-invalid test now uses a semantic failure (syntax errors no longer open the store); new test asserts a syntax error 400s with the opener never called. The gist's opener-wrapper (chunking inside a closure re-binding self.opener) is deliberately not taken: it only removes a lazy metadata-level re-extraction, at the cost of request-dependent opener behavior. See refactor/where-opener-wrapper for the comparison branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thanks, @chuckwondo! I took most of your suggestion in |
I definitely agree with you here! I think in the future we should see about using native Zarr laziness and leaving xarray/dask out of the picture for regularly gridded Icechunk stores. That said, I'd rather have an MVP out and refactor later, as long as we're happy with the API design. |
Hi @maxrjones, just to clarify, in my gist suggestions, performance isn't the concern. Rather, the reason to drop the re-extraction is provenance/"drift avoidance". Right now In other words, this guarantees that However, that has to happen in the opener, and the reason for that is upstream, not anything in this PR. The inherited self.ds = self.opener(self.src_path, **opener_options)
self.input = get_variable(self.ds, self.variable, sel=self.sel)There is no hook between those two statements that we can leverage from a subclass. The only things a subclass can influence beforehand are Here are the adjustments I suggest:
Why point 1 is important: my gist has the wrapper |
|
@maxrjones, if you would prefer not to take my latest suggestion in full, then the very least I suggest you do is the bit that removes |
Note, however, doing this does not address the provenance/drift issue, so I'm encouraging you to take the suggestion in full. |
|
thanks for pushing back @chuckwondo. I'll make those changes. I'm also going to break off the independent changes to some smaller PRs. |
|
Closing in favor of #166. We can always re-open if others disagree with this decision. |
This PR adds a where query parameter for filtering rendered data, which is necessary for visualizing TEMPO data in a similar way to worldview.
Testing
PR checks
run-cdk-checkslabel to this PR.deploy-devlabel. It smoke-tests tiles from the native MUR, virtual MUR, and virtual NLDAS Icechunk stores after deployment.