Skip to content

Commit 05fb97e

Browse files
authored
feat: DPS STAC user catalogs and STAC Browser deployment (#133)
1 parent e71a42f commit 05fb97e

24 files changed

Lines changed: 1308 additions & 90 deletions

.github/workflows/deploy.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ jobs:
4040
STAC_BROWSER_CERTIFICATE_ARN: ${{ vars.STAC_BROWSER_CERTIFICATE_ARN }}
4141
STAC_BROWSER_CUSTOM_DOMAIN_NAME: ${{ vars.STAC_BROWSER_CUSTOM_DOMAIN_NAME }}
4242
STAC_BROWSER_REPO_TAG: ${{ vars.STAC_BROWSER_REPO_TAG }}
43+
USER_STAC_BROWSER_CERTIFICATE_ARN: ${{ vars.USER_STAC_BROWSER_CERTIFICATE_ARN }}
44+
USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME }}
4345
STAGE: ${{ github.event.inputs.deployment_environment || 'synthtest' }}
4446
TITILER_DATA_ACCESS_ROLE_ARN: ${{ vars.TITILER_DATA_ACCESS_ROLE_ARN }}
4547
TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ stac-browser/
1919
.pgdata/
2020

2121
dev-docs/plans/
22+
issue-drafts/

Dockerfile.stac-browser

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
FROM node:lts-alpine AS build
2+
ARG STAC_BROWSER_REPO_TAG=v5.1.0
3+
ARG DYNAMIC_CONFIG=true
4+
5+
RUN apk add --no-cache git
6+
WORKDIR /app
7+
RUN git clone --depth 1 --branch "$STAC_BROWSER_REPO_TAG" https://github.com/radiantearth/stac-browser.git .
8+
COPY cdk/stac_browser_user_config.js ./stac_browser_user_config.js
9+
RUN npm install
10+
ENV DYNAMIC_CONFIG="$DYNAMIC_CONFIG" \
11+
SB_CONFIG=./stac_browser_user_config.js
12+
RUN [ "$DYNAMIC_CONFIG" = "true" ] && sed -i 's/<!--RC//;s/RC-->//' index.html
13+
RUN npm run build
14+
15+
FROM nginxinc/nginx-unprivileged:1-alpine
16+
17+
USER root
18+
RUN apk add --no-cache jq pcre-tools
19+
COPY --from=build /app/config.schema.json /etc/nginx/conf.d/config.schema.json
20+
COPY --from=build /app/dist /usr/share/nginx/html
21+
COPY --from=build /app/docker/default.conf /etc/nginx/conf.d/default.conf.template
22+
COPY --from=build /app/docker/docker-entrypoint.sh /docker-entrypoint.d/40-stac-browser-entrypoint.sh
23+
RUN rm -f /etc/nginx/conf.d/default.conf && \
24+
chown -R nginx:nginx /usr/share/nginx/html && \
25+
chmod +x /docker-entrypoint.d/40-stac-browser-entrypoint.sh
26+
27+
ENV SB_pathPrefix=/
28+
EXPOSE 8080
29+
USER nginx

README.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ asset containing the source `.met.json` file. The generator also overwrites the
3232
STAC Common Metadata `created` property with the UTC publication time shared by
3333
all Items generated from that catalog.
3434

35+
For each job with generated items, the same SNS stream also receives a plain
36+
STAC 1.1.0 user Catalog and one Collection. If the input catalog contains one
37+
source Collection for those generated items, its useful metadata and resolved
38+
asset links are reused with the deterministic collection ID and the URL-safe
39+
user catalog ID as its only `parent_ids` value. With no source Collection, the
40+
Collection uses whole-world, open-ended extents. Multiple source Collections
41+
for one job fail rather than being merged. Authorized named collections remain
42+
Item-only.
43+
44+
Repeated upserts overwrite manual curation on generated Catalog and Collection
45+
records. The deployed loader still has `CREATE_COLLECTIONS_IF_MISSING=TRUE`,
46+
and pgSTAC is configured to maintain collection extents from ingested Items, so
47+
a source extent is initial metadata and may be updated asynchronously from
48+
Items. Named collections are untouched.
49+
50+
To add hierarchy records for historical generated collections, preview this
51+
conservative, restartable backfill before applying it:
52+
53+
```bash
54+
uv run --script scripts/backfill_dps_user_catalogs.py --dry-run
55+
uv run --script scripts/backfill_dps_user_catalogs.py --apply
56+
```
57+
58+
The backfill uses hydrated item metadata and actual collection IDs. It recognizes
59+
both current three-part and legacy tag-specific four-part generated IDs, and
60+
skips named, authorized, mixed, incomplete, and ambiguous collections. Historical
61+
authorization cannot always be proven when its registry is incomplete, so review
62+
the dry-run report. Existing Collection metadata is preserved; apply only adds
63+
the parent relationship and creates a missing user Catalog. It does not rewrite
64+
or rename Items.
65+
3566
To merge legacy tag-specific DPS collections into these tag-free IDs, preview
3667
then apply the database migration:
3768

