Skip to content

Commit 353e062

Browse files
authored
Merge pull request #144 from seqeralabs/cursor/issue-72-api-preflight-bc67
Improve Seqera API preflight errors
2 parents 1fa5b19 + 4bbb8b8 commit 353e062

11 files changed

Lines changed: 311 additions & 22 deletions

File tree

.seqera/context/ERRORS.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,45 @@ Both runs `1cF65l2PDvxNd5` (maniac_celsius) and `5VGC0gjEmghOaz` (clever_kalman)
2222

2323
**Fix:** Verify the org/workspace names in the input CSV match the Platform exactly (case-sensitive).
2424

25-
### 3. API Rate Limiting / Transient Failures
25+
### 3. API Endpoint / Token Preflight Failure
26+
27+
**When:** The API endpoint is wrong, unreachable, or the access token is invalid.
28+
29+
**Error:** `RuntimeException: Seqera Platform API preflight failed at '<endpoint>/service-info' ...` or `RuntimeException: Authentication failed at '<endpoint>/user-info' ...`
30+
31+
**Fix:** Verify `--seqera_api_endpoint` (or the samplesheet `platform` column), network/truststore settings, and the token stored in `TOWER_ACCESS_TOKEN` (or the per-row `token_env` override).
32+
33+
### 4. API Rate Limiting / Transient Failures
2634

2735
**When:** Fetching data from busy Platform instances with many concurrent runs.
2836

2937
**Mitigation:** Built-in retry with exponential backoff (3 attempts, 1s/2s/4s delays). After 3 failures, the pipeline aborts with the original error.
3038

31-
### 4. Wave Container Build Failures
39+
### 5. Wave Container Build Failures
3240

3341
**When:** Using `wave` profile with `spack` strategy.
3442

3543
**Error:** Wave freeze builds fail with spack.
3644

3745
**Fix:** The wave profile explicitly uses `strategy = ['conda', 'container', 'dockerfile']` — no spack. Don't add spack to the strategy list.
3846

39-
### 5. CGROUPv2 Docker Failures (Cloud VM / Firecracker)
47+
### 6. CGROUPv2 Docker Failures (Cloud VM / Firecracker)
4048

4149
**When:** Running with Docker in Cloud VMs where cgroup resource delegation is restricted.
4250

4351
**Error:** `cannot enter cgroupv2 ... with domain controllers`
4452

4553
**Fix:** Apply the runc wrapper documented in AGENTS.md that strips `linux.resources` from the OCI spec.
4654

47-
### 6. Empty Benchmark Report
55+
### 7. Empty Benchmark Report
4856

4957
**When:** API runs are provided but `--generate_benchmark_report` is not set.
5058

5159
**Warning:** `Found N API run(s) but --generate_benchmark_report is not enabled. API runs will not produce any output.`
5260

5361
**Fix:** Add `--generate_benchmark_report` to the run command.
5462

55-
### 7. Scheduling Overhead (Observed Pattern)
63+
### 8. Scheduling Overhead (Observed Pattern)
5664

5765
**Not an error per se**, but both current runs show 4–8 minute gaps between task submit and task start times. This is expected with AWS Batch spot instances — EC2 instances must be provisioned and containers pulled before execution begins. Not actionable unless overhead exceeds ~15 minutes consistently.
5866

.seqera/context/PIPELINE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ results/
8989

9090
## Data Flow Details
9191

92-
1. **API path:** `SeqeraApi.fetchRunData()` runs in Groovy process memory (not a Nextflow task). It resolves workspace name → ID, then fetches `/workflow/{id}`, `/workflow/{id}/metrics`, `/workflow/{id}/tasks` (paginated), and `/workflow/{id}/progress`. Results are written to temp JSON files.
92+
1. **API path:** `SeqeraApi.fetchRunData()` runs in Groovy process memory (not a Nextflow task). It first validates the configured Platform endpoint with `/service-info` and the bearer token with `/user-info`, then resolves workspace name → ID and fetches `/workflow/{id}`, `/workflow/{id}/metrics`, `/workflow/{id}/tasks` (paginated), and `/workflow/{id}/progress`. Results are written to temp JSON files.
9393
2. **External path:** EXTRACT_TARBALL unpacks `.tar.gz` into a directory of JSON files. Directories are used directly.
9494
3. **All JSON files** are collected into a single temp directory and passed to the 3-stage Python pipeline: normalize → aggregate → render.
9595
4. The Python stages are separate Nextflow processes sharing one Wave container image (`python_duckdb_jinja2_typer_pruned`).

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Thank you to everyone else that has contributed by reporting bugs, enhancements
1515

