omicslab 2.0.0__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {omicslab-2.0.0 → omicslab-2.1.0}/Makefile +0 -2
- {omicslab-2.0.0 → omicslab-2.1.0}/PKG-INFO +1 -1
- {omicslab-2.0.0 → omicslab-2.1.0}/README.developer.md +3 -3
- omicslab-2.1.0/src/omicslab/_version.py +1 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/main.py +15 -7
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_data_prep.py +20 -12
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_exec.py +7 -1
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_git.py +44 -2
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_nextflow.py +34 -15
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_storage.py +61 -7
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/analysis.py +14 -10
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/client.py +10 -1
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/executor.py +11 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/conftest.py +17 -8
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__data_prep.py +80 -5
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__engine_helpers.py +19 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__git.py +68 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__nextflow.py +39 -5
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__storage.py +59 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_client.py +23 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_executor_extra.py +13 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_storage.py +3 -0
- omicslab-2.0.0/src/omicslab/_version.py +0 -1
- {omicslab-2.0.0 → omicslab-2.1.0}/.gitignore +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/README.md +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/.gitignore +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/hcm.pixi.lock +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/hcm.pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/pixi.lock +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/us-east-1.pixi.lock +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/us-east-1.pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/hcm.pixi.lock +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/hcm.pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/pixi.lock +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/envs/regions.json +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/pixi.config.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/pyproject.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/scripts/bootstrap-env.sh +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/scripts/check_publish.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/scripts/write_version_manifest.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/analysis_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/auth_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/context_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/credential_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/fuse_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/job_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/org_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/runner_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/setup_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/studio_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/token_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/utils.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/workspace_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/ws_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/client.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/config.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/exceptions.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_auth.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_config_download.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_consts.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_env.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_images.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_log.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_monitor.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_params.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_proc.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_proxy.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_spec.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_studio_prep.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/studio.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/console.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/paths.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/system.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/analysis.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/base.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/credentials.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/jobs.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/orgs.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/runners.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/storage.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/studio.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/tokens.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/workspaces.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/selfupdate.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/conftest.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/__init__.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/test_cli_e2e.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/test_tokens.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__auth.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__env.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__get_images_cli.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__images.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__log.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__monitor.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__params.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__proxy.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__proxy_extra.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__spec.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_cli.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_client_extra.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_executor.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_fuse.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_resource.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_system.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_studio_validate_cli.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_ws_helpers.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_catalog.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_context.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_utils.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_client.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_fuse.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_job_exec.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_misc.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_resources.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_runner_helpers.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_runner_self_update.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_sandbox_report.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_selfupdate.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_studio_snapshot.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_ws_commands.py +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/input.txt +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/main.nf +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/schema.json +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-simple/pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-simple/schema.json +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/Snakefile +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/input.txt +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
- {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/schema.json +0 -0
|
@@ -191,7 +191,7 @@ This:
|
|
|
191
191
|
4. Polls `http://localhost:8000/api/v1/health` until healthy.
|
|
192
192
|
|
|
193
193
|
Notes:
|
|
194
|
-
- **Storage is S3-compatible (SeaweedFS).** `
|
|
194
|
+
- **Storage is S3-compatible (SeaweedFS).** `AWS_ENDPOINT_URL=http://localhost:4566`
|
|
195
195
|
(from `.env.test`) with credentials `test`/`testtest` (`AWS_ACCESS_KEY_ID` /
|
|
196
196
|
`AWS_SECRET_ACCESS_KEY`).
|
|
197
197
|
- `APP_ENV=test` is set so the backend keeps the DB hermetic and uses test env
|
|
@@ -308,7 +308,7 @@ CI re-upload the wheel whenever SDK code changes without regenerating locks,
|
|
|
308
308
|
and refresh the pixi cache only when `sdk/envs/pixi.toml` changes (see the
|
|
309
309
|
gated `upload-bootstrap-env` target in the root `Makefile`).
|
|
310
310
|
|
|
311
|
-
Config overrides (env vars): `
|
|
311
|
+
Config overrides (env vars): `AWS_ENDPOINT_URL`, `AWS_REGION`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_PREFIX`, `S3_REGION`.
|
|
312
312
|
|
|
313
313
|
### 8.1 Runner SDK self-update
|
|
314
314
|
|
|
@@ -489,7 +489,7 @@ Cloud tests are slow (VM boot + pixi install); use generous timeouts.
|
|
|
489
489
|
- **Token scope == access.** An org-scoped token can't touch other orgs. Bootstrap
|
|
490
490
|
fresh users/orgs for isolated scenarios.
|
|
491
491
|
- **Storage is S3-compatible (SeaweedFS)** at `:4566`. Use `AWS_ACCESS_KEY_ID=test`,
|
|
492
|
-
`AWS_SECRET_ACCESS_KEY=testtest`, `
|
|
492
|
+
`AWS_SECRET_ACCESS_KEY=testtest`, `AWS_ENDPOINT_URL=http://localhost:4566`.
|
|
493
493
|
- **Empty credential listing**: the backend now returns `[]` (via `ok_json_list`)
|
|
494
494
|
even when no credentials exist; older behavior returned an empty body.
|
|
495
495
|
- **Single-get routes** for org/workspace/analysis don't exist; verify via lists.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.1.0"
|
|
@@ -670,7 +670,8 @@ def upload(
|
|
|
670
670
|
},
|
|
671
671
|
)
|
|
672
672
|
success(
|
|
673
|
-
f"Uploaded {local.name} → {remote_key}
|
|
673
|
+
f"Uploaded {local.name} → {remote_key} "
|
|
674
|
+
f"({file_size / 1024 / 1024:.1f} MB) using presigned URL"
|
|
674
675
|
)
|
|
675
676
|
except Exception as e:
|
|
676
677
|
try:
|
|
@@ -759,7 +760,8 @@ def upload(
|
|
|
759
760
|
|
|
760
761
|
if resp.is_success:
|
|
761
762
|
success(
|
|
762
|
-
f"Uploaded {local.name} → {remote_key}
|
|
763
|
+
f"Uploaded {local.name} → {remote_key} "
|
|
764
|
+
f"({file_size / 1024 / 1024:.1f} MB) using presigned URL"
|
|
763
765
|
)
|
|
764
766
|
break
|
|
765
767
|
|
|
@@ -793,6 +795,8 @@ def upload(
|
|
|
793
795
|
info("No files found to upload")
|
|
794
796
|
return
|
|
795
797
|
|
|
798
|
+
info(f"Uploading {len(file_list)} files using presigned URLs...")
|
|
799
|
+
|
|
796
800
|
from rich.progress import (
|
|
797
801
|
BarColumn,
|
|
798
802
|
DownloadColumn,
|
|
@@ -880,11 +884,11 @@ def upload(
|
|
|
880
884
|
if uploaded[0] < len(file_list):
|
|
881
885
|
fail(
|
|
882
886
|
f"Upload incomplete: {uploaded[0]}/{len(file_list)} files uploaded "
|
|
883
|
-
f"({total_bytes / 1024 / 1024:.1f} MB)"
|
|
887
|
+
f"({total_bytes / 1024 / 1024:.1f} MB) using presigned URLs"
|
|
884
888
|
)
|
|
885
889
|
success(
|
|
886
890
|
f"Uploaded {uploaded[0]}/{len(file_list)} files "
|
|
887
|
-
f"({total_bytes / 1024 / 1024:.1f} MB)"
|
|
891
|
+
f"({total_bytes / 1024 / 1024:.1f} MB) using presigned URLs"
|
|
888
892
|
)
|
|
889
893
|
|
|
890
894
|
|
|
@@ -1014,6 +1018,8 @@ def download(
|
|
|
1014
1018
|
info("No files found in this path.")
|
|
1015
1019
|
return
|
|
1016
1020
|
|
|
1021
|
+
info(f"Downloading {len(download_items)} files using presigned URLs...")
|
|
1022
|
+
|
|
1017
1023
|
downloaded = 0
|
|
1018
1024
|
failed = 0
|
|
1019
1025
|
progress = Progress(
|
|
@@ -1075,11 +1081,12 @@ def download(
|
|
|
1075
1081
|
if failed:
|
|
1076
1082
|
success(
|
|
1077
1083
|
f"Downloaded {downloaded}/{len(download_items)} files "
|
|
1078
|
-
f"({failed} failed) to {dest_dir}"
|
|
1084
|
+
f"({failed} failed) to {dest_dir} using presigned URLs"
|
|
1079
1085
|
)
|
|
1080
1086
|
else:
|
|
1081
1087
|
success(
|
|
1082
|
-
f"Downloaded {downloaded}/{len(download_items)} files to {dest_dir}"
|
|
1088
|
+
f"Downloaded {downloaded}/{len(download_items)} files to {dest_dir} "
|
|
1089
|
+
f"using presigned URLs"
|
|
1083
1090
|
)
|
|
1084
1091
|
return
|
|
1085
1092
|
|
|
@@ -1127,7 +1134,8 @@ def download(
|
|
|
1127
1134
|
progress.update(task, advance=len(chunk))
|
|
1128
1135
|
|
|
1129
1136
|
success(
|
|
1130
|
-
f"Downloaded {full_key} → {dest}
|
|
1137
|
+
f"Downloaded {full_key} → {dest} "
|
|
1138
|
+
f"({dest.stat().st_size / 1024 / 1024:.1f} MB) using presigned URL"
|
|
1131
1139
|
)
|
|
1132
1140
|
except typer.Exit:
|
|
1133
1141
|
raise
|
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
Implements the analysis branch of ``omicslab job prepare-data``: scans
|
|
4
4
|
``params.json`` for ``s3://`` values (except ``outdir``), resolves each
|
|
5
5
|
bucket to a workspace via the API, and decides how to make the data
|
|
6
|
-
available locally — public buckets are FUSE-mounted
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
available locally — public buckets are FUSE-mounted and private buckets are
|
|
7
|
+
downloaded via presigned URLs (``storage_mode="sandbox"``, the platform
|
|
8
|
+
default for every job). Clients running under the future
|
|
9
|
+
EXPORT_JOB_IAM_KEYS safeguard may carry real credentials and mount instead.
|
|
10
|
+
``outdir`` is always rewritten to a
|
|
9
11
|
local path, preserving the original S3 path for post-job upload.
|
|
10
12
|
|
|
11
13
|
CSV/TSV file params (samplesheets) are additionally localized *inside*:
|
|
12
14
|
every ``s3://`` cell is rewritten to the local path of that object, and the
|
|
13
|
-
param is repointed at the rewritten copy. On mounted jobs (
|
|
14
|
-
|
|
15
|
+
param is repointed at the rewritten copy. On mounted jobs (credential-export
|
|
16
|
+
safeguard path) the cells point into the mount; on sandbox jobs the referenced
|
|
15
17
|
objects are downloaded next to the localized file first.
|
|
16
18
|
"""
|
|
17
19
|
|
|
@@ -78,6 +80,10 @@ def _download_object(
|
|
|
78
80
|
|
|
79
81
|
Used by sandbox jobs, which carry no S3 credentials; the presigned URL is
|
|
80
82
|
minted with the job's API token (``OMICSLAB_TOKEN``).
|
|
83
|
+
|
|
84
|
+
``key_path`` is the raw parsed ``s3://bucket/data/...`` key; the storage
|
|
85
|
+
API re-adds the ``data/`` prefix internally, so it is stripped here (same
|
|
86
|
+
convention as ``resolve_s3_path``) before minting the URL.
|
|
81
87
|
"""
|
|
82
88
|
api_base = os.environ.get("API_BASE_URL", "")
|
|
83
89
|
token = os.environ.get("OMICSLAB_TOKEN", "")
|
|
@@ -93,7 +99,9 @@ def _download_object(
|
|
|
93
99
|
|
|
94
100
|
client = Client(token=token, base_url=api_base)
|
|
95
101
|
try:
|
|
96
|
-
result = client.storage.get_download_url(
|
|
102
|
+
result = client.storage.get_download_url(
|
|
103
|
+
workspace_id, key_path.removeprefix("data/")
|
|
104
|
+
)
|
|
97
105
|
finally:
|
|
98
106
|
client.close()
|
|
99
107
|
url = result.get("url") if isinstance(result, dict) else result
|
|
@@ -123,9 +131,9 @@ def prepare_data(params_file: str, job_dir: str) -> None:
|
|
|
123
131
|
|
|
124
132
|
**Public workspace**: mounts read-only via s3fs ``--public``.
|
|
125
133
|
**Private workspace + sandbox mode**: downloads via ``omicslab download``.
|
|
126
|
-
**Private workspace + real creds (
|
|
127
|
-
the workspace bucket is mounted read-only with the job's
|
|
128
|
-
(STS tokens or long-lived provider keys).
|
|
134
|
+
**Private workspace + real creds (EXPORT_JOB_IAM_KEYS safeguard path)**:
|
|
135
|
+
rewrites path; the workspace bucket is mounted read-only with the job's
|
|
136
|
+
credentials (STS tokens or long-lived provider keys).
|
|
129
137
|
**Bucket not found** (e.g. ``s3://igenomes``): keeps S3 path as-is (Nextflow handles it).
|
|
130
138
|
|
|
131
139
|
``outdir`` is always rewritten to ``{{ job_dir }}/outdir`` (local only).
|
|
@@ -340,7 +348,7 @@ def prepare_data(params_file: str, job_dir: str) -> None:
|
|
|
340
348
|
else:
|
|
341
349
|
info(f"Public data already mounted: {public_mount}")
|
|
342
350
|
|
|
343
|
-
# === Mount cross-workspace private buckets (
|
|
351
|
+
# === Mount cross-workspace private buckets (EXPORT_JOB_IAM_KEYS path) ===
|
|
344
352
|
for cm in cross_ws_mounts:
|
|
345
353
|
os.makedirs(cm["mount_point"], exist_ok=True)
|
|
346
354
|
mount_cmd = [
|
|
@@ -396,12 +404,12 @@ def prepare_data(params_file: str, job_dir: str) -> None:
|
|
|
396
404
|
# Single object: fetch it to the exact path the param points at.
|
|
397
405
|
local_path, mode, workspace_id = _local_for(bucket, key_path)
|
|
398
406
|
if mode == "sandbox" and local_path and not os.path.isfile(local_path):
|
|
399
|
-
info(f"Downloading {value} → {local_path}")
|
|
407
|
+
info(f"Downloading {value} → {local_path} using presigned URL")
|
|
400
408
|
_download_object(local_path, key_path, workspace_id, job_dir)
|
|
401
409
|
continue
|
|
402
410
|
target = os.path.join(job_dir, "workspace", "data")
|
|
403
411
|
os.makedirs(target, exist_ok=True)
|
|
404
|
-
info(f"Downloading {value} → {target}")
|
|
412
|
+
info(f"Downloading {value} → {target} using presigned URLs")
|
|
405
413
|
result = subprocess.run(
|
|
406
414
|
["omicslab", "download", "-y", "-r", value, target],
|
|
407
415
|
capture_output=True,
|
|
@@ -46,7 +46,13 @@ def write_omicslab_config(
|
|
|
46
46
|
tool_params = params.get("params", {})
|
|
47
47
|
if not isinstance(tool_params, dict) or not tool_params:
|
|
48
48
|
tool_params = params
|
|
49
|
-
|
|
49
|
+
# ``None`` is not a valid Groovy config literal (``None is not defined``),
|
|
50
|
+
# so schema-default nulls must be omitted and left to the pipeline defaults.
|
|
51
|
+
filtered = {
|
|
52
|
+
k: v
|
|
53
|
+
for k, v in tool_params.items()
|
|
54
|
+
if v is not None and not isinstance(v, (dict, list))
|
|
55
|
+
}
|
|
50
56
|
|
|
51
57
|
_nextflow_internal_prefixes = (
|
|
52
58
|
"$projectDir",
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
import time
|
|
7
8
|
from urllib.parse import urlparse
|
|
@@ -55,6 +56,33 @@ def is_nf_pipeline(params: dict) -> bool:
|
|
|
55
56
|
return False
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
def _is_git_clone(path: str) -> bool:
|
|
60
|
+
"""True when ``path`` is a usable git work tree, not a partial clone."""
|
|
61
|
+
if not os.path.isdir(os.path.join(path, ".git")):
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["git", "rev-parse", "--verify", "HEAD"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
timeout=10,
|
|
69
|
+
cwd=path,
|
|
70
|
+
)
|
|
71
|
+
except (OSError, subprocess.SubprocessError):
|
|
72
|
+
return False
|
|
73
|
+
return result.returncode == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _remove_dir(path: str) -> None:
|
|
77
|
+
"""Remove ``path`` if it exists (including a symlink, dangling or not)."""
|
|
78
|
+
if not os.path.lexists(path):
|
|
79
|
+
return
|
|
80
|
+
if os.path.islink(path):
|
|
81
|
+
os.remove(path)
|
|
82
|
+
return
|
|
83
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
84
|
+
|
|
85
|
+
|
|
58
86
|
def clone_repo(
|
|
59
87
|
git_url: str,
|
|
60
88
|
tag: str,
|
|
@@ -64,7 +92,10 @@ def clone_repo(
|
|
|
64
92
|
) -> str:
|
|
65
93
|
"""Clone a git repository at a specific tag.
|
|
66
94
|
|
|
67
|
-
|
|
95
|
+
Idempotent: an existing checkout is reused. A directory left behind by an
|
|
96
|
+
interrupted clone (or a racing job) is cleared before cloning, otherwise
|
|
97
|
+
``git clone`` fails with "destination path already exists and is not an
|
|
98
|
+
empty directory".
|
|
68
99
|
Git authentication uses the ``.git-credentials`` file written by
|
|
69
100
|
``omicslab job setup-auth`` — no embedded tokens in URLs.
|
|
70
101
|
|
|
@@ -82,9 +113,11 @@ def clone_repo(
|
|
|
82
113
|
Raises:
|
|
83
114
|
RuntimeError: If git clone fails after all retries.
|
|
84
115
|
"""
|
|
85
|
-
if
|
|
116
|
+
if _is_git_clone(target_dir):
|
|
86
117
|
print(f"Repository already cloned at {target_dir}")
|
|
87
118
|
else:
|
|
119
|
+
# Drop a partial/abandoned checkout so the clone has a clean target.
|
|
120
|
+
_remove_dir(target_dir)
|
|
88
121
|
print(f"Cloning {git_url} into {target_dir} (tag={tag})")
|
|
89
122
|
last_error = None
|
|
90
123
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
@@ -113,6 +146,15 @@ def clone_repo(
|
|
|
113
146
|
last_error = f"git clone timed out after 120s: {git_url}"
|
|
114
147
|
print(f"Clone attempt {attempt}/{MAX_RETRIES} timed out")
|
|
115
148
|
|
|
149
|
+
# A concurrent job may have completed the clone while we retried.
|
|
150
|
+
if _is_git_clone(target_dir):
|
|
151
|
+
print(f"Repository already cloned at {target_dir}")
|
|
152
|
+
last_error = None
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
# Clear the partial checkout so the next attempt starts clean.
|
|
156
|
+
_remove_dir(target_dir)
|
|
157
|
+
|
|
116
158
|
if attempt < MAX_RETRIES:
|
|
117
159
|
delay = RETRY_DELAY_BASE * attempt
|
|
118
160
|
print(f"Retrying in {delay}s...")
|
|
@@ -2,6 +2,18 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
+
# RAM reserved for the Nextflow parent controller. With ``executor = "local"``
|
|
6
|
+
# the controller and the pipeline processes share the same instance, so process
|
|
7
|
+
# memory requests must stay below the instance's usable memory or Nextflow
|
|
8
|
+
# refuses to schedule them, e.g.
|
|
9
|
+
# "Process requirement exceeds available memory -- req: 16 GB; avail: 15.3 GB".
|
|
10
|
+
CONTROLLER_MEMORY_RESERVE_GB = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def process_memory_gb(base_memory: int) -> int:
|
|
14
|
+
"""Process memory request minus the controller reserve, never below 1 GB."""
|
|
15
|
+
return max(int(base_memory) - CONTROLLER_MEMORY_RESERVE_GB, 1)
|
|
16
|
+
|
|
5
17
|
|
|
6
18
|
def write_runtime_config(
|
|
7
19
|
output_path: str,
|
|
@@ -16,9 +28,20 @@ def write_runtime_config(
|
|
|
16
28
|
Credentials are NOT written to this file — Nextflow reads them natively
|
|
17
29
|
from ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` environment variables.
|
|
18
30
|
|
|
31
|
+
``resourceLimits`` stays at the job ``memory``; each process memory request
|
|
32
|
+
is reduced by ``CONTROLLER_MEMORY_RESERVE_GB`` because the Nextflow parent
|
|
33
|
+
controller runs on the same instance as the pipeline processes
|
|
34
|
+
(``executor = "local"``).
|
|
35
|
+
|
|
19
36
|
Written with 0o600 permissions to prevent credential leakage.
|
|
20
37
|
Process labels follow nf-core conventions.
|
|
21
38
|
"""
|
|
39
|
+
mem_default = process_memory_gb(2)
|
|
40
|
+
mem_single = process_memory_gb(2)
|
|
41
|
+
mem_low = process_memory_gb(4)
|
|
42
|
+
mem_medium = process_memory_gb(16)
|
|
43
|
+
mem_high = process_memory_gb(32)
|
|
44
|
+
mem_high_memory = process_memory_gb(200)
|
|
22
45
|
endpoint = os.environ.get("AWS_ENDPOINT_URL", "")
|
|
23
46
|
region = os.environ.get("AWS_REGION", "") or os.environ.get("AWS_REGION_NAME", "")
|
|
24
47
|
has_aws_creds = bool(os.environ.get("AWS_ACCESS_KEY_ID", ""))
|
|
@@ -36,15 +59,8 @@ def write_runtime_config(
|
|
|
36
59
|
|
|
37
60
|
config = f"""\
|
|
38
61
|
{aws_block}\
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
docker {{
|
|
42
|
-
pullTimeout = 30.min
|
|
43
|
-
retry {{
|
|
44
|
-
enabled = true
|
|
45
|
-
maxRetries = 3
|
|
46
|
-
delay = 5000
|
|
47
|
-
}}
|
|
62
|
+
apptainer {{
|
|
63
|
+
pullTimeout = 8.h
|
|
48
64
|
}}
|
|
49
65
|
|
|
50
66
|
timeline {{
|
|
@@ -69,6 +85,9 @@ dag {{
|
|
|
69
85
|
}}
|
|
70
86
|
|
|
71
87
|
process {{
|
|
88
|
+
// Reserve {CONTROLLER_MEMORY_RESERVE_GB}.GB for the Nextflow parent controller
|
|
89
|
+
// sharing this instance (executor = local); resourceLimits stays at the
|
|
90
|
+
// job memory and only the process requests are reduced.
|
|
72
91
|
resourceLimits = [
|
|
73
92
|
cpus: {cpu},
|
|
74
93
|
memory: {memory}.GB,
|
|
@@ -76,34 +95,34 @@ process {{
|
|
|
76
95
|
]
|
|
77
96
|
|
|
78
97
|
cpus = {{ 1 * task.attempt }}
|
|
79
|
-
memory = {{
|
|
98
|
+
memory = {{ {mem_default}.GB * task.attempt }}
|
|
80
99
|
time = {{ 4.h * task.attempt }}
|
|
81
100
|
|
|
82
101
|
withLabel:process_single {{
|
|
83
102
|
cpus = {{ 1 }}
|
|
84
|
-
memory = {{
|
|
103
|
+
memory = {{ {mem_single}.GB * task.attempt }}
|
|
85
104
|
time = {{ 4.h * task.attempt }}
|
|
86
105
|
}}
|
|
87
106
|
withLabel:process_low {{
|
|
88
107
|
cpus = {{ 2 * task.attempt }}
|
|
89
|
-
memory = {{
|
|
108
|
+
memory = {{ {mem_low}.GB * task.attempt }}
|
|
90
109
|
time = {{ 4.h * task.attempt }}
|
|
91
110
|
}}
|
|
92
111
|
withLabel:process_medium {{
|
|
93
112
|
cpus = {{ 8 * task.attempt }}
|
|
94
|
-
memory = {{
|
|
113
|
+
memory = {{ {mem_medium}.GB * task.attempt }}
|
|
95
114
|
time = {{ 8.h * task.attempt }}
|
|
96
115
|
}}
|
|
97
116
|
withLabel:process_high {{
|
|
98
117
|
cpus = {{ 16 * task.attempt }}
|
|
99
|
-
memory = {{
|
|
118
|
+
memory = {{ {mem_high}.GB * task.attempt }}
|
|
100
119
|
time = {{ 16.h * task.attempt }}
|
|
101
120
|
}}
|
|
102
121
|
withLabel:process_long {{
|
|
103
122
|
time = {{ 20.h * task.attempt }}
|
|
104
123
|
}}
|
|
105
124
|
withLabel:process_high_memory {{
|
|
106
|
-
memory = {{
|
|
125
|
+
memory = {{ {mem_high_memory}.GB * task.attempt }}
|
|
107
126
|
}}
|
|
108
127
|
}}
|
|
109
128
|
"""
|
|
@@ -25,6 +25,32 @@ def _has_any_aws_creds() -> bool:
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _count_files(source_dir: str) -> int:
|
|
29
|
+
"""Number of files under ``source_dir`` (0 when unreadable)."""
|
|
30
|
+
try:
|
|
31
|
+
return sum(len(files) for _, _, files in os.walk(source_dir))
|
|
32
|
+
except OSError:
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _failure_detail(result) -> str:
|
|
37
|
+
"""Best-effort error text from a captured subprocess result.
|
|
38
|
+
|
|
39
|
+
The CLI reports errors on stdout, so stderr alone is often empty (storage
|
|
40
|
+
quota and allocation errors in particular) — fall back to stdout, then to
|
|
41
|
+
the exit code.
|
|
42
|
+
"""
|
|
43
|
+
for stream in (getattr(result, "stderr", None), getattr(result, "stdout", None)):
|
|
44
|
+
if isinstance(stream, bytes):
|
|
45
|
+
text = stream.decode("utf-8", errors="replace")
|
|
46
|
+
else:
|
|
47
|
+
text = str(stream or "")
|
|
48
|
+
text = text.strip()
|
|
49
|
+
if text:
|
|
50
|
+
return text[-500:]
|
|
51
|
+
return f"exit code {getattr(result, 'returncode', '?')}"
|
|
52
|
+
|
|
53
|
+
|
|
28
54
|
def resolve_bucket_mount_info(bucket: str) -> dict | None:
|
|
29
55
|
"""Resolve an S3 bucket to its workspace mount configuration via the API.
|
|
30
56
|
|
|
@@ -227,14 +253,17 @@ def upload_outputs(
|
|
|
227
253
|
user_email: str = "",
|
|
228
254
|
*,
|
|
229
255
|
aws_credentials: bool | None = None,
|
|
256
|
+
on_log: callable | None = None,
|
|
230
257
|
) -> bool:
|
|
231
|
-
"""Upload job outputs to S3, auto-detecting
|
|
258
|
+
"""Upload job outputs to S3, auto-detecting credentials vs presigned mode.
|
|
232
259
|
|
|
233
|
-
**
|
|
260
|
+
**Credential path** (``EXPORT_JOB_IAM_KEYS`` safeguard, or a standalone SDK
|
|
261
|
+
run with its own AWS env): if ``AWS_ACCESS_KEY_ID`` is set, uses
|
|
234
262
|
``aws s3 sync`` with the endpoint auto-resolved from the S3 path.
|
|
235
263
|
|
|
236
|
-
**Sandbox mode
|
|
237
|
-
the S3 path to a workspace and uploads via
|
|
264
|
+
**Sandbox mode** (the platform default): otherwise, uses ``omicslab
|
|
265
|
+
upload`` which resolves the S3 path to a workspace and uploads via
|
|
266
|
+
presigned URLs.
|
|
238
267
|
|
|
239
268
|
Args:
|
|
240
269
|
source_dir: Local directory with output files.
|
|
@@ -245,6 +274,9 @@ def upload_outputs(
|
|
|
245
274
|
user_email: Optional user email for audit logging.
|
|
246
275
|
aws_credentials: Explicit decision for direct S3 upload. ``None`` keeps
|
|
247
276
|
the legacy environment-based detection.
|
|
277
|
+
on_log: Optional callback receiving the transfer-method log lines
|
|
278
|
+
(``using presigned URLs`` / ``using workspace credentials``) so the
|
|
279
|
+
job log shows which path was used.
|
|
248
280
|
|
|
249
281
|
Returns:
|
|
250
282
|
True if the upload succeeded.
|
|
@@ -258,6 +290,16 @@ def upload_outputs(
|
|
|
258
290
|
logger.warning("Invalid S3 path: %s", dest_path)
|
|
259
291
|
return False
|
|
260
292
|
|
|
293
|
+
def _emit(message: str) -> None:
|
|
294
|
+
if on_log is None:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
on_log(message)
|
|
298
|
+
except Exception: # noqa: BLE001 — logging must never break the upload
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
file_count = _count_files(source_dir)
|
|
302
|
+
|
|
261
303
|
# Auto-resolve endpoint from workspace if not explicitly provided
|
|
262
304
|
if not endpoint:
|
|
263
305
|
endpoint = _resolve_workspace_endpoint(bucket)
|
|
@@ -269,6 +311,8 @@ def upload_outputs(
|
|
|
269
311
|
aws_credentials = _has_aws_creds()
|
|
270
312
|
|
|
271
313
|
if aws_credentials:
|
|
314
|
+
method = "workspace credentials (aws s3 sync)"
|
|
315
|
+
_emit(f"Uploading {file_count} file(s) using {method}...")
|
|
272
316
|
try:
|
|
273
317
|
aws_cmd = ["aws", "s3", "sync", source_dir, s3_target]
|
|
274
318
|
if endpoint:
|
|
@@ -276,12 +320,18 @@ def upload_outputs(
|
|
|
276
320
|
result = subprocess.run(aws_cmd, capture_output=True, timeout=300)
|
|
277
321
|
if result.returncode == 0:
|
|
278
322
|
logger.info("Synced %s → %s", source_dir, s3_target)
|
|
323
|
+
_emit(f"Uploaded {file_count} file(s) using {method} → {s3_target}")
|
|
279
324
|
success = True
|
|
280
325
|
else:
|
|
281
|
-
|
|
326
|
+
detail = _failure_detail(result)
|
|
327
|
+
logger.warning("aws s3 sync failed: %s", detail)
|
|
328
|
+
_emit(f"Upload failed using {method}: {detail}")
|
|
282
329
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
283
|
-
|
|
330
|
+
logger.warning("aws s3 sync command failed")
|
|
331
|
+
_emit(f"Upload failed using {method}: command unavailable")
|
|
284
332
|
else:
|
|
333
|
+
method = "presigned URLs"
|
|
334
|
+
_emit(f"Uploading {file_count} file(s) using {method}...")
|
|
285
335
|
try:
|
|
286
336
|
result = subprocess.run(
|
|
287
337
|
["omicslab", "upload", "-y", source_dir, dest_path],
|
|
@@ -290,11 +340,15 @@ def upload_outputs(
|
|
|
290
340
|
)
|
|
291
341
|
if result.returncode == 0:
|
|
292
342
|
logger.info("Uploaded %s → %s", source_dir, dest_path)
|
|
343
|
+
_emit(f"Uploaded {file_count} file(s) using {method} → {dest_path}")
|
|
293
344
|
success = True
|
|
294
345
|
else:
|
|
295
|
-
|
|
346
|
+
detail = _failure_detail(result)
|
|
347
|
+
logger.warning("omicslab upload failed: %s", detail)
|
|
348
|
+
_emit(f"Upload failed using {method}: {detail}")
|
|
296
349
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
297
350
|
logger.warning("omicslab upload command failed")
|
|
351
|
+
_emit(f"Upload failed using {method}: command unavailable")
|
|
298
352
|
|
|
299
353
|
if success and job_id:
|
|
300
354
|
_log_job_output_upload(dest_path, source_dir, job_id, user_id, user_email)
|
|
@@ -54,10 +54,12 @@ def _promote_workspace_env(ws_info: dict) -> None:
|
|
|
54
54
|
def _workspace_has_creds(params: dict) -> bool:
|
|
55
55
|
"""True when the job carries real workspace S3 credentials.
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
By default the platform never sends S3 keys to compute — every job runs
|
|
58
|
+
with presigned URLs only. Credentials appear only under the future
|
|
59
|
+
EXPORT_JOB_IAM_KEYS safeguard: marketplace cloud-VM jobs may then receive
|
|
60
|
+
STS tokens when the provider exposes an assume-role ARN, otherwise the
|
|
61
|
+
marketplace provider's long-lived keys. Runner/HPC jobs never receive S3
|
|
62
|
+
credentials.
|
|
61
63
|
"""
|
|
62
64
|
ws_id = params.get("workspace_id", "")
|
|
63
65
|
workspaces = params.get("workspaces")
|
|
@@ -71,12 +73,12 @@ def _workspace_has_creds(params: dict) -> bool:
|
|
|
71
73
|
def _aws_upload_enabled(params: dict) -> bool:
|
|
72
74
|
"""Whether outputs may be uploaded over S3 with the job's own credentials.
|
|
73
75
|
|
|
74
|
-
Only jobs the backend
|
|
75
|
-
|
|
76
|
-
``aws s3 sync``.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
Only jobs the backend explicitly gave workspace credentials to (the
|
|
77
|
+
EXPORT_JOB_IAM_KEYS safeguard path: STS tokens or the provider's
|
|
78
|
+
long-lived keys) upload via ``aws s3 sync``. By default every job is a
|
|
79
|
+
sandbox job and must use the presigned ``omicslab upload`` path. The legacy
|
|
80
|
+
``_has_aws_creds`` check preserves standalone SDK runs that configure AWS
|
|
81
|
+
credentials themselves.
|
|
80
82
|
"""
|
|
81
83
|
if _workspace_has_creds(params):
|
|
82
84
|
return True
|
|
@@ -426,6 +428,7 @@ def _run_nextflow(
|
|
|
426
428
|
outdir_s3_full,
|
|
427
429
|
endpoint="",
|
|
428
430
|
aws_credentials=_aws_upload_enabled(params),
|
|
431
|
+
on_log=_log,
|
|
429
432
|
)
|
|
430
433
|
return 0 if ok else 1
|
|
431
434
|
return 0
|
|
@@ -511,6 +514,7 @@ def _run_tool(
|
|
|
511
514
|
outdir_s3_full,
|
|
512
515
|
endpoint="",
|
|
513
516
|
aws_credentials=aws_credentials,
|
|
517
|
+
on_log=_log,
|
|
514
518
|
)
|
|
515
519
|
return 0 if ok else 1
|
|
516
520
|
return 0
|
|
@@ -617,13 +617,22 @@ class RunnerClient:
|
|
|
617
617
|
await asyncio.sleep(self.job_poll_interval)
|
|
618
618
|
|
|
619
619
|
def _reap_terminal_jobs(self) -> None:
|
|
620
|
-
"""Move terminal jobs from ``_dispatched_jobs`` to ``_completed_jobs``.
|
|
620
|
+
"""Move terminal jobs from ``_dispatched_jobs`` to ``_completed_jobs``.
|
|
621
|
+
|
|
622
|
+
Also drops the job from the executor's SLURM tracking map: the
|
|
623
|
+
supervisor detects completion via the ``exitcode`` file and never
|
|
624
|
+
polls ``get_status`` (which is what normally prunes that map), so
|
|
625
|
+
leaving the entry behind made ``is_idle()`` report the runner as busy
|
|
626
|
+
forever and blocked SDK self-updates.
|
|
627
|
+
"""
|
|
621
628
|
for job_id in list(self._dispatched_jobs):
|
|
622
629
|
if not self.running:
|
|
623
630
|
break
|
|
624
631
|
if self._job_is_terminal(job_id):
|
|
625
632
|
self._dispatched_jobs.pop(job_id, None)
|
|
626
633
|
self._completed_jobs.add(job_id)
|
|
634
|
+
if self.executor is not None:
|
|
635
|
+
self.executor.forget_slurm_job(job_id)
|
|
627
636
|
|
|
628
637
|
def _job_is_terminal(self, job_id: str) -> bool:
|
|
629
638
|
"""True when a dispatched job has reached a terminal state.
|
|
@@ -170,6 +170,17 @@ class JobExecutor:
|
|
|
170
170
|
finally:
|
|
171
171
|
self._slurm_jobs.pop(job_id, None)
|
|
172
172
|
|
|
173
|
+
def forget_slurm_job(self, job_id: str) -> None:
|
|
174
|
+
"""Drop a finished job from the SLURM tracking map (no scancel).
|
|
175
|
+
|
|
176
|
+
``get_status`` prunes terminal jobs when it is polled, but the runner
|
|
177
|
+
controller can reap a job from its own dispatch map via the
|
|
178
|
+
``exitcode`` file without ever calling ``get_status`` — leaving a stale
|
|
179
|
+
entry here that makes ``is_idle()`` report the runner as busy forever
|
|
180
|
+
and blocks SDK self-updates.
|
|
181
|
+
"""
|
|
182
|
+
self._slurm_jobs.pop(job_id, None)
|
|
183
|
+
|
|
173
184
|
# ── Status / log / exit code (filesystem, job run self-manages) ─────
|
|
174
185
|
|
|
175
186
|
def get_status(self, job_id: str) -> dict:
|