This repo is public. The pod's git-clone initContainer (k8s/deployment.yaml) runs git clone --depth 1 https://github.com/boettiger-lab/bosl-high-seas.git on each pod start and copies index.html, docs.html, bosl-logo.svg, layers-input.json, and system-prompt.md into the nginx html dir. Pod content tracks main. The k8s/configmap.yaml ConfigMap holds only the LLM model list and the nginx reverse-proxy template — not website content.
cirrus is scaled down. Production runs on NRP Nautilus only; deploy there and nowhere else. The cirrus manifests (
k8s/cirrus-*.yaml,replicas: 0) andlayers-input.cirrus.jsonare kept so it can be revived, but they are not maintained. Before reviving it, regenerate the mirror (below) and setreplicas: 1.
The table records both setups for reference:
| NRP Nautilus (prod) | cirrus (local k3s, scaled down) | |
|---|---|---|
| Manifests | k8s/{configmap,deployment,service,ingress}.yaml |
k8s/cirrus-*.yaml |
| Namespace | schmidtdse |
high-seas |
| URL | https://high-seas.nrp-nautilus.io | https://high-seas.carlboettiger.info |
| Layer config | layers-input.json (s3-west.nrp-nautilus.io) |
layers-input.cirrus.json (minio.carlboettiger.info) |
| MCP server | duckdb-mcp.nrp-nautilus.io |
duckdb-mcp.carlboettiger.info (mcp ns) |
| LLM | open-llm-proxy → OpenRouter + NRP models | in-cluster vLLM qwen3-6 only (vllm ns) |
| Ingress | HAProxy annotations | Traefik CRDs + cert-manager + external-dns |
layers-input.cirrus.json is a committed copy of layers-input.json with the S3
and MCP hosts rewritten — config.json can override mcp_server_url but not the
STAC catalog/collection URLs, so the layer file itself has to differ. While cirrus is scaled
down, keeping it in sync is optional; regenerate it before reviving cirrus:
sed -e 's|https://s3-west\.nrp-nautilus\.io|https://minio.carlboettiger.info|g' \
-e 's|https://duckdb-mcp\.nrp-nautilus\.io/mcp|https://duckdb-mcp.carlboettiger.info/mcp|g' \
-e 's|"titiler_url": "https://titiler\.nrp-nautilus\.io"|"titiler_url": "https://titiler.carlboettiger.info"|' \
layers-input.json > layers-input.cirrus.jsonThe cirrus initContainer copies it over layers-input.json in the nginx html dir.
All three expressions are required. cirrus has its own titiler at
https://titiler.carlboettiger.info (verified healthy), and that is what the committed mirror
uses. Regenerating with only the first two expressions silently repoints every cirrus raster
tile request at NRP.
One-time setup (namespace + a copy of the vLLM API key, which nginx injects into
the /api/llm/ Authorization header; secrets can't be read across namespaces):
kubectl create namespace high-seas
kubectl create secret generic vllm-api-key -n high-seas \
--from-literal=api-key="$(kubectl get secret vllm-api-key -n vllm -o jsonpath='{.data.api-key}' | base64 -d)"
kubectl apply -f k8s/cirrus-configmap.yaml -f k8s/cirrus-service.yaml \
-f k8s/cirrus-deployment.yaml -f k8s/cirrus-ingress.yamlRoutine content edits — same two-step as NRP (push, then restart so the pod re-clones):
git add <source-files> && git commit -m "<message>" && git push
kubectl rollout restart deployment/bosl-high-seas -n high-seas
kubectl rollout status deployment/bosl-high-seas -n high-seashttps://high-seas-private.nrp-nautilus.io is the same app plus the restricted-licence layers
(IMMA, KBA) whose hosting agreement requires a password-protected app and exclusion from the
source.coop mirror. Protection is by licence policy, not cybersecurity: the files themselves sit in
ordinary public-* buckets, and that is accepted under the agreement.
- Never add a restricted layer to
layers-input.json. It goes inprivate/layers.json(withinsert_after) and its licence notes inprivate/system-prompt-addendum.md. - Generated files (
layers-input.private.json,system-prompt.private.md,k8s/private-{configmap,deployment}.yaml) come fromscripts/build-private.py. Re-run it after ANY edit tolayers-input.json,system-prompt.md,k8s/configmap.yamlork8s/deployment.yaml, and commit the output in the same PR, or the private app drifts. - Auth: nginx
auth_basicon every path except/healthand/api/llm/. geo-agent sends LLM requests withAuthorization: Bearer <api_key>, which replaces the browser's saved password, so/api/llm/instead checks a token:APP_TOKEN, derived at pod start from the htpasswd Secret and served only in the password-protectedconfig.json. Without it the chat fails with 401 and the browser re-prompts for the password. The htpasswd file comes from thebosl-high-seas-private-htpasswdSecret inschmidtdse. It is never committed. The username isbosl, and the passphrase is shared only with approved colleagues. - Rotate the password (then share the new one out of band):
PP='<new passphrase>' kubectl -n schmidtdse create secret generic bosl-high-seas-private-htpasswd \ --from-literal=htpasswd="bosl:$(openssl passwd -apr1 "$PP")" --dry-run=client -o yaml | kubectl apply -f - kubectl -n schmidtdse rollout restart deployment/bosl-high-seas-private
- Deploy: same push-then-restart model as prod, using the private manifests:
Check:
kubectl apply -f k8s/private-configmap.yaml -f k8s/private-deployment.yaml \ -f k8s/private-service.yaml -f k8s/private-ingress.yaml kubectl -n schmidtdse rollout restart deployment/bosl-high-seas-privatecurl -s -o /dev/null -w '%{http_code}' https://high-seas-private.nrp-nautilus.io/→401.
The private app also connects to a prototype literature server, high-seas-text-query
(code: SchmidtDSE/text-query, main). It reads the copyrighted corpus from the
private rustfs bucket high-seas-lit with a read-only key that glen-ops mints, so it has
no ingress; the private nginx proxies /api/text-query/ to it behind the password.
k8s/text-query.yaml holds its ConfigMap (the corpus description the agent reads) and
Service, and k8s/text-query-deployment.yaml its Deployment, which is alone in that file
because the glen-ops weekly age reset deletes and re-applies it. Apply the Service before
restarting the private app: nginx will not start if the route's Service is missing.
docs/text-query-walkthrough.md follows one question through the tools and the data.
A second, no-LLM app at https://high-seas.nrp-nautilus.io/explorer/ that ranks the 336 EBSAs
on five provisional pillars, built on glen-charts
(pinned in explorer/index.html). It is configuration, SQL and precomputed data only; see
explorer/README.md for units, variables and scoring. The git-clone initContainer copies the
whole explorer/ folder, so a push plus a rollout restart deploys it like the main app.
# After changing explorer SQL: re-run the precompute (MCP; about a minute in all) and commit data/
npx github:SchmidtDSE/glen-charts#v0.2.4 precompute --site explorer lists
npx github:SchmidtDSE/glen-charts#v0.2.4 precompute --site explorer results
npx github:SchmidtDSE/glen-charts#v0.2.4 precompute --site explorer sourcesThe explorer's header carries its own pin of geo-agent (the DSE mark) and the BOSL logo
(../bosl-logo.svg); when bumping geo-agent, bump explorer/index.html too.
| Repo | Purpose |
|---|---|
geo-agent |
Core library (map, chat, agent, tools). Source of truth for all functionality. |
bosl-high-seas |
Application repo. Configure layers-input.json, system-prompt.md, and k8s/ for this dataset. |
Full docs: schmidtdse.github.io/glen/
You configure: layers-input.json, system-prompt.md, index.html, and k8s/ manifests.
You do not write JavaScript. Core modules are loaded from the CDN.
If you lack credentials or permissions to run
kubectlorgit push, do not attempt to discover or work around credentials. Instead, provide the user with the exact commands to run.
# 1. Edit source files (index.html, layers-input.json, system-prompt.md)
# 2. Commit and push to main — the initContainer clones from GitHub
git add <source-files> && git commit -m "<message>" && git push
# 3. Restart the deployment so a new pod re-clones the latest main
kubectl rollout restart deployment/bosl-high-seas -n schmidtdse
kubectl rollout status deployment/bosl-high-seas -n schmidtdseThe git push does not update running pods — step 3 does.
If you change k8s/configmap.yaml (LLM model list or nginx template), apply it before the rollout: kubectl apply -f k8s/configmap.yaml -n schmidtdse. Routine content edits don't need this.
index.html pins the geo-agent library version. Verify jsDelivr serves a new tag before deploying:
curl -sI https://cdn.jsdelivr.net/gh/SchmidtDSE/glen@vX.Y.Z/app/style.css | grep HTTP
# Must return HTTP/2 200 — a 404 means jsDelivr hasn't indexed the tag yetFor private data modules (rclone sidecar, oauth2-proxy, private parquet credentials): docs/guide/private-deployment
-
Check whether the COG's declared nodata matches any actual pixel. geo-agent passes the STAC
raster:bands[0].nodatastraight to titiler, so a declared nodata that no pixel matches masks nothing and the tile comes back 100% opaque — the layer then covers the basemap across its whole footprint.ship-densitydeclares2147483647and contains none, so every asset sets"nodata": 0to mask the real "nothing recorded" value.config.nodataoverrides the STAC value per asset. Verify with the tile's alpha channel, not by eye:curl -s -o t.png "$TITILER/cog/tiles/WebMercatorQuad/3/4/2.png?url=$ENCODED&colormap_name=reds&rescale=0,N&nodata=0" python3 -c "import rasterio,numpy as np; a=rasterio.open('t.png').read(); print((a[3]==0).mean())"
-
Set
rescalefrom measured percentiles, not from min/max. These fields are strongly skewed, and geo-agent only supports a linear ramp (no log/sqrt expression), so absolute min/max flattens everything to the bottom colour. Compute the percentile at the overview level the default global view renders from (roughly 1/16 to 1/64 for a global raster), not at native resolution. Precedents:seafloor-carbon-fluxuses 2nd-98th percentiles,ship-densitythe 99th percentile of nonzero pixels.
For full layers-input.json schema, troubleshooting, and configuration reference see the glen-template AGENTS.md or the docs.