Skip to content

Commit 1e7473e

Browse files
committed
Add CLI for launching nextflow jobs via seqera platform
This generally aligns more closely with seqera cli's `tw launch` than with existing analysis-runner conventions. https://docs.seqera.io/platform-cli/reference/launch Though there is some analysis-runner logic applied - or at least scaffolded to be applied in the server side implementation. - Rather than specifying compute env and workspace, you specify dataset and access level, and the server will look up the compute env and workspace from those. - Overriding of config with local config is disallowed for access levels other than test. This is because some config fields could be used to execute arbitrary code on the workflow runner. For access levels standard/full, a config can be specified but it must abide by the same rules as the workflow itself, ie. being on the main branch of an allow-listed repository. - Several other `tw launch` args are omitted as we don't have a clear use case yet, they can be added later if needed.
1 parent 847004a commit 1e7473e

5 files changed

Lines changed: 321 additions & 0 deletions

File tree

packages/analysis-runner/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
# a much newer version
1616
"grpcio-status>=1.48,<1.50",
1717
"hail>=0.2.134",
18+
"pyyaml",
1819
"requests",
1920
"tabulate",
2021
"toml",

packages/analysis-runner/src/analysis_runner/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from analysis_runner.cli_config import add_config_args, run_config_from_args
1818
from analysis_runner.cli_cromwell import add_cromwell_args, run_cromwell_from_args
19+
from analysis_runner.cli_seqera import add_seqera_args, run_seqera_from_args
1920

2021