@@ -42,10 +73,10 @@ then apply the database migration:
4273

4374
It recognizes four-part IDs (`username__algorithm__version__tag`), merges their
4475
items into the corresponding three-part ID, and adds the DPS metadata fields
45-
from the legacy ID. Collections containing an item-ID collision after merging
46-
are reported and left unchanged. For a deployed database, follow the
47-
[RDS connection guide](#connect-to-rds-through-an-ssm-tunnel) below and the
48-
RDS usage instructions in the migration script's docstring.
76+
from the legacy ID. Collections containing an item-ID collision retain their
77+
legacy ID, but their Items still receive those metadata fields. For a deployed
78+
database, follow the [RDS connection guide](#connect-to-rds-through-an-ssm-tunnel)
79+
below and the RDS usage instructions in the migration script's docstring.
4980

5081
Collection-only STAC transactions can still be enabled with:
5182

@@ -74,6 +105,8 @@ The script is standalone and uses an inline `uv` execution header, so it install
74105
- `DPS Team Catalogs` as a root catalog, containing the shared `maap-demo-team` catalog
75106
- two synthetic DPS-output collections per user
76107

108+
Open <http://127.0.0.1:8080> 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.
109+
77110
Useful options:
78111

79112
```bash
@@ -93,6 +126,16 @@ For a catalogs-enabled deployment, verify:
93126
- `GET /` includes `rel="child"` links for listed catalogs so STAC Browser can discover catalog roots.
94127
- catalog write routes are absent unless `USER_STAC_CATALOG_TRANSACTIONS_AUTH_MODE=basic` is configured.
95128

129+
The deployment includes a public STAC Browser and, when
130+
`USER_STAC_BROWSER_CUSTOM_DOMAIN_NAME` and `USER_STAC_BROWSER_CERTIFICATE_ARN` are
131+
set, a separate user-STAC Browser. Set `STAC_BROWSER_REPO_TAG` to `v5.1.0` (or a
132+
compatible STAC Browser v5 release) for the user browser configuration. On the
133+
user-STAC Browser landing page, verify that catalog links are shown and the
134+
landing page's broad `rel="data"` link is not. Open a child catalog and verify
135+
that its scoped `rel="data"` link still lists collections, then open a
136+
collection and verify that `rel="items"` lists its items. The customization
137+
uses the browser path for root detection, not an API landing-page ID.
138+
96139
For a transaction-enabled internal deployment, verify:
97140

98141
- `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
166209
from Secrets Manager. In the same terminal:
167210

168211
```bash
169-
SECRET_ID='<database secret physical ID (not arn) from the table>'
212+
SECRET_ID='<database secret arn from the table>'
170213
DB_SECRET=$(aws secretsmanager get-secret-value \
171214
--secret-id "$SECRET_ID" --query SecretString --output text)
172215

app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
pgstac_db_config=config.pgstac_db(),
6464
stac_api_config=config.user_stac_api(),
6565
titiler_pgstac_config=config.user_titiler_pgstac(),
66+
stac_browser_config=config.user_stac_browser(),
6667
add_stactools_item_generator=False,
6768
**(
6869
{"dps_stac_item_gen_config": dps_stac_item_gen_config} # type: ignore [arg-type]

cdk/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ class Config(BaseSettings):
9595
stac_browser_repo_tag: str
9696
stac_browser_custom_domain_name: str
9797
stac_browser_certificate_arn: str
98+
user_stac_browser_custom_domain_name: str | None = None
99+
user_stac_browser_certificate_arn: str | None = None
98100
stac_api_custom_domain_name: str
99101
pgstac_version: str
100102
web_acl_arn: str
@@ -187,6 +189,14 @@ def validate_required_pairs(self) -> Config:
187189
"user_stac_catalog_transactions_auth_secret_arn",
188190
"user_stac_catalog_transactions_auth_mode",
189191
),
192+
(
193+
"user_stac_browser_custom_domain_name",
194+
"user_stac_browser_certificate_arn",
195+
),
196+
(
197+
"user_stac_browser_certificate_arn",
198+
"user_stac_browser_custom_domain_name",
199+
),
190200
(
191201
"user_stac_catalog_transactions_auth_mode",
192202
"user_stac_catalogs_enabled",
@@ -314,6 +324,17 @@ def stac_browser(self) -> StacBrowserConfig:
314324
certificate_arn=self.stac_browser_certificate_arn,
315325
)
316326

327+
def user_stac_browser(self) -> StacBrowserConfig | None:
328+
"""Build user-STAC Browser settings when its domain is configured."""
329+
if self.user_stac_browser_custom_domain_name is None:
330+
return None
331+
assert self.user_stac_browser_certificate_arn is not None
332+
return StacBrowserConfig(
333+
repo_tag=self.stac_browser_repo_tag,
334+
custom_domain_name=self.user_stac_browser_custom_domain_name,
335+
certificate_arn=self.user_stac_browser_certificate_arn,
336+
)
337+
317338
def ingestor(self) -> IngestorConfig:
318339
return IngestorConfig(
319340
jwks_url=self.jwks_url,

cdk/constructs/DpsStacItemGenerator/runtime/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
dev = [
2020
"boto3>=1.43.42",
2121
"httpx>=0.28.1",
22+
"psycopg>=3.3.6",
2223
"pytest>=9.0.3",
2324
"pytest-mock>=3.14.0",
2425
]

cdk/constructs/DpsStacItemGenerator/runtime/src/dps_stac_item_generator/handler.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import boto3
99
from pydantic import ValidationError
1010

11-
from dps_stac_item_generator.item import get_stac_items
11+
from dps_stac_item_generator.stac import get_stac_documents
1212

1313
if TYPE_CHECKING:
1414
from aws_lambda_typing.context import Context
@@ -128,6 +128,7 @@ def handler(
128128
)
129129

130130
batch_item_failures: list[BatchItemFailure] = []
131+
published_hierarchy_documents: set[tuple[str, str]] = set()
131132

132133
for record in records:
133134
message_id = record.get("messageId")
@@ -144,21 +145,34 @@ def handler(
144145
logger.debug(f"[{message_id}] SNS Message content: {message_str}")
145146

146147
catalog_json_key = get_catalog_json_key(message_str)
147-
for stac_item in get_stac_items(
148+
for stac_document in get_stac_documents(
148149
catalog_json_key,
149150
collection_id_registry=COLLECTION_ID_REGISTRY,
150151
):
151-
stac_item_json = stac_item.model_dump_json()
152+
if isinstance(stac_document, dict):
153+
document_key = (stac_document["type"], stac_document["id"])
154+
if document_key in published_hierarchy_documents:
155+
continue
156+
stac_document_json = json.dumps(stac_document)
157+
document_description = (
158+
f"STAC {stac_document['type'].lower()} {stac_document['id']}"
159+
)
160+
else:
161+
document_key = None
162+
stac_document_json = stac_document.model_dump_json()
163+
document_description = f"STAC item {stac_document.id}"
152164

153165
item_load_topic_arn = get_topic_arn()
154166
logger.info(
155-
f"[{message_id}] Publishing STAC item {stac_item.id} "
167+
f"[{message_id}] Publishing {document_description} "
156168
f"to {item_load_topic_arn}"
157169
)
158170
response = sns_client.publish(
159171
TopicArn=item_load_topic_arn,
160-
Message=stac_item_json,
172+
Message=stac_document_json,
161173
)
174+
if document_key is not None:
175+
published_hierarchy_documents.add(document_key)
162176
logger.info(
163177
f"[{message_id}] SNS publish response MessageId: "
164178
f"{response.get('MessageId')}"

0 commit comments

Comments
 (0)