diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ef621e2..1a0d898 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,8 @@ jobs: STAC_BROWSER_CERTIFICATE_ARN: ${{ vars.STAC_BROWSER_CERTIFICATE_ARN }} STAC_BROWSER_CUSTOM_DOMAIN_NAME: ${{ vars.STAC_BROWSER_CUSTOM_DOMAIN_NAME }} STAC_BROWSER_REPO_TAG: ${{ vars.STAC_BROWSER_REPO_TAG }} + USER_STAC_BROWSER_CERTIFICATE_ARN: ${{ vars.USER_STAC_BROWSER_CERTIFICATE_ARN }} + USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME }} STAGE: ${{ github.event.inputs.deployment_environment || 'synthtest' }} TITILER_DATA_ACCESS_ROLE_ARN: ${{ vars.TITILER_DATA_ACCESS_ROLE_ARN }} TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }} diff --git a/.gitignore b/.gitignore index f4845f0..db8b6a5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ stac-browser/ .pgdata/ dev-docs/plans/ +issue-drafts/ diff --git a/Dockerfile.stac-browser b/Dockerfile.stac-browser new file mode 100644 index 0000000..17fc8e4 --- /dev/null +++ b/Dockerfile.stac-browser @@ -0,0 +1,29 @@ +FROM node:lts-alpine AS build +ARG STAC_BROWSER_REPO_TAG=v5.1.0 +ARG DYNAMIC_CONFIG=true + +RUN apk add --no-cache git +WORKDIR /app +RUN git clone --depth 1 --branch "$STAC_BROWSER_REPO_TAG" https://github.com/radiantearth/stac-browser.git . +COPY cdk/stac_browser_user_config.js ./stac_browser_user_config.js +RUN npm install +ENV DYNAMIC_CONFIG="$DYNAMIC_CONFIG" \ + SB_CONFIG=./stac_browser_user_config.js +RUN [ "$DYNAMIC_CONFIG" = "true" ] && sed -i 's///' index.html +RUN npm run build + +FROM nginxinc/nginx-unprivileged:1-alpine + +USER root +RUN apk add --no-cache jq pcre-tools +COPY --from=build /app/config.schema.json /etc/nginx/conf.d/config.schema.json +COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/docker/default.conf /etc/nginx/conf.d/default.conf.template +COPY --from=build /app/docker/docker-entrypoint.sh /docker-entrypoint.d/40-stac-browser-entrypoint.sh +RUN rm -f /etc/nginx/conf.d/default.conf && \ + chown -R nginx:nginx /usr/share/nginx/html && \ + chmod +x /docker-entrypoint.d/40-stac-browser-entrypoint.sh + +ENV SB_pathPrefix=/ +EXPOSE 8080 +USER nginx diff --git a/README.md b/README.md index 1945e08..f524904 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,37 @@ asset containing the source `.met.json` file. The generator also overwrites the STAC Common Metadata `created` property with the UTC publication time shared by all Items generated from that catalog. +For each job with generated items, the same SNS stream also receives a plain +STAC 1.1.0 user Catalog and one Collection. If the input catalog contains one +source Collection for those generated items, its useful metadata and resolved +asset links are reused with the deterministic collection ID and the URL-safe +user catalog ID as its only `parent_ids` value. With no source Collection, the +Collection uses whole-world, open-ended extents. Multiple source Collections +for one job fail rather than being merged. Authorized named collections remain +Item-only. + +Repeated upserts overwrite manual curation on generated Catalog and Collection +records. The deployed loader still has `CREATE_COLLECTIONS_IF_MISSING=TRUE`, +and pgSTAC is configured to maintain collection extents from ingested Items, so +a source extent is initial metadata and may be updated asynchronously from +Items. Named collections are untouched. + +To add hierarchy records for historical generated collections, preview this +conservative, restartable backfill before applying it: + +```bash +uv run --script scripts/backfill_dps_user_catalogs.py --dry-run +uv run --script scripts/backfill_dps_user_catalogs.py --apply +``` + +The backfill uses hydrated item metadata and actual collection IDs. It recognizes +both current three-part and legacy tag-specific four-part generated IDs, and +skips named, authorized, mixed, incomplete, and ambiguous collections. Historical +authorization cannot always be proven when its registry is incomplete, so review +the dry-run report. Existing Collection metadata is preserved; apply only adds +the parent relationship and creates a missing user Catalog. It does not rewrite +or rename Items. + To merge legacy tag-specific DPS collections into these tag-free IDs, preview then apply the database migration: @@ -42,10 +73,10 @@ then apply the database migration: It recognizes four-part IDs (`username__algorithm__version__tag`), merges their items into the corresponding three-part ID, and adds the DPS metadata fields -from the legacy ID. Collections containing an item-ID collision after merging -are reported and left unchanged. For a deployed database, follow the -[RDS connection guide](#connect-to-rds-through-an-ssm-tunnel) below and the -RDS usage instructions in the migration script's docstring. +from the legacy ID. Collections containing an item-ID collision retain their +legacy ID, but their Items still receive those metadata fields. For a deployed +database, follow the [RDS connection guide](#connect-to-rds-through-an-ssm-tunnel) +below and the RDS usage instructions in the migration script's docstring. Collection-only STAC transactions can still be enabled with: @@ -74,6 +105,8 @@ The script is standalone and uses an inline `uv` execution header, so it install - `DPS Team Catalogs` as a root catalog, containing the shared `maap-demo-team` catalog - two synthetic DPS-output collections per user +Open to test the user STAC Browser configuration used by the deployment. Its landing page shows only root catalogs; opening a catalog shows its scoped collections. + Useful options: ```bash @@ -93,6 +126,16 @@ For a catalogs-enabled deployment, verify: - `GET /` includes `rel="child"` links for listed catalogs so STAC Browser can discover catalog roots. - catalog write routes are absent unless `USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE=basic` is configured. +The deployment includes a public STAC Browser and, when +`USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME` and `USER_STAC_BROWSER_CERTIFICATE_ARN` are +set, a separate user-STAC Browser. Set `STAC_BROWSER_REPO_TAG` to `v5.1.0` (or a +compatible STAC Browser v5 release) for the user browser configuration. On the +user-STAC Browser landing page, verify that catalog links are shown and the +landing page's broad `rel="data"` link is not. Open a child catalog and verify +that its scoped `rel="data"` link still lists collections, then open a +collection and verify that `rel="items"` lists its items. The customization +uses the browser path for root detection, not an API landing-page ID. + For a transaction-enabled internal deployment, verify: - `GET /conformance` includes `https://api.stacspec.org/v1.0.0/collections/extensions/transaction` when collection transactions are enabled. @@ -166,7 +209,7 @@ STAC HTTP basic-auth secret. CloudFormation gives you the secret's identifier; r from Secrets Manager. In the same terminal: ```bash -SECRET_ID='' +SECRET_ID='' DB_SECRET=$(aws secretsmanager get-secret-value \ --secret-id "$SECRET_ID" --query SecretString --output text) diff --git a/app.py b/app.py index d7745fc..480bd54 100644 --- a/app.py +++ b/app.py @@ -63,6 +63,7 @@ pgstac_db_config=config.pgstac_db(), stac_api_config=config.user_stac_api(), titiler_pgstac_config=config.user_titiler_pgstac(), + stac_browser_config=config.user_stac_browser(), add_stactools_item_generator=False, **( {"dps_stac_item_gen_config": dps_stac_item_gen_config} # type: ignore [arg-type] diff --git a/cdk/config.py b/cdk/config.py index fd30d10..01c6c73 100644 --- a/cdk/config.py +++ b/cdk/config.py @@ -95,6 +95,8 @@ class Config(BaseSettings): stac_browser_repo_tag: str stac_browser_custom_domain_name: str stac_browser_certificate_arn: str + user_stac_browser_custom_domain_name: str | None = None + user_stac_browser_certificate_arn: str | None = None stac_api_custom_domain_name: str pgstac_version: str web_acl_arn: str @@ -187,6 +189,14 @@ def validate_required_pairs(self) -> Config: "user_stac_catalog_transactions_auth_secret_arn", "user_stac_catalog_transactions_auth_mode", ), + ( + "user_stac_browser_custom_domain_name", + "user_stac_browser_certificate_arn", + ), + ( + "user_stac_browser_certificate_arn", + "user_stac_browser_custom_domain_name", + ), ( "user_stac_catalog_transactions_auth_mode", "user_stac_catalogs_enabled", @@ -314,6 +324,17 @@ def stac_browser(self) -> StacBrowserConfig: certificate_arn=self.stac_browser_certificate_arn, ) + def user_stac_browser(self) -> StacBrowserConfig | None: + """Build user-STAC Browser settings when its domain is configured.""" + if self.user_stac_browser_custom_domain_name is None: + return None + assert self.user_stac_browser_certificate_arn is not None + return StacBrowserConfig( + repo_tag=self.stac_browser_repo_tag, + custom_domain_name=self.user_stac_browser_custom_domain_name, + certificate_arn=self.user_stac_browser_certificate_arn, + ) + def ingestor(self) -> IngestorConfig: return IngestorConfig( jwks_url=self.jwks_url, diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/pyproject.toml b/cdk/constructs/DpsStacItemGenerator/runtime/pyproject.toml index 52b983b..ccf8811 100644 --- a/cdk/constructs/DpsStacItemGenerator/runtime/pyproject.toml +++ b/cdk/constructs/DpsStacItemGenerator/runtime/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ dev = [ "boto3>=1.43.42", "httpx>=0.28.1", + "psycopg>=3.3.6", "pytest>=9.0.3", "pytest-mock>=3.14.0", ] diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/handler.py b/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/handler.py index 0e6c9ea..ef496da 100644 --- a/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/handler.py +++ b/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/handler.py @@ -8,7 +8,7 @@ import boto3 from pydantic import ValidationError -from dps_stac_item_generator.item import get_stac_items +from dps_stac_item_generator.stac import get_stac_documents if TYPE_CHECKING: from aws_lambda_typing.context import Context @@ -128,6 +128,7 @@ def handler( ) batch_item_failures: list[BatchItemFailure] = [] + published_hierarchy_documents: set[tuple[str, str]] = set() for record in records: message_id = record.get("messageId") @@ -144,21 +145,34 @@ def handler( logger.debug(f"[{message_id}] SNS Message content: {message_str}") catalog_json_key = get_catalog_json_key(message_str) - for stac_item in get_stac_items( + for stac_document in get_stac_documents( catalog_json_key, collection_id_registry=COLLECTION_ID_REGISTRY, ): - stac_item_json = stac_item.model_dump_json() + if isinstance(stac_document, dict): + document_key = (stac_document["type"], stac_document["id"]) + if document_key in published_hierarchy_documents: + continue + stac_document_json = json.dumps(stac_document) + document_description = ( + f"STAC {stac_document['type'].lower()} {stac_document['id']}" + ) + else: + document_key = None + stac_document_json = stac_document.model_dump_json() + document_description = f"STAC item {stac_document.id}" item_load_topic_arn = get_topic_arn() logger.info( - f"[{message_id}] Publishing STAC item {stac_item.id} " + f"[{message_id}] Publishing {document_description} " f"to {item_load_topic_arn}" ) response = sns_client.publish( TopicArn=item_load_topic_arn, - Message=stac_item_json, + Message=stac_document_json, ) + if document_key is not None: + published_hierarchy_documents.add(document_key) logger.info( f"[{message_id}] SNS publish response MessageId: " f"{response.get('MessageId')}" diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/item.py b/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/stac.py similarity index 55% rename from cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/item.py rename to cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/stac.py index 186d5e7..e57fc22 100644 --- a/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/item.py +++ b/cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/stac.py @@ -3,7 +3,7 @@ import logging import re from collections.abc import Generator -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from urllib.parse import urlparse @@ -22,6 +22,81 @@ COLLECTION_ID_FORMAT = "{username}__{algorithm_name}__{algorithm_version}" +def user_catalog_id(username: str) -> str: + """Return a stable, readable catalog ID for a DPS username.""" + return f"user-{slugify(username, regex_pattern=r'[/\?#%& ]+')}" + + +def generated_collection_id(job_metadata: dict[str, str]) -> str: + """Return the existing deterministic DPS collection ID.""" + return slugify( + COLLECTION_ID_FORMAT.format(**job_metadata), regex_pattern=r"[/\?#%& ]+" + ) + + +def _generated_catalog(username: str) -> dict[str, Any]: + return { + "type": "Catalog", + "stac_version": "1.1.0", + "id": user_catalog_id(username), + "title": f"{username} DPS Outputs", + "description": f"DPS output collections generated for {username}.", + "parent_ids": [], + "links": [], + } + + +def _generated_collection( + collection_id: str, username: str, job_metadata: dict[str, str], user_catalog: str +) -> dict[str, Any]: + return { + "type": "Collection", + "stac_version": "1.1.0", + "id": collection_id, + "title": ( + f"{job_metadata['algorithm_name']} " + f"{job_metadata['algorithm_version']} outputs" + ), + "description": ( + f"DPS outputs generated by {job_metadata['algorithm_name']} " + f"({job_metadata['algorithm_version']}) for {username}." + ), + "license": "proprietary", + "extent": { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": {"interval": [[None, None]]}, + }, + "parent_ids": [user_catalog], + "links": [], + } + + +def _source_collection( + source: pystac.Collection, collection_id: str, user_catalog: str +) -> dict[str, Any]: + """Copy source Collection metadata with fresh generated identity links.""" + collection = source.clone() + collection.make_asset_hrefs_absolute() + document = collection.to_dict(include_self_link=False) + document["id"] = collection_id + document["parent_ids"] = [user_catalog] + document["links"] = [ + link + for link in document["links"] + if link.get("rel") + not in { + "self", + "root", + "parent", + "child", + "item", + "collection", + "alternate", + } + ] + return document + + class ObstoreStacIO(DefaultStacIO): def read_text(self, source: str | Link, *args: Any, **kwargs: Any) -> str: parsed = urlparse(str(source)) @@ -111,21 +186,16 @@ def is_authorized( return False -def get_stac_items( +def get_stac_documents( catalog_json_key: str, collection_id_registry: dict[str, list[str]] | None = None, -) -> Generator[Item, Any, Any]: - """Yield STAC items out of a catalog.json. - - If collection_id_registry is provided, items whose existing collection ID - is authorized for the submitting user are published as-is. All other items - receive a deterministic collection ID derived from DPS job metadata. +) -> Generator[Item | dict[str, Any], Any, Any]: + """Yield generated Catalog, Collection, and Item JSON documents. - Args: - catalog_json_key: S3 URI of the catalog.json file. - collection_id_registry: Optional mapping of collection ID patterns to - lists of authorized usernames. When omitted, all items receive the - deterministic collection ID. + Authorized named collections yield Items only. Generated collections yield a + user Catalog and one Collection before their Items. A single source Collection + in the input hierarchy is copied with fresh generated identity; otherwise a + conservative default Collection is used. """ registry = collection_id_registry or {} @@ -136,7 +206,6 @@ def get_stac_items( ) s3_key_parsed = urlparse(catalog_json_key) - met_json = load_met_json(s3_key_parsed.netloc, job_output_prefix) if not met_json: raise ValueError( @@ -145,27 +214,31 @@ def get_stac_items( ) job_metadata, met_json_key = met_json - deterministic_collection_id = slugify( - COLLECTION_ID_FORMAT.format(**job_metadata), regex_pattern=r"[/\?#%& ]+" - ) + deterministic_collection_id = generated_collection_id(job_metadata) username = job_metadata.get("username", "") met_json_href = f"s3://{s3_key_parsed.netloc}/{met_json_key.lstrip('/')}" - processing_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + processing_time = datetime.now(UTC).isoformat().replace("+00:00", "Z") catalog = pystac.Catalog.from_file(catalog_json_key) catalog.make_all_asset_hrefs_absolute() + items: list[tuple[Item, bool]] = [] + generated_source_collection_ids: set[str] = set() for item in catalog.get_all_items(): item_collection_id = item.collection_id - - if item_collection_id and is_authorized(username, item_collection_id, registry): + generated = not ( + item_collection_id and is_authorized(username, item_collection_id, registry) + ) + if generated: + item.collection_id = deterministic_collection_id + if item_collection_id: + generated_source_collection_ids.add(item_collection_id) + else: logger.info( "Preserving user-specified collection %s for user %s", item_collection_id, username, ) - else: - item.collection_id = deterministic_collection_id item.stac_extensions[:] = list(dict.fromkeys(item.stac_extensions)) MaapDpsExtension.ext(item, add_if_missing=True).apply( @@ -189,5 +262,43 @@ def get_stac_items( for link in item.links if not (link.rel == "via" and link.href == met_json_href) ] + items.append((Item(**item.to_dict()), generated)) + + if any(generated for _, generated in items): + user_catalog = user_catalog_id(username) + source_collections = [ + source_collection + for source_collection in catalog.get_all_collections() + if source_collection.id in generated_source_collection_ids + ] + yield _generated_catalog(username) + if ( + len(generated_source_collection_ids) == 1 + and len(source_collections) == 1 + ): + yield _source_collection( + source_collections[0], deterministic_collection_id, user_catalog + ) + else: + yield _generated_collection( + deterministic_collection_id, username, job_metadata, user_catalog + ) + + for item, _ in items: + yield item - yield Item(**item.to_dict()) + +def get_stac_items( + catalog_json_key: str, + collection_id_registry: dict[str, list[str]] | None = None, +) -> Generator[Item, Any, Any]: + """Yield enriched STAC Items out of a catalog.json. + + Authorized named collections remain Item-only. Use :func:`get_stac_documents` + when the publishing workflow also needs generated hierarchy documents. + """ + yield from ( + document + for document in get_stac_documents(catalog_json_key, collection_id_registry) + if isinstance(document, Item) + ) diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_backfill.py b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_backfill.py new file mode 100644 index 0000000..471b363 --- /dev/null +++ b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_backfill.py @@ -0,0 +1,109 @@ +import importlib.util +from pathlib import Path + +SCRIPT = Path(__file__).parents[5] / "scripts" / "backfill_dps_user_catalogs.py" +MODULE_SPEC = importlib.util.spec_from_file_location( + "backfill_dps_user_catalogs", SCRIPT +) +assert MODULE_SPEC and MODULE_SPEC.loader +backfill = importlib.util.module_from_spec(MODULE_SPEC) +MODULE_SPEC.loader.exec_module(backfill) + + +def row(collection_id: str, **metadata: str) -> dict: + """Build one hydrated item row for the backfill planner.""" + return { + "collection_id": collection_id, + "collection_content": {"type": "Collection", "id": collection_id}, + **metadata, + } + + +def test_backfill_plan_is_dry_run_safe_and_idempotent(): + """Planning does not mutate records and a linked collection is skipped.""" + metadata = { + "username": "user/name", + "algorithm_name": "algo", + "algorithm_version": "1.0", + "tag": "nightly", + } + collection_id = backfill.generated_collection_id(metadata) + records = { + collection_id: {"type": "Collection", "id": collection_id, "parent_ids": []} + } + before = {key: value.copy() for key, value in records.items()} + + plan, skipped = backfill.build_plan([row(collection_id, **metadata)], records, {}) + + assert len(plan) == 1 + assert skipped == [] + assert records == before + assert plan[0]["catalog_id"] == backfill.user_catalog_id("user/name") + assert plan[0]["catalog"]["stac_version"] == "1.1.0" + + records[collection_id]["parent_ids"] = [plan[0]["catalog_id"]] + plan, skipped = backfill.build_plan([row(collection_id, **metadata)], records, {}) + assert plan == [] + assert skipped == [(collection_id, "already linked")] + + +def test_backfill_plans_legacy_tag_specific_collection(): + """The planner links legacy IDs when their tag matches item metadata.""" + metadata = { + "username": "alice", + "algorithm_name": "algo", + "algorithm_version": "1.0", + "tag": "nightly", + } + collection_id = backfill.generated_collection_id( + metadata, backfill.LEGACY_COLLECTION_ID_FORMAT + ) + records = { + collection_id: {"type": "Collection", "id": collection_id, "parent_ids": []} + } + + plan, skipped = backfill.build_plan([row(collection_id, **metadata)], records, {}) + + assert skipped == [] + assert plan[0]["catalog_id"] == backfill.user_catalog_id("alice") + + +def test_backfill_skips_ambiguous_and_authorized_collections(): + """The planner reports all conservative exclusions instead of guessing.""" + generated_metadata = { + "username": "alice", + "algorithm_name": "algo", + "algorithm_version": "1.0", + "tag": "nightly", + } + generated_id = backfill.generated_collection_id(generated_metadata) + mixed_metadata = {**generated_metadata, "username": "bob"} + authorized_metadata = {**generated_metadata, "username": "carol"} + authorized_id = backfill.generated_collection_id(authorized_metadata) + named_id = "alice-shared" + rows = [ + row(generated_id, **generated_metadata), + row(generated_id, **mixed_metadata), + row(authorized_id, **authorized_metadata), + row(named_id, **generated_metadata), + row("missing", username="alice", algorithm_name="algo"), + ] + records = { + generated_id: {"type": "Collection", "id": generated_id, "parent_ids": []}, + authorized_id: {"type": "Collection", "id": authorized_id, "parent_ids": []}, + named_id: {"type": "Collection", "id": named_id, "parent_ids": []}, + "missing": {"type": "Collection", "id": "missing", "parent_ids": []}, + } + + plan, skipped = backfill.build_plan(rows, records, {authorized_id: ["carol"]}) + + assert plan == [] + assert (generated_id, "mixed DPS metadata") in skipped + assert (authorized_id, "authorized collection override") in skipped + assert (named_id, "named or non-generated collection ID") in skipped + assert ("missing", "missing DPS metadata") in skipped + + +def test_user_catalog_id_uses_collection_slugification_rules(): + """Backfilled catalog IDs match the generator's readable format.""" + assert backfill.user_catalog_id("User Name/One") == "user-user-name-one" diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_item_gen_handler.py b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_handler.py similarity index 88% rename from cdk/constructs/DpsStacItemGenerator/runtime/tests/test_item_gen_handler.py rename to cdk/constructs/DpsStacItemGenerator/runtime/tests/test_handler.py index 444c7ba..9545073 100644 --- a/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_item_gen_handler.py +++ b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_handler.py @@ -63,7 +63,7 @@ def mock_get_stac_items(mocker): mock_item = Item(**mock_item_dict) mock_func = mocker.patch( - "dps_stac_item_generator.handler.get_stac_items", return_value=[mock_item] + "dps_stac_item_generator.handler.get_stac_documents", return_value=[mock_item] ) mock_func.mock_item = mock_item @@ -219,6 +219,83 @@ def test_handler_success_multiple_messages( assert "Finished processing batch. All records successful." in caplog.text +def test_handler_deduplicates_hierarchy_documents_per_invocation( + mock_context, mock_sns_client, mock_get_stac_items +): + """Repeated records publish hierarchy documents once but retain each Item.""" + s3_event_data = { + "bucket": {"name": "test-catalog-bucket"}, + "object": {"key": "path/catalog.json"}, + } + event = create_sqs_event_with_s3_notification([s3_event_data, s3_event_data]) + catalog = { + "type": "Catalog", + "stac_version": "1.1.0", + "id": "user-user", + "title": "user DPS Outputs", + "description": "DPS output collections generated for user.", + "parent_ids": [], + "links": [], + } + collection = { + "type": "Collection", + "stac_version": "1.1.0", + "id": "user__algo__1", + "title": "algo 1 outputs", + "description": "DPS outputs generated by algo (1) for user.", + "license": "proprietary", + "extent": { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": {"interval": [[None, None]]}, + }, + "parent_ids": ["user-user"], + "links": [], + } + mock_get_stac_items.return_value = [ + catalog, + collection, + mock_get_stac_items.mock_item, + ] + + assert item_gen_handler.handler(event, mock_context) is None + assert mock_sns_client.publish.call_count == 4 + published = [ + json.loads(call.kwargs["Message"]) + for call in mock_sns_client.publish.call_args_list + ] + assert [document["type"] for document in published] == [ + "Catalog", + "Collection", + "Feature", + "Feature", + ] + + +def test_handler_partial_hierarchy_publication_is_retryable( + mock_context, mock_sns_client, mock_get_stac_items +): + """A failed hierarchy publish fails the SQS record after prior SNS work.""" + s3_event_data = { + "bucket": {"name": "test-catalog-bucket"}, + "object": {"key": "path/catalog.json"}, + } + event = create_sqs_event_with_s3_notification([s3_event_data]) + mock_get_stac_items.return_value = [ + {"type": "Catalog", "stac_version": "1.1.0", "id": "user-a"}, + {"type": "Collection", "stac_version": "1.1.0", "id": "collection"}, + mock_get_stac_items.mock_item, + ] + mock_sns_client.publish.side_effect = [ + {"MessageId": "catalog-published"}, + Exception("SNS publish failed"), + ] + + result = item_gen_handler.handler(event, mock_context) + + assert result == {"batchItemFailures": [{"itemIdentifier": "sqs-msg-id-0"}]} + assert mock_sns_client.publish.call_count == 2 + + def test_handler_partial_failure_get_stac_items( mock_context, mock_sns_client, mock_get_stac_items, caplog ): @@ -625,11 +702,11 @@ def test_handler_registry_preserves_authorized_collection_id( with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=(job_metadata, "2023/01/15/10/30/45/123456/.met.json"), ), ): diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_migrate_dps_collection_ids.py b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_migrate_dps_collection_ids.py new file mode 100644 index 0000000..fa9359b --- /dev/null +++ b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_migrate_dps_collection_ids.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +SCRIPT = Path(__file__).parents[5] / "scripts" / "migrate_dps_collection_ids.py" +MODULE_SPEC = importlib.util.spec_from_file_location( + "migrate_dps_collection_ids", SCRIPT +) +assert MODULE_SPEC and MODULE_SPEC.loader +migration = importlib.util.module_from_spec(MODULE_SPEC) +MODULE_SPEC.loader.exec_module(migration) + + +class RecordingCursor: + """Record the SQL issued by a migration helper.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[list[str], ...]]] = [] + + def __enter__(self) -> RecordingCursor: + return self + + def __exit__(self, *args: object) -> None: + return None + + def execute(self, statement: str, parameters: tuple[list[str], ...]) -> None: + """Record an executed statement and its parameters.""" + self.calls.append((statement, parameters)) + + +class RecordingConnection: + """Provide the cursor boundary needed by the migration helper.""" + + def __init__(self) -> None: + self.recording_cursor = RecordingCursor() + + def cursor(self) -> RecordingCursor: + """Return the recording cursor.""" + return self.recording_cursor + + +def test_apply_item_metadata_keeps_legacy_collection_id(): + """Conflicting legacy collections receive metadata without being moved.""" + connection = RecordingConnection() + source_id = "alice__algorithm__1.0__nightly" + + migration.apply_item_metadata(connection, [source_id]) + + statement, parameters = connection.recording_cursor.calls[0] + assert "INSERT INTO pgstac.items_staging_upsert" in statement + assert "'{collection}'" not in statement + assert parameters == ([source_id], ["alice"], ["algorithm"], ["1.0"], ["nightly"]) diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_item.py b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_stac.py similarity index 66% rename from cdk/constructs/DpsStacItemGenerator/runtime/tests/test_item.py rename to cdk/constructs/DpsStacItemGenerator/runtime/tests/test_stac.py index 540e032..12290a1 100644 --- a/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_item.py +++ b/cdk/constructs/DpsStacItemGenerator/runtime/tests/test_stac.py @@ -4,7 +4,13 @@ import pystac import pytest -from dps_stac_item_generator.item import get_stac_items, is_authorized, load_met_json +from dps_stac_item_generator.stac import ( + get_stac_documents, + get_stac_items, + is_authorized, + load_met_json, + user_catalog_id, +) from stac_pydantic.item import Item @@ -54,9 +60,9 @@ def test_load_met_json_returns_discovered_key(self): '{"algorithm_name": "awesome-algo"}' ) with ( - patch("dps_stac_item_generator.item.from_url", return_value=store), + patch("dps_stac_item_generator.stac.from_url", return_value=store), patch( - "dps_stac_item_generator.item.obstore.list", + "dps_stac_item_generator.stac.obstore.list", return_value=[ [ {"path": "2023/01/15/10/30/45/123456/catalog.json"}, @@ -65,7 +71,7 @@ def test_load_met_json_returns_discovered_key(self): ], ) as mock_list, patch( - "dps_stac_item_generator.item.obstore.get", + "dps_stac_item_generator.stac.obstore.get", return_value=met_json_object, ) as mock_get, ): @@ -127,6 +133,7 @@ def mock_catalog(self): ), ) catalog.get_all_items.return_value = [item1, item2] + catalog.get_all_collections.return_value = [] catalog.make_all_asset_hrefs_absolute.return_value = None return catalog @@ -151,17 +158,17 @@ def test_get_stac_items_success(self, mock_catalog, mock_job_metadata): with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", ), ), - patch("dps_stac_item_generator.item.datetime") as mock_datetime, + patch("dps_stac_item_generator.stac.datetime") as mock_datetime, ): mock_datetime.now.return_value = processing_time items = list(get_stac_items(catalog_s3_key)) @@ -215,11 +222,11 @@ def test_get_stac_items_invalid_s3_key_format( with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -235,10 +242,10 @@ def test_get_stac_items_missing_met_json(self, mock_catalog): with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), - patch("dps_stac_item_generator.item.load_met_json", return_value=None), + patch("dps_stac_item_generator.stac.load_met_json", return_value=None), pytest.raises(ValueError, match="could not locate the .met.json file"), ): list(get_stac_items(catalog_s3_key)) @@ -251,11 +258,11 @@ def test_get_stac_items_load_met_json_called_correctly( with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -277,11 +284,11 @@ def test_get_stac_items_empty_catalog(self, mock_job_metadata): with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=empty_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -300,11 +307,11 @@ def test_get_stac_items_catalog_loading_failure(self, mock_job_metadata): with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", side_effect=Exception("Failed to load catalog"), ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -320,11 +327,11 @@ def test_get_stac_items_generator_behavior(self, mock_catalog, mock_job_metadata with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -348,14 +355,14 @@ def test_get_stac_items_invalid_catalog_json(self, mock_job_metadata): with ( patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", ), ), patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", side_effect=Exception("Failed to parse catalog.json: invalid format"), ), pytest.raises( @@ -373,11 +380,11 @@ def test_santitize_collection_id(self, mock_catalog, mock_job_metadata): with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -396,11 +403,11 @@ def test_authorized_collection_id_preserved(self, mock_catalog, mock_job_metadat with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -422,11 +429,11 @@ def test_unauthorized_collection_id_replaced(self, mock_catalog, mock_job_metada with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -447,11 +454,11 @@ def test_wildcard_registry_pattern(self, mock_catalog, mock_job_metadata): with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", @@ -465,6 +472,167 @@ def test_wildcard_registry_pattern(self, mock_catalog, mock_job_metadata): for item in items: assert item.collection == "test-collection" + def test_generated_documents_include_hierarchy_once( + self, mock_catalog, mock_job_metadata + ): + """Generated items publish one 1.1 hierarchy with conservative extents.""" + catalog_s3_key = "s3://test-bucket/2023/01/15/10/30/45/123456/catalog.json" + + with ( + patch( + "dps_stac_item_generator.stac.pystac.Catalog.from_file", + return_value=mock_catalog, + ), + patch( + "dps_stac_item_generator.stac.load_met_json", + return_value=( + mock_job_metadata, + "2023/01/15/10/30/45/123456/.met.json", + ), + ), + ): + documents = list(get_stac_documents(catalog_s3_key)) + + assert [ + document["type"] if isinstance(document, dict) else "Feature" + for document in documents + ] == ["Catalog", "Collection", "Feature", "Feature"] + catalog, collection = documents[:2] + assert catalog["stac_version"] == "1.1.0" + assert collection["stac_version"] == "1.1.0" + assert collection["parent_ids"] == [user_catalog_id("superman")] + assert collection["extent"] == { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": {"interval": [[None, None]]}, + } + + def test_generated_collection_reuses_source_metadata( + self, mock_catalog, mock_job_metadata + ): + """One source Collection supplies metadata while identity is regenerated.""" + source = pystac.Collection( + id="test-collection", + description="Curated source description", + title="Source title", + license="CC-BY-4.0", + keywords=["source-keyword"], + extent=pystac.Extent( + pystac.SpatialExtent([[-10, -5, 10, 5]]), + pystac.TemporalExtent( + [[DateTime(2020, 1, 1, tzinfo=timezone.utc), None]] + ), + ), + ) + source.set_self_href("s3://test-bucket/2023/01/15/10/30/45/123456/source.json") + source.add_asset("preview", pystac.Asset("preview.png")) + source.add_link(pystac.Link("documentation", "https://example.test/docs")) + source.add_link(pystac.Link("parent", "https://example.test/old-parent")) + mock_catalog.get_all_collections.return_value = [source] + catalog_s3_key = "s3://test-bucket/2023/01/15/10/30/45/123456/catalog.json" + + with ( + patch( + "dps_stac_item_generator.stac.pystac.Catalog.from_file", + return_value=mock_catalog, + ), + patch( + "dps_stac_item_generator.stac.load_met_json", + return_value=( + mock_job_metadata, + "2023/01/15/10/30/45/123456/.met.json", + ), + ), + ): + documents = list(get_stac_documents(catalog_s3_key)) + + collection = documents[1] + assert collection["id"] == "superman__awesome-algo__0.1" + assert collection["parent_ids"] == [user_catalog_id("superman")] + assert collection["description"] == "Curated source description" + assert collection["license"] == "CC-BY-4.0" + assert collection["keywords"] == ["source-keyword"] + assert collection["assets"]["preview"]["href"] == ( + "s3://test-bucket/2023/01/15/10/30/45/123456/preview.png" + ) + assert [link["rel"] for link in collection["links"]] == ["documentation"] + + def test_generated_collection_uses_boilerplate_for_ambiguous_sources( + self, mock_catalog, mock_job_metadata + ): + """Generated items from multiple source Collections use boilerplate metadata.""" + first = pystac.Collection( + id="first", + description="first", + extent=pystac.Extent( + pystac.SpatialExtent([[-180, -90, 180, 90]]), + pystac.TemporalExtent([[None, None]]), + ), + ) + second = first.clone() + second.id = "second" + mock_catalog.get_all_items.return_value[0].collection_id = "first" + mock_catalog.get_all_items.return_value[1].collection_id = "second" + mock_catalog.get_all_collections.return_value = [first, second] + catalog_s3_key = "s3://test-bucket/2023/01/15/10/30/45/123456/catalog.json" + + with ( + patch( + "dps_stac_item_generator.stac.pystac.Catalog.from_file", + return_value=mock_catalog, + ), + patch( + "dps_stac_item_generator.stac.load_met_json", + return_value=( + mock_job_metadata, + "2023/01/15/10/30/45/123456/.met.json", + ), + ), + ): + documents = list(get_stac_documents(catalog_s3_key)) + + collection = documents[1] + assert collection["description"] == ( + "DPS outputs generated by awesome-algo (0.1) for superman." + ) + assert collection["license"] == "proprietary" + assert "assets" not in collection + + def test_authorized_generated_looking_collection_is_item_only( + self, mock_catalog, mock_job_metadata + ): + """An authorized override never receives generated hierarchy documents.""" + generated_id = "superman__awesome-algo__0.1" + for item in mock_catalog.get_all_items.return_value: + item.collection_id = generated_id + catalog_s3_key = "s3://test-bucket/2023/01/15/10/30/45/123456/catalog.json" + + with ( + patch( + "dps_stac_item_generator.stac.pystac.Catalog.from_file", + return_value=mock_catalog, + ), + patch( + "dps_stac_item_generator.stac.load_met_json", + return_value=( + mock_job_metadata, + "2023/01/15/10/30/45/123456/.met.json", + ), + ), + ): + documents = list( + get_stac_documents( + catalog_s3_key, + collection_id_registry={generated_id: ["superman"]}, + ) + ) + + assert all(not isinstance(document, dict) for document in documents) + assert all(document.collection == generated_id for document in documents) + + def test_user_catalog_id_uses_collection_slugification_rules(self): + """Catalog IDs are readable and URL-safe.""" + assert user_catalog_id("User Name/One") == "user-user-name-one" + def test_empty_registry_uses_deterministic_id( self, mock_catalog, mock_job_metadata ): @@ -474,11 +642,11 @@ def test_empty_registry_uses_deterministic_id( with ( patch( - "dps_stac_item_generator.item.pystac.Catalog.from_file", + "dps_stac_item_generator.stac.pystac.Catalog.from_file", return_value=mock_catalog, ), patch( - "dps_stac_item_generator.item.load_met_json", + "dps_stac_item_generator.stac.load_met_json", return_value=( mock_job_metadata, "2023/01/15/10/30/45/123456/.met.json", diff --git a/cdk/constructs/DpsStacItemGenerator/runtime/uv.lock b/cdk/constructs/DpsStacItemGenerator/runtime/uv.lock index fde66eb..8319ac9 100644 --- a/cdk/constructs/DpsStacItemGenerator/runtime/uv.lock +++ b/cdk/constructs/DpsStacItemGenerator/runtime/uv.lock @@ -109,6 +109,7 @@ dependencies = [ dev = [ { name = "boto3" }, { name = "httpx" }, + { name = "psycopg" }, { name = "pytest" }, { name = "pytest-mock" }, ] @@ -127,6 +128,7 @@ requires-dist = [ dev = [ { name = "boto3", specifier = ">=1.43.42" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "psycopg", specifier = ">=3.3.6" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-mock", specifier = ">=3.14.0" }, ] @@ -301,6 +303,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + [[package]] name = "pydantic" version = "2.12.0" @@ -952,6 +967,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" diff --git a/cdk/pgstac_infra.py b/cdk/pgstac_infra.py index 60bb941..d999bee 100644 --- a/cdk/pgstac_infra.py +++ b/cdk/pgstac_infra.py @@ -550,7 +550,11 @@ def __init__( access_control=s3.BucketAccessControl.PRIVATE, removal_policy=RemovalPolicy.DESTROY, block_public_access=s3.BlockPublicAccess.BLOCK_ALL, - bucket_name=f"maap-stac-browser-{stage}", + bucket_name=( + f"maap-user-stac-browser-{stage}" + if type == "internal" + else f"maap-stac-browser-{stage}" + ), enforce_ssl=True, ) @@ -604,6 +608,12 @@ def __init__( bucket_arn=stac_browser_bucket.bucket_arn, stac_catalog_url=stac_catalog_url, github_repo_tag=stac_browser_config.repo_tag, + clone_directory=f"./stac-browser-{type}", + config_file_path=( + str(_CDK_DIR / "stac_browser_user_config.js") + if type == "internal" + else None + ), website_index_document=root_path, ) diff --git a/cdk/stac_browser_user_config.js b/cdk/stac_browser_user_config.js new file mode 100644 index 0000000..99b4a26 --- /dev/null +++ b/cdk/stac_browser_user_config.js @@ -0,0 +1,69 @@ +export default { + catalogUrl: null, + catalogTitle: "MAAP DPS User STAC", + catalogTitleAfterImage: null, + catalogImage: null, + allowExternalAccess: true, + allowedDomains: [], + enforcedColorMode: "auto", + detectLocaleFromBrowser: true, + storeLocale: true, + locale: "en", + fallbackLocale: "en", + supportedLocales: [ + "ar", + "de", + "es", + "en", + "fr", + "it", + "ro", + "ru", + "ja", + "pt", + "id", + "pl", + "sv", + ], + apiCatalogPriority: null, + useTileLayerAsFallback: false, + displayGeoTiffByDefault: false, + displayPreview: true, + displayOverview: true, + displayOverviewsForChildren: false, + maxDisplayPixels: null, + buildTileUrlTemplate: null, + getMapSourceOptions: null, + getStacLayerOptions: null, + pathPrefix: "/", + historyMode: "history", + cardViewMode: "cards", + showFavorites: true, + defaultCollectionSort: "title", + defaultItemSort: null, + showKeywordsInItemCards: false, + showKeywordsInCatalogCards: false, + preferredAssets: true, + showThumbnailsAsAssets: false, + searchResultsPerPage: null, + itemsPerPage: null, + collectionsPerPage: null, + maxEntriesPerPage: 1000, + defaultThumbnailSize: null, + crossOriginMedia: null, + requestHeaders: {}, + requestQueryParameters: {}, + socialSharing: ["email", "bsky", "mastodon", "x"], + preprocessSTAC: (stac, state, getters) => { + if (getters.toBrowserPath(stac.getAbsoluteUrl()) === "/") { + stac.links = stac.links.filter((link) => link.rel !== "data"); + } + return stac; + }, + authConfig: null, + transactions: "auto", + transactionsRequireLogin: true, + transactionsRequirePreflight: true, + crs: {}, + footerLinks: null, +}; diff --git a/docker-compose.yml b/docker-compose.yml index 2b7da4d..636d6da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -114,12 +114,15 @@ services: - ./.pgdata:/var/lib/postgresql/data stac-browser: - image: ghcr.io/radiantearth/stac-browser:latest + build: + context: . + dockerfile: Dockerfile.stac-browser + args: + STAC_BROWSER_REPO_TAG: ${STAC_BROWSER_REPO_TAG:-v5.1.0} ports: - "${MY_DOCKER_IP:-127.0.0.1}:8080:8080" environment: SB_catalogUrl: "http://${MY_DOCKER_IP:-127.0.0.1}:8081" - # SB_apiCatalogPriority: "childs" depends_on: - stac diff --git a/pyproject.toml b/pyproject.toml index b6b20af..4932636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ name = "maap-eoapi" version = "0.0" requires-python = ">=3.12" dependencies = [ - "aws-cdk-lib==2.220.0", + "aws-cdk-lib==2.224.0", "constructs==10.6.0", - "eoapi-cdk>=11.6.4", + "eoapi-cdk>=11.7.1", "pydantic-settings>=2.0", "pyyaml>=6.0", ] diff --git a/scripts/backfill_dps_user_catalogs.py b/scripts/backfill_dps_user_catalogs.py new file mode 100755 index 0000000..380fa7b --- /dev/null +++ b/scripts/backfill_dps_user_catalogs.py @@ -0,0 +1,295 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "psycopg[binary]>=3.2,<4", +# "python-slugify==8.0.4", +# ] +# /// +"""Safely backfill generated DPS collections into per-user STAC catalogs. + +The default is a dry run. Review the report, then rerun with ``--apply``. The +backfill uses hydrated item metadata and the actual collection ID; it does not +parse collection IDs to infer ownership. It only handles collections whose +items agree on one complete DPS metadata tuple and whose ID matches either the +current generator default or its legacy tag-specific format. Collections +authorized by the supplied registry, named collections, mixed collections, and +incomplete or ambiguous metadata are reported and skipped. + +Historical authorization is not present in every item record. A collection +that happens to have a generated-looking ID can therefore be indistinguishable +from an old authorized override when the registry is incomplete. This script +stays conservative by requiring an exact metadata match and skips any ID +currently authorized by the registry; review the remaining report before apply. +Existing catalog and collection metadata is preserved. Applying this script +only creates a missing user Catalog and adds its parent ID to the existing +Collection. It does not rewrite or rename Items. + +Examples:: + + uv run --script scripts/backfill_dps_user_catalogs.py --dry-run + uv run --script scripts/backfill_dps_user_catalogs.py --apply + uv run --script scripts/backfill_dps_user_catalogs.py --registry registry.json +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import logging +import os +from collections import defaultdict +from pathlib import Path +from typing import Any + +from slugify import slugify + +LOGGER = logging.getLogger(__name__) +DEFAULT_DATABASE_URL = "postgresql://username:password@127.0.0.1:5439/postgis" +COLLECTION_ID_FORMAT = "{username}__{algorithm_name}__{algorithm_version}" +LEGACY_COLLECTION_ID_FORMAT = "{username}__{algorithm_name}__{algorithm_version}__{tag}" +METADATA_FIELDS = ( + "username", + "algorithm_name", + "algorithm_version", + "tag", +) + + +def user_catalog_id(username: str) -> str: + """Return the generator's stable, readable catalog ID for a username.""" + return f"user-{slugify(username, regex_pattern=r'[/\?#%& ]+')}" + + +def generated_collection_id( + metadata: dict[str, str], collection_id_format: str = COLLECTION_ID_FORMAT +) -> str: + """Return a slugified generated collection ID for the supplied format.""" + return slugify(collection_id_format.format(**metadata), regex_pattern=r"[/\?#%& ]+") + + +def catalog_document(username: str) -> dict[str, Any]: + """Build a fresh generated user Catalog document.""" + return { + "type": "Catalog", + "stac_version": "1.1.0", + "id": user_catalog_id(username), + "title": f"{username} DPS Outputs", + "description": f"DPS output collections generated for {username}.", + "parent_ids": [], + "links": [], + } + + +def load_registry(path: str | None) -> dict[str, list[str]]: + """Load an authorization registry from JSON or the matching environment.""" + raw = ( + Path(path).read_text(encoding="utf-8") + if path + else os.environ.get("USER_STAC_COLLECTION_ID_REGISTRY", "{}") + ) + registry = json.loads(raw) + if not isinstance(registry, dict): + raise ValueError("registry must be a JSON object") + return registry + + +def is_authorized( + username: str, collection_id: str, registry: dict[str, list[str]] +) -> bool: + """Return whether the registry authorizes this user for this collection.""" + return any( + fnmatch.fnmatch(collection_id, pattern) and username in users + for pattern, users in registry.items() + ) + + +def collection_rows(connection: Any) -> list[dict[str, Any]]: + """Read collections with hydrated item-level DPS metadata.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + collections.id AS collection_id, + collections.content AS collection_content, + pgstac.format_item(items)->'properties'->>'maap-dps:username' + AS username, + pgstac.format_item(items)->'properties'->>'maap-dps:algorithm_name' + AS algorithm_name, + pgstac.format_item(items)->'properties'->>'processing:version' + AS algorithm_version, + pgstac.format_item(items)->'properties'->>'maap-dps:tag' AS tag + FROM pgstac.collections AS collections + JOIN pgstac.items AS items + ON items.collection = collections.id + WHERE collections.content->>'type' = 'Collection' + ORDER BY collections.id + """ + ) + return list(cursor.fetchall()) + + +def existing_records(connection: Any) -> dict[str, dict[str, Any]]: + """Return existing pgSTAC Catalog and Collection documents by ID.""" + with connection.cursor() as cursor: + cursor.execute("SELECT id, content FROM pgstac.collections") + return {row["id"]: row["content"] for row in cursor.fetchall()} + + +def build_plan( + rows: list[dict[str, Any]], + records: dict[str, dict[str, Any]], + registry: dict[str, list[str]], +) -> tuple[list[dict[str, Any]], list[tuple[str, str]]]: + """Build candidate backfills and explicit skip reasons without writing.""" + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[row["collection_id"]].append(row) + + plan: list[dict[str, Any]] = [] + skipped: list[tuple[str, str]] = [] + for collection_id, item_rows in grouped.items(): + values = { + field: {row.get(field) for row in item_rows if row.get(field)} + for field in METADATA_FIELDS + } + if any(not values[field] for field in METADATA_FIELDS): + skipped.append((collection_id, "missing DPS metadata")) + continue + if any(len(values[field]) != 1 for field in METADATA_FIELDS): + skipped.append((collection_id, "mixed DPS metadata")) + continue + + metadata = {field: values[field].pop() for field in METADATA_FIELDS} + username = metadata["username"] + generated_ids = { + generated_collection_id(metadata), + generated_collection_id(metadata, LEGACY_COLLECTION_ID_FORMAT), + } + if collection_id not in generated_ids: + skipped.append((collection_id, "named or non-generated collection ID")) + continue + if is_authorized(username, collection_id, registry): + skipped.append((collection_id, "authorized collection override")) + continue + + catalog_id = user_catalog_id(username) + catalog = records.get(catalog_id) + if catalog is not None and catalog.get("type") != "Catalog": + skipped.append((collection_id, f"ambiguous catalog ID {catalog_id}")) + continue + collection = records.get(collection_id, {}) + parent_ids = collection.get("parent_ids", []) + if catalog_id in parent_ids: + skipped.append((collection_id, "already linked")) + continue + plan.append( + { + "collection_id": collection_id, + "catalog_id": catalog_id, + "username": username, + "catalog": catalog_document(username), + } + ) + + return plan, skipped + + +def apply_plan(connection: Any, plan: list[dict[str, Any]]) -> None: + """Create missing Catalogs and add parent links without replacing metadata.""" + with connection.cursor() as cursor: + for candidate in plan: + cursor.execute( + """ + INSERT INTO pgstac.collections (content) + VALUES (%s::jsonb) + ON CONFLICT (id) DO NOTHING + """, + (json.dumps(candidate["catalog"]),), + ) + cursor.execute( + """ + UPDATE pgstac.collections + SET content = jsonb_set( + content, + '{parent_ids}', + CASE + WHEN COALESCE(content->'parent_ids', '[]'::jsonb) + ? %s + THEN COALESCE(content->'parent_ids', '[]'::jsonb) + ELSE COALESCE(content->'parent_ids', '[]'::jsonb) + || jsonb_build_array(%s::text) + END, + true + ) + WHERE id = %s + """, + ( + candidate["catalog_id"], + candidate["catalog_id"], + candidate["collection_id"], + ), + ) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--database-url", + default=os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL), + help=( + "PostgreSQL URL; defaults to DATABASE_URL then the local Compose database." + ), + ) + parser.add_argument("--apply", action="store_true", help="Perform the backfill.") + parser.add_argument( + "--dry-run", action="store_true", help="Report changes without applying them." + ) + parser.add_argument( + "--registry", + help="JSON authorization registry file; defaults to the environment variable.", + ) + return parser.parse_args() + + +def main() -> None: + """Report or apply the conservative DPS catalog backfill.""" + args = parse_args() + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + registry = load_registry(args.registry) + + from psycopg import connect + from psycopg.errors import UndefinedTable + from psycopg.rows import dict_row + + try: + with connect(args.database_url, row_factory=dict_row) as connection: + rows = collection_rows(connection) + records = existing_records(connection) + plan, skipped = build_plan(rows, records, registry) + for candidate in plan: + LOGGER.info( + "%s -> %s (catalog %s)", + candidate["collection_id"], + "add parent relationship", + candidate["catalog_id"], + ) + for collection_id, reason in skipped: + LOGGER.info("Skipping %s: %s", collection_id, reason) + + if args.dry_run or not args.apply: + LOGGER.info( + "Dry run. Re-run with --apply to backfill %d collection(s).", + len(plan), + ) + return + apply_plan(connection, plan) + LOGGER.info("Backfilled %d collection(s).", len(plan)) + except UndefinedTable as exc: + raise SystemExit("The target database does not look like pgSTAC.") from exc + + +if __name__ == "__main__": + main() diff --git a/scripts/load_demo_stac_catalogs.py b/scripts/load_demo_stac_catalogs.py index d12efe5..23675b7 100755 --- a/scripts/load_demo_stac_catalogs.py +++ b/scripts/load_demo_stac_catalogs.py @@ -3,6 +3,7 @@ # requires-python = ">=3.12" # dependencies = [ # "pypgstac[psycopg]>=0.9,<0.10", +# "python-slugify==8.0.4", # ] # /// """Load demo STAC catalogs and collections into the local pgSTAC database. @@ -24,6 +25,7 @@ from psycopg.errors import UndefinedFunction, UndefinedTable from pypgstac.db import PgstacDB from pypgstac.load import Loader, Methods +from slugify import slugify LOGGER = logging.getLogger(__name__) @@ -51,6 +53,11 @@ def utc_now() -> str: return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") +def user_catalog_id(username: str) -> str: + """Return the generated catalog ID for a demo user.""" + return f"user-{slugify(username, regex_pattern=r'[/\?#%& ]+')}" + + def catalog( catalog_id: str, title: str, @@ -60,7 +67,7 @@ def catalog( """Build a minimal STAC Catalog record compatible with the Catalogs Extension.""" return { "type": "Catalog", - "stac_version": "1.0.0", + "stac_version": "1.1.0", "id": catalog_id, "title": title, "description": description, @@ -74,7 +81,7 @@ def collection(config: DemoCollection) -> dict[str, Any]: now = utc_now() return { "type": "Collection", - "stac_version": "1.0.0", + "stac_version": "1.1.0", "id": config.id, "title": config.title, "description": config.description, @@ -130,10 +137,10 @@ def build_demo_records(users: tuple[str, ...]) -> list[dict[str, Any]]: ] for username in users: - user_catalog_id = f"user-{username}" + catalog_id = user_catalog_id(username) records.append( catalog( - user_catalog_id, + catalog_id, f"{username} DPS Outputs", f"Demo per-user catalog for DPS outputs owned by {username}.", (DEMO_USER_CATALOGS_ID,), @@ -151,7 +158,7 @@ def build_demo_records(users: tuple[str, ...]) -> list[dict[str, Any]]: "and scoped catalog browsing." ), owner=username, - parent_ids=(user_catalog_id, DEMO_GROUP_ID), + parent_ids=(catalog_id, DEMO_GROUP_ID), keywords=("maap", "dps", "canopy-height", username), ) ), @@ -165,7 +172,7 @@ def build_demo_records(users: tuple[str, ...]) -> list[dict[str, Any]]: "catalog workflows." ), owner=username, - parent_ids=(user_catalog_id,), + parent_ids=(catalog_id,), keywords=("maap", "dps", "biomass", username), ) ), diff --git a/scripts/migrate_dps_collection_ids.py b/scripts/migrate_dps_collection_ids.py index 847b744..43304a1 100755 --- a/scripts/migrate_dps_collection_ids.py +++ b/scripts/migrate_dps_collection_ids.py @@ -7,8 +7,10 @@ # /// """Merge legacy DPS tag collections into tag-free collection IDs. -By default (or with ``--dry-run``) this reports the changes. Pass ``--apply`` to -make them. The script uses the local compose database by default; use the +Collections that cannot be merged because of duplicate item IDs retain their +legacy collection ID, but their Items still receive DPS metadata inferred from +that ID. By default (or with ``--dry-run``) this reports the changes. Pass +``--apply`` to make them. The script uses the local compose database by default; use the Docker-network URL when running from a container: ``postgresql://username:password@database:5432/postgis``. @@ -144,6 +146,48 @@ def conflicting_item_ids(connection: Any, plan: dict[str, list[str]]) -> list[st return [f"{row['target_id']}/{row['id']}" for row in cursor.fetchall()] +def apply_item_metadata(connection: Any, source_ids: list[str]) -> None: + """Add DPS metadata inferred from legacy IDs without moving their items.""" + if not source_ids: + return + + source_parts = [source_id.split("__") for source_id in source_ids] + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO pgstac.items_staging_upsert (content) + SELECT jsonb_set( + jsonb_set( + jsonb_set( + jsonb_set( + pgstac.format_item(items), + '{properties,maap-dps:algorithm_name}', + to_jsonb(mapping.algorithm_name) + ), + '{properties,processing:version}', + to_jsonb(mapping.algorithm_version) + ), + '{properties,maap-dps:username}', to_jsonb(mapping.username) + ), + '{properties,maap-dps:tag}', to_jsonb(mapping.tag) + ) + FROM pgstac.items + JOIN unnest( + %s::text[], %s::text[], %s::text[], %s::text[], %s::text[] + ) AS mapping( + source_id, username, algorithm_name, algorithm_version, tag + ) ON items.collection = mapping.source_id + """, + ( + source_ids, + [parts[0] for parts in source_parts], + [parts[1] for parts in source_parts], + [parts[2] for parts in source_parts], + [parts[3] for parts in source_parts], + ), + ) + + def apply_migration(connection: Any, plan: dict[str, list[str]]) -> None: """Create tag-free collections, move their items, and remove old collections.""" with connection.cursor() as cursor: @@ -265,6 +309,8 @@ def main() -> None: if source_ids } + for source_id in skipped_sources: + LOGGER.info("%s -> retain collection and add DPS item metadata", source_id) for target_id, source_ids in plan.items(): LOGGER.info("%s -> %s", ", ".join(source_ids), target_id) @@ -275,13 +321,21 @@ def main() -> None: ) if args.dry_run or not args.apply: LOGGER.info( - "Dry run. Re-run with --apply to migrate %d collection(s).", + "Dry run. Re-run with --apply to migrate %d collection(s) and add " + "DPS item metadata to %d retained collection(s).", sum(map(len, plan.values())), + len(skipped_sources), ) return + apply_item_metadata(connection, skipped_sources) apply_migration(connection, plan) - LOGGER.info("Migrated %d collection(s).", sum(map(len, plan.values()))) + LOGGER.info( + "Migrated %d collection(s) and added DPS item metadata to %d retained " + "collection(s).", + sum(map(len, plan.values())), + len(skipped_sources), + ) if __name__ == "__main__": diff --git a/tests/test_config.py b/tests/test_config.py index ba8428a..0e7914b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -99,6 +99,27 @@ def test_optional_env_vars(required_env: None, monkeypatch: pytest.MonkeyPatch) assert config.titiler_pg_stac_api_custom_domain_name == "titiler.example.com" +def test_user_stac_browser_config( + required_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv( + "USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME", "user-stac-browser.example.com" + ) + monkeypatch.setenv( + "USER_STAC_BROWSER_CERTIFICATE_ARN", + "arn:aws:acm:us-east-1:123456789012:certificate/user-browser", + ) + + browser = Config().user_stac_browser() + + assert browser is not None + assert browser.repo_tag == "latest" + assert browser.custom_domain_name == "user-stac-browser.example.com" + assert browser.certificate_arn == ( + "arn:aws:acm:us-east-1:123456789012:certificate/user-browser" + ) + + def test_user_stac_collection_transactions_defaults( required_env: None, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_pgstac_infra.py b/tests/test_pgstac_infra.py index b8e23d6..938ac5e 100644 --- a/tests/test_pgstac_infra.py +++ b/tests/test_pgstac_infra.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +import subprocess +from pathlib import Path from unittest.mock import patch import aws_cdk as cdk @@ -12,6 +15,7 @@ CollectionTransactionsConfig, PgStacDbConfig, StacApiConfig, + StacBrowserConfig, StacCatalogsConfig, TitilerPgstacConfig, ) @@ -74,6 +78,107 @@ def build_template(overrides: dict | None = None) -> assertions.Template: class TestPgStacInfraStacRuntimeWiring: + def test_passes_user_stac_browser_config_without_changing_catalog_url(self): + browser_kwargs = {} + + def fake_stac_browser(scope, id, **kwargs): + browser_kwargs.update(kwargs) + + with patch("cdk.pgstac_infra.eoapi_cdk.StacBrowser", fake_stac_browser): + build_template( + { + "type": "internal", + "stac_api_config": StacApiConfig( + custom_domain_name="user-stac-api.example.com" + ), + "stac_browser_config": StacBrowserConfig( + repo_tag="v5.1.0", + custom_domain_name="user-browser.example.com", + certificate_arn=( + "arn:aws:acm:us-east-1:123456789012:certificate/browser" + ), + ), + } + ) + + assert browser_kwargs["stac_catalog_url"] == ( + "https://user-stac-api.example.com/" + ) + assert browser_kwargs["config_file_path"] == str( + Path(__file__).parents[1] / "cdk" / "stac_browser_user_config.js" + ) + + def test_default_stac_browser_does_not_use_user_config(self): + browser_kwargs = {} + + def fake_stac_browser(scope, id, **kwargs): + browser_kwargs.update(kwargs) + + with patch("cdk.pgstac_infra.eoapi_cdk.StacBrowser", fake_stac_browser): + build_template( + { + "type": "public", + "stac_browser_config": StacBrowserConfig( + repo_tag="v5.1.0", + custom_domain_name="browser.example.com", + certificate_arn=( + "arn:aws:acm:us-east-1:123456789012:certificate/browser" + ), + ), + } + ) + + assert browser_kwargs["config_file_path"] is None + assert browser_kwargs["stac_catalog_url"] == "https://stac-api.example.com/" + + def test_stac_browser_config_preserves_v5_defaults_and_strips_root_data_links(self): + config_url = ( + (Path(__file__).parents[1] / "cdk" / "stac_browser_user_config.js") + .resolve() + .as_uri() + ) + script = f""" +import config from {json.dumps(config_url)}; + +const root = {{ + links: [ + {{ rel: "data", href: "/collections" }}, + {{ rel: "child", href: "/catalog" }} + ], + getAbsoluteUrl: () => "https://stac.example.com/" +}}; +const catalog = {{ + links: [ + {{ rel: "data", href: "/catalog/collections" }}, + {{ rel: "child", href: "/catalog/child" }} + ], + getAbsoluteUrl: () => "https://stac.example.com/catalog" +}}; +const getters = {{ + toBrowserPath: (url) => url.endsWith("/") ? "/" : "/catalog" +}}; + +if (config.catalogTitle !== "DPS User STAC" || config.catalogUrl !== null) {{ + throw new Error("v5 configuration"); +}} + +config.preprocessSTAC(root, {{}}, getters); +config.preprocessSTAC(catalog, {{}}, getters); + +if (root.links.length !== 1 || root.links[0].rel !== "child") {{ + throw new Error("root links"); +}} +if (catalog.links.length !== 2 || catalog.links[0].rel !== "data") {{ + throw new Error("catalog links"); +}} +""" + subprocess.run( + ["node", "--input-type=module", "--eval", script], + check=True, + capture_output=True, + text=True, + ) + def test_uses_custom_stac_handler_and_keeps_transactions_disabled_by_default(self): template = build_template( { diff --git a/uv.lock b/uv.lock index e7ccd93..3c835a6 100644 --- a/uv.lock +++ b/uv.lock @@ -64,7 +64,7 @@ wheels = [ [[package]] name = "aws-cdk-lib" -version = "2.220.0" +version = "2.224.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aws-cdk-asset-awscli-v1" }, @@ -75,9 +75,9 @@ dependencies = [ { name = "publication" }, { name = "typeguard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/fa/22bd5a46f672a2591f0701522477cf143f9f5ca255268b39b9cc101fe5d2/aws_cdk_lib-2.220.0.tar.gz", hash = "sha256:0c5242dd740e5c0f31cf7f65f6f7b4fecada0d5e4054570944c5308230d797ae", size = 44854427, upload-time = "2025-10-14T13:58:13.477Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/f3/c8d4d5e826f755150119c08166fa666ed28ce14e08a011c2ef13da60ef2c/aws_cdk_lib-2.224.0.tar.gz", hash = "sha256:7a471b9c4213ac0b916edd53e57119b9cdaa3d8e2fb470cd105fddf5576a08ca", size = 45197710, upload-time = "2025-11-13T17:39:47.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/01/00d6a7fde042b7bf447f13a0cc18ee355949b278dfb0690cab9f87dd47e8/aws_cdk_lib-2.220.0-py3-none-any.whl", hash = "sha256:2f69bae74529142a8decb3d1591cf54c8ed9fc033e503a09bfbdd534509a6785", size = 45113171, upload-time = "2025-10-14T13:57:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0d/411b5dedd28e77144faed209dd14f004ce14821b7ca4d96d240da42db656/aws_cdk_lib-2.224.0-py3-none-any.whl", hash = "sha256:33831b69ad2c8f2aaf08ba6a80b8fa5f81eaa52b4a086d4b299818676e071be2", size = 45843209, upload-time = "2025-11-13T17:39:09.838Z" }, ] [[package]] @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "eoapi-cdk" -version = "11.6.4" +version = "11.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aws-cdk-lib" }, @@ -321,9 +321,9 @@ dependencies = [ { name = "jsii" }, { name = "publication" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/32/4a97680be094c057583ebc20358e7a50abd04d7a28ef85f6ed3a05968c40/eoapi_cdk-11.6.4.tar.gz", hash = "sha256:c9a8e8fe2852b78e9070f8174db80613a84d1d4625e6a0665b8cffbd65e1a1fd", size = 641691, upload-time = "2026-08-07T14:14:10.685Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/76db6042356e0ce498a09f5f4bb8041df79a970f1e56063e261559a0bb34/eoapi_cdk-11.7.1.tar.gz", hash = "sha256:be3cf7a7487a6e47ccde7abe80b3fe8a3db40bf5eee64c11cd38b70f722d60a0", size = 674494, upload-time = "2026-09-19T12:07:25.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/17/e0e5ae397f3923e375c006b7433644795b7891902912417d8cd269e03862/eoapi_cdk-11.6.4-py3-none-any.whl", hash = "sha256:ccb5c8dbc8081cb44e215a47af32772367b5279ac812cea67fef287740af372d", size = 640276, upload-time = "2026-08-07T14:14:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b0/a9ef40f50699256614112d3318e42144f12d73fe4086a7c85ad42995760a/eoapi_cdk-11.7.1-py3-none-any.whl", hash = "sha256:3d21807c3abc98f57d5445560e1b452dc6f218ea2327ec2a458c95a5cc348559", size = 673008, upload-time = "2026-09-19T12:07:24.347Z" }, ] [[package]] @@ -439,9 +439,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aws-cdk-lib", specifier = "==2.220.0" }, + { name = "aws-cdk-lib", specifier = "==2.224.0" }, { name = "constructs", specifier = "==10.6.0" }, - { name = "eoapi-cdk", specifier = ">=11.6.4" }, + { name = "eoapi-cdk", specifier = ">=11.7.1" }, { name = "pydantic-settings", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, ]