Skip to content

Commit a52f8d1

Browse files
Merge pull request #2 from deepesdl/tejas-xxx-decouple-publishing-to-osc
decouple publishing to osc
2 parents abe5d91 + 8bd1432 commit a52f8d1

21 files changed

Lines changed: 8352 additions & 384 deletions

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
name: Unit tests + DAG validation
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.11"
17+
cache: pip
18+
19+
- name: Install package and test deps
20+
run: |
21+
python -m pip install --upgrade pip
22+
pip install -e .
23+
pip install pytest
24+
25+
# tests/test_workflow.py imports smos_ard.workflow, which imports
26+
# procodile — a local-path development dependency that is not yet on
27+
# PyPI/conda. Exclude it from CI until procodile is publishable.
28+
- name: Run tests + DAG import check
29+
run: pytest tests/ --ignore=tests/test_workflow.py -v

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ __pycache__/
77
notebooks/
88
.gitaccess
99
docker/src/
10-
k8s/secret.local.yaml
10+
k8s/

README.md

Lines changed: 34 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,36 @@ All core pipeline components are implemented and validated end-to-end.
1717
| Airflow DAG | done | KubernetesPodOperator per step; run_step.py entrypoint wired |
1818
| DAG auto-generation | done | appligator generates the Airflow DAG from the ProcessRegistry in workflow.py |
1919
| Docker build | done | Build context generated automatically from the workflow definition |
20-
| OSC publication | done | deep-code creates STAC collection and opens a GitHub PR on the OSC metadata repo |
21-
| Demo run | validated | Global cube produced for a one-week time window |
20+
| OSC publication | done | pipeline stages YAML artefacts to S3; `publish_trigger.py` runs deep-code separately to open a GitHub PR |
21+
| Operational run | live | Daily scheduled updates at 23:00 UTC; global cube appended incrementally each day |
2222

2323
## Project Structure
2424

2525
```
2626
smos-ard-pipeline/
27-
├── build_docker_template.py # generates docker/ build context via appligator
2827
├── pixi.toml # environment + dependency management
2928
├── pyproject.toml # package metadata, ruff/mypy/pytest config
29+
├── run_step.py # container entrypoint (reads STEP_CONFIG/PARAMS/XCOM)
30+
├── build_docker_template.py # generates docker/ build context via appligator
3031
├── src/
3132
│ └── smos_ard/
3233
│ ├── workflow.py # procodile workflow definition + ProcessRegistry
3334
│ ├── fetch.py # Step 1: xcube-smos → staging Zarr
3435
│ ├── aggregate.py # Step 2: temporal mean + QC → ARD Zarr
35-
│ ├── publish.py # Step 3: STAC item → deep-code → OSC
36+
│ ├── publish.py # Step 3: stage YAML artefacts to S3 catalogue-pending/
37+
│ ├── publish_trigger.py # Separate CLI: deep-code → GitHub PR (runs outside pipeline)
3638
│ └── utils.py # Shared S3 helpers
3739
├── dags/
38-
│ ├── smos_ard_update_dag.py # hand-written Airflow DAG (copy to Airflow DAGs folder)
39-
│ └── main_step.py # auto-generated DAG (produced by appligator)
40+
│ ├── smos-ard-service-v2.py # active production DAG (appligator-generated, daily schedule)
41+
│ ├── main_step.py # legacy auto-generated DAG
42+
│ └── smos_ard_update_dag.py # legacy hand-written DAG
43+
├── k8s/
44+
│ ├── rbac.yaml # Airflow service account role + binding for earthcode namespace
45+
│ └── secret.yaml # template for earthcode-secrets K8s secret
46+
├── notebooks/
47+
│ └── demo_update_service.ipynb # interactive demo of the update service
4048
├── tests/ # pytest unit tests
49+
├── .github/workflows/ci.yml # CI: lint, typecheck, tests on push/PR
4150
└── docker/ # generated build context — do not edit or commit
4251
```
4352

