"
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
"source": [
"# override the collection id to match your new collection\n",
"for item in items:\n",
@@ -277,7 +8313,7 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 9,
"id": "82443eb2-0d59-46b2-9178-25acb3d0593e",
"metadata": {},
"outputs": [],
@@ -295,10 +8331,18 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 10,
"id": "48133d14-e301-45b4-973d-1b846a6c503a",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "there are 948 items\n"
+ ]
+ }
+ ],
"source": [
"n_items = db.query_one(\n",
" f\"SELECT COUNT(*) FROM items where collection = '{my_collection.id}';\"\n",
@@ -318,17 +8362,50 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 11,
"id": "ba5f6278-4d92-4386-af1d-94ca5aed6c30",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ " \n",
+ " "
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
"source": [
"from IPython.display import IFrame\n",
"\n",
- "stac_api_endpoint = os.getenv(\"STAC_API_ENDPOINT\").replace(\"stac-auth-proxy:8000\", \"localhost:8084\")\n",
+ "# Use the stack's own STAC Browser when deployed (its catalog is already this\n",
+ "# STAC API); otherwise fall back to the public STAC Browser in external mode.\n",
+ "stac_browser_endpoint = os.getenv(\"STAC_BROWSER_ENDPOINT\")\n",
+ "\n",
+ "if stac_browser_endpoint:\n",
+ " browser_url = f\"{stac_browser_endpoint}/#/collections/{my_collection.id}\"\n",
+ "else:\n",
+ " browser_stac_url = os.getenv(\"STAC_API_ENDPOINT\").replace(\n",
+ " \"stac-auth-proxy:8000\", \"localhost:8084\"\n",
+ " )\n",
+ " browser_url = f\"https://radiantearth.github.io/stac-browser/#/external/{browser_stac_url}/collections/{my_collection.id}\"\n",
"\n",
"IFrame(\n",
- " f\"https://radiantearth.github.io/stac-browser/#/external/{stac_api_endpoint}/collections/{my_collection.id}\",\n",
+ " browser_url,\n",
" 1200,\n",
" 800,\n",
")"
@@ -348,7 +8425,7 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 12,
"id": "4c6782bf-b043-49a0-a338-7676ff52d005",
"metadata": {},
"outputs": [],
@@ -362,10 +8439,18 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 13,
"id": "d3030aa7-4aaa-4ead-8589-b88759968f99",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "now there are 947 items\n"
+ ]
+ }
+ ],
"source": [
"new_n_items = db.query_one(\n",
" f\"SELECT COUNT(*) FROM items where collection = '{my_collection.id}';\"\n",
@@ -375,7 +8460,7 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 14,
"id": "dd34e829-0fc7-4abd-88ed-6fce8e73d1bf",
"metadata": {},
"outputs": [],
@@ -396,12 +8481,20 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 15,
"id": "7137a06e-135c-478a-a79f-d7134bd48d3d",
"metadata": {
"scrolled": true
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "{\"type\":\"FeatureCollection\",\"links\":[{\"rel\":\"root\",\"href\":\".\",\"type\":\"application/json\"},{\"rel\":\"self\",\"href\":\"./search\",\"type\":\"application/json\"},{\"rel\":\"next\",\"href\":\"./search?token=next:dark-dream-2670-sentinel-2-c1-l2a:S2B_T53HLB_20250416T010240_L2A\",\"type\":\"application/geo+json\",\"method\":\"GET\"}],\"features\":[{\"id\":\"S2B_T53HLB_20250416T010240_L2A\",\"bbox\":[132.986678,-35.327182,134.019373,-34.329311],\"type\":\"Feature\",\"links\":[{\"rel\":\"self\",\"href\":\"https://earth-search.aws.element84.com/v1/collections/sentinel-2-c1-l2a/items/S2B_T53HLB_20250416T010240_L2A\",\"type\":\"application/geo+json\"},{\"rel\":\"canonical\",\"href\":\"s3://e84-earth-search-sentinel-data/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/S2B_T53HLB_20250416T010240_L2A.json\",\"type\":\"application/json\"},{\"rel\":\"via\",\"href\":\"s3://sentinel-s2-l2a/tiles/53/H/LB/2025/4/16/0/metadata.xml\",\"type\":\"application/xml\",\"title\":\"Granule Metadata in Sinergize RODA Archive\"},{\"rel\":\"parent\",\"href\":\"https://earth-search.aws.element84.com/v1/collections/sentinel-2-c1-l2a\",\"type\":\"application/json\"},{\"rel\":\"root\",\"href\":\"https://earth-search.aws.element84.com/v1\",\"type\":\"application/json\",\"title\":\"Earth Search by Element 84\"},{\"rel\":\"thumbnail\",\"href\":\"https://earth-search.aws.element84.com/v1/collections/sentinel-2-c1-l2a/items/S2B_T53HLB_20250416T010240_L2A/thumbnail\"},{\"rel\":\"collection\",\"type\":\"application/json\"}],\"assets\":{\"aot\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/AOT.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\"],\"title\":\"Aerosol optical thickness (AOT)\",\"file:size\":273323,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.001,\"nodata\":0,\"offset\":0,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"12204952da596a5c851876f3cd466e374f1f29cc6da8a69d863d2228966972539891\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"nir\":{\"gsd\":10,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B08.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"NIR 1 - 10m\",\"eo:bands\":[{\"name\":\"B08\",\"common_name\":\"nir\",\"center_wavelength\":0.842,\"full_width_half_max\":0.145}],\"file:size\":133559679,\"proj:shape\":[10980,10980],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":10}],\"file:checksum\":\"1220554285286e6dc529d78438689e81762a9aee8595b3cf80c7424e8154c6cb5a84\",\"proj:transform\":[10,0,300000,0,-10,6200020]},\"red\":{\"gsd\":10,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B04.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Red - 10m\",\"eo:bands\":[{\"name\":\"B04\",\"common_name\":\"red\",\"center_wavelength\":0.665,\"full_width_half_max\":0.038}],\"file:size\":133807213,\"proj:shape\":[10980,10980],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":10}],\"file:checksum\":\"1220c430e71e9c3ac487660cbf5b4f5a5151ef0a9a53cb3964143394386e5e6696ee\",\"proj:transform\":[10,0,300000,0,-10,6200020]},\"scl\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/SCL.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\"],\"title\":\"Scene classification map (SCL)\",\"file:size\":1195551,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"nodata\":0,\"data_type\":\"uint8\",\"spatial_resolution\":20}],\"file:checksum\":\"12201803d9dedbdcf77cfa05815bf082381857ca898ccd2fb98370387a8287361e6f\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"wvp\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/WVP.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\"],\"title\":\"Water Vapour (WVP)\",\"file:size\":186898,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"unit\":\"cm\",\"scale\":0.001,\"nodata\":0,\"offset\":0,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"1220fa2cbfb26c752d559b1b568e507c4ee8367fd1bd8aa638add4ed021cf5401d10\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"blue\":{\"gsd\":10,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B02.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Blue - 10m\",\"eo:bands\":[{\"name\":\"B02\",\"common_name\":\"blue\",\"center_wavelength\":0.49,\"full_width_half_max\":0.098}],\"file:size\":139809856,\"proj:shape\":[10980,10980],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":10}],\"file:checksum\":\"12208f0dcac15d931e1a916f60f956926425ef868510ebaca4d9c6299a0f7a25c4e4\",\"proj:transform\":[10,0,300000,0,-10,6200020]},\"snow\":{\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/SNW_20m.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"snow-ice\"],\"title\":\"Snow Probabilities\",\"file:size\":53931,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"nodata\":0,\"data_type\":\"uint8\",\"spatial_resolution\":20}],\"file:checksum\":\"1220a2e0bc57410a89813d86952206c620f6b0c29e580ea1b602a31a9f8f18b3f204\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"cloud\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/CLD_20m.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"cloud\"],\"title\":\"Cloud Probabilities\",\"file:size\":3905464,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"nodata\":0,\"data_type\":\"uint8\",\"spatial_resolution\":20}],\"file:checksum\":\"12203b479cd7a439ec4de573b5d80748eb7e8535ce7815e33f749c20d92f1e7f3b62\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"green\":{\"gsd\":10,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B03.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Green - 10m\",\"eo:bands\":[{\"name\":\"B03\",\"common_name\":\"green\",\"center_wavelength\":0.56,\"full_width_half_max\":0.045}],\"file:size\":136213923,\"proj:shape\":[10980,10980],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":10}],\"file:checksum\":\"12203c216da945d32b1e2175ae1d17ab5bb2c163fa88a205e83dfb674383a0da7f58\",\"proj:transform\":[10,0,300000,0,-10,6200020]},\"nir08\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B8A.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"NIR 2 - 20m\",\"eo:bands\":[{\"name\":\"B8A\",\"common_name\":\"nir08\",\"center_wavelength\":0.865,\"full_width_half_max\":0.033}],\"file:size\":35952693,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"1220a6fc63bf322fc11ce9ac689970bd8e88d01d09efc4107eca2b7df9b99aec6f81\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"nir09\":{\"gsd\":60,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B09.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"NIR 3 - 60m\",\"eo:bands\":[{\"name\":\"B09\",\"common_name\":\"nir09\",\"center_wavelength\":0.945,\"full_width_half_max\":0.026}],\"file:size\":4399445,\"proj:shape\":[1830,1830],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":60}],\"file:checksum\":\"12209778a02fd1bd25ca48b3397d6783d71165b741a577aa0a5386de7e10b78a9a3a\",\"proj:transform\":[60,0,300000,0,-60,6200020]},\"swir16\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B11.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"SWIR 1.6μm - 20m\",\"eo:bands\":[{\"name\":\"B11\",\"common_name\":\"swir16\",\"center_wavelength\":1.61,\"full_width_half_max\":0.143}],\"file:size\":35152883,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"122006417729758845a7f6bae55f5fd3b67e9039157d90fc0aedef4080ec5840ba78\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"swir22\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B12.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"SWIR 2.2μm - 20m\",\"eo:bands\":[{\"name\":\"B12\",\"common_name\":\"swir22\",\"center_wavelength\":2.19,\"full_width_half_max\":0.242}],\"file:size\":35968600,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"12206c533c7371eded58f9e85b640cbbda9f69be10bb4dce7144b2843a4eae92e92c\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"visual\":{\"gsd\":10,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/TCI.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"visual\"],\"title\":\"True color image\",\"eo:bands\":[{\"name\":\"B04\",\"common_name\":\"red\",\"center_wavelength\":0.665,\"full_width_half_max\":0.038},{\"name\":\"B03\",\"common_name\":\"green\",\"center_wavelength\":0.56,\"full_width_half_max\":0.045},{\"name\":\"B02\",\"common_name\":\"blue\",\"center_wavelength\":0.49,\"full_width_half_max\":0.098}],\"file:size\":134313884,\"proj:shape\":[10980,10980],\"raster:bands\":[{\"nodata\":0,\"data_type\":\"uint8\",\"spatial_resolution\":10},{\"nodata\":0,\"data_type\":\"uint8\",\"spatial_resolution\":10},{\"nodata\":0,\"data_type\":\"uint8\",\"spatial_resolution\":10}],\"file:checksum\":\"1220d594a4d8fbae5afcde075c02868b80ddf94049f832b427cad44c2bc2ec48cea1\",\"proj:transform\":[10,0,300000,0,-10,6200020]},\"coastal\":{\"gsd\":60,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B01.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Coastal - 60m\",\"eo:bands\":[{\"name\":\"B01\",\"common_name\":\"coastal\",\"center_wavelength\":0.443,\"full_width_half_max\":0.027}],\"file:size\":4041730,\"proj:shape\":[1830,1830],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":60}],\"file:checksum\":\"1220bf8b9d3d17783c45ba128c64da4b821f121a817b769615894279df9307f408aa\",\"proj:transform\":[60,0,300000,0,-60,6200020]},\"preview\":{\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/L2A_PVI.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"overview\"],\"title\":\"True color preview\",\"eo:bands\":[{\"name\":\"B04\",\"common_name\":\"red\",\"center_wavelength\":0.665,\"full_width_half_max\":0.038},{\"name\":\"B03\",\"common_name\":\"green\",\"center_wavelength\":0.56,\"full_width_half_max\":0.045},{\"name\":\"B02\",\"common_name\":\"blue\",\"center_wavelength\":0.49,\"full_width_half_max\":0.098}],\"file:size\":143296,\"file:checksum\":\"1220a6d5e1e1df4e2decbcb1d180295019e3c23a3c650cd3a25c5397b09ac541199c\"},\"rededge1\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B05.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Red Edge 1 - 20m\",\"eo:bands\":[{\"name\":\"B05\",\"common_name\":\"rededge\",\"center_wavelength\":0.704,\"full_width_half_max\":0.019}],\"file:size\":36112592,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"1220d7ed056f05d925711c31aa1eceb4bf339b9db4c4c0591c4058a62b9b785062e2\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"rededge2\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B06.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Red Edge 2 - 20m\",\"eo:bands\":[{\"name\":\"B06\",\"common_name\":\"rededge\",\"center_wavelength\":0.74,\"full_width_half_max\":0.018}],\"file:size\":35889857,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"12206511a70a2f30a4b168533c1c8c70bcce53836d19e19cd29d941ac689b0540c44\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"rededge3\":{\"gsd\":20,\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/B07.tif\",\"type\":\"image/tiff; application=geotiff; profile=cloud-optimized\",\"roles\":[\"data\",\"reflectance\"],\"title\":\"Red Edge 3 - 20m\",\"eo:bands\":[{\"name\":\"B07\",\"common_name\":\"rededge\",\"center_wavelength\":0.783,\"full_width_half_max\":0.028}],\"file:size\":36093258,\"proj:shape\":[5490,5490],\"raster:bands\":[{\"scale\":0.0001,\"nodata\":0,\"offset\":-0.1,\"data_type\":\"uint16\",\"spatial_resolution\":20}],\"file:checksum\":\"12208ba064ff4d4d2f65903a1c3d9084a2b82e5a848b4b39e8ea9e8b9a409fc33069\",\"proj:transform\":[20,0,300000,0,-20,6200020]},\"thumbnail\":{\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/L2A_PVI.jpg\",\"type\":\"image/jpeg\",\"roles\":[\"thumbnail\"],\"title\":\"Thumbnail of preview image\",\"file:size\":28745,\"file:checksum\":\"12209e0e170b9f5c28771c86eb8c35b3ade75e847b0eee99747c939044eeb0ae71d6\"},\"granule_metadata\":{\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/metadata.xml\",\"type\":\"application/xml\",\"roles\":[\"metadata\"],\"file:size\":375533,\"file:checksum\":\"1220703c9620d14c017a8cb80af8c19b53d94287f0c202b2ec5e05db23be749c6957\"},\"product_metadata\":{\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/product_metadata.xml\",\"type\":\"application/xml\",\"roles\":[\"metadata\"],\"file:size\":54893,\"file:checksum\":\"12204173b8a9029b04fa726763c0051e84654f5e1780840107bd37f79633391ef702\"},\"tileinfo_metadata\":{\"href\":\"https://e84-earth-search-sentinel-data.s3.us-west-2.amazonaws.com/sentinel-2-c1-l2a/53/H/LB/2025/4/S2B_T53HLB_20250416T010240_L2A/tileInfo.json\",\"type\":\"application/json\",\"roles\":[\"metadata\"],\"file:size\":1521,\"file:checksum\":\"1220e35fa9e4140350903268c9db4cb83c5582827bd26ea1eea7e61b52041c50ab50\"}},\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[133.2986412494336,-34.329310504040116],[132.98667821085152,-35.31448967467239],[134.00756378262122,-35.32718167573166],[134.0193728890354,-34.33720451171367],[133.2986412494336,-34.329310504040116]]]},\"collection\":\"dark-dream-2670-sentinel-2-c1-l2a\",\"properties\":{\"created\":\"2025-04-16T03:28:24.759Z\",\"updated\":\"2025-04-16T03:28:24.759Z\",\"datetime\":\"2025-04-16T01:07:00.210000Z\",\"platform\":\"sentinel-2b\",\"grid:code\":\"MGRS-53HLB\",\"proj:code\":\"EPSG:32753\",\"s2:tile_id\":\"S2B_OPER_MSI_L2A_TL_2BPS_20250416T023215_A042356_T53HLB_N05.11\",\"instruments\":[\"msi\"],\"view:azimuth\":104.01898444691977,\"constellation\":\"sentinel-2\",\"mgrs:utm_zone\":53,\"proj:centroid\":{\"lat\":-34.85543,\"lon\":133.5735},\"eo:cloud_cover\":36.831665,\"s2:datatake_id\":\"GS2B_20250416T005709_042356_N05.11\",\"s2:product_uri\":\"S2B_MSIL2A_20250416T005709_N0511_R002_T53HLB_20250416T023215.SAFE\",\"storage:region\":\"us-west-2\",\"s2:datastrip_id\":\"S2B_OPER_MSI_L2A_DS_2BPS_20250416T023215_S20250416T010240_N05.11\",\"s2:product_type\":\"S2MSI2A\",\"mgrs:grid_square\":\"LB\",\"s2:datatake_type\":\"INS-NOBS\",\"storage:platform\":\"AWS\",\"view:sun_azimuth\":37.707085408929,\"mgrs:latitude_band\":\"H\",\"s2:generation_time\":\"2025-04-16T02:32:15.000000Z\",\"view:sun_elevation\":36.9090104766897,\"processing:software\":{\"sentinel-2-c1-l2a-to-stac\":\"v2024.02.01\"},\"s2:water_percentage\":63.167995,\"view:incidence_angle\":8.65244882282184,\"earthsearch:payload_id\":\"roda-sentinel-2-c1-l2a/workflow-sentinel-2-c1-l2a-to-stac/28166453882f488743e2ffeb07d3f531\",\"s2:processing_baseline\":\"05.11\",\"s2:snow_ice_percentage\":0,\"storage:requester_pays\":false,\"s2:vegetation_percentage\":0.000348,\"s2:thin_cirrus_percentage\":0.000658,\"s2:cloud_shadow_percentage\":0,\"s2:nodata_pixel_percentage\":27.439335,\"s2:unclassified_percentage\":0,\"s2:not_vegetated_percentage\":0,\"s2:degraded_msi_data_percentage\":0.0099,\"s2:high_proba_clouds_percentage\":25.317863,\"s2:reflectance_conversion_factor\":0.995650530204258,\"s2:medium_proba_clouds_percentage\":11.513144,\"s2:saturated_defective_pixel_percentage\":0},\"stac_version\":\"1.1.0\",\"stac_extensions\":[\"https://stac-extensions.github.io/eo/v1.1.0/schema.json\",\"https://stac-extensions.github.io/file/v2.1.0/schema.json\",\"https://stac-extensions.github.io/grid/v1.1.0/schema.json\",\"https://stac-extensions.github.io/mgrs/v1.0.0/schema.json\",\"https://stac-extensions.github.io/processing/v1.1.0/schema.json\",\"https://stac-extensions.github.io/projection/v2.0.0/schema.json\",\"https://stac-extensions.github.io/raster/v1.1.0/schema.json\",\"https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json\",\"https://stac-extensions.github.io/storage/v1.0.0/schema.json\",\"https://stac-extensions.github.io/view/v1.0.0/schema.json\"]}],\"numberReturned\":1}\n"
+ ]
+ }
+ ],
"source": [
"search_results = db.search(query={\"collections\": [my_collection.id], \"limit\": 1})\n",
"\n",
@@ -420,10 +8513,21 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 16,
"id": "81f8addb-d890-434f-984e-b4ea591e94ef",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "('quiet-surf-4719-sentinel-2-c1-l2a', 2521)\n",
+ "('glad-global-forest-change-1.11', 100)\n",
+ "('super-mouse-3144-sentinel-2-c1-l2a', 12)\n",
+ "('dark-dream-2670-sentinel-2-c1-l2a', 948)\n"
+ ]
+ }
+ ],
"source": [
"summary_query = db.query(\n",
" \"SELECT collection, COUNT(*) as count FROM items GROUP BY collection;\"\n",
@@ -434,7 +8538,7 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 17,
"id": "a9438ee2-50ee-48e6-907f-4b7e6c6ac151",
"metadata": {},
"outputs": [],
@@ -467,7 +8571,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.11"
+ "version": "3.12.13"
}
},
"nbformat": 4,
diff --git a/docs/03-stac_fastapi_pgstac.ipynb b/docs/03-stac_fastapi_pgstac.ipynb
index 4ebe046..548cefa 100644
--- a/docs/03-stac_fastapi_pgstac.ipynb
+++ b/docs/03-stac_fastapi_pgstac.ipynb
@@ -1,553 +1,27820 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# 3. The STAC API: stac-fastapi-pgstac\n",
- "\n",
- "The STAC API provided by eoAPI is [stac-fastapi-pgstac](https://github.com/stac-utils/stac-fastapi-pgstac): a stac-fastapi application with a pgstac backend.\n",
- "stac-fastapi-pgstac translates STAC API requests into pgstac queries and returns the results to the requester.\n",
- "\n",
- "The stac-fastapi-pgstac STAC API can be accessed using any HTTP client but STAC API clients like `pystac-client` provide a more intuitive interface. In this tutorial you will learn how to use HTTP requests via `httpx` as well as `pystac-client` methods."
- ],
- "id": "7f4f9109-ac1f-473b-b30b-c35294ae426d"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## 3.1 stac-fastapi-pgstac structure\n",
- "\n",
- "A standard eoAPI deployment will run an unmodified version of the FastAPI application defined in `stac_fastapi.pgstac.app:app` ([source](https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/app.py)). Unless otherwise specified, all of the extensions except the `transaction` and `bulk-transaction` extensions will be enabled but be sure to double check this in your own deployment.\n",
- "\n",
- "
\n",
- "Warning: Do not turn on the transaction or bulk transactions extensions for a public-facing STAC API without some kind of auth layer enabled! Enabling the transactions extensions enables users to POST collections or items to the database via stac-fastapi-pgstac.\n",
- "
\n",
- "\n",
- "stac-fastapi-pgstac implements a pgstac client that is capable of serving the routes defined by stac-fastapi's base `StacApi` factory class ([source](https://github.com/stac-utils/stac-fastapi/blob/main/stac_fastapi/api/stac_fastapi/api/app.py)). The pgstac client's methods contain the logic for translating API requests into pgstac database queries.\n",
- "\n",
- "For example, a search request for items in the \"amazing\" collection where the item bounding box intersects (0, 0, 10, 10) would get converted to a PostgreSQL query like this pseudo-sql:\n",
- "```sql\n",
- "SELECT * FROM items\n",
- "WHERE \n",
- " collection = 'amazing' AND\n",
- " ST_Intersects(bbox, ST_MakeEnvelope(0, 0, 10, 10));\n",
- "```\n",
- "stac-fastapi-pgstac transforms the search results into the format expected in the API response and return it to the user. If you want to see how the actual SQL queries look in `pgstac`, check out the [pgstac source code](https://github.com/stac-utils/pgstac/tree/main/src/pgstac/sql).\n",
- "\n",
- "### 3.1.1 Customization\n",
- "There are several options in the default stac-fastapi-pgstac application that are configurable at run time via environment variables (using [pydantic's settings features](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)):\n",
- "- the `ENABLED_EXTENSIONS` environment variable controls which extensions are enabled\n",
- "- `pgstac` database credentials are set by `POSTGRES_*` environment variables ([source](https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/config.py))\n",
- "- take a look at [stac_fastapi/pgstac/config.py](https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/config.py) for the settings module.\n",
- "\n",
- "Any other modifications to the default application will require a custom runtime in your eoAPI deployment. If you do this you will need to provide the full custom runtime (application code and handler) via a Dockerfile. Check out [eoapi-devseed](https://github.com/developmentseed/eoapi-devseed) for an example of building custom runtimes for eoAPI services.\n",
- "\n",
- "### 3.1.2 Authentication\n",
- "stac-fastapi-pgstac does not contain any authentication mechanism out-of-the-box, meaning your STAC API will be accessible to anyone if it is deployed to a public web address. If you want to make your STAC API accessible only with a username/password or token, check out the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/security) for examples of how to add them to the application in a custom runtime.\n",
- "\n",
- "There is a new project called [stac-auth-proxy](https://github.com/developmentseed/stac-auth-proxy) that can provide fine-grained access controls to a STAC API by adding a proxy layer between users and the actual STAC API. In this workshop stack, the STAC API is exposed through stac-auth-proxy at `http://localhost:8084`.\n",
- "\n",
- "See [6. STAC Transactions with Authentication](06-stac_transactions_auth.ipynb) for a hands-on exercise with authenticated writes.\n",
- "\n",
- "### 3.1.3 STAC API interface\n",
- "Once your STAC API is up and running, its capabilities will be described in the `/conformance` endpoint response:"
- ],
- "id": "c8ec4c5b-1156-4608-8ab9-b2cdcbcc47e1"
- },
- {
- "cell_type": "code",
- "metadata": {
- "editable": true,
- "slideshow": {
- "slide_type": ""
- },
- "tags": []
- },
- "source": [
- "import json\n",
- "import os\n",
- "\n",
- "import httpx\n",
- "\n",
- "stac_api_endpoint = os.getenv(\"STAC_API_ENDPOINT\")\n",
- "\n",
- "conformance_response = httpx.get(f\"{stac_api_endpoint}/conformance\").json()\n",
- "\n",
- "print(stac_api_endpoint)\n",
- "print(json.dumps(conformance_response, indent=2))"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "3d4e7fe4-c21b-45c8-a56c-9b649f9f4924"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "The result is hard (for a human) to read, but these conformance classes help client applications (like `pystac-client` or STAC Browser) understand the API's capabilities. The list will change as you enable/disable various extensions or endpoints.\n",
- "\n",
- "
\n",
- "Note: If you visit the urls listed in the conformance classes you may get a 404 - this is expected\n",
- "
"
- ],
- "id": "94af52ad-87b1-461c-8d74-fc0158e488e5"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## 3.2 Collections\n",
- "\n",
- "The `/collections` endpoint is useful for finding collections in the catalog. To retrieve all collections in the catalog you can simply send a GET request to the `/collections` endpoint. This will return a paginated list (length of each page is set by the `limit` parameter) of all of the collections in the catalog."
- ],
- "id": "09e419d9-42e8-4b4b-9fdc-8d118bcc87c1"
- },
- {
- "cell_type": "code",
- "metadata": {
- "scrolled": true
- },
- "source": [
- "collections_response = httpx.get(\n",
- " f\"{stac_api_endpoint}/collections\", params={\"limit\": 2}\n",
- ").json()\n",
- "\n",
- "print(json.dumps(collections_response, indent=2))"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "ca0033ed-4898-4c21-8aa9-aaf79fc8d570"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### 3.2.1 All Collections\n",
- "You can retrieve all of a catalog's collection using the `get_all_collections` method from `pystac-client`:"
- ],
- "id": "ad74c861-7b4b-491e-9e17-82bcafd1cec4"
- },
- {
- "cell_type": "code",
- "metadata": {},
- "source": [
- "import pystac_client\n",
- "\n",
- "client = pystac_client.Client.open(stac_api_endpoint)\n",
- "\n",
- "collections = list(client.get_all_collections())\n",
- "for collection in collections:\n",
- " print(collection.id)"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "48664f4b-453d-47b8-9e3b-24a13c11cfcd"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### 3.2.2 Collection Search Query\n",
- "Some APIs contain many many collections so, if the `collection-search` extension is enabled, it can be helpful to apply filters using the available query parameters like:\n",
- "- `q`: free-text search parameter\n",
- "- `datetime`: temporal filters\n",
- "- `bbox`: spatial filters\n",
- "- `filter`: cql2-text filters\n",
- "\n",
- "To check if any STAC API has the `collection-search` extension enabled, you can look for it in the `/conformance` endpoint response."
- ],
- "id": "c066ffe0-5506-4268-be8b-26329b4e70fd"
- },
- {
- "cell_type": "code",
- "metadata": {
- "scrolled": true
- },
- "source": [
- "for conformance_class in conformance_response[\"conformsTo\"]:\n",
- " if \"collection-search\" in conformance_class:\n",
- " print(conformance_class)"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "6a463071-ade7-4baf-bba5-500a9304d145"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Since the `collection-search` base conformance class is listed that means we can pass the `bbox` and `datetime` parameters to the `/collections` endpoint. Additional parameters are unlocked by the various extensions that are implemented alongside the `collection-search` extension. For example, you can also see `https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter` which means we can use the `filter` parameter in requests to the `/collections` endpoint!\n",
- "\n",
- "
\n",
- "stac-fastapi-pgstac ships with the `collection-search` extension paired with the `free-text` extension which enables simple text searches against the collection title, description, and keywords fields.\n",
- "
\n",
- "\n",
- "For a nice view of the available query parameters for the `/collections` endpoint, check out the spiffy API documentation that the `stac-fastapi-pgstac` application generates using `FastAPI`."
- ],
- "id": "b03ff54f-0548-443c-86f0-1f064f3fe7cf"
- },
- {
- "cell_type": "code",
- "metadata": {},
- "source": [
- "from IPython.display import IFrame\n",
- "\n",
- "local_stac_api_endpoint = stac_api_endpoint.replace(\"stac-auth-proxy:8000\", \"localhost:8084\")\n",
- "api_docs = (\n",
- " f\"{local_stac_api_endpoint}/api.html#/default/Get_Collections_collections_get\"\n",
- ")\n",
- "print(api_docs)\n",
- "\n",
- "IFrame(\n",
- " api_docs,\n",
- " 1200,\n",
- " 800,\n",
- ")"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "8201551a-b51d-4ee5-b238-4fd79547afe1"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Try applying the `filter` parameter to do a cql2-text query on the id field to find the collection you created in the `database` exercies.\n",
- "\n",
- "
\n",
- "Tip: Try out the CQL2 Playground to learn how to write cql2-text or cql2-json queries\n",
- "
\n",
- "\n"
- ],
- "id": "5a9f5c87-ef5b-49d3-b6b8-e8fdca6cef1e"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "If you didn't run Part 2 on Databases, you can either go back to make a username or you can copy one from section 3.2.1 collection ids."
- ],
- "id": "fb94fe39-5091-41c1-8c2c-a227eb1b5c3c"
- },
- {
- "cell_type": "code",
- "metadata": {},
- "source": [
- "import ipywidgets as widgets\n",
- "from IPython.display import display\n",
- "\n",
- "username_input = widgets.Text(\n",
- " value=None,\n",
- " placeholder=\"Enter your username\",\n",
- " description=\"username:\",\n",
- " disabled=False,\n",
- ")\n",
- "\n",
- "display(username_input)"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "30844f03-1834-4e93-9494-a59abdc4f651"
- },
- {
- "cell_type": "code",
- "metadata": {},
- "source": [
- "# using pystac-client\n",
- "my_collection_search = client.collection_search(\n",
- " filter=f\"id LIKE '%{username_input.value}%'\"\n",
- ")\n",
- "\n",
- "results = my_collection_search.collection_list()\n",
- "\n",
- "if results:\n",
- " my_collection = results[0]\n",
- " display(my_collection)"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "8d9d740d-1e2d-45c4-8fcc-1c7cb31a19cc"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "
\n",
- "Note: If your collection did not appear, try adjusting your collection search terms!\n",
- "
"
- ],
- "id": "1d7fe9b0-f05c-4e0b-a24a-e8040b9d09c4"
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "7f4f9109-ac1f-473b-b30b-c35294ae426d",
+ "metadata": {},
+ "source": [
+ "# 3. The STAC API: stac-fastapi-pgstac\n",
+ "\n",
+ "The STAC API provided by eoAPI is [stac-fastapi-pgstac](https://github.com/stac-utils/stac-fastapi-pgstac): a stac-fastapi application with a pgstac backend.\n",
+ "stac-fastapi-pgstac translates STAC API requests into pgstac queries and returns the results to the requester.\n",
+ "\n",
+ "The stac-fastapi-pgstac STAC API can be accessed using any HTTP client but STAC API clients like `pystac-client` provide a more intuitive interface. In this tutorial you will learn how to use HTTP requests via `httpx` as well as `pystac-client` methods."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c8ec4c5b-1156-4608-8ab9-b2cdcbcc47e1",
+ "metadata": {},
+ "source": [
+ "## 3.1 stac-fastapi-pgstac structure\n",
+ "\n",
+ "A standard eoAPI deployment will run an unmodified version of the FastAPI application defined in `stac_fastapi.pgstac.app:app` ([source](https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/app.py)). Unless otherwise specified, all of the extensions except the `transaction` and `bulk-transaction` extensions will be enabled but be sure to double check this in your own deployment.\n",
+ "\n",
+ "
\n",
+ "Warning: Do not turn on the transaction or bulk transactions extensions for a public-facing STAC API without some kind of auth layer enabled! Enabling the transactions extensions enables users to POST collections or items to the database via stac-fastapi-pgstac.\n",
+ "
\n",
+ "\n",
+ "stac-fastapi-pgstac implements a pgstac client that is capable of serving the routes defined by stac-fastapi's base `StacApi` factory class ([source](https://github.com/stac-utils/stac-fastapi/blob/main/stac_fastapi/api/stac_fastapi/api/app.py)). The pgstac client's methods contain the logic for translating API requests into pgstac database queries.\n",
+ "\n",
+ "For example, a search request for items in the \"amazing\" collection where the item bounding box intersects (0, 0, 10, 10) would get converted to a PostgreSQL query like this pseudo-sql:\n",
+ "```sql\n",
+ "SELECT * FROM items\n",
+ "WHERE \n",
+ " collection = 'amazing' AND\n",
+ " ST_Intersects(bbox, ST_MakeEnvelope(0, 0, 10, 10));\n",
+ "```\n",
+ "stac-fastapi-pgstac transforms the search results into the format expected in the API response and return it to the user. If you want to see how the actual SQL queries look in `pgstac`, check out the [pgstac source code](https://github.com/stac-utils/pgstac/tree/main/src/pgstac/sql).\n",
+ "\n",
+ "### 3.1.1 Customization\n",
+ "There are several options in the default stac-fastapi-pgstac application that are configurable at run time via environment variables (using [pydantic's settings features](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)):\n",
+ "- the `ENABLED_EXTENSIONS` environment variable controls which extensions are enabled\n",
+ "- `pgstac` database credentials are set by `POSTGRES_*` environment variables ([source](https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/config.py))\n",
+ "- take a look at [stac_fastapi/pgstac/config.py](https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/config.py) for the settings module.\n",
+ "\n",
+ "Any other modifications to the default application will require a custom runtime in your eoAPI deployment. If you do this you will need to provide the full custom runtime (application code and handler) via a Dockerfile. Check out [eoapi-devseed](https://github.com/developmentseed/eoapi-devseed) for an example of building custom runtimes for eoAPI services.\n",
+ "\n",
+ "### 3.1.2 Authentication\n",
+ "stac-fastapi-pgstac does not contain any authentication mechanism out-of-the-box, meaning your STAC API will be accessible to anyone if it is deployed to a public web address. If you want to make your STAC API accessible only with a username/password or token, check out the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/security) for examples of how to add them to the application in a custom runtime.\n",
+ "\n",
+ "There is a new project called [stac-auth-proxy](https://github.com/developmentseed/stac-auth-proxy) that can provide fine-grained access controls to a STAC API by adding a proxy layer between users and the actual STAC API. In this workshop stack, the STAC API is exposed through stac-auth-proxy at `http://localhost:8084`.\n",
+ "\n",
+ "See [6. STAC Transactions with Authentication](06-stac_transactions_auth.ipynb) for a hands-on exercise with authenticated writes.\n",
+ "\n",
+ "### 3.1.3 STAC API interface\n",
+ "Once your STAC API is up and running, its capabilities will be described in the `/conformance` endpoint response:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "id": "3d4e7fe4-c21b-45c8-a56c-9b649f9f4924",
+ "metadata": {
+ "editable": true,
+ "slideshow": {
+ "slide_type": ""
},
+ "tags": []
+ },
+ "outputs": [
{
- "cell_type": "code",
- "metadata": {
- "scrolled": true
- },
- "source": [
- "# using http client\n",
- "print(\n",
- " json.dumps(\n",
- " httpx.get(\n",
- " f\"{stac_api_endpoint}/collections\",\n",
- " params={\"filter\": f\"id LIKE '%{username_input.value}%'\"},\n",
- " ).json(),\n",
- " indent=2,\n",
- " )\n",
- ")"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "5f75438e-cca6-4447-b9cb-f3349ae04c76"
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "http://stac-auth-proxy:8000\n",
+ "{\n",
+ " \"conformsTo\": [\n",
+ " \"http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2\",\n",
+ " \"http://www.opengis.net/spec/cql2/1.0/conf/cql2-json\",\n",
+ " \"http://www.opengis.net/spec/cql2/1.0/conf/cql2-text\",\n",
+ " \"http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query\",\n",
+ " \"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core\",\n",
+ " \"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson\",\n",
+ " \"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30\",\n",
+ " \"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter\",\n",
+ " \"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.1/collection-search\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.1/collection-search#fields\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.1/collection-search#query\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.1/collection-search#sort\",\n",
+ " \"https://api.stacspec.org/v1.0.0-rc.2/item-search#filter\",\n",
+ " \"https://api.stacspec.org/v1.0.0/collections\",\n",
+ " \"https://api.stacspec.org/v1.0.0/collections/extensions/transaction\",\n",
+ " \"https://api.stacspec.org/v1.0.0/core\",\n",
+ " \"https://api.stacspec.org/v1.0.0/item-search\",\n",
+ " \"https://api.stacspec.org/v1.0.0/item-search#fields\",\n",
+ " \"https://api.stacspec.org/v1.0.0/item-search#query\",\n",
+ " \"https://api.stacspec.org/v1.0.0/item-search#sort\",\n",
+ " \"https://api.stacspec.org/v1.0.0/ogcapi-features\",\n",
+ " \"https://api.stacspec.org/v1.0.0/ogcapi-features#fields\",\n",
+ " \"https://api.stacspec.org/v1.0.0/ogcapi-features#query\",\n",
+ " \"https://api.stacspec.org/v1.0.0/ogcapi-features#sort\",\n",
+ " \"https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction\"\n",
+ " ]\n",
+ "}\n"
+ ]
+ }
+ ],
+ "source": [
+ "import json\n",
+ "import os\n",
+ "\n",
+ "import httpx\n",
+ "\n",
+ "stac_api_endpoint = os.getenv(\"STAC_API_ENDPOINT\")\n",
+ "\n",
+ "conformance_response = httpx.get(f\"{stac_api_endpoint}/conformance\").json()\n",
+ "\n",
+ "print(stac_api_endpoint)\n",
+ "print(json.dumps(conformance_response, indent=2))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "94af52ad-87b1-461c-8d74-fc0158e488e5",
+ "metadata": {},
+ "source": [
+ "The result is hard (for a human) to read, but these conformance classes help client applications (like `pystac-client` or STAC Browser) understand the API's capabilities. The list will change as you enable/disable various extensions or endpoints.\n",
+ "\n",
+ "
\n",
+ "Note: If you visit the urls listed in the conformance classes you may get a 404 - this is expected\n",
+ "
\n",
+ "stac-fastapi-pgstac ships with the `collection-search` extension paired with the `free-text` extension which enables simple text searches against the collection title, description, and keywords fields.\n",
+ "
\n",
+ "\n",
+ "For a nice view of the available query parameters for the `/collections` endpoint, check out the spiffy API documentation that the `stac-fastapi-pgstac` application generates using `FastAPI`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "8201551a-b51d-4ee5-b238-4fd79547afe1",
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "metadata": {},
- "source": [
- "from datetime import datetime, UTC\n",
- "\n",
- "search = client.search(\n",
- " collections=[my_collection.id],\n",
- " datetime=[datetime(2025, 1, 4), None],\n",
- ")\n",
- "\n",
- "items = search.item_collection()\n",
- "\n",
- "print(f\"found {len(items)} items\")\n",
- "items[0]"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "e73a9ef7-d8f8-407d-965e-c549f112899c"
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "http://localhost:8084/api.html#/default/Get_Collections_collections_get\n"
+ ]
},
{
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "The same query can be made with an HTTP client:"
+ "data": {
+ "text/html": [
+ "\n",
+ " \n",
+ " "
],
- "id": "3e504a62-0c1e-4f00-aa54-e286252c493b"
- },
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 19,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from IPython.display import IFrame\n",
+ "\n",
+ "local_stac_api_endpoint = os.getenv(\n",
+ " \"STAC_API_BROWSER_URL\"\n",
+ ") or stac_api_endpoint.replace(\"stac-auth-proxy:8000\", \"localhost:8084\")\n",
+ "api_docs = (\n",
+ " f\"{local_stac_api_endpoint}/api.html#/default/Get_Collections_collections_get\"\n",
+ ")\n",
+ "print(api_docs)\n",
+ "\n",
+ "IFrame(\n",
+ " api_docs,\n",
+ " 1200,\n",
+ " 800,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5a9f5c87-ef5b-49d3-b6b8-e8fdca6cef1e",
+ "metadata": {},
+ "source": [
+ "Try applying the `filter` parameter to do a cql2-text query on the id field to find the collection you created in the `database` exercies.\n",
+ "\n",
+ "
\n",
+ "Tip: Try out the CQL2 Playground to learn how to write cql2-text or cql2-json queries\n",
+ "
\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fb94fe39-5091-41c1-8c2c-a227eb1b5c3c",
+ "metadata": {},
+ "source": [
+ "If you didn't run Part 2 on Databases, you can either go back to make a username or you can copy one from section 3.2.1 collection ids."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "id": "30844f03-1834-4e93-9494-a59abdc4f651",
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "metadata": {
- "scrolled": true
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "37bee02111a6435f86b44e27a42b2683",
+ "version_major": 2,
+ "version_minor": 0
},
- "source": [
- "datetime_string = datetime(2025, 1, 4, tzinfo=UTC).isoformat()\n",
- "\n",
- "item_search_request = httpx.get(\n",
- " f\"{stac_api_endpoint}/search\",\n",
- " params={\n",
- " \"collections\": my_collection.id,\n",
- " \"datetime\": f\"{datetime_string}/..\", # open interval from 2025-04-04 forward\n",
- " \"limit\": 1, # one result per page for brevity in this example\n",
- " },\n",
- ")\n",
- "\n",
- "print(json.dumps(item_search_request.json(), indent=2))"
- ],
- "execution_count": null,
- "outputs": [],
- "id": "57405533-6bbf-402c-9b26-99b97ab234ec"
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "stac-fastapi-pgstac constructs the `next` link using a token that it can pass to a `pgstac` query to retrieve the next page of results from this search. STAC API clients like `pystac-client` use these links to concatenate paginated results without any additional input from the user."
- ],
- "id": "3625785f-8aca-4465-93bc-3c1370c0e2fb"
- },
+ "text/plain": [
+ "Text(value='super-mouse-3144', description='username:', placeholder='Enter your username')"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "import ipywidgets as widgets\n",
+ "from IPython.display import display\n",
+ "\n",
+ "# Prefill with the username of the most recent sentinel-2 collection in the\n",
+ "# catalog (created in notebook 02). If you picked your own username there,\n",
+ "# replace the value below - but keep in mind that notebook 02 generates a\n",
+ "# random default (e.g. \"quiet-surf-4719\"), so what you type here must match\n",
+ "# the collection id that actually exists in the catalog (see section 3.2.1).\n",
+ "_collections = (\n",
+ " httpx.get(f\"{stac_api_endpoint}/collections\", timeout=30)\n",
+ " .json()\n",
+ " .get(\"collections\", [])\n",
+ ")\n",
+ "_usernames = [\n",
+ " c[\"id\"].removesuffix(\"-sentinel-2-c1-l2a\")\n",
+ " for c in _collections\n",
+ " if c[\"id\"].endswith(\"-sentinel-2-c1-l2a\")\n",
+ "]\n",
+ "\n",
+ "username_input = widgets.Text(\n",
+ " value=_usernames[-1] if _usernames else None,\n",
+ " placeholder=\"Enter your username\",\n",
+ " description=\"username:\",\n",
+ " disabled=False,\n",
+ ")\n",
+ "\n",
+ "display(username_input)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "id": "8d9d740d-1e2d-45c4-8fcc-1c7cb31a19cc",
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Now limit the search to items where `eo:cloud_cover` is less than 10"
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "\n",
+ "
"
],
- "execution_count": null,
- "outputs": [],
- "id": "5972eaaf-d6b3-4bcf-b2fd-f7d451100581"
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3 (ipykernel)",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.12.11"
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 14,
+ "metadata": {},
+ "output_type": "execute_result"
}
+ ],
+ "source": [
+ "collection_client = client.get_collection(my_collection.id)\n",
+ "\n",
+ "collection_client.get_item(item_id)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7795389e-2007-4e79-b1ef-a788ac259694",
+ "metadata": {},
+ "source": [
+ "## Conclusion\n",
+ "\n",
+ "That's it! You have taken a full tour of the stac-fastapi-pgstac STAC API. Here is a look at the full API documentation for the deployed API:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5972eaaf-d6b3-4bcf-b2fd-f7d451100581",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "api_docs = f\"{local_stac_api_endpoint}/api.html\"\n",
+ "print(api_docs)\n",
+ "IFrame(api_docs, 1200, 800)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
},
- "nbformat": 4,
- "nbformat_minor": 5
-}
\ No newline at end of file
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/04-titiler_pgstac.ipynb b/docs/04-titiler_pgstac.ipynb
index 95a9bb9..2c92037 100644
--- a/docs/04-titiler_pgstac.ipynb
+++ b/docs/04-titiler_pgstac.ipynb
@@ -38,11 +38,27 @@
"metadata": {},
"outputs": [],
"source": [
+ "import os\n",
+ "\n",
+ "import httpx\n",
"import ipywidgets as widgets\n",
"from IPython.display import display\n",
"\n",
+ "# Prefill with the username of the most recent sentinel-2 collection in the\n",
+ "# catalog (created in notebook 02) — replace it with your own if it differs.\n",
+ "_collections = (\n",
+ " httpx.get(f\"{os.getenv('STAC_API_ENDPOINT')}/collections\", timeout=30)\n",
+ " .json()\n",
+ " .get(\"collections\", [])\n",
+ ")\n",
+ "_usernames = [\n",
+ " c[\"id\"].removesuffix(\"-sentinel-2-c1-l2a\")\n",
+ " for c in _collections\n",
+ " if c[\"id\"].endswith(\"-sentinel-2-c1-l2a\")\n",
+ "]\n",
+ "\n",
"username_input = widgets.Text(\n",
- " value=None,\n",
+ " value=_usernames[-1] if _usernames else None,\n",
" placeholder=\"Enter your username\",\n",
" description=\"username:\",\n",
" disabled=False,\n",
@@ -70,10 +86,13 @@
"\n",
"from IPython.display import IFrame, Image\n",
"\n",
- "titiler_pgstac_endpoint = os.getenv(\"TITILER_PGSTAC_API_ENDPOINT\").replace(\n",
- " \"titiler-pgstac\", \"localhost\"\n",
- ")\n",
- "api_docs = f\"{titiler_pgstac_endpoint}/api.html\"\n",
+ "titiler_pgstac_endpoint = os.getenv(\"TITILER_PGSTAC_API_ENDPOINT\")\n",
+ "# browser-facing URL for the IFrame/map cells (the user's browser can't reach\n",
+ "# the server-side endpoint above when running on Kubernetes or docker-compose)\n",
+ "titiler_browser_endpoint = os.getenv(\n",
+ " \"TITILER_BROWSER_URL\"\n",
+ ") or titiler_pgstac_endpoint.replace(\"titiler-pgstac\", \"localhost\")\n",
+ "api_docs = f\"{titiler_browser_endpoint}/api.html\"\n",
"print(api_docs)\n",
"\n",
"IFrame(\n",
@@ -152,7 +171,7 @@
" - Leaflet\n",
" - Mapbox\n",
"\n",
- "For now you can take a shortcut to view the map directly in this notebook using the `/map` endpoint which will generate an HTML file with a Leaflet map that can be rendered directly in the notebook.\n",
+ "For now you can take a shortcut to view the map directly in this notebook using the `/map.html` endpoint which will generate an HTML file with a Leaflet map that can be rendered directly in the notebook.\n",
"\n",
"
\n",
"It may take a while to render tiles for the full view because the titiler-pgstac container is downloading data from S3 in order to render the images - zoom in to have a better browsing experience. This performance can be improved when deploying your own eoAPI through careful preparation of data and sourcing of hardware resources, the demo runs on a very small server.\n",
@@ -167,7 +186,7 @@
"outputs": [],
"source": [
"IFrame(\n",
- " f\"{titiler_pgstac_endpoint}/collections/{collection_id}/WebMercatorQuad/map?{urlencode(params, doseq=True)}\",\n",
+ " f\"{titiler_browser_endpoint}/collections/{collection_id}/WebMercatorQuad/map.html?{urlencode(params, doseq=True)}\",\n",
" 1200,\n",
" 800,\n",
")"
@@ -180,8 +199,8 @@
"source": [
"### How does it work?\n",
"\n",
- "- titiler-pgstac is running as a Lambda (serverless) function in AWS that started up when you made the request for the `/map` endpoint.\n",
- "- The `/map` endpoint returns an HTML file that is pre-populated with some map code that includes the layer that you specified with the request parameters\n",
+ "- titiler-pgstac is running as a Lambda (serverless) function in AWS that started up when you made the request for the `/map.html` endpoint.\n",
+ "- The `/map.html` endpoint returns an HTML file that is pre-populated with some map code that includes the layer that you specified with the request parameters\n",
"- As you browse the map, the map is sending XYZ tile requests to titiler-pgstac function in AWS\n",
"- Each request contains the information titiler-pgstac needs to search for items in the pgstac database and how to construct an image from the items' assets\n",
" - `collection_id`: by specifying the collection ID in the request path you are instructing titiler-pgstac to search for items from a specific STAC collection. Unless otherwise specified, pgstac will retrieve the STAC items in descending order by datetime and it will stop returning results when a tile's geometry is completely covered.\n",
@@ -247,7 +266,7 @@
"source": [
"The response comes back with an `id` which uniquely identifies this search and a handful of useful links associated with our newly registered search.\n",
"\n",
- "Now you can browse the results of this search with the `/map` endpoint like you did earlier."
+ "Now you can browse the results of this search with the `/map.html` endpoint like you did earlier."
]
},
{
@@ -267,7 +286,7 @@
")\n",
"\n",
"IFrame(\n",
- " f\"{titiler_pgstac_endpoint}/searches/{search_id}/WebMercatorQuad/map?{urlencode(params, doseq=True)}\",\n",
+ " f\"{titiler_browser_endpoint}/searches/{search_id}/WebMercatorQuad/map.html?{urlencode(params, doseq=True)}\",\n",
" 1200,\n",
" 800,\n",
")"
@@ -303,6 +322,8 @@
"outputs": [],
"source": [
"params = (\n",
+ " (\"assets\", \"nir\"),\n",
+ " (\"assets\", \"red\"),\n",
" (\"asset_as_band\", \"True\"),\n",
" (\"expression\", \"(nir - red) / (nir + red)\"),\n",
" (\"colormap_name\", \"viridis\"),\n",
@@ -310,7 +331,7 @@
")\n",
"\n",
"IFrame(\n",
- " f\"{titiler_pgstac_endpoint}/searches/{search_id}/WebMercatorQuad/map?{urlencode(params, doseq=True)}\",\n",
+ " f\"{titiler_browser_endpoint}/searches/{search_id}/WebMercatorQuad/map.html?{urlencode(params, doseq=True)}\",\n",
" 1200,\n",
" 800,\n",
")"
@@ -366,6 +387,7 @@
" params={\n",
" \"url\": cog_href,\n",
" },\n",
+ " timeout=None,\n",
")\n",
"\n",
"print(json.dumps(cog_info_request.json(), indent=2))"
@@ -394,6 +416,7 @@
" \"url\": cog_href,\n",
" \"maxsize\": 2048,\n",
" },\n",
+ " timeout=None,\n",
")\n",
"\n",
"Image(preview_request.content)"
@@ -506,7 +529,7 @@
"outputs": [],
"source": [
"map_request = httpx.get(\n",
- " f\"{titiler_pgstac_endpoint}/external/WebMercatorQuad/map\",\n",
+ " f\"{titiler_pgstac_endpoint}/external/WebMercatorQuad/map.html\",\n",
" params={\n",
" \"url\": cog_href,\n",
" \"maxsize\": 2048,\n",
@@ -517,7 +540,7 @@
"\n",
"\n",
"IFrame(\n",
- " map_request.url,\n",
+ " str(map_request.url).replace(titiler_pgstac_endpoint, titiler_browser_endpoint),\n",
" 1200,\n",
" 800,\n",
")"
@@ -541,7 +564,7 @@
"outputs": [],
"source": [
"map_request = httpx.get(\n",
- " f\"{titiler_pgstac_endpoint}/collections/glad-global-forest-change-1.11/WebMercatorQuad/map\",\n",
+ " f\"{titiler_pgstac_endpoint}/collections/glad-global-forest-change-1.11/WebMercatorQuad/map.html\",\n",
" params={\n",
" \"assets\": \"lossyear\",\n",
" \"colormap\": json.dumps({i: rgb for i, rgb in colormap.items()}),\n",
@@ -551,7 +574,7 @@
"\n",
"\n",
"IFrame(\n",
- " map_request.url,\n",
+ " str(map_request.url).replace(titiler_pgstac_endpoint, titiler_browser_endpoint),\n",
" 1200,\n",
" 800,\n",
")"
diff --git a/docs/05-tipg.ipynb b/docs/05-tipg.ipynb
index 800f0e8..b3b9b90 100644
--- a/docs/05-tipg.ipynb
+++ b/docs/05-tipg.ipynb
@@ -60,6 +60,11 @@
"import httpx\n",
"\n",
"tipg_endpoint = os.getenv(\"TIPG_API_ENDPOINT\")\n",
+ "# browser-facing URL for the IFrame/viewer cells (the user's browser can't\n",
+ "# reach the server-side endpoint above when running on Kubernetes)\n",
+ "tipg_browser_endpoint = os.getenv(\"TIPG_BROWSER_URL\") or tipg_endpoint.replace(\n",
+ " \"tipg\", \"localhost\"\n",
+ ")\n",
"\n",
"collections_request = httpx.get(f\"{tipg_endpoint}/collections\")\n",
"\n",
@@ -76,9 +81,9 @@
"- `/collections/{collection_id}/items`: where features can be accessed\n",
"- `/collections/{collection_id}/tiles`: list of tile matrix set IDs that are available for tile requests\n",
"- `/collections/{collection_id}/tiles/{tileMatrixSetId}`: returns a tilejson for a vector tile layer\n",
- "- `/collections/{collection_id}/tiles/{tileMatrixSetId}/viewer`: interactive map of the collection\n",
+ "- `/collections/{collection_id}/tiles/{tileMatrixSetId}/map.html`: interactive map of the collection\n",
"\n",
- "The `/items`, `/tiles/{tileMatrixSetId}`, and `/tiles/{tileMatrixSetId}/viewer` endpoints will all accept field filters in the form of `{queryable}={value}` where `queryable` is one of the fields listed in the `/queryables` response for that collection."
+ "The `/items`, `/tiles/{tileMatrixSetId}`, and `/tiles/{tileMatrixSetId}/map.html` endpoints will all accept field filters in the form of `{queryable}={value}` where `queryable` is one of the fields listed in the `/queryables` response for that collection."
]
},
{
@@ -261,7 +266,7 @@
" },\n",
")\n",
"\n",
- "local_url = str(bbox_filtered_request.url).replace(\"tipg\", \"localhost\")\n",
+ "local_url = str(bbox_filtered_request.url).replace(tipg_endpoint, tipg_browser_endpoint)\n",
"\n",
"IFrame(\n",
" local_url,\n",
@@ -285,7 +290,7 @@
"metadata": {},
"outputs": [],
"source": [
- "local_tipg_endpoint = tipg_endpoint.replace(\"tipg\", \"localhost\")\n",
+ "local_tipg_endpoint = tipg_browser_endpoint\n",
"IFrame(\n",
" f\"{local_tipg_endpoint}/api.html#OGC Features API/items_collections__collectionId__items_get\",\n",
" width=1200,\n",
@@ -374,11 +379,11 @@
"outputs": [],
"source": [
"viewer_request = httpx.get(\n",
- " f\"{tipg_endpoint}/collections/{collection_id}/tiles/WebMercatorQuad/viewer\",\n",
+ " f\"{tipg_endpoint}/collections/{collection_id}/tiles/WebMercatorQuad/map.html\",\n",
")\n",
"\n",
"IFrame(\n",
- " str(viewer_request.url).replace(\"tipg\", \"localhost\"),\n",
+ " str(viewer_request.url).replace(tipg_endpoint, tipg_browser_endpoint),\n",
" width=1200,\n",
" height=800,\n",
")"
@@ -400,14 +405,14 @@
"outputs": [],
"source": [
"filtered_viewer_request = httpx.get(\n",
- " f\"{tipg_endpoint}/collections/{collection_id}/tiles/WebMercatorQuad/viewer\",\n",
+ " f\"{tipg_endpoint}/collections/{collection_id}/tiles/WebMercatorQuad/map.html\",\n",
" params={\n",
" \"na_l2name\": \"MEDITERRANEAN CALIFORNIA\",\n",
" },\n",
")\n",
"\n",
"IFrame(\n",
- " str(filtered_viewer_request.url).replace(\"tipg\", \"localhost\"),\n",
+ " str(filtered_viewer_request.url).replace(tipg_endpoint, tipg_browser_endpoint),\n",
" width=1200,\n",
" height=800,\n",
")"
diff --git a/docs/06-stac_transactions_auth.ipynb b/docs/06-stac_transactions_auth.ipynb
index f0e4dc9..f2e6ee3 100644
--- a/docs/06-stac_transactions_auth.ipynb
+++ b/docs/06-stac_transactions_auth.ipynb
@@ -32,7 +32,7 @@
"source": [
"## 6.1 Get an access token\n",
"\n",
- "To write to the API you need to prove who you are. The stack runs a small [mock OIDC server](https://github.com/alukach/mock-oidc-server) that stands in for a real identity provider (like Auth0, Keycloak, or Cognito). You ask it for a token and it hands one back \u2014 no password required, since it is only for local testing.\n",
+ "To write to the API you need to prove who you are. The stack runs a small [mock OIDC server](https://github.com/alukach/mock-oidc-server) that stands in for a real identity provider (like Auth0, Keycloak, or Cognito). You ask it for a token and it hands one back — no password required, since it is only for local testing.\n",
"\n",
"The `stac_auth` helper module wraps that exchange so the notebook stays focused on STAC:\n",
"\n",
@@ -95,7 +95,7 @@
"source": [
"## 6.2 Confirm the transactions extension is enabled\n",
"\n",
- "The transactions endpoints only exist if the API was deployed with the extension turned on. As with any STAC capability, you can check the `/conformance` response before relying on it \u2014 look for conformance classes containing `transaction`.\n",
+ "The transactions endpoints only exist if the API was deployed with the extension turned on. As with any STAC capability, you can check the `/conformance` response before relying on it — look for conformance classes containing `transaction`.\n",
"\n",
"
\n",
"Warning: Never enable the transactions extension on a public API without an auth layer. Doing so lets anyone write to your catalog. That is exactly why this deployment sits behind stac-auth-proxy.\n",
@@ -130,8 +130,8 @@
"\n",
"We `POST` the same collection twice:\n",
"\n",
- "1. **Without a token** \u2014 the proxy rejects it with `401`/`403`\n",
- "2. **With our bearer token** \u2014 the write succeeds with `201`\n",
+ "1. **Without a token** — the proxy rejects it with `401`/`403`\n",
+ "2. **With our bearer token** — the write succeeds with `201`\n",
"\n",
"Then we `GET` the collection back with no token, confirming reads stay public."
]
@@ -256,7 +256,9 @@
"updated_item = httpx.put(item_url, headers=write_headers, json=stored_item, timeout=10)\n",
"print(f\"PUT item with token -> {updated_item.status_code} (updated)\")\n",
"assert updated_item.status_code == 200, updated_item.text\n",
- "assert updated_item.json()[\"properties\"][\"description\"] == \"Updated by authenticated PUT\""
+ "assert (\n",
+ " updated_item.json()[\"properties\"][\"description\"] == \"Updated by authenticated PUT\"\n",
+ ")"
]
},
{
@@ -298,7 +300,7 @@
"\n",
"Finally, delete the temporary collection so we leave the catalog as we found it. Deleting a collection is a write too, so it also needs the token.\n",
"\n",
- "You have now exercised the full transaction life cycle \u2014 create, read, update, delete \u2014 and seen the auth proxy enforce the read/write boundary at every step."
+ "You have now exercised the full transaction life cycle — create, read, update, delete — and seen the auth proxy enforce the read/write boundary at every step."
]
},
{
@@ -311,7 +313,9 @@
"collection_url = f\"{stac_api_endpoint}/collections/{collection_id}\"\n",
"\n",
"denied_collection_delete = httpx.delete(collection_url, timeout=10)\n",
- "print(f\"DELETE collection without token -> {denied_collection_delete.status_code} (rejected)\")\n",
+ "print(\n",
+ " f\"DELETE collection without token -> {denied_collection_delete.status_code} (rejected)\"\n",
+ ")\n",
"assert denied_collection_delete.status_code in (401, 403)\n",
"\n",
"deleted_collection = httpx.delete(collection_url, headers=write_headers, timeout=10)\n",
diff --git a/docs/stac_auth.py b/docs/stac_auth.py
index eee7c70..27be225 100644
--- a/docs/stac_auth.py
+++ b/docs/stac_auth.py
@@ -13,9 +13,7 @@
import httpx
-_TOKEN_PATTERN = re.compile(
- r'', re.S
-)
+_TOKEN_PATTERN = re.compile(r'', re.S)
def stac_endpoint() -> str:
diff --git a/infrastructure/charts/eoapi-workshop/.gitignore b/infrastructure/charts/eoapi-workshop/.gitignore
new file mode 100644
index 0000000..e2cdd2e
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/.gitignore
@@ -0,0 +1,6 @@
+# Host-specific install overrides generated by deploy.sh — never committed.
+.deploy/
+
+# Vendored chart dependencies — rebuilt from Chart.lock by `helm dependency
+# build` (deploy.sh runs it). Chart.lock IS tracked; the archives are not.
+charts/
diff --git a/infrastructure/charts/eoapi-workshop/.helmignore b/infrastructure/charts/eoapi-workshop/.helmignore
new file mode 100644
index 0000000..f6bf03f
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/.helmignore
@@ -0,0 +1,13 @@
+# Patterns to ignore when building Helm packages.
+.DS_Store
+.git/
+.gitignore
+*.tmproj
+*.swp
+*.bak
+*.orig
+.idea/
+.vscode/
+# Local tooling / generated artifacts — not part of the packaged chart.
+deploy.sh
+.deploy/
diff --git a/infrastructure/charts/eoapi-workshop/Chart.lock b/infrastructure/charts/eoapi-workshop/Chart.lock
new file mode 100644
index 0000000..63780a7
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/Chart.lock
@@ -0,0 +1,9 @@
+dependencies:
+- name: eoapi
+ repository: https://developmentseed.org/eoapi-k8s/
+ version: 0.13.1
+- name: stac-manager
+ repository: https://stac-manager.ds.io/
+ version: 1.0.3
+digest: sha256:c266058775f9745e48df835657208d23f5785875904049c880093faa5a7886bd
+generated: "2026-07-01T17:27:13.34478+03:00"
diff --git a/infrastructure/charts/eoapi-workshop/Chart.yaml b/infrastructure/charts/eoapi-workshop/Chart.yaml
new file mode 100644
index 0000000..ea356bc
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v2
+name: eoapi-workshop
+description: Minimal, docker-compose-aligned eoAPI deployment for the workshop (no observability/monitoring stack)
+type: application
+version: 0.1.0
+# appVersion tracks the eoAPI application shipped by the eoapi dependency below.
+appVersion: "6.3.1"
+icon: https://eoapi.dev/img/eoAPI.png
+home: https://github.com/developmentseed/eoapi-workshop
+sources:
+ - https://github.com/developmentseed/eoapi-workshop
+ - https://github.com/developmentseed/eoapi-k8s
+dependencies:
+ # Upstream eoAPI chart. Its packaged .tgz vendors its own subcharts
+ # (postgrescluster, stac-auth-proxy, prometheus, grafana, knative, ...).
+ # Disabled components are turned off via values; their `condition` flags
+ # keep them from rendering, so only the workshop services are deployed.
+ # NOTE: devseed.com/eoapi-k8s/ 301-redirects to developmentseed.org/eoapi-k8s/.
+ - name: eoapi
+ version: 0.13.1
+ repository: https://developmentseed.org/eoapi-k8s/
+ # stac-manager: STAC collection/item editing UI (deployed the EOEPCA way —
+ # its published chart). Routed at /manager via the passthrough ingress.
+ - name: stac-manager
+ version: 1.0.3
+ repository: https://stac-manager.ds.io/
+ condition: stac-manager.enabled
diff --git a/infrastructure/charts/eoapi-workshop/README.md b/infrastructure/charts/eoapi-workshop/README.md
new file mode 100644
index 0000000..b0d906f
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/README.md
@@ -0,0 +1,158 @@
+# eoapi-workshop Helm chart
+
+A docker-compose-aligned Helm deployment of [eoAPI](https://eoapi.dev) for the
+workshop: an *umbrella* chart over the upstream
+[`eoapi`](https://github.com/developmentseed/eoapi-k8s) and
+[`stac-manager`](https://github.com/developmentseed/stac-manager) charts, plus
+per-participant **JupyterLab** environments — no observability/monitoring stack.
+
+Every service is served at the **root of its own subdomain** under a wildcard
+domain (`*.`, default `eoapi-workshop.ds.io`).
+
+## What gets deployed
+
+| Component | Subdomain of `eoapi-workshop.ds.io` | Notes |
+|---|---|---|
+| STAC API (via stac-auth-proxy) | `stac.` | pgstac + stac-fastapi, fronted by the auth proxy |
+| Raster (titiler-pgstac) | `raster.` | |
+| Vector (tipg) | `vector.` | serves `features.ecoregions` (loaded by the features-loader Job) |
+| STAC Browser | `browser.` | root-serving `radiantearth/stac-browser` |
+| STAC Manager (editing UI) | `manager.` | `stac-manager` chart 1.0.3 |
+| Mock OIDC server | `mock-oidc.` | test-only auth |
+| JupyterLab × N | `lab-01.`…`lab-05.` | one isolated pod + PVC + token each |
+| Database (pgstac) | in-cluster only | Crunchy `PostgresCluster` |
+
+Disabled (unlike upstream `experimental.yaml`): `multidim`, `docServer`,
+`eoapi-notifier`, `knative`, `monitoring.*`, `observability.grafana`, autoscaling.
+
+## Contracts (read first)
+
+- **Wildcard DNS required** — `*.` must A-record to the ingress
+ LoadBalancer IP (check: `dig +short stac.eoapi-workshop.ds.io`).
+- **Release name and namespace must both be `eoapi`** — the proxy's in-cluster
+ OIDC URL (`eoapi-mock-oidc-server.eoapi.svc…`) is derived from them. `deploy.sh`
+ defaults to this.
+- **Test-only auth, http by default** — the mock OIDC ships `test-client` /
+ `test-secret` and reads are public (`DEFAULT_PUBLIC=true`). STAC Manager (and
+ Browser) *login/editing* needs a secure context, so enable `routing.tls` for
+ HTTPS; over http the UIs are browse/read-only. Not for production.
+
+## Prerequisites
+
+Kubernetes 1.23+ with an **NGINX ingress controller**, the **Crunchy Postgres
+Operator (PGO)** (hard requirement — `postgrescluster` only reconciles if PGO/CRDs
+are installed), Helm 3.8+, and the wildcard DNS above. `deploy.sh` installs the
+two operators for you (unless `SKIP_PREREQS=1`).
+
+## Deploy
+
+`deploy.sh` installs prerequisites, generates host overrides (per-subdomain URLs +
+a stable per-participant token), installs the release, waits for rollouts, and
+verifies end-to-end. Idempotent — tokens/URLs stay stable across re-runs.
+
+```bash
+cd infrastructure/charts/eoapi-workshop
+./deploy.sh deploy # prerequisites + chart + verify
+./deploy.sh verify # re-run endpoint/auth checks, print Lab URLs
+./deploy.sh urls # print participant Lab URLs (+ tokens)
+./deploy.sh teardown [--all] # remove release (--all also removes operators)
+```
+
+Env vars: `BASE_DOMAIN` (default `eoapi-workshop.ds.io`), `SKIP_PREREQS=1`,
+`GHCR_USER`+`GHCR_TOKEN` (pull secret for a private image — see
+[Participant JupyterLabs](#participant-jupyterlabs)). `RELEASE`/`NAMESPACE` must
+stay `eoapi`.
+
+The pgstac DB is created asynchronously by PGO and seeded with sample STAC data,
+so API pods may restart a few times before `Ready` on first install.
+
+To install without `deploy.sh`: `helm dependency update`, then `helm install eoapi
+. -n eoapi --create-namespace` with a `-f` overrides file (generate one for a
+non-default domain via `BASE_DOMAIN=… ./deploy.sh overrides`).
+
+## Routing
+
+All routing is one Ingress (`templates/subdomain-ingress.yaml`): a host rule per
+service, each serving at `/` with no rewrite. The upstream path-based ingress is
+off and each app serves at its subdomain root — stac/raster/vector with
+`--root-path=`, proxy `ROOT_PATH=""`, browser via the root-serving
+`radiantearth/stac-browser`, Labs without `--ServerApp.base_url`. Per-subdomain
+URLs default to the workshop domain in `values.yaml`; `deploy.sh` rewrites them for
+another `BASE_DOMAIN` via the gitignored `.deploy/overrides.yaml`.
+
+## Verify
+
+`./deploy.sh verify` checks every service subdomain, runs the auth test, and prints
+the Lab URLs. Manually:
+
+```bash
+kubectl -n eoapi get pods
+curl -s http://stac.eoapi-workshop.ds.io/healthz # also raster. / vector.
+curl -s http://stac.eoapi-workshop.ds.io/collections # sample items
+# UIs: browser. manager. mock-oidc./.well-known/openid-configuration
+```
+
+## Participant JupyterLabs
+
+`jupyter.participants` (default `lab-01`…`lab-05`; edit for any N) → one Deployment
++ Service + PVC each at `.`, running the GHCR image
+`ghcr.io/developmentseed/eoapi-workshop` (built by
+`.github/workflows/publish-workshop-image.yml`). Each Lab gets the eoAPI endpoints
++ DB creds injected (from the `eoapi-pguser-eoapi` PGO secret) and an access token
+(`./deploy.sh urls` prints them).
+
+- **Persistence:** notebooks come fresh from the image (`/home/jovyan/docs`) on
+ every start, so updates always appear; only `/home/jovyan/work` persists (save
+ work there — edits to the provided notebooks reset on restart).
+- **Private image:** GHCR packages are private by default. Either make the package
+ public, or pass a pull token — `GHCR_USER= GHCR_TOKEN=
+ ./deploy.sh deploy` creates the `ghcr-pull` secret and wires it to the default
+ ServiceAccount before the Labs start.
+
+## Testing auth
+
+`stac-auth-proxy` fronts STAC at `stac.`: **GET is public, mutations
+need a bearer token** from the mock OIDC server (`jq` required).
+
+```bash
+b=eoapi-workshop.ds.io
+curl -s -o/dev/null -w '%{http_code}\n' http://stac.$b/collections # 200 (public read)
+curl -s -o/dev/null -w '%{http_code}\n' -X POST http://stac.$b/collections \
+ -H 'Content-Type: application/json' -d '{}' # 401 (no token)
+TOKEN=$(curl -s http://mock-oidc.$b/ \
+ --data-raw 'username=testuser&scopes=openid+stac:read+stac:write' \
+ -H 'Accept: application/json' | jq -r .token)
+curl -s -o/dev/null -w '%{http_code}\n' -X POST http://stac.$b/collections \
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}' # NOT 401
+```
+
+**401 without a token, non-401 with one** = working. If it stays 401, check
+`kubectl -n eoapi logs deploy/eoapi-stac-auth-proxy` (usual cause: release/namespace
+not `eoapi`).
+
+## Upgrade / uninstall
+
+```bash
+./deploy.sh deploy # idempotent re-deploy (tokens preserved)
+helm uninstall eoapi -n eoapi # or ./deploy.sh teardown
+kubectl -n eoapi delete pvc --all # PVCs (DB + Lab work) are retained by design
+```
+
+## Notebook data
+
+The workshop notebooks (`docs/00`–`06`) run in the Labs against this deployment:
+- `pgstacBootstrap.loadSamples` is **off** — the upstream sample collection
+ `noaa-emergency-response` is stored without a STAC `type` field and breaks
+ `pystac_client` (notebook 03). The notebooks create their own STAC data.
+- the **features-loader Job** (`featuresLoader.enabled`) loads the NA CEC Level III
+ Ecoregions into `features.ecoregions`, and tipg is configured with
+ `TIPG_DB_SCHEMAS=["features","public"]`, so notebook 05 has vector data.
+
+## Limitations
+- **UI login needs TLS** — STAC Manager / Browser OIDC login uses PKCE (needs
+ HTTPS); over http they're read-only. Enable `routing.tls`. (Browser's
+ `redirect_uri` also still derives from the apex host upstream.)
+- **Capacity** — N always-on Labs at `limit 2 CPU / 4Gi` (default 5 ≈ ≤10 CPU /
+ 20Gi) + stac-manager's ~4Gi startup build + the backend. Size nodes to N.
+- **Not production** — test auth, single 1-replica DB (5Gi), http. For production
+ use the CDK/AWS stack in [`DEPLOYMENT.md`](../../../DEPLOYMENT.md).
diff --git a/infrastructure/charts/eoapi-workshop/deploy.sh b/infrastructure/charts/eoapi-workshop/deploy.sh
new file mode 100755
index 0000000..109780e
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/deploy.sh
@@ -0,0 +1,230 @@
+#!/usr/bin/env bash
+#
+# Reproducible deploy for the eoapi-workshop chart (subdomain-per-service).
+#
+# Every service is served at the root of its own subdomain under a wildcard
+# domain: stac. raster. vector. browser. manager. mock-oidc. lab-01..NN. of
+# ${BASE_DOMAIN}. A wildcard DNS record `*.${BASE_DOMAIN}` must point at the
+# ingress LoadBalancer.
+#
+# Usage:
+# ./deploy.sh deploy # prerequisites + chart + verify (idempotent)
+# ./deploy.sh verify # re-run endpoint/auth checks + print Lab URLs
+# ./deploy.sh urls # print the participant Lab URLs (with tokens)
+# ./deploy.sh overrides # (re)generate + print .deploy/overrides.yaml, no deploy
+# ./deploy.sh teardown # remove the release, PVCs and namespace
+# ./deploy.sh teardown --all # also remove ingress-nginx + PGO
+#
+# Env:
+# RELEASE Helm release name (default: eoapi) -- see OIDC contract below
+# NAMESPACE target namespace (default: eoapi) -- see OIDC contract below
+# BASE_DOMAIN wildcard base domain (default: eoapi-workshop.ds.io)
+# SKIP_PREREQS=1 skip the ingress-nginx + PGO install
+# GHCR_TOKEN token with read:packages → create an imagePullSecret so the
+# cluster can pull a PRIVATE workshop image (with GHCR_USER).
+# Omit if the GHCR package is public.
+#
+# !!! OIDC CONTRACT !!! The proxy's OIDC_DISCOVERY_INTERNAL_URL is pinned to the
+# Service DNS name eoapi-mock-oidc-server.eoapi.svc.cluster.local, derived from
+# RELEASE + NAMESPACE. Both MUST stay "eoapi" or in-cluster OIDC discovery breaks.
+set -euo pipefail
+
+RELEASE="${RELEASE:-eoapi}"
+NAMESPACE="${NAMESPACE:-eoapi}"
+BASE_DOMAIN="${BASE_DOMAIN:-eoapi-workshop.ds.io}"
+CHART_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+OVERRIDES="${CHART_DIR}/.deploy/overrides.yaml"
+
+log() { printf '\n\033[1;34m==> %s\033[0m\n' "$*" >&2; }
+
+install_prereqs() {
+ if [[ "${SKIP_PREREQS:-0}" == "1" ]]; then log "Skipping prerequisites (SKIP_PREREQS=1)"; return; fi
+ if kubectl get ingressclass nginx >/dev/null 2>&1; then
+ log "An 'nginx' ingressclass already exists — leaving ingress-nginx untouched"
+ else
+ log "Installing NGINX ingress controller"
+ helm upgrade --install ingress-nginx ingress-nginx \
+ --repo https://kubernetes.github.io/ingress-nginx \
+ --namespace ingress-nginx --create-namespace --wait --timeout 5m
+ fi
+ log "Installing Crunchy Postgres Operator (PGO)"
+ helm upgrade --install pgo oci://registry.developers.crunchydata.com/crunchydata/pgo \
+ --namespace postgres-operator --create-namespace --wait --timeout 5m
+}
+
+# Participant names, read from the rendered chart (single source of truth = values).
+participant_names() {
+ helm template "$RELEASE" "$CHART_DIR" -n "$NAMESPACE" \
+ --show-only templates/jupyter.yaml 2>/dev/null \
+ | grep -oE "^ name: ${RELEASE}-[a-z0-9-]+" | sed "s/ name: ${RELEASE}-//" | sort -u
+}
+
+# Reuse a participant's token from the existing overrides (idempotent URLs across
+# re-deploys); prints nothing and returns 1 if not present.
+existing_token() { #
+ [[ -f "$OVERRIDES" ]] || return 1
+ local t; t="$(grep -E "name: $1, token:" "$OVERRIDES" 2>/dev/null | sed -E 's/.*token: "([^"]+)".*/\1/' | head -1)"
+ [[ -n "$t" ]] && printf '%s' "$t"
+}
+
+# Host-specific overrides — derived, NEVER committed (gitignored .deploy/).
+write_overrides() {
+ mkdir -p "$(dirname "$OVERRIDES")"
+ local tmp; tmp="$(mktemp)"
+ {
+ echo "# Generated by deploy.sh — DO NOT COMMIT. baseDomain=${BASE_DOMAIN}"
+ echo "routing:"
+ echo " baseDomain: \"${BASE_DOMAIN}\""
+ echo "eoapi:"
+ echo " browser:"
+ echo " catalogUrl: \"http://stac.${BASE_DOMAIN}\""
+ echo " oidcDiscoveryUrl: \"http://mock-oidc.${BASE_DOMAIN}/.well-known/openid-configuration\""
+ # NOTE: stac-auth-proxy OIDC_DISCOVERY_URL is intentionally NOT overridden —
+ # it must stay the in-cluster URL (the proxy fetches JWKS from that origin;
+ # an external LB URL hairpins and fails). It is domain-independent.
+ echo " testing:"
+ echo " mockOidcServer:"
+ echo " extraEnv:" # list: restate in full
+ echo " - name: ISSUER"
+ echo " value: \"http://mock-oidc.${BASE_DOMAIN}\""
+ echo " - name: SCOPES"
+ echo " value: \"stac:read,stac:write\""
+ echo "stac-manager:"
+ echo " publicUrl: \"http://manager.${BASE_DOMAIN}\""
+ echo " stacApi: \"http://stac.${BASE_DOMAIN}\""
+ echo " stacBrowser: \"http://browser.${BASE_DOMAIN}\""
+ echo " oidc:"
+ echo " authority: \"http://mock-oidc.${BASE_DOMAIN}\""
+ echo "jupyter:"
+ echo " participants:"
+ local name tok
+ while read -r name; do
+ [[ -n "$name" ]] || continue
+ tok="$(existing_token "$name" || true)"; [[ -n "$tok" ]] || tok="$(openssl rand -hex 16)"
+ echo " - { name: ${name}, token: \"${tok}\" }"
+ done < <(participant_names)
+ } > "$tmp"
+ mv "$tmp" "$OVERRIDES"
+}
+
+# Optional: let the cluster pull a PRIVATE workshop image. Set GHCR_TOKEN (a
+# token with read:packages) + GHCR_USER to create an imagePullSecret and attach
+# it to the namespace's default ServiceAccount (which the Labs use). Must run
+# BEFORE the Lab pods are created so the secret is injected at creation time.
+# Not needed if the GHCR package is public.
+setup_pull_secret() {
+ if [[ -z "${GHCR_TOKEN:-}" ]]; then
+ log "No GHCR_TOKEN set — assuming the workshop image is public (skipping pull secret)"
+ return
+ fi
+ log "Creating GHCR pull secret + attaching it to the default ServiceAccount"
+ kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
+ kubectl -n "$NAMESPACE" create secret docker-registry ghcr-pull \
+ --docker-server=ghcr.io --docker-username="${GHCR_USER:-$USER}" --docker-password="$GHCR_TOKEN" \
+ --dry-run=client -o yaml | kubectl apply -f - >/dev/null
+ kubectl -n "$NAMESPACE" patch serviceaccount default \
+ -p '{"imagePullSecrets":[{"name":"ghcr-pull"}]}' >/dev/null
+}
+
+deploy_chart() {
+ log "Building chart dependencies"
+ # Register the dependency repos so `helm dependency build` can resolve them
+ # from Chart.lock on a fresh machine (the vendored .tgz are gitignored).
+ helm repo add eoapi https://developmentseed.org/eoapi-k8s/ --force-update >/dev/null 2>&1 || true
+ helm repo add stac-manager https://stac-manager.ds.io/ --force-update >/dev/null 2>&1 || true
+ helm repo update eoapi stac-manager >/dev/null 2>&1 || true
+ helm dependency build "$CHART_DIR" >/dev/null
+ log "Writing host overrides for ${BASE_DOMAIN} (tokens preserved across re-deploys)"
+ write_overrides
+ echo " overrides: ${OVERRIDES}"
+ setup_pull_secret # before helm upgrade, so new Lab pods inherit the secret
+ log "Deploying release '${RELEASE}' in namespace '${NAMESPACE}'"
+ helm upgrade --install "$RELEASE" "$CHART_DIR" \
+ -n "$NAMESPACE" --create-namespace -f "$OVERRIDES"
+ log "Waiting for deployments (the database is created asynchronously by PGO)"
+ local d
+ for d in stac raster vector browser stac-auth-proxy mock-oidc-server stac-manager $(participant_names); do
+ kubectl -n "$NAMESPACE" rollout status "deploy/${RELEASE}-${d}" --timeout=300s || true
+ done
+}
+
+# curl a URL until it returns the expected code (nginx warmup can lag).
+_expect() { # -> echoes actual code, returns 0 if matched
+ local url="$1" want="$2" code="" _
+ for _ in 1 2 3 4 5 6 7 8; do
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" || true)"
+ [[ "$code" == "$want" ]] && break; sleep 3
+ done
+ printf '%s' "$code"
+}
+
+verify() {
+ local b="$BASE_DOMAIN" ok=1 code
+ log "Verifying service subdomains at *.$b"
+ declare -a checks=(
+ "http://stac.$b/healthz|200|stac"
+ "http://stac.$b/collections|200|stac collections"
+ "http://raster.$b/healthz|200|raster"
+ "http://vector.$b/healthz|200|vector"
+ "http://browser.$b/|200|browser"
+ "http://manager.$b/|200|manager"
+ "http://mock-oidc.$b/.well-known/openid-configuration|200|mock-oidc"
+ )
+ local c url want name
+ for c in "${checks[@]}"; do
+ IFS='|' read -r url want name <<<"$c"
+ code="$(_expect "$url" "$want")"
+ printf ' %-16s %-52s %s\n' "$name" "$url" "$code"
+ [[ "$code" == "$want" ]] || ok=0
+ done
+
+ log "Verifying auth (expect 401 without a token, non-401 with one)"
+ local no_tok token with_tok
+ no_tok="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
+ -X POST "http://stac.$b/collections" -H 'Content-Type: application/json' -d '{}' || true)"
+ token="$(curl -s --max-time 15 "http://mock-oidc.$b/" \
+ --data-raw 'username=testuser&scopes=openid+stac:read+stac:write' \
+ -H 'Accept: application/json' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')"
+ with_tok="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
+ -X POST "http://stac.$b/collections" -H "Authorization: Bearer ${token}" \
+ -H 'Content-Type: application/json' -d '{}' || true)"
+ printf ' POST without token: %s with token: %s\n' "$no_tok" "$with_tok"
+ [[ "$no_tok" == "401" && "$with_tok" != "401" && -n "$with_tok" ]] || ok=0
+
+ print_urls
+ if [[ "$ok" == 1 ]]; then log "OK — services reachable and auth enforced."; else log "FAILED — see codes above"; exit 1; fi
+}
+
+print_urls() {
+ log "Participant JupyterLab URLs"
+ local name tok
+ while read -r name; do
+ [[ -n "$name" ]] || continue
+ tok="$(existing_token "$name" || true)"
+ printf ' %-8s http://%s.%s/lab?token=%s\n' "$name" "$name" "$BASE_DOMAIN" "$tok"
+ done < <(participant_names)
+ printf ' %-8s http://manager.%s/\n' "manager" "$BASE_DOMAIN"
+ printf ' %-8s http://browser.%s/\n' "browser" "$BASE_DOMAIN"
+}
+
+teardown() {
+ log "Uninstalling release '${RELEASE}'"
+ helm uninstall "$RELEASE" -n "$NAMESPACE" 2>/dev/null || true
+ kubectl -n "$NAMESPACE" delete pvc --all 2>/dev/null || true
+ kubectl delete namespace "$NAMESPACE" --timeout=180s 2>/dev/null || true
+ if [[ "${1:-}" == "--all" ]]; then
+ log "Removing prerequisites (ingress-nginx + PGO)"
+ helm uninstall ingress-nginx -n ingress-nginx 2>/dev/null || true
+ helm uninstall pgo -n postgres-operator 2>/dev/null || true
+ kubectl delete namespace ingress-nginx postgres-operator --timeout=180s 2>/dev/null || true
+ fi
+}
+
+case "${1:-deploy}" in
+ deploy) install_prereqs; deploy_chart; verify ;;
+ verify) verify ;;
+ urls) print_urls ;;
+ overrides) write_overrides; echo "written: ${OVERRIDES}"; cat "$OVERRIDES" ;;
+ teardown) teardown "${2:-}" ;;
+ *) echo "Usage: $0 {deploy|verify|urls|teardown [--all]}" >&2; exit 2 ;;
+esac
diff --git a/infrastructure/charts/eoapi-workshop/templates/NOTES.txt b/infrastructure/charts/eoapi-workshop/templates/NOTES.txt
new file mode 100644
index 0000000..f5709cf
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/templates/NOTES.txt
@@ -0,0 +1,29 @@
+eoAPI workshop — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
+
+Every service is served at the root of its own subdomain under
+*.{{ .Values.routing.baseDomain }} (requires a wildcard DNS record pointing at
+the ingress LoadBalancer):
+
+ STAC API http://stac.{{ .Values.routing.baseDomain }}
+ Raster http://raster.{{ .Values.routing.baseDomain }}
+ Vector http://vector.{{ .Values.routing.baseDomain }}
+ Browser http://browser.{{ .Values.routing.baseDomain }}
+{{- $sm := index .Values "stac-manager" }}
+{{- if and $sm $sm.enabled }}
+ Manager http://manager.{{ .Values.routing.baseDomain }}
+{{- end }}
+ Mock OIDC http://mock-oidc.{{ .Values.routing.baseDomain }} (test-only auth)
+
+{{- if .Values.jupyter.enabled }}
+
+Participant JupyterLabs:
+{{- range .Values.jupyter.participants }}
+ {{ .name }} http://{{ .name }}.{{ $.Values.routing.baseDomain }}/lab{{ if .token }}?token={{ .token }}{{ end }}
+{{- end }}
+{{- if not (first .Values.jupyter.participants).token }}
+ (tokens are empty — deploy with ./deploy.sh so each Lab gets an access token)
+{{- end }}
+{{- end }}
+
+Verify endpoints + auth and (re)print the Lab URLs:
+ ./deploy.sh verify
diff --git a/infrastructure/charts/eoapi-workshop/templates/features-loader-job.yaml b/infrastructure/charts/eoapi-workshop/templates/features-loader-job.yaml
new file mode 100644
index 0000000..f0442d1
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/templates/features-loader-job.yaml
@@ -0,0 +1,122 @@
+{{/*
+k8s equivalent of the docker-compose `features-loader` + `stac-loader`: load
+the NA CEC Level III Ecoregions shapefile into features.ecoregions (notebook
+05 / tipg) and the glad STAC collection from the MAAP STAC into pgstac
+(notebook 04 §4.5 / titiler). Runs as a post-install/post-upgrade hook,
+idempotently (each container skips if its data is already present). Uses the
+PGO superuser secret to CREATE SCHEMA and grants read to all users so tipg can
+serve it. Loads into the `eoapi` database (what tipg and pgstac serve).
+*/}}
+{{- if .Values.featuresLoader.enabled }}
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ .Release.Name }}-features-loader
+ labels:
+ app: {{ .Release.Name }}-features-loader
+ app.kubernetes.io/component: workshop-features-loader
+ annotations:
+ helm.sh/hook: post-install,post-upgrade
+ helm.sh/hook-weight: "10" # after the DB / pgstac bootstrap
+ helm.sh/hook-delete-policy: before-hook-creation
+spec:
+ backoffLimit: 3
+ template:
+ metadata:
+ labels:
+ app: {{ .Release.Name }}-features-loader
+ spec:
+ restartPolicy: Never
+ containers:
+ - name: features-loader
+ image: {{ .Values.featuresLoader.image | quote }}
+ command:
+ - bash
+ - -c
+ - |
+ set -euo pipefail
+ apt-get update -qq && apt-get install -y -qq postgresql-client >/dev/null
+ for _ in $(seq 1 60); do pg_isready -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" && break; echo "waiting for database..."; sleep 5; done
+ if psql -tAc "select 1 from information_schema.tables where table_schema='features' and table_name='ecoregions'" | grep -q 1; then
+ echo "features.ecoregions already present — skipping load."; exit 0
+ fi
+ psql -v ON_ERROR_STOP=1 -c "CREATE SCHEMA IF NOT EXISTS features;"
+ ogr2ogr -f PostgreSQL "PG:dbname=$PGDATABASE host=$PGHOST port=$PGPORT user=$PGUSER password=$PGPASSWORD" \
+ {{ .Values.featuresLoader.shapefileUrl | quote }} \
+ -nln features.ecoregions -t_srs EPSG:4326 \
+ -lco GEOMETRY_NAME=geom -lco FID=id -lco PRECISION=NO -nlt PROMOTE_TO_MULTI
+ psql -v ON_ERROR_STOP=1 -c "GRANT USAGE ON SCHEMA features TO PUBLIC; GRANT SELECT ON ALL TABLES IN SCHEMA features TO PUBLIC;"
+ echo "features.ecoregions loaded."
+ env:
+ # Superuser creds (needed for CREATE SCHEMA); DB is pinned to `eoapi`
+ # (what tipg queries), not the secret's dbname.
+ - name: PGDATABASE
+ value: "eoapi"
+ - name: PGHOST
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: host } }
+ - name: PGPORT
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: port } }
+ - name: PGUSER
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: user } }
+ - name: PGPASSWORD
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: password } }
+ resources:
+ {{- toYaml .Values.featuresLoader.resources | nindent 12 }}
+ - name: stac-loader
+ image: {{ .Values.featuresLoader.stac.image | quote }}
+ command:
+ - bash
+ - -c
+ - |
+ set -euo pipefail
+ pip install -q "pypgstac[psycopg]=={{ .Values.featuresLoader.stac.pypgstacVersion }}"
+ python3 - <<'PY'
+ import json
+ import sys
+ import urllib.request
+
+ from pypgstac.db import PgstacDB
+ from pypgstac.load import Loader, Methods
+
+ SRC = {{ .Values.featuresLoader.stac.source | quote }}
+ COLLECTION = {{ .Values.featuresLoader.stac.collection | quote }}
+ LIMIT = {{ .Values.featuresLoader.stac.itemLimit }}
+
+ db = PgstacDB()
+ if db.query_one(
+ "select 1 from pgstac.collections where id=%s", [COLLECTION]
+ ):
+ print(f"{COLLECTION} already present -- skipping load.")
+ sys.exit(0)
+
+ urllib.request.urlretrieve(
+ f"{SRC}/collections/{COLLECTION}", "/tmp/collection.json"
+ )
+ with urllib.request.urlopen(
+ f"{SRC}/search?collections={COLLECTION}&limit={LIMIT}"
+ ) as r:
+ features = json.load(r)["features"]
+ with open("/tmp/items.ndjson", "w") as f:
+ for feat in features:
+ f.write(json.dumps(feat) + "\n")
+
+ loader = Loader(db=db)
+ loader.load_collections("/tmp/collection.json", Methods.upsert)
+ loader.load_items("/tmp/items.ndjson", Methods.upsert)
+ print(f"{COLLECTION}: collection + {len(features)} items loaded.")
+ PY
+ env:
+ - name: PGDATABASE
+ value: "eoapi"
+ - name: PGHOST
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: host } }
+ - name: PGPORT
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: port } }
+ - name: PGUSER
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: user } }
+ - name: PGPASSWORD
+ valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-pguser-postgres, key: password } }
+ resources:
+ requests: { cpu: "100m", memory: "256Mi" }
+ limits: { cpu: "500m", memory: "1Gi" }
+{{- end }}
diff --git a/infrastructure/charts/eoapi-workshop/templates/jupyter.yaml b/infrastructure/charts/eoapi-workshop/templates/jupyter.yaml
new file mode 100644
index 0000000..ea8ace4
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/templates/jupyter.yaml
@@ -0,0 +1,139 @@
+{{/*
+Per-participant JupyterLab environments (one isolated Deployment+Service+PVC
+each), served at the ROOT of . and routed by
+templates/subdomain-ingress.yaml (no path prefix, no base_url). Notebooks come
+from the image; only /home/jovyan/work is persisted. Endpoints + DB creds are
+injected to mirror the docker-compose `jupyterhub` service so the workshop
+notebooks run unchanged.
+*/}}
+{{- if .Values.jupyter.enabled }}
+{{- $root := . }}
+{{- $j := .Values.jupyter }}
+{{- $base := .Values.routing.baseDomain }}
+{{- range $j.participants }}
+{{- $name := .name }}
+{{- $token := .token | default "" }}
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ $root.Release.Name }}-{{ $name }}
+ labels:
+ app: {{ $root.Release.Name }}-{{ $name }}
+ app.kubernetes.io/component: workshop-jupyterlab
+spec:
+ accessModes: ["ReadWriteOnce"]
+ resources:
+ requests:
+ storage: {{ $j.storage.size | quote }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ $root.Release.Name }}-{{ $name }}
+ labels:
+ app: {{ $root.Release.Name }}-{{ $name }}
+ app.kubernetes.io/component: workshop-jupyterlab
+spec:
+ selector:
+ app: {{ $root.Release.Name }}-{{ $name }}
+ ports:
+ - name: http
+ port: 8888
+ targetPort: 8888
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ $root.Release.Name }}-{{ $name }}
+ labels:
+ app: {{ $root.Release.Name }}-{{ $name }}
+ app.kubernetes.io/component: workshop-jupyterlab
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate # RWO PVC: the new pod can't mount until the old one releases it
+ selector:
+ matchLabels:
+ app: {{ $root.Release.Name }}-{{ $name }}
+ template:
+ metadata:
+ labels:
+ app: {{ $root.Release.Name }}-{{ $name }}
+ spec:
+ securityContext:
+ fsGroup: 1000 # jovyan GID — makes the work PVC writable by the notebook user
+ # Notebooks live in the image at /home/jovyan/docs and come FRESH on every
+ # start, so image/notebook updates always appear. Only /home/jovyan/work is
+ # a persistent PVC — no seed initContainer, no home shadowing. (Trade-off:
+ # edits to the provided notebooks reset on pod restart; save work under work/.)
+ containers:
+ - name: jupyterlab
+ image: "{{ $j.image.repository }}:{{ $j.image.tag }}"
+ imagePullPolicy: {{ $j.image.pullPolicy }}
+ # args only (NO command): keep the image ENTRYPOINT (/entrypoint.sh
+ # activates the conda env) and just override the launch command.
+ # Served at the root of . — no base_url prefix.
+ args:
+ - jupyter
+ - lab
+ - --ServerApp.token={{ $token }}
+ - --ip=0.0.0.0
+ - --port=8888
+ - --no-browser
+ ports:
+ - name: http
+ containerPort: 8888
+ env:
+ # eoAPI endpoints (in-cluster) — mirrors the docker-compose jupyterhub service.
+ - { name: STAC_API_ENDPOINT, value: "http://{{ $root.Release.Name }}-stac-auth-proxy:8080" }
+ - { name: STAC_AUTH_PROXY_ENDPOINT, value: "http://{{ $root.Release.Name }}-stac-auth-proxy:8080" }
+ - { name: TITILER_PGSTAC_API_ENDPOINT, value: "http://{{ $root.Release.Name }}-raster:8080" }
+ - { name: TIPG_API_ENDPOINT, value: "http://{{ $root.Release.Name }}-vector:8080" }
+ - { name: MOCK_OIDC_ENDPOINT, value: "http://{{ $root.Release.Name }}-mock-oidc-server:8080" }
+ - { name: STAC_BROWSER_ENDPOINT, value: "http://browser.{{ $base }}" }
+ # Browser-facing API URLs (external subdomains) — the notebooks' IFrame/
+ # viewer cells hand these to the user's browser, which cannot reach the
+ # in-cluster Service DNS above. Server-side httpx calls keep the *_ENDPOINT
+ # vars; absent these (compose/2i2c) the notebooks fall back to .replace().
+ - { name: STAC_API_BROWSER_URL, value: "http://stac.{{ $base }}" }
+ - { name: TITILER_BROWSER_URL, value: "http://raster.{{ $base }}" }
+ - { name: TIPG_BROWSER_URL, value: "http://vector.{{ $base }}" }
+ # DB creds from the PGO-generated secret, using the DIRECT primary keys
+ # (host/port) — NOT pgbouncer-*, whose transaction pooling breaks the
+ # DDL/COPY in 02-database.ipynb. Secret name is release-derived
+ # (release/namespace contract: both must be "eoapi").
+ - name: PGHOST
+ valueFrom: { secretKeyRef: { name: {{ $root.Release.Name }}-pguser-eoapi, key: host } }
+ - name: PGPORT
+ valueFrom: { secretKeyRef: { name: {{ $root.Release.Name }}-pguser-eoapi, key: port } }
+ - name: PGDATABASE
+ valueFrom: { secretKeyRef: { name: {{ $root.Release.Name }}-pguser-eoapi, key: dbname } }
+ - name: PGUSER
+ valueFrom: { secretKeyRef: { name: {{ $root.Release.Name }}-pguser-eoapi, key: user } }
+ - name: PGPASSWORD
+ valueFrom: { secretKeyRef: { name: {{ $root.Release.Name }}-pguser-eoapi, key: password } }
+ volumeMounts:
+ - name: work
+ mountPath: /home/jovyan/work
+ # JupyterLab has no unauthenticated HTTP health endpoint → probe TCP.
+ # Generous startupProbe so the first (large) image pull doesn't trip liveness.
+ startupProbe:
+ tcpSocket: { port: 8888 }
+ periodSeconds: 10
+ failureThreshold: 30
+ readinessProbe:
+ tcpSocket: { port: 8888 }
+ periodSeconds: 10
+ livenessProbe:
+ tcpSocket: { port: 8888 }
+ periodSeconds: 30
+ failureThreshold: 3
+ resources:
+ {{- toYaml $j.resources | nindent 12 }}
+ volumes:
+ - name: work
+ persistentVolumeClaim:
+ claimName: {{ $root.Release.Name }}-{{ $name }}
+{{- end }}
+{{- end }}
diff --git a/infrastructure/charts/eoapi-workshop/templates/subdomain-ingress.yaml b/infrastructure/charts/eoapi-workshop/templates/subdomain-ingress.yaml
new file mode 100644
index 0000000..56b2aa7
--- /dev/null
+++ b/infrastructure/charts/eoapi-workshop/templates/subdomain-ingress.yaml
@@ -0,0 +1,69 @@
+{{/*
+Subdomain-per-service ingress. Each enabled service is exposed at
+