2122
def main_from_args(args: Sequence[str] | None = None):
@@ -37,6 +38,7 @@ def main_from_args(args: Sequence[str] | None = None):
3738
modes: dict[str, tuple[Callable[[], argparse.ArgumentParser], Callable]] = {
3839
'analysis-runner': (add_analysis_runner_args, run_analysis_runner_from_args),
3940
'cromwell': (add_cromwell_args, run_cromwell_from_args),
41+
'seqera': (add_seqera_args, run_seqera_from_args),
4042
'config': (add_config_args, run_config_from_args),
4143
}
4244

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""
2+
CLI options for launching Nextflow workflows on the Seqera platform
3+
"""
4+
5+
import argparse
6+
import sys
7+
from typing import Any
8+
9+
import requests
10+
import yaml
11+
12+
from analysis_runner.util import (
13+
SERVER_ENDPOINT,
14+
_perform_version_check,
15+
confirm_choice,
16+
get_server_endpoint,
17+
logger,
18+
)
19+
from cpg_utils.cloud import get_google_identity_token
20+
21+
22+
def add_seqera_args(
23+
parser: argparse.ArgumentParser | None = None,
24+
) -> argparse.ArgumentParser:
25+
"""
26+
Add CLI arguments for launching Nextflow workflows on Seqera.
27+
28+
Flag names more or less mirror the Seqera Platform CLI (``tw launch``)
29+
"""
30+
if not parser:
31+
parser = argparse.ArgumentParser('seqera analysis-runner')
32+
33+
parser.add_argument(
34+
'--dataset',
35+
required=True,
36+
type=str,
37+
help='The dataset name, determines what data the run should have access to.',
38+
)
39+
parser.add_argument(
40+
'--access-level',
41+
choices=(['test', 'standard', 'full']),
42+
default='test',
43+
help='Which permissions level to grant when running the job.',
44+
)
45+
46+
parser.add_argument(
47+
'--repository',
48+
'--repo',
49+
required=True,
50+
help='The name of the repository where the pipeline to run resides.',
51+
)
52+
53+
parser.add_argument(
54+
'--revision',
55+
required=True,
56+
help='The git branch or tag to use, for an exact commit use --commit-id.',
57+
)
58+
parser.add_argument(
59+
'--commit-id',
60+
required=True,
61+
help='Specific Git commit hash to pin the pipeline execution to.',
62+
)
63+
64+
parser.add_argument(
65+
'--main-script',
66+
required=False,
67+
default='main.nf',
68+
help='The Nextflow entry script to run. Defaults to "main.nf".',
69+
)
70+
71+
parser.add_argument(
72+
'--params-file',
73+
required=False,
74+
help='Path to a params file (YAML or JSON) forwarded to Nextflow as a '
75+
'-params-file. Use "-" to read params from stdin.',
76+
)
77+
78+
parser.add_argument(
79+
'--config',
80+
required=False,
81+
help=(
82+
'A full Nextflow config file to apply to the run, given as a github URL '
83+
'(github.com/... or raw.githubusercontent.com/...). For standard / full '
84+
'access the URL must point to a config on the main branch of an '
85+
'allow-listed repository. Test access is less restricted: the URL may '
86+
'reference any branch, or a local file path may be supplied instead.'
87+
'Specifying a config will override pipeline config files.'
88+
),
89+
)
90+
91+
parser.add_argument(
92+
'--use-test-server',
93+
action='store_true',
94+
help='Use the test analysis-runner server',
95+
)
96+
parser.add_argument(
97+
'--server-url',
98+
required=False,
99+
default=SERVER_ENDPOINT,
100+
help='Supply a server URL to use, this will override the "--use-test-server"',
101+
)
102+
103+
return parser
104+
105+
106+
def run_seqera_from_args(args: argparse.ArgumentParser):
107+
"""Run seqera nextflow submission from argparse.parse_arguments"""
108+
return run_seqera(**vars(args))
109+
110+
111+
def _read_params(params: str) -> dict:
112+
"""Read a params file (YAML or JSON; "-" for stdin) into a dict."""
113+
if params == '-':
114+
content = sys.stdin.read()
115+
else:
116+
with open(params) as f:
117+
content = f.read()
118+
119+
# JSON is a subset of YAML, so safe_load handles both formats.
120+
parsed = yaml.safe_load(content)
121+
if not isinstance(parsed, dict):
122+
raise ValueError('The params file must contain a top-level mapping')
123+
return parsed
124+
125+
126+
def run_seqera(
127+
dataset: str,
128+
access_level: str,
129+
repository: str,
130+
commit_id: str,
131+
revision: str,
132+
main_script: str = 'main.nf',
133+
params_file: str | None = None,
134+
config: str | None = None,
135+
use_test_server: bool = False,
136+
server_url: str | None = None,
137+
) -> None:
138+
"""
139+
Prepare parameters and submit a Nextflow workflow to the analysis-runner.
140+
"""
141+
_perform_version_check()
142+
143+
if access_level == 'full' and not confirm_choice(
144+
'Full access increases the risk of accidental data loss. Continue?',
145+
):
146+
raise SystemExit
147+
148+
server_args: dict[str, Any] = {
149+
'dataset': dataset,
150+
'access_level': access_level,
151+
'main_script': main_script,
152+
'repository': repository,
153+
'commit_id': commit_id,
154+
'revision': revision,
155+
}
156+
157+
if params_file:
158+
server_args['params'] = _read_params(params_file)
159+
160+
if config:
161+
if config.startswith(('http://', 'https://')):
162+
# A github URL, this is further validated on the server side to ensure
163+
# the file is in an appropriate repo on an appropriate branch
164+
server_args['config_url'] = config
165+
elif access_level == 'test':
166+
# Test access level can use a local config file
167+
with open(config) as f:
168+
server_args['config_text'] = f.read()
169+
else:
170+
raise SystemExit(
171+
'For standard/full access, --config must be a github URL to a config '
172+
'file on the main branch of an allow-listed repository.',
173+
)
174+
175+
logger.info(
176+
f'Submitting Nextflow workflow {repository}@{commit_id} on {revision} '
177+
f'for dataset "{dataset}"',
178+
)
179+
180+
server_endpoint = get_server_endpoint(
181+
server_url=server_url, is_test=use_test_server
182+
)
183+
endpoint = server_endpoint.rstrip('/') + '/seqera'
184+
_token = get_google_identity_token(server_endpoint)
185+
186+
response = requests.post(
187+
endpoint,
188+
json=server_args,
189+
headers={'Authorization': f'Bearer {_token}'},
190+
timeout=60,
191+
)
192+
try:
193+
response.raise_for_status()
194+
logger.info(f'Request submitted successfully: {response.text}')
195+
except requests.HTTPError as e:
196+
logger.critical(
197+
f'Request failed with status {response.status_code}: {e!s}\n'
198+
f'Full response: {response.text}',
199+
)

packages/analysis-runner/tests/test_analysis_runner.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
# ruff: noqa: S105
2+
import os
3+
import tempfile
24
import unittest
35
from typing import Any
46
from unittest.mock import MagicMock, patch
57

8+
import pytest
9+
610
from analysis_runner._version import __version__
711
from analysis_runner.cli import main_from_args
812

913
IMPORT_AR_IDENTITY_TOKEN_PATH = (
1014
'analysis_runner.cli_analysisrunner.get_google_identity_token'
1115
)
1216
IMPORT_CR_IDENTITY_TOKEN_PATH = 'analysis_runner.cli_cromwell.get_google_identity_token'
17+
IMPORT_SEQERA_IDENTITY_TOKEN_PATH = (
18+
'analysis_runner.cli_seqera.get_google_identity_token'
19+
)
1320

1421
REQUEST_POST_PATH = 'requests.post'
1522
REQUEST_GET_PATH = 'requests.get'
@@ -127,5 +134,115 @@ def test_submit_cli(self, mock_post: MagicMock, mock_identity_token: MagicMock):
127134
mock_identity_token.assert_called()
128135

129136

137+
class TestCliSeqera(unittest.TestCase):
138+
SEQERA_ARGS = (
139+
'seqera',
140+
'--dataset',
141+
'fewgenomes',
142+
'--access-level',
143+
'test',
144+
'--repository',
145+
'my-nf-pipeline',
146+
'--commit-id',
147+
'abc123',
148+
'--revision',
149+
'main',
150+
)
151+
152+
@patch(IMPORT_SEQERA_IDENTITY_TOKEN_PATH)
153+
@patch(REQUEST_POST_PATH)
154+
def test_submit_cli(self, mock_post: MagicMock, mock_identity_token: MagicMock):
155+
apply_mock_behaviour(
156+
mock_post=mock_post,
157+
mock_identity_token=mock_identity_token,
158+
)
159+
160+
main_from_args(list(self.SEQERA_ARGS))
161+
162+
mock_post.assert_called()
163+
mock_identity_token.assert_called()
164+
165+
# The request must hit the /seqera endpoint with the tower-style, snake_case
166+
# arg set, and must NOT contain the standard analysis-runner / legacy keys.
167+
_, kwargs = mock_post.call_args
168+
assert mock_post.call_args[0][0].endswith('/seqera')
169+
body = kwargs['json']
170+
for key in (
171+
'dataset',
172+
'access_level',
173+
'repository',
174+
'commit_id',
175+
'revision',
176+
'main_script',
177+
):
178+
assert key in body
179+
for key in (
180+
'cwd',
181+
'cpu',
182+
'memory',
183+
'storage',
184+
'output',
185+
'output_dir',
186+
'description',
187+
'accessLevel',
188+
):
189+
assert key not in body
190+
191+
@patch(IMPORT_SEQERA_IDENTITY_TOKEN_PATH)
192+
@patch(REQUEST_POST_PATH)
193+
def test_config_github_url_sent_as_config_url(
194+
self,
195+
mock_post: MagicMock,
196+
mock_identity_token: MagicMock,
197+
):
198+
apply_mock_behaviour(
199+
mock_post=mock_post, mock_identity_token=mock_identity_token
200+
)
201+
202+
url = 'https://github.com/org/configs/blob/main/conf/prod.config'
203+
main_from_args([*self.SEQERA_ARGS, '--config', url])
204+
205+
body = mock_post.call_args.kwargs['json']
206+
assert body['config_url'] == url
207+
assert 'config_text' not in body
208+
209+
@patch(IMPORT_SEQERA_IDENTITY_TOKEN_PATH)
210+
@patch(REQUEST_POST_PATH)
211+
def test_config_local_file_inlined_for_test(
212+
self,
213+
mock_post: MagicMock,
214+
mock_identity_token: MagicMock,
215+
):
216+
apply_mock_behaviour(
217+
mock_post=mock_post, mock_identity_token=mock_identity_token
218+
)
219+
220+
with tempfile.NamedTemporaryFile('w', suffix='.config', delete=False) as f:
221+
f.write('process {}')
222+
config_path = f.name
223+
self.addCleanup(os.unlink, config_path)
224+
225+
main_from_args([*self.SEQERA_ARGS, '--config', config_path])
226+
227+
body = mock_post.call_args.kwargs['json']
228+
assert body['config_text'] == 'process {}'
229+
assert 'config_url' not in body
230+
231+
@patch(IMPORT_SEQERA_IDENTITY_TOKEN_PATH)
232+
@patch(REQUEST_POST_PATH)
233+
def test_config_local_file_rejected_for_standard(
234+
self,
235+
mock_post: MagicMock,
236+
mock_identity_token: MagicMock,
237+
):
238+
apply_mock_behaviour(
239+
mock_post=mock_post, mock_identity_token=mock_identity_token
240+
)
241+
242+
args = [a if a != 'test' else 'standard' for a in self.SEQERA_ARGS]
243+
with pytest.raises(SystemExit):
244+
main_from_args([*args, '--config', '/some/local/file.config'])
245+
246+
130247
if __name__ == '__main__':
131248
unittest.main()

packages/analysis-runner/uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)