@@ -47,7 +56,8 @@ smos-ard-pipeline/
4756
|---|---|---|
4857
| 1 | `fetch_smos_data` | Opens xcube-smos store on CREODIAS, writes raw Zarr to `s3://{bucket}/staging/{prefix}/raw.zarr` |
4958
| 2 | `aggregate_datacube` | Temporal resample (mean) + QC masking + variable metadata, writes/appends ARD Zarr to `s3://{bucket}/ard/{prefix}/cube.zarr` |
50-
| 3 | `publish_to_catalogue` | Builds STAC collection + OGC API records via deep-code, opens a GitHub PR on the OSC metadata repo |
59+
| 3 | `publish_to_catalogue` | Generates `dataset-config.yaml`, `workflow-config.yaml`, and `manifest.json`; writes them to `s3://{bucket}/catalogue-pending/{collection_id}/` |
60+
|| `publish_trigger.py` | Separate CLI: reads staged artefacts from S3, calls deep-code to open a GitHub PR on the OSC metadata repo, archives artefacts to `catalogue-published/` on success |
5161

5262
### Why fetch and aggregate are separate steps
5363

@@ -61,38 +71,28 @@ smos-ard-pipeline/
6171

6272
```
6373
start_date, end_date, bbox, output_prefix, stac_s3_bucket
64-
65-
74+
75+
6676
fetch_smos_data → S3: staging/{prefix}/raw.zarr
67-
│ FetchResult
68-
77+
│ FetchResult
78+
6979
aggregate_datacube → S3: ard/{prefix}/cube.zarr
70-
│ AggregateResult │
71-
▼ ▼
72-
publish_to_catalogue → GitHub PR on OSC metadata repo
73-
│ → S3: {stac_s3_bucket}/stac/{prefix}/
80+
│ AggregateResult
81+
82+
publish_to_catalogue → S3: catalogue-pending/{collection_id}/
83+
│ dataset-config.yaml
84+
│ workflow-config.yaml
85+
│ manifest.json
7486
│ PublishResult
7587
76-
pull_request_url
77-
```
78-
79-
### Local container test
88+
catalogue_pending_uri
8089
81-
`run_step.py` takes a single JSON argument via `sys.argv[1]`. To test a step locally without Airflow or Kubernetes:
90+
── separate step, run manually or via a trigger ──
8291
83-
```bash
84-
docker run --entrypoint sh -it quay.io/earthcode/smos-ard-pipeline:0.0.5
85-
```
86-
87-
Inside the container, write the payload to a file (avoids shell quoting/newline issues) and invoke `main` directly:
88-
89-
```bash
90-
echo '{"func_module":"smos_ard.workflow","func_qualname":"pipeline_params","inputs":{"start_date":"2025-01-01","end_date":"2025-01-02","bbox":"[-180,-90,180,90]","output_prefix":"test/demo","stac_s3_bucket":null},"output_keys":["start_date","end_date","bbox","output_prefix","stac_s3_bucket"]}' > /tmp/p.json
91-
python -c "from run_step import main; import json; main(**json.load(open('/tmp/p.json')))" && cat /airflow/xcom/return.json
92+
publish_trigger.py → GitHub PR on OSC metadata repo
93+
(reads pending/) → S3: catalogue-published/{collection_id}/{timestamp}/
9294
```
9395

94-
`pipeline_params` is a plain passthrough function with no external dependencies — safe to run without S3 or CREODIAS credentials. Replace `func_qualname` with any other step to test it (credentials required for `fetch_data` onwards).
95-
9696
## procodile Workflow (`workflow.py`)
9797