1616
### Enhancements & fixes
1717

18+
- Improve Seqera API preflight errors for invalid API endpoints and access tokens
1819
- [PR #88](https://github.com/seqeralabs/nf-aggregate/pull/88) - Update tw cli container version to 0.11.2 and allow .nextflow.log to be missing from tw call
1920
- [PR #89](https://github.com/seqeralabs/nf-aggregate/pull/89) - Enable usage of external run dumps with nf-aggregate & update devcontainer specifications
2021
- [PR #91](https://github.com/seqeralabs/nf-aggregate/pull/91) - Update benchmark report image to include a fix causing large memory footprint for reshaping large AWS cost report files

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
The pipeline fetches run data from the Seqera Platform API and generates benchmark reports comparing pipeline runs.
1717

18+
Before the pipeline resolves workspaces or fetches run details, it performs API preflight checks against `/service-info` and `/user-info`. This helps surface misconfigured API endpoints, missing truststore configuration, and invalid access tokens with explicit error messages.
19+
1820
## Prerequisites
1921

2022
- [Nextflow](https://www.nextflow.io/docs/latest/getstarted.html#installation) >=25.10.0

bin/benchmark_report_fetch.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from __future__ import annotations
55

66
import json
7+
from functools import lru_cache
8+
from urllib.error import HTTPError, URLError
79
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
810
from urllib.request import Request, urlopen
911

@@ -21,6 +23,62 @@ def _api_get(url: str, headers: dict[str, str], params: dict[str, str] | None =
2123
return json.loads(resp.read())
2224

2325

26+
def _format_http_error(exc: HTTPError) -> str:
27+
return f"API request failed: {exc.url} → HTTP {exc.code}"
28+
29+
30+
@lru_cache(maxsize=None)
31+
def _validate_api_access_cached(api_endpoint: str, authorization: str, token_env_var: str) -> None:
32+
headers = {"Authorization": authorization}
33+
34+
try:
35+
_api_get(f"{api_endpoint}/service-info", headers=headers)
36+
except HTTPError as exc:
37+
raise RuntimeError(
38+
"Seqera Platform API preflight failed at "
39+
f"'{api_endpoint}/service-info'. Check the API endpoint URL from "
40+
"--api-endpoint or the input samplesheet platform column. "
41+
f"Original error: {_format_http_error(exc)}"
42+
) from exc
43+
except URLError as exc:
44+
raise RuntimeError(
45+
"Could not reach Seqera Platform API preflight endpoint "
46+
f"'{api_endpoint}/service-info'. Check the API endpoint URL, network "
47+
f"access, and TLS configuration. Original error: {exc.reason}"
48+
) from exc
49+
50+
try:
51+
_api_get(f"{api_endpoint}/user-info", headers=headers)
52+
except HTTPError as exc:
53+
if exc.code in {401, 403}:
54+
raise RuntimeError(
55+
"Authentication failed at "
56+
f"'{api_endpoint}/user-info'. Check the access token stored in "
57+
f"'{token_env_var}'. Original error: {_format_http_error(exc)}"
58+
) from exc
59+
raise RuntimeError(
60+
"Seqera Platform API auth preflight failed at "
61+
f"'{api_endpoint}/user-info'. Check the API endpoint URL and the "
62+
f"access token stored in '{token_env_var}'. Original error: "
63+
f"{_format_http_error(exc)}"
64+
) from exc
65+
except URLError as exc:
66+
raise RuntimeError(
67+
"Could not complete Seqera Platform auth preflight at "
68+
f"'{api_endpoint}/user-info'. Check network access and the access "
69+
f"token stored in '{token_env_var}'. Original error: {exc.reason}"
70+
) from exc
71+
72+
73+
def validate_api_access(
74+
api_endpoint: str,
75+
headers: dict[str, str],
76+
token_env_var: str = "TOWER_ACCESS_TOKEN",
77+
) -> None:
78+
authorization = headers.get("Authorization", "")
79+
_validate_api_access_cached(api_endpoint, authorization, token_env_var)
80+
81+
2482
def resolve_workspace_id(workspace: str, api_endpoint: str, headers: dict[str, str]) -> int:
2583
org_name, workspace_name = workspace.split("/", 1)
2684

@@ -67,6 +125,7 @@ def fetch_all_tasks(base_url: str, headers: dict[str, str]) -> list[dict]:
67125

68126
def fetch_run_data(run_id: str, workspace: str, api_endpoint: str, token: str) -> dict:
69127
headers = {"Authorization": f"Bearer {token}"}
128+
validate_api_access(api_endpoint, headers=headers)
70129
ws_id = resolve_workspace_id(workspace, api_endpoint, headers)
71130

72131
workflow_data = _api_get(

bin/test_benchmark_report_fetch.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from urllib.error import HTTPError
12
from unittest.mock import patch
23

34
import pytest
45

5-
from benchmark_report_fetch import fetch_all_tasks, fetch_run_data, resolve_workspace_id
6+
from benchmark_report_fetch import fetch_all_tasks, fetch_run_data, resolve_workspace_id, validate_api_access
67

78

89
def test_resolve_workspace_id():
@@ -33,12 +34,32 @@ def test_fetch_all_tasks_paginates():
3334

3435
def test_fetch_run_data_keys():
3536
with patch("benchmark_report_fetch.resolve_workspace_id", return_value=10):
36-
with patch("benchmark_report_fetch._api_get") as mock_get:
37-
with patch("benchmark_report_fetch.fetch_all_tasks", return_value=[{"task": {"id": 1}}]):
38-
mock_get.side_effect = [
39-
{"workflow": {"id": "run1"}},
40-
{"metrics": []},
41-
{"progress": {"workflowProgress": {}}},
42-
]
43-
data = fetch_run_data("run1", "org/ws", "https://api.example.com", "tok")
44-
assert set(data.keys()) == {"workflow", "metrics", "tasks", "progress"}
37+
with patch("benchmark_report_fetch.validate_api_access") as mock_validate:
38+
with patch("benchmark_report_fetch._api_get") as mock_get:
39+
with patch("benchmark_report_fetch.fetch_all_tasks", return_value=[{"task": {"id": 1}}]):
40+
mock_get.side_effect = [
41+
{"workflow": {"id": "run1"}},
42+
{"metrics": []},
43+
{"progress": {"workflowProgress": {}}},
44+
]
45+
data = fetch_run_data("run1", "org/ws", "https://api.example.com", "tok")
46+
assert set(data.keys()) == {"workflow", "metrics", "tasks", "progress"}
47+
mock_validate.assert_called_once_with("https://api.example.com", headers={"Authorization": "Bearer tok"})
48+
49+
50+
def test_validate_api_access_bad_token():
51+
error = HTTPError("https://api.example.com/user-info", 401, "Unauthorized", hdrs=None, fp=None)
52+
53+
with patch("benchmark_report_fetch._api_get") as mock_get:
54+
mock_get.side_effect = [{}, error]
55+
56+
with pytest.raises(RuntimeError, match="Authentication failed at 'https://api.example.com/user-info'"):
57+
validate_api_access("https://api.example.com", headers={"Authorization": "Bearer tok"})
58+
59+
60+
def test_validate_api_access_bad_endpoint():
61+
error = HTTPError("https://bad.example.com/service-info", 404, "Not Found", hdrs=None, fp=None)
62+
63+
with patch("benchmark_report_fetch._api_get", side_effect=error):
64+
with pytest.raises(RuntimeError, match="preflight failed at 'https://bad.example.com/service-info'"):
65+
validate_api_access("https://bad.example.com", headers={"Authorization": "Bearer tok"})

lib/AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@ Plain `java.net.URL.openConnection()` HTTP client for Seqera Platform API. Used
1313
| `apiGet(url, headers)` | Single GET request, returns parsed JSON map |
1414
| `apiGetAllTasks(baseUrl, headers)` | Paginated GET for `/tasks` endpoint (100/page) |
1515
| `resolveWorkspaceId(workspace, apiEndpoint, headers)` | "org/workspace" string → numeric workspace ID |
16-
| `fetchRunData(meta, apiEndpoint)` | Orchestrator: calls 4 endpoints per run → `{workflow, metrics, tasks, progress}` |
16+
| `fetchRunData(meta, apiEndpoint)` | Orchestrator: preflights `/service-info` + `/user-info`, then calls 4 run endpoints`{workflow, metrics, tasks, progress}` |
1717

18-
### API Endpoints Called (per run)
18+
### API Endpoints Called
1919

20+
Preflight once per endpoint/token pair:
21+
22+
1. `GET /service-info` — validates the API endpoint is reachable
23+
2. `GET /user-info` — validates the bearer token before workspace resolution
24+
25+
Per run:
2026
1. `GET /workflow/{id}?workspaceId={wsId}` — run metadata
2127
2. `GET /workflow/{id}/metrics?workspaceId={wsId}` — resource metrics
2228
3. `GET /workflow/{id}/tasks?workspaceId={wsId}` — all tasks (paginated)
@@ -35,3 +41,4 @@ Calls `/orgs` → finds org by name → calls `/orgs/{orgId}/workspaces` → fin
3541
- Uses plain `java.net.URL.openConnection()` for HTTP requests — no external plugin dependency
3642
- Runs in the Nextflow head JVM, not in a container process
3743
- Network errors throw `RuntimeException` which will fail the pipeline
44+
- Bad endpoints fail during `/service-info` preflight; bad or expired tokens fail during `/user-info` preflight

lib/SeqeraApi.groovy

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
// Seqera Platform API client using plain java.net HTTP
22

33
class SeqeraApi {
4+
private static final Set<String> validatedApiSessions = Collections.synchronizedSet(new HashSet<String>())
5+
6+
static class ApiRequestException extends RuntimeException {
7+
final String url
8+
final int statusCode
9+
10+
ApiRequestException(String url, int statusCode) {
11+
super("API request failed: ${url} → HTTP ${statusCode}")
12+
this.url = url
13+
this.statusCode = statusCode
14+
}
15+
}
416

517
static Map apiGet(String url, Map headers) {
6-
def conn = new URL(url).openConnection()
18+
def conn = (HttpURLConnection) new URL(url).openConnection()
719
try {
820
conn.setRequestMethod('GET')
921
headers.each { k, v -> conn.setRequestProperty(k, v) }
1022
def code = conn.getResponseCode()
1123
if (code != 200) {
12-
throw new RuntimeException("API request failed: ${url} → HTTP ${code}")
24+
throw new ApiRequestException(url, code)
1325
}
1426
def stream = conn.getInputStream()
1527
try {
@@ -23,6 +35,69 @@ class SeqeraApi {
2335
}
2436
}
2537

38+
static void validateApiAccess(String apiEndpoint, Map headers, String tokenEnvVar, String token) {
39+
def tokenDigest = java.security.MessageDigest.getInstance('SHA-256')
40+
.digest(token.getBytes('UTF-8'))
41+
.collect { String.format('%02x', it) }
42+
.join()
43+
def validationKey = "${apiEndpoint}|${tokenEnvVar}|${tokenDigest}"
44+
if (validatedApiSessions.contains(validationKey)) {
45+
return
46+
}
47+
48+
synchronized (validatedApiSessions) {
49+
if (validatedApiSessions.contains(validationKey)) {
50+
return
51+
}
52+
53+
try {
54+
apiGet("${apiEndpoint}/service-info", headers)
55+
} catch (ApiRequestException e) {
56+
throw new RuntimeException(
57+
"Seqera Platform API preflight failed at '${apiEndpoint}/service-info'. " +
58+
"Check the API endpoint URL from --seqera_api_endpoint or the input samplesheet platform column. " +
59+
"Original error: ${e.message}",
60+
e
61+
)
62+
} catch (Exception e) {
63+
throw new RuntimeException(
64+
"Could not reach Seqera Platform API preflight endpoint '${apiEndpoint}/service-info'. " +
65+
"Check the API endpoint URL, network access, and JVM truststore settings. " +
66+
"Original error: ${e.message}",
67+
e
68+
)
69+
}
70+
71+
try {
72+
apiGet("${apiEndpoint}/user-info", headers)
73+
} catch (ApiRequestException e) {
74+
if (e.statusCode in [401, 403]) {
75+
throw new RuntimeException(
76+
"Authentication failed at '${apiEndpoint}/user-info'. " +
77+
"Check the access token stored in '${tokenEnvVar}'. " +
78+
"Original error: ${e.message}",
79+
e
80+
)
81+
}
82+
throw new RuntimeException(
83+
"Seqera Platform API auth preflight failed at '${apiEndpoint}/user-info'. " +
84+
"Check the API endpoint URL and the access token stored in '${tokenEnvVar}'. " +
85+
"Original error: ${e.message}",
86+
e
87+
)
88+
} catch (Exception e) {
89+
throw new RuntimeException(
90+
"Could not complete Seqera Platform auth preflight at '${apiEndpoint}/user-info'. " +
91+
"Check network access and the access token stored in '${tokenEnvVar}'. " +
92+
"Original error: ${e.message}",
93+
e
94+
)
95+
}
96+
97+
validatedApiSessions.add(validationKey)
98+
}
99+
}
100+
26101
/**
27102
* Paginate through /tasks endpoint. Returns flat list of all tasks.
28103
*/
@@ -79,6 +154,7 @@ class SeqeraApi {
79154
)
80155
}
81156
def headers = ["Authorization": "Bearer ${token}"]
157+
validateApiAccess(effectiveEndpoint, headers, tokenEnvVar, token)
82158
def wsId = resolveWorkspaceId(meta.workspace, effectiveEndpoint, headers)
83159
def base = "${effectiveEndpoint}/workflow/${meta.id}?workspaceId=${wsId}"
84160

tests/AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Layout
44

5-
Each pipeline-level scenario lives in its own directory:
5+
Pipeline-level scenarios live in their own directories:
66

77
- `pipeline_api_only/`
88
- `pipeline_mixed_no_benchmark/`
@@ -16,13 +16,21 @@ Each scenario directory contains:
1616
- `main.nf.test.snap` — the snapshot for that scenario
1717
- `AGENTS.md` — scenario-specific guidance for future edits
1818

19+
Function-level nf-test coverage for `lib/` helpers lives separately under:
20+
21+
- `lib/`
22+
23+
Reference: nf-test Function Testing docs — https://www.nf-test.com/docs/testcases/nextflow_function/
24+
1925
## Conventions
2026

2127
- One scenario per directory.
2228
- Keep assertions local and explicit rather than heavily abstracted.
2329
- Prefer stable fixture files under `workflows/nf_aggregate/assets/`.
2430
- If routing behavior changes, update the scenario-specific `AGENTS.md` along with the test.
25-
- This directory is for pipeline routing/integration scenarios only; stage-specific pytest tests now live beside the relevant module under `modules/local/*/tests/`.
31+
- Pipeline routing/integration scenarios live under `tests/pipeline_*/`.
32+
- Function-level nf-test coverage for Groovy helpers under `lib/` lives under `tests/lib/`.
33+
- Stage-specific pytest tests live beside the relevant module under `modules/local/*/tests/`.
2634

2735
## Running tests
2836

tests/lib/AGENTS.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# tests/lib/
2+
3+
## Purpose
4+
5+
This directory holds nf-test `nextflow_function` tests for helper code under `lib/`.
6+
7+
Current coverage:
8+
9+
- `SeqeraApi.groovy.test` — unit-ish behavioral coverage for `SeqeraApi` helpers using nf-test function tests and inline Groovy stubbing.
10+
11+
## Conventions
12+
13+
Reference: nf-test Function Testing docs — https://www.nf-test.com/docs/testcases/nextflow_function/
14+
15+
- Prefer `nextflow_function` tests here instead of introducing a separate Spock/Gradle harness.
16+
- Keep assertions local and explicit.
17+
- Stub `SeqeraApi.metaClass.'static'.apiGet` inline when isolating pagination or workspace-resolution behavior.
18+
- Reserve pipeline routing/integration scenarios for the sibling `tests/pipeline_*/` directories.

0 commit comments

Comments
 (0)