Skip to content

Commit efd650c

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 efd650c

5 files changed

Lines changed: 313 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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
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
@@ -10,6 +12,9 @@
1012
'analysis_runner.cli_analysisrunner.get_google_identity_token'
1113
)
1214
IMPORT_CR_IDENTITY_TOKEN_PATH = 'analysis_runner.cli_cromwell.get_google_identity_token'
15+
IMPORT_SEQERA_IDENTITY_TOKEN_PATH = (
16+
'analysis_runner.cli_seqera.get_google_identity_token'
17+
)
1318

1419
REQUEST_POST_PATH = 'requests.post'
1520
REQUEST_GET_PATH = 'requests.get'
@@ -127,5 +132,109 @@ def test_submit_cli(self, mock_post: MagicMock, mock_identity_token: MagicMock):
127132
mock_identity_token.assert_called()
128133

129134

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