9898
`workflow.py` is the single source of truth for the pipeline. It uses the [procodile](https://github.com/deepesdl/procodile) framework to declare the pipeline as a typed, inspectable workflow rather than a plain Python script. Three procodile constructs do the work:
@@ -117,7 +117,7 @@ PublishResult — output of publish_data step
117117
| `end_date` | `date` || End of the time range to process |
118118
| `bbox` | `bbox` | `[-180, -90, 180, 90]` | Spatial extent. Defaults to global; use a smaller bbox for demos |
119119
| `output_prefix` | `string` | `smos-sm/global` | S3 key prefix for staging and ARD stores. Use a unique prefix for demo/test runs to avoid writing to the production cube |
120-
| `stac_s3_bucket` | `string` | `null` | S3 bucket for the STAC catalog and deep-code user storage. Defaults to the ARD cube bucket when not set |
120+
| `stac_s3_bucket` | `string` | `s3://deep-esdl-public/stac/smos-sm/global/` | S3 bucket for the STAC catalog and deep-code user storage. Defaults to the ARD cube bucket when not set |
121121

122122
### Running the workflow
123123

@@ -157,112 +157,12 @@ pixi run appligator smos_ard.workflow:registry \
157157

158158
## Airflow DAG
159159

160-
The hand-written DAG in `dags/smos_ard_update_dag.py` uses `KubernetesPodOperator`. Each step runs `run_step.py` as the container entrypoint, which:
160+
The active DAG (`dags/smos-ard-service-v2.py`) uses `KubernetesPodOperator`. Each step runs `run_step.py` as the container entrypoint, which:
161161
1. Reads `STEP_CONFIG` (static), `STEP_PARAMS` (DAG params), `STEP_XCOM` (upstream xcom)
162162
2. Merges inputs and dynamically calls the target function
163163
3. Writes the return dict to `/airflow/xcom/return.json`
164164

165-
### Trigger via Airflow CLI
166-
167-
```bash
168-
# Production global run
169-
airflow dags trigger smos_ard_update_service --conf '{
170-
"start_date": "2024-01-01",
171-
"end_date": "2024-01-07"
172-
}'
173-
174-
# Demo run with small bbox and isolated prefix
175-
airflow dags trigger smos_ard_update_service --conf '{
176-
"start_date": "2024-01-01",
177-
"end_date": "2024-01-02",
178-
"bbox": [10, 50, 12, 52],
179-
"output_prefix": "smos-sm/demo"
180-
}'
181-
182-
# Run with STAC catalog written to a separate bucket
183-
airflow dags trigger smos_ard_update_service --conf '{
184-
"start_date": "2024-01-01",
185-
"end_date": "2024-01-07",
186-
"stac_s3_bucket": "my-stac-catalog-bucket"
187-
}'
188-
```
189-
190-
### Trigger via REST API
191-
192-
```bash
193-
curl -X POST http://localhost:8080/api/v1/dags/smos_ard_update_service/dagRuns \
194-
-H "Content-Type: application/json" \
195-
-d '{"conf": {"start_date": "2024-01-01", "end_date": "2024-01-07"}}'
196-
```
197-
198-
## Setup
199-
200-
### 1. Build and push the image
201-
202-
The `docker/` directory is the build context, generated by appligator. It contains a two-stage Dockerfile (pixi build stage → lean Debian runtime), a `pixi.toml`, a `pyproject.toml`, and a locked `pixi.lock`.
203-
204-
**Why `pixi.toml` and `pyproject.toml` are needed alongside `pixi.lock`:**
205-
- `pixi.toml` — pixi unconditionally requires a manifest to run; `pixi install --locked` won't start without one. The lock file has all resolved packages, but pixi still needs the manifest to know the project name, channels, and which environments exist. The `docker/pixi.toml` is a minimal stub that satisfies this requirement.
206-
- `pyproject.toml` — needed by the `pip install --no-deps -e .` step that installs the `smos_ard` package from the copied `src/`. `smos-ard-pipeline` cannot be declared in `pixi.toml` as a `path = "."` dependency and use `--locked`, because pixi hashes the local source when generating the lock file on the host; in Docker the path content differs, so pixi reports the lock as out-of-date and refuses to install. Installing it separately with pip after `pixi install --locked` sidesteps this.
207-
208-
`procodile` and `gavicore` exist on PyPI but the latest changes needed by this pipeline are only on the `main` branch and not yet in a published release, so the Dockerfile installs them directly from the [`eo-tools/eozilla`](https://github.com/eo-tools/eozilla) git repo during the build stage. The `smos_ard` package is installed from `src/` which is copied in from the project root.
209-
210-
The build context is `docker/`. Since `src/` lives at the project root it must be copied in before building (`docker/src/` is gitignored):
211-
212-
```bash
213-
# Copy smos_ard source into the build context
214-
cp -r src docker/src
215-
216-
# Build and push
217-
docker build -t earthcode/smos-ard-pipeline:0.0.1 docker/
218-
docker push earthcode/smos-ard-pipeline:latest
219-
```
220-
221-
### 2. Apply Kubernetes RBAC
222-
223-
The Airflow service account (`airflow-sa` in the `airflow` namespace) needs permission to create and manage pods in the `earthcode` namespace. Apply the role and binding once:
224-
225-
```bash
226-
kubectl apply -f k8s/rbac.yaml
227-
```
228-
229-
### 3. Create Kubernetes secret
230-
231-
Every pod receives these environment variables from the `earthcode-secrets` secret:
232-
233-
| Variable | Required | Used by | Description |
234-
|---|---|---|---|
235-
| `AWS_S3_ACCESS_KEY_ID` | yes | fetch, aggregate, publish | AWS credentials for reading/writing S3 |
236-
| `AWS_S3_SECRET_ACCESS_KEY` | yes | fetch, aggregate, publish | AWS credentials for reading/writing S3 |
237-
| `AWS_DEFAULT_REGION` | no | fetch, aggregate, publish | AWS region; defaults to `eu-central-1` |
238-
| `CREODIAS_S3_KEY` | yes | fetch | CREODIAS S3 key for the xcube-smos data store |
239-
| `CREODIAS_S3_SECRET` | yes | fetch | CREODIAS S3 secret for the xcube-smos data store |
240-
| `GITHUB_USERNAME` | yes | publish | GitHub username for opening the OSC catalogue PR |
241-
| `GITHUB_TOKEN` | yes | publish | GitHub PAT with `repo` scope for opening the OSC catalogue PR |
242-
243-
> `publish` also needs `S3_USER_STORAGE_{BUCKET,KEY,SECRET}` (expected by deep-code). `KEY` and `SECRET` are derived at runtime from `AWS_S3_ACCESS_KEY_ID` / `AWS_S3_SECRET_ACCESS_KEY`. `BUCKET` defaults to the ARD cube bucket but can be overridden via the `stac_s3_bucket` DAG parameter when the STAC catalog should live in a separate bucket.
244-
245-
```bash
246-
kubectl create secret generic earthcode-secrets \
247-
--namespace=earthcode \
248-
--from-literal=AWS_S3_ACCESS_KEY_ID=<key> \
249-
--from-literal=AWS_S3_SECRET_ACCESS_KEY=<secret> \
250-
--from-literal=AWS_DEFAULT_REGION=eu-central-1 \
251-
--from-literal=CREODIAS_S3_KEY=<key> \
252-
--from-literal=CREODIAS_S3_SECRET=<secret> \
253-
--from-literal=GITHUB_USERNAME=<user> \
254-
--from-literal=GITHUB_TOKEN=<token>
255-
```
256-
257-
### 4. Deploy the DAG
258-
259-
Airflow picks up DAGs from the S3 bucket `s3://airflow-dags-bc/earthcode/`. Copy the generated DAG there to make it visible in the Airflow UI:
260-
261-
```bash
262-
aws s3 cp dags/main_step.py s3://airflow-dags-bc/earthcode/
263-
```
264-
265-
Airflow polls the bucket periodically, so the DAG will appear within a minute or two.
165+
For deployment instructions, secret setup, trigger commands, and scheduled run behaviour see [deployment_guide.md](deployment_guide.md).
266166

267167
## Development
268168

changes.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Changelog
2+
3+
## v0.1 — Initial Release
4+
5+
### Data Pipeline
6+
7+
- **Fetch** (`smos_ard.fetch`): opens SMOS L2C soil moisture (`SMOS-L2C-SM`) and ocean
8+
salinity (`SMOS-L2C-OS`) data from the CREODIAS EODATA archive via xcube-smos; writes
9+
raw orbital-pass data to a staging Zarr store on S3
10+
(`s3://{bucket}/staging/{prefix}/raw.zarr`)
11+
12+
- **Aggregate** (`smos_ard.aggregate`): reads staging Zarr, deduplicates and sorts the
13+
up-to-29 orbital passes per day, resamples to configurable temporal periods (default
14+
daily mean), applies physical-range QC masks (`Soil_Moisture ∈ [0, 1]` m³/m³;
15+
`SSS_corr ∈ [0, 45]` PSU), enriches variable metadata with long names and GCMD keyword
16+
URLs, and writes/appends the ARD cube to S3 (`s3://{bucket}/ard/{prefix}/cube.zarr`)
17+
using zappend for atomic incremental updates
18+
19+
- **Publish** (`smos_ard.publish`): generates `dataset-config.yaml`,
20+
`workflow-config.yaml`, and `manifest.json` from pipeline metadata and stages them to
21+
`s3://{bucket}/catalogue-pending/{collection_id}/`; no deep-code dependency in the
22+
pipeline image
23+
24+
- **Publish trigger** (`smos_ard.publish_trigger`): standalone CLI that reads staged
25+
artefacts from S3, calls `deep_code.tools.publish.Publisher` to fork the OSC catalogue
26+
repo and open a GitHub PR, then archives the artefacts to
27+
`catalogue-published/{collection_id}/{timestamp}/` on success
28+
29+
### Orchestration
30+
31+
- Airflow DAG (`dags/smos-ard-service-v2.py`) generated by appligator from the procodile
32+
`ProcessRegistry` in `workflow.py`; runs daily at 23:00 UTC via
33+
`KubernetesPodOperator` on the `earthcode` namespace
34+
- `run_step.py` container entrypoint: merges `STEP_CONFIG`, `STEP_PARAMS`, and
35+
`STEP_XCOM` environment variables, dynamically dispatches to the target function, and
36+
writes the return dict to `/airflow/xcom/return.json`
37+
- `start_date` and `end_date` derived automatically from Airflow's `{{ ds }}` logical
38+
date on scheduled runs; manual overrides supported via DAG params
39+
40+
### ARD Cube
41+
42+
- Format: Zarr v2, consolidated metadata
43+
- Default spatial coverage: global (`[-180, -90, 180, 90]`)
44+
- Default temporal aggregation: daily mean (`1D`)
45+
- Chunk layout: `{time: 1, lat: 2048, lon: 2048}`
46+
- Write modes: initial write on first run; atomic append via zappend on subsequent runs;
47+
skipped if all timesteps already present
48+
- Variables: `Soil_Moisture`, `Chi_2`, `Chi_2_P`, `N_RFI_X`, `N_RFI_Y`, `RFI_Prob`,
49+
`Soil_Moisture_DQX`
50+
- Production location: `s3://earthcode-ard-cubes/ard/smos-sm/global/cube.zarr`
51+
52+
### Bug Fixes
53+
54+
- Fixed alternate-day append failures caused by `_validate_append_compat` running before
55+
`_clip_to_new_times`: the boundary day (`new_min == existing_max`) was incorrectly
56+
rejected before the overlap could be stripped

0 commit comments

Comments
 (0)