omicslab 2.1.0__tar.gz → 2.2.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.1.0 → omicslab-2.2.0}/PKG-INFO +1 -1
- omicslab-2.2.0/scripts/bootstrap-env.sh +224 -0
- omicslab-2.2.0/src/omicslab/_version.py +1 -0
- omicslab-2.2.0/src/omicslab/cli/job_commands.py +441 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/main.py +84 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/runner_commands.py +7 -1
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/studio_commands.py +53 -21
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/client.py +32 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_exec.py +16 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_git.py +13 -3
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_monitor.py +23 -5
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_nextflow.py +12 -7
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_proxy.py +9 -1
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_studio_prep.py +18 -8
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/analysis.py +36 -7
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/studio.py +116 -29
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/client.py +12 -2
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/storage.py +109 -2
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/conftest.py +2 -1
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__engine_helpers.py +128 -1
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__git.py +13 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__monitor.py +4 -1
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__nextflow.py +8 -5
- omicslab-2.2.0/tests/job/_run/test__studio_docker.py +107 -0
- omicslab-2.2.0/tests/job/_run/test_studio_proxy_start.py +85 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_client_extra.py +41 -0
- omicslab-2.2.0/tests/job/test_snapshot_request.py +37 -0
- omicslab-2.2.0/tests/job/test_studio_image_download.py +66 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_storage.py +34 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_client.py +24 -0
- omicslab-2.2.0/tests/test_job_heartbeat.py +84 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_resources.py +111 -3
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_runner_helpers.py +3 -0
- omicslab-2.2.0/tests/test_studio_snapshot.py +224 -0
- omicslab-2.2.0/tests/test_system_monitor_interval.py +36 -0
- omicslab-2.1.0/scripts/bootstrap-env.sh +0 -103
- omicslab-2.1.0/src/omicslab/_version.py +0 -1
- omicslab-2.1.0/src/omicslab/cli/job_commands.py +0 -238
- omicslab-2.1.0/tests/test_studio_snapshot.py +0 -106
- {omicslab-2.1.0 → omicslab-2.2.0}/.gitignore +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/Makefile +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/README.developer.md +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/README.md +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/.gitignore +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/hcm.pixi.lock +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/hcm.pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/pixi.lock +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/us-east-1.pixi.lock +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/dev/us-east-1.pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/hcm.pixi.lock +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/hcm.pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/pixi.lock +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/prod/pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/envs/regions.json +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/pixi.config.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/pyproject.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/scripts/check_publish.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/scripts/write_version_manifest.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/analysis_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/auth_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/context_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/credential_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/fuse_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/org_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/setup_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/token_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/utils.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/workspace_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/cli/ws_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/config.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/exceptions.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_auth.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_config_download.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_consts.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_data_prep.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_env.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_images.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_log.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_params.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_proc.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_spec.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/_run/_storage.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/console.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/executor.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/paths.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/job/system.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/analysis.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/base.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/credentials.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/jobs.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/orgs.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/runners.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/studio.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/tokens.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/resources/workspaces.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/src/omicslab/selfupdate.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/conftest.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/__init__.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/test_cli_e2e.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/e2e/test_tokens.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__auth.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__data_prep.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__env.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__get_images_cli.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__images.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__log.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__params.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__proxy.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__proxy_extra.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__spec.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/_run/test__storage.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_cli.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_client.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_executor.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_executor_extra.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_fuse.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_resource.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_job_system.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_studio_validate_cli.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/job/test_ws_helpers.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_catalog.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_context.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_cli_utils.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_fuse.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_job_exec.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_misc.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_runner_self_update.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_sandbox_report.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_selfupdate.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/test_ws_commands.py +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/input.txt +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/main.nf +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-nextflow/schema.json +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-simple/pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-simple/schema.json +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/Snakefile +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/input.txt +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
- {omicslab-2.1.0 → omicslab-2.2.0}/tests/tools/hello-snakemake/schema.json +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# bootstrap-env.sh — Install pixi + omicslab SDK from a public tools bucket.
|
|
3
|
+
#
|
|
4
|
+
# Environment variables:
|
|
5
|
+
# SDK_TOOLS_BASE — tools bucket base URL (default: R2 production). Override
|
|
6
|
+
# to point at any public S3-compatible provider (e.g.
|
|
7
|
+
# local S3: http://localhost:4566/omicslab/data/tools).
|
|
8
|
+
# OMICSLAB_HOME — where pixi.toml/lock live (default: $HOME)
|
|
9
|
+
# PIXI_TOML — manifest filename (default: pixi.toml, or {region}.pixi.toml)
|
|
10
|
+
# PIXI_LOCK — lock filename (default: pixi.lock, or {region}.pixi.lock)
|
|
11
|
+
# PIXI_VERSION — pixi release to fetch from the tools bucket
|
|
12
|
+
# (default: v0.76.1)
|
|
13
|
+
# INSTALL_DOCKER — set to 1 for studio jobs to install the docker engine
|
|
14
|
+
# (client + daemon) and start its daemon (default: 0)
|
|
15
|
+
#
|
|
16
|
+
# The script is idempotent: re-running it after a successful install is a
|
|
17
|
+
# no-op (pixi binary present, environment installed, SDK already loaded).
|
|
18
|
+
#
|
|
19
|
+
# It:
|
|
20
|
+
# 1. Installs pixi from the tools bucket (pixi/bin/pixi-<version>-linux-amd64.tar.gz)
|
|
21
|
+
# into an absolute path under $HOME — never hits pixi.sh / GitHub, so it
|
|
22
|
+
# works on air-gapped runners.
|
|
23
|
+
# 2. Downloads the wheel-free manifest from the same bucket.
|
|
24
|
+
# 3. Runs pixi install --frozen (all conda + pypi deps, both cached in the
|
|
25
|
+
# bucket channel/pypi mirrors).
|
|
26
|
+
# 4. Downloads omicslab-latest.whl, extracts version from wheel metadata,
|
|
27
|
+
# renames to a valid PEP 427 filename, pip installs.
|
|
28
|
+
# 5. Grants the pixi interpreter CAP_NET_BIND_SERVICE (falling back to
|
|
29
|
+
# lowering ip_unprivileged_port_start) so the studio TLS proxy can bind
|
|
30
|
+
# the privileged port 443.
|
|
31
|
+
set -euo pipefail
|
|
32
|
+
|
|
33
|
+
TOOLS_BASE="${SDK_TOOLS_BASE:-https://s3-hcm-r2.s3cloud.vn/omicslab/data/tools}"
|
|
34
|
+
OMICSLAB_HOME="${OMICSLAB_HOME:-$HOME}"
|
|
35
|
+
PIXI_TOML="${PIXI_TOML:-pixi.toml}"
|
|
36
|
+
PIXI_LOCK="${PIXI_LOCK:-pixi.lock}"
|
|
37
|
+
PIXI_VERSION="${PIXI_VERSION:-v0.76.1}"
|
|
38
|
+
RETRIES=3
|
|
39
|
+
|
|
40
|
+
# ── 1. pixi (from the tools bucket, absolute path under $HOME) ────────────
|
|
41
|
+
PIXI_BIN="${HOME}/.pixi/bin/pixi"
|
|
42
|
+
if [ ! -x "${PIXI_BIN}" ]; then
|
|
43
|
+
PIXI_URL="$TOOLS_BASE/pixi/bin/pixi-${PIXI_VERSION}-linux-amd64.tar.gz"
|
|
44
|
+
echo "Installing pixi ${PIXI_VERSION} from: $PIXI_URL"
|
|
45
|
+
mkdir -p "$(dirname "${PIXI_BIN}")"
|
|
46
|
+
curl -fsSL "$PIXI_URL" -o /tmp/pixi.tar.gz
|
|
47
|
+
tar -xzf /tmp/pixi.tar.gz -C "$(dirname "${PIXI_BIN}")"
|
|
48
|
+
rm -f /tmp/pixi.tar.gz
|
|
49
|
+
else
|
|
50
|
+
echo "pixi already installed at ${PIXI_BIN}"
|
|
51
|
+
fi
|
|
52
|
+
export PATH="$(dirname "${PIXI_BIN}"):$PATH"
|
|
53
|
+
|
|
54
|
+
# ── 1b. FUSE helper for mountpoint-s3 (non-root mounts) ────────────────────
|
|
55
|
+
# mount-s3 performs unprivileged FUSE mounts via `fusermount3`, which must be
|
|
56
|
+
# root-owned + setuid. The pixi env ships a non-setuid copy under sbin/ (also
|
|
57
|
+
# not on PATH), so use the distro package when it is missing. Best-effort: on
|
|
58
|
+
# air-gapped hosts without apt, mounts still work when run as root.
|
|
59
|
+
if ! command -v fusermount3 >/dev/null 2>&1; then
|
|
60
|
+
if command -v apt-get >/dev/null 2>&1; then
|
|
61
|
+
SUDO=""
|
|
62
|
+
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|
63
|
+
SUDO="sudo -n"
|
|
64
|
+
fi
|
|
65
|
+
echo "Installing fuse3 (fusermount3 helper for mountpoint-s3)..."
|
|
66
|
+
$SUDO apt-get update -qq >/dev/null 2>&1 || true
|
|
67
|
+
$SUDO apt-get install -y -qq fuse3 >/dev/null 2>&1 || \
|
|
68
|
+
echo "WARNING: fuse3 install failed; mountpoint-s3 mounts will require root"
|
|
69
|
+
else
|
|
70
|
+
echo "WARNING: no apt-get; mountpoint-s3 non-root mounts will require root"
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# ── 1c. docker engine (studio jobs) ────────────────────────────────────────
|
|
75
|
+
# Studio jobs start their session container with `docker run`. On the
|
|
76
|
+
# Docker-simulated VM the host daemon answers `docker info` through the
|
|
77
|
+
# mounted /var/run/docker.sock, so only the client is needed; a real cloud VM
|
|
78
|
+
# (CloudFly / GreenNode) has no host socket, so install the engine (client +
|
|
79
|
+
# daemon), start the daemon, and let the job user reach its socket. Gated by
|
|
80
|
+
# INSTALL_DOCKER so analysis jobs pay nothing for it. Best-effort: on
|
|
81
|
+
# air-gapped hosts without apt the install is skipped and `job run --studio`
|
|
82
|
+
# reports the unreachable daemon.
|
|
83
|
+
if [ "${INSTALL_DOCKER:-0}" = "1" ]; then
|
|
84
|
+
SUDO=""
|
|
85
|
+
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|
86
|
+
SUDO="sudo -n"
|
|
87
|
+
fi
|
|
88
|
+
if ! command -v docker >/dev/null 2>&1; then
|
|
89
|
+
if command -v apt-get >/dev/null 2>&1; then
|
|
90
|
+
echo "Installing docker engine (client + daemon)..."
|
|
91
|
+
$SUDO apt-get update -qq >/dev/null 2>&1 || true
|
|
92
|
+
$SUDO apt-get install -y -qq docker.io >/dev/null 2>&1 || \
|
|
93
|
+
echo "WARNING: docker install failed; the studio session cannot start"
|
|
94
|
+
else
|
|
95
|
+
echo "WARNING: no apt-get; cannot install docker for the studio session"
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
98
|
+
# Start the daemon: systemd where available, else a detached dockerd.
|
|
99
|
+
# Check for the socket (not `docker info`) so a permission problem on the
|
|
100
|
+
# socket is not mistaken for a stopped daemon and does not spawn a second
|
|
101
|
+
# dockerd next to the systemd unit.
|
|
102
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
103
|
+
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
|
|
104
|
+
fi
|
|
105
|
+
if [ ! -S /var/run/docker.sock ] && command -v dockerd >/dev/null 2>&1; then
|
|
106
|
+
echo "Starting dockerd..."
|
|
107
|
+
$SUDO dockerd >/dev/null 2>&1 &
|
|
108
|
+
fi
|
|
109
|
+
for _ in $(seq 1 15); do
|
|
110
|
+
[ -S /var/run/docker.sock ] && break
|
|
111
|
+
sleep 1
|
|
112
|
+
done
|
|
113
|
+
# The daemon socket is root:docker mode 0660. The studio agent runs as the
|
|
114
|
+
# (possibly non-root) SSH user and reuses this very session, so `usermod`
|
|
115
|
+
# membership alone would only apply to a later login. Grant the socket to
|
|
116
|
+
# the job user's own group for the current session instead — least
|
|
117
|
+
# privilege, never world-writable. The studio container itself is started
|
|
118
|
+
# with --security-opt no-new-privileges and without the host socket mounted,
|
|
119
|
+
# so the web app inside it cannot reach this daemon.
|
|
120
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
121
|
+
$SUDO usermod -aG docker "$USER" >/dev/null 2>&1 || true
|
|
122
|
+
$SUDO chgrp "$(id -gn)" /var/run/docker.sock >/dev/null 2>&1 || true
|
|
123
|
+
$SUDO chmod 660 /var/run/docker.sock >/dev/null 2>&1 || true
|
|
124
|
+
fi
|
|
125
|
+
if docker info >/dev/null 2>&1; then
|
|
126
|
+
echo "docker daemon ready"
|
|
127
|
+
else
|
|
128
|
+
echo "WARNING: docker daemon not reachable after install"
|
|
129
|
+
fi
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# ── 2. manifest (from the tools bucket) ────────────────────────────────────
|
|
133
|
+
TOML_URL="$TOOLS_BASE/pixi/config/$PIXI_TOML"
|
|
134
|
+
LOCK_URL="$TOOLS_BASE/pixi/config/$PIXI_LOCK"
|
|
135
|
+
echo "Fetching manifest:"
|
|
136
|
+
echo " TOML: $TOML_URL"
|
|
137
|
+
echo " LOCK: $LOCK_URL"
|
|
138
|
+
mkdir -p "$OMICSLAB_HOME"
|
|
139
|
+
curl -fsSL "$TOML_URL" -o "$OMICSLAB_HOME/pixi.toml"
|
|
140
|
+
curl -fsSL "$LOCK_URL" -o "$OMICSLAB_HOME/pixi.lock"
|
|
141
|
+
|
|
142
|
+
# ── 3. pixi install + SDK wheel (with retries) ────────────────────────────
|
|
143
|
+
cd "$OMICSLAB_HOME"
|
|
144
|
+
for i in $(seq 1 $RETRIES); do
|
|
145
|
+
if "${PIXI_BIN}" install --frozen; then
|
|
146
|
+
break
|
|
147
|
+
fi
|
|
148
|
+
echo "bootstrap-retry-$i"
|
|
149
|
+
sleep 5
|
|
150
|
+
done
|
|
151
|
+
|
|
152
|
+
# Download the stable alias and resolve the real version from wheel
|
|
153
|
+
# metadata so pip gets a valid PEP 427 filename.
|
|
154
|
+
echo "Installing omicslab SDK wheel..."
|
|
155
|
+
curl -fsSL "$TOOLS_BASE/omicslab-latest.whl" -o /tmp/omicslab.whl
|
|
156
|
+
VERSION=$("${PIXI_BIN}" run python3 -c "
|
|
157
|
+
import zipfile, os
|
|
158
|
+
z = zipfile.ZipFile('/tmp/omicslab.whl')
|
|
159
|
+
meta = [n for n in z.namelist() if n.endswith('.dist-info/METADATA')][0]
|
|
160
|
+
ver = [l.split(': ',1)[1] for l in z.read(meta).decode().splitlines() if l.startswith('Version:')][0]
|
|
161
|
+
os.rename('/tmp/omicslab.whl', f'/tmp/omicslab-{ver}-py3-none-any.whl')
|
|
162
|
+
print(ver)
|
|
163
|
+
")
|
|
164
|
+
WHEEL_PATH="/tmp/omicslab-${VERSION}-py3-none-any.whl"
|
|
165
|
+
WHEEL_SHA256=$(sha256sum "$WHEEL_PATH" | awk '{print $1}')
|
|
166
|
+
if "${PIXI_BIN}" run python -m pip install --no-deps "$WHEEL_PATH"; then
|
|
167
|
+
# Record the installed wheel so the runner auto-updater can tell whether
|
|
168
|
+
# the tools bucket has a newer build (by SHA-256, not just version).
|
|
169
|
+
RECORD_DIR="${HOME}/.omicslab"
|
|
170
|
+
mkdir -p "$RECORD_DIR"
|
|
171
|
+
cat > "$RECORD_DIR/install.json" <<JSON
|
|
172
|
+
{
|
|
173
|
+
"version": "${VERSION}",
|
|
174
|
+
"sha256": "${WHEEL_SHA256}",
|
|
175
|
+
"wheel": "omicslab-${VERSION}-py3-none-any.whl",
|
|
176
|
+
"tools_base": "${TOOLS_BASE}",
|
|
177
|
+
"installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
178
|
+
}
|
|
179
|
+
JSON
|
|
180
|
+
chmod 600 "$RECORD_DIR/install.json"
|
|
181
|
+
rm -f "$WHEEL_PATH"
|
|
182
|
+
echo "SDK ${VERSION} installed successfully"
|
|
183
|
+
else
|
|
184
|
+
echo "Bootstrap failed after $RETRIES attempts"
|
|
185
|
+
exit 1
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
# ── 4. studio direct access: allow binding the privileged port 443 ─────────
|
|
189
|
+
# A studio session's in-VM TLS proxy binds 0.0.0.0:443 so
|
|
190
|
+
# {job_id}.<studio-domain> resolves straight to this host. An unprivileged
|
|
191
|
+
# user cannot bind ports below 1024 by default, so grant the pixi interpreter
|
|
192
|
+
# CAP_NET_BIND_SERVICE — a targeted, per-binary capability with no system-wide
|
|
193
|
+
# effect. Where setcap is unavailable (or the filesystem rejects capabilities)
|
|
194
|
+
# fall back to lowering the kernel's unprivileged port floor. This is
|
|
195
|
+
# provider-agnostic: it runs on every Linux host the SDK is bootstrapped on.
|
|
196
|
+
# Best-effort — `job run --studio` still starts, and the proxy logs an
|
|
197
|
+
# actionable error at startup if 443 turns out not to be bindable.
|
|
198
|
+
PIXI_PY="$(readlink -f "${OMICSLAB_HOME}/.pixi/envs/default/bin/python" 2>/dev/null || true)"
|
|
199
|
+
if [ ! -x "${PIXI_PY}" ]; then
|
|
200
|
+
PIXI_PY="$("${PIXI_BIN}" run python -c 'import sys; print(sys.executable)' 2>/dev/null | tail -n1)"
|
|
201
|
+
PIXI_PY="$(readlink -f "${PIXI_PY}" 2>/dev/null || true)"
|
|
202
|
+
fi
|
|
203
|
+
if [ -z "${PIXI_PY}" ] || [ ! -x "${PIXI_PY}" ]; then
|
|
204
|
+
echo "WARNING: could not locate the pixi python; studio proxy may be unable to bind 443"
|
|
205
|
+
else
|
|
206
|
+
SUDO_CAP=""
|
|
207
|
+
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|
208
|
+
SUDO_CAP="sudo -n"
|
|
209
|
+
fi
|
|
210
|
+
if ! command -v setcap >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
|
|
211
|
+
$SUDO_CAP apt-get update -qq >/dev/null 2>&1 || true
|
|
212
|
+
$SUDO_CAP apt-get install -y -qq libcap2-bin >/dev/null 2>&1 || true
|
|
213
|
+
fi
|
|
214
|
+
if command -v setcap >/dev/null 2>&1 && \
|
|
215
|
+
$SUDO_CAP setcap cap_net_bind_service=+ep "${PIXI_PY}" 2>/dev/null; then
|
|
216
|
+
echo "Granted CAP_NET_BIND_SERVICE to ${PIXI_PY}"
|
|
217
|
+
elif $SUDO_CAP sysctl -w net.ipv4.ip_unprivileged_port_start=0 >/dev/null 2>&1; then
|
|
218
|
+
echo "net.ipv4.ip_unprivileged_port_start=0" | \
|
|
219
|
+
$SUDO_CAP tee /etc/sysctl.d/99-omicslab-studio.conf >/dev/null 2>&1 || true
|
|
220
|
+
echo "Lowered net.ipv4.ip_unprivileged_port_start to 0"
|
|
221
|
+
else
|
|
222
|
+
echo "WARNING: could not enable binding port 443; direct studio access may be unavailable"
|
|
223
|
+
fi
|
|
224
|
+
fi
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.2.0"
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""``omicslab job run`` — the single job-execution command.
|
|
2
|
+
|
|
3
|
+
``job run`` IS the job. On a cloud VM it runs directly; on SLURM it is the
|
|
4
|
+
sbatch payload running on the compute node. It runs the native engine
|
|
5
|
+
(:mod:`omicslab.job._run`) synchronously, self-reports RUNNING / incremental
|
|
6
|
+
logs / terminal status over REST, and exits with the job's exit code. There
|
|
7
|
+
is no controller, no daemon and no rendered ``job.sh`` on the execution path.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
omicslab job run --analysis --id <id> --work-dir <dir>
|
|
12
|
+
omicslab job run --studio --id <id> --work-dir <dir>
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from .utils import error, info, success
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
job_app = typer.Typer(
|
|
30
|
+
name="job",
|
|
31
|
+
help="Run jobs (analysis / studio) — self-managed, synchronous",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _snapshot_argv(*, job_id: str, output_dir: str, request: dict) -> list[str]:
|
|
37
|
+
"""Build the ``omicslab studio snapshot`` argv for a queued request.
|
|
38
|
+
|
|
39
|
+
The request is queued by the backend and delivered over the status poll
|
|
40
|
+
(the cloud agent has no WebSocket channel). The agent runs the snapshot
|
|
41
|
+
locally, so no SSH credential is needed.
|
|
42
|
+
"""
|
|
43
|
+
argv = [
|
|
44
|
+
"studio",
|
|
45
|
+
"snapshot",
|
|
46
|
+
"--container",
|
|
47
|
+
f"omicslab-studio-{job_id}",
|
|
48
|
+
"--output-dir",
|
|
49
|
+
output_dir,
|
|
50
|
+
"--filename",
|
|
51
|
+
str(request.get("filename") or f"studio-snapshot-{job_id}.tar.zst"),
|
|
52
|
+
"--upload",
|
|
53
|
+
"--workspace-id",
|
|
54
|
+
str(request.get("workspace_id") or ""),
|
|
55
|
+
]
|
|
56
|
+
if request.get("key"):
|
|
57
|
+
argv += ["--key", str(request["key"])]
|
|
58
|
+
if request.get("op_id"):
|
|
59
|
+
argv += ["--op-id", str(request["op_id"])]
|
|
60
|
+
return argv
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# The backend marks a job FAILED when no status/log update arrives for 10
|
|
64
|
+
# minutes (service_job/tasks/sweep.py), treating it as compute-disconnected. An
|
|
65
|
+
# interactive studio session can sit idle for far longer than that (code-server
|
|
66
|
+
# emits no logs while the user is not typing), so ``job run`` must prove the VM
|
|
67
|
+
# is alive on a periodic cadence. RUNNING → RUNNING is a no-op transition on the
|
|
68
|
+
# backend, so re-reporting RUNNING is safe.
|
|
69
|
+
JOB_HEARTBEAT_INTERVAL_SECONDS = 120
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _job_heartbeat_loop(
|
|
73
|
+
job_client,
|
|
74
|
+
job_id: str,
|
|
75
|
+
stop_event,
|
|
76
|
+
interval: int = JOB_HEARTBEAT_INTERVAL_SECONDS,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Post RUNNING every ``interval`` seconds until ``stop_event`` is set.
|
|
79
|
+
|
|
80
|
+
Refreshes the backend's ``runner:job:last_update`` heartbeat so a quiet job
|
|
81
|
+
is not swept as disconnected. Failures are swallowed — a heartbeat POST must
|
|
82
|
+
never take the job down.
|
|
83
|
+
"""
|
|
84
|
+
while not stop_event.wait(interval):
|
|
85
|
+
try:
|
|
86
|
+
job_client.post(f"/jobs/{job_id}/status/", json_data={"status": "running"})
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.debug("heartbeat failed for job %s", job_id)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Cadence of the periodic ``[system]`` log line (CPU / memory / disk). A fast
|
|
92
|
+
# cadence is useful locally, but on production it floods the job log/API over a
|
|
93
|
+
# long interactive session, so use 30s for local (http) and 5 minutes for
|
|
94
|
+
# production (https) — the same split as the runner client (job/client.py).
|
|
95
|
+
JOB_SYSTEM_MONITOR_INTERVAL_SECONDS = 30
|
|
96
|
+
JOB_SYSTEM_MONITOR_INTERVAL_PROD_SECONDS = 300
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _system_monitor_interval(server_url: str | None) -> int:
|
|
100
|
+
"""Seconds between ``[system]`` lines: fast locally, 5 min in production.
|
|
101
|
+
|
|
102
|
+
``OMICSLAB_SYSTEM_MONITOR_INTERVAL`` overrides both (used by tests/ops).
|
|
103
|
+
"""
|
|
104
|
+
override = os.environ.get("OMICSLAB_SYSTEM_MONITOR_INTERVAL", "")
|
|
105
|
+
if override:
|
|
106
|
+
try:
|
|
107
|
+
return max(1, int(override))
|
|
108
|
+
except ValueError:
|
|
109
|
+
pass
|
|
110
|
+
if (server_url or "").startswith("http://"):
|
|
111
|
+
return JOB_SYSTEM_MONITOR_INTERVAL_SECONDS
|
|
112
|
+
return JOB_SYSTEM_MONITOR_INTERVAL_PROD_SECONDS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _start_system_monitor(
|
|
116
|
+
job_dir: str,
|
|
117
|
+
stop_event,
|
|
118
|
+
emit,
|
|
119
|
+
slurm_job_id: str | None = None,
|
|
120
|
+
interval: int = JOB_SYSTEM_MONITOR_INTERVAL_SECONDS,
|
|
121
|
+
) -> threading.Thread:
|
|
122
|
+
"""Start a daemon thread emitting periodic ``[system]`` log lines.
|
|
123
|
+
|
|
124
|
+
Writes into the job's ``job.log`` and forwards each line to ``emit`` (the
|
|
125
|
+
``job run`` log callback that POSTs it to the API) so the platform shows
|
|
126
|
+
live CPU / memory / disk. Uses SLURM ``sstat`` on a compute node, else
|
|
127
|
+
psutil. Returns the started thread.
|
|
128
|
+
"""
|
|
129
|
+
from ..job._run._monitor import (
|
|
130
|
+
format_slurm_stats,
|
|
131
|
+
format_system_stats,
|
|
132
|
+
monitor_slurm_job,
|
|
133
|
+
monitor_system,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
log_file = os.path.join(job_dir, "job.log")
|
|
137
|
+
if slurm_job_id:
|
|
138
|
+
target = monitor_slurm_job
|
|
139
|
+
kwargs = {
|
|
140
|
+
"job_id": slurm_job_id,
|
|
141
|
+
"log_file": log_file,
|
|
142
|
+
"callback": lambda stats: emit(format_slurm_stats(stats)),
|
|
143
|
+
}
|
|
144
|
+
else:
|
|
145
|
+
target = monitor_system
|
|
146
|
+
kwargs = {
|
|
147
|
+
"log_file": log_file,
|
|
148
|
+
"callback": lambda stats: emit(format_system_stats(stats)),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
thread = threading.Thread(
|
|
152
|
+
target=target,
|
|
153
|
+
kwargs={**kwargs, "interval": interval, "stop_event": stop_event},
|
|
154
|
+
name="job-system-monitor",
|
|
155
|
+
daemon=True,
|
|
156
|
+
)
|
|
157
|
+
thread.start()
|
|
158
|
+
return thread
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@job_app.command("run")
|
|
162
|
+
def run_job(
|
|
163
|
+
id: str | None = typer.Option(
|
|
164
|
+
None,
|
|
165
|
+
"--id",
|
|
166
|
+
"-j",
|
|
167
|
+
help="Job ID to execute",
|
|
168
|
+
),
|
|
169
|
+
work_dir: str | None = typer.Option(
|
|
170
|
+
None,
|
|
171
|
+
"--work-dir",
|
|
172
|
+
"-w",
|
|
173
|
+
help="Job working directory (contains params.json). Executed in place.",
|
|
174
|
+
),
|
|
175
|
+
studio: bool = typer.Option(
|
|
176
|
+
False,
|
|
177
|
+
"--studio",
|
|
178
|
+
help="Run a studio job (interactive container session). Default: analysis.",
|
|
179
|
+
),
|
|
180
|
+
server_url: str | None = typer.Option(
|
|
181
|
+
None,
|
|
182
|
+
"--server-url",
|
|
183
|
+
"-s",
|
|
184
|
+
help="Server API base URL (e.g. http://omx-backend:8000/api/v1)",
|
|
185
|
+
),
|
|
186
|
+
api_token: str | None = typer.Option(
|
|
187
|
+
None,
|
|
188
|
+
"--token",
|
|
189
|
+
envvar="OMICSLAB_TOKEN",
|
|
190
|
+
help="API token for authentication",
|
|
191
|
+
),
|
|
192
|
+
no_report: bool = typer.Option(
|
|
193
|
+
False,
|
|
194
|
+
"--no-report",
|
|
195
|
+
help="Skip API reporting (writes status/log locally; for testing)",
|
|
196
|
+
),
|
|
197
|
+
):
|
|
198
|
+
"""Execute a job synchronously and self-report its lifecycle.
|
|
199
|
+
|
|
200
|
+
``job run --id <id> --work-dir <dir>`` runs a Nextflow / tool analysis
|
|
201
|
+
job in place; ``job run --studio --id <id> --work-dir <dir>`` runs an
|
|
202
|
+
interactive studio container session. Both are self-managed: they POST
|
|
203
|
+
RUNNING, stream incremental log lines, watch for CANCELLING and post the
|
|
204
|
+
terminal status over REST as they run. No job.sh, no background submit,
|
|
205
|
+
no monitor loop.
|
|
206
|
+
"""
|
|
207
|
+
from datetime import datetime as _dt
|
|
208
|
+
|
|
209
|
+
kind = "studio" if studio else "analysis"
|
|
210
|
+
|
|
211
|
+
if not work_dir:
|
|
212
|
+
error("Provide --work-dir <dir> (the staged job directory with params.json).")
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
job_dir = os.path.abspath(work_dir)
|
|
216
|
+
os.makedirs(job_dir, exist_ok=True)
|
|
217
|
+
params_file = os.path.join(job_dir, "params.json")
|
|
218
|
+
if not os.path.isfile(params_file):
|
|
219
|
+
error(f"Params file not found: {params_file}")
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
try:
|
|
222
|
+
with open(params_file) as f:
|
|
223
|
+
params_dict = json.load(f)
|
|
224
|
+
except json.JSONDecodeError as e:
|
|
225
|
+
error(f"Invalid JSON in {params_file}: {e}")
|
|
226
|
+
raise typer.Exit(1)
|
|
227
|
+
|
|
228
|
+
# ── Resolve the job id ─────────────────────────────────────────────
|
|
229
|
+
# The staged work-dir is ``<jobs_dir>/<job_id>`` so its basename is the
|
|
230
|
+
# job id; this lets the runner dispatch ``job run --work-dir <dir>``
|
|
231
|
+
# without threading ``--id`` through the sbatch wrapper.
|
|
232
|
+
if not id:
|
|
233
|
+
id = os.path.basename(job_dir.rstrip("/")) or None
|
|
234
|
+
if not id:
|
|
235
|
+
error("Provide --id <job-id>.")
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
# ── Resolve server URL / token (staged creds win over CLI/env) ──
|
|
239
|
+
if not no_report:
|
|
240
|
+
from ..config import Config
|
|
241
|
+
|
|
242
|
+
config = Config()
|
|
243
|
+
if not api_token:
|
|
244
|
+
api_token = params_dict.get("OMICSLAB_TOKEN") or config.get_token()
|
|
245
|
+
if not api_token:
|
|
246
|
+
error(
|
|
247
|
+
"No API token. Provide --token, set OMICSLAB_TOKEN, or stage "
|
|
248
|
+
"OMICSLAB_TOKEN in params.json."
|
|
249
|
+
)
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
if not server_url:
|
|
252
|
+
server_url = params_dict.get("API_BASE_URL") or config.get_base_url()
|
|
253
|
+
if not server_url:
|
|
254
|
+
error(
|
|
255
|
+
"Server URL required. Provide --server-url, stage API_BASE_URL "
|
|
256
|
+
"in params.json, or set it via 'omicslab auth login'."
|
|
257
|
+
)
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
|
|
260
|
+
# ── API client for self-reporting ──
|
|
261
|
+
client = None
|
|
262
|
+
if not no_report:
|
|
263
|
+
from ..client import Client
|
|
264
|
+
|
|
265
|
+
resolved_url = server_url.rstrip("/")
|
|
266
|
+
if not resolved_url.endswith("/api/v1"):
|
|
267
|
+
resolved_url += "/api/v1"
|
|
268
|
+
client = Client(token=api_token, base_url=resolved_url)
|
|
269
|
+
|
|
270
|
+
if client:
|
|
271
|
+
try:
|
|
272
|
+
client.post(f"/jobs/{id}/status/", json_data={"status": "running"})
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning("Failed to report running: %s", e)
|
|
275
|
+
|
|
276
|
+
start = _dt.now()
|
|
277
|
+
|
|
278
|
+
def _post_log(line: str):
|
|
279
|
+
if client is None:
|
|
280
|
+
return
|
|
281
|
+
try:
|
|
282
|
+
client.post(f"/jobs/{id}/log/", json_data={"lines": [line]})
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
# ── Watch for CANCELLING / snapshot requests while the engine runs ──
|
|
287
|
+
cancel_flag = threading.Event()
|
|
288
|
+
cancelled = threading.Event()
|
|
289
|
+
job_client = client
|
|
290
|
+
|
|
291
|
+
def _launch_snapshot(request: dict) -> None:
|
|
292
|
+
"""Run a backend-queued snapshot locally (the agent owns the container)."""
|
|
293
|
+
import shutil
|
|
294
|
+
import subprocess
|
|
295
|
+
import sys as _sys
|
|
296
|
+
|
|
297
|
+
from ..job._run._log import append as _append_log
|
|
298
|
+
|
|
299
|
+
out_dir = os.path.join(job_dir, "snapshots")
|
|
300
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
301
|
+
env = dict(os.environ)
|
|
302
|
+
if api_token:
|
|
303
|
+
env["OMICSLAB_TOKEN"] = api_token
|
|
304
|
+
if server_url:
|
|
305
|
+
env["OMICSLAB_BASE_URL"] = server_url
|
|
306
|
+
exe = shutil.which("omicslab") or _sys.argv[0]
|
|
307
|
+
argv = [exe, *_snapshot_argv(job_id=id, output_dir=out_dir, request=request)]
|
|
308
|
+
logger.info(
|
|
309
|
+
"Snapshot requested for job %s; launching local studio snapshot", id
|
|
310
|
+
)
|
|
311
|
+
log_path = os.path.join(out_dir, "snapshot.log")
|
|
312
|
+
try:
|
|
313
|
+
proc = subprocess.Popen(
|
|
314
|
+
argv,
|
|
315
|
+
stdin=subprocess.DEVNULL,
|
|
316
|
+
stdout=subprocess.PIPE,
|
|
317
|
+
stderr=subprocess.STDOUT,
|
|
318
|
+
start_new_session=True,
|
|
319
|
+
close_fds=True,
|
|
320
|
+
env=env,
|
|
321
|
+
)
|
|
322
|
+
except Exception as exc: # noqa: BLE001 — surface it, never crash the agent
|
|
323
|
+
_append_log(job_dir, f"[snapshot] failed to start: {exc}", _post_log)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
def _stream_snapshot_log(proc=proc, log_path=log_path) -> None:
|
|
327
|
+
# Tee the snapshot output: keep the on-VM snapshot.log and mirror it
|
|
328
|
+
# into the job log (and the API) so snapshot progress is visible in
|
|
329
|
+
# the platform, not only on the VM.
|
|
330
|
+
try:
|
|
331
|
+
with open(log_path, "a") as log:
|
|
332
|
+
for raw in iter(proc.stdout.readline, b""):
|
|
333
|
+
line = raw.decode("utf-8", "replace").rstrip("\n")
|
|
334
|
+
log.write(line + "\n")
|
|
335
|
+
log.flush()
|
|
336
|
+
_append_log(job_dir, f"[snapshot] {line}", _post_log)
|
|
337
|
+
except Exception:
|
|
338
|
+
logger.debug("snapshot log stream ended", exc_info=True)
|
|
339
|
+
finally:
|
|
340
|
+
try:
|
|
341
|
+
proc.stdout.close()
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
threading.Thread(
|
|
346
|
+
target=_stream_snapshot_log,
|
|
347
|
+
name=f"snapshot-log-{id[:8]}",
|
|
348
|
+
daemon=True,
|
|
349
|
+
).start()
|
|
350
|
+
|
|
351
|
+
def _watch_cancel():
|
|
352
|
+
while not cancel_flag.is_set():
|
|
353
|
+
if job_client is None:
|
|
354
|
+
return
|
|
355
|
+
try:
|
|
356
|
+
resp = job_client.get(f"/jobs/{id}/")
|
|
357
|
+
except Exception:
|
|
358
|
+
resp = None
|
|
359
|
+
if isinstance(resp, dict):
|
|
360
|
+
if kind == "studio" and resp.get("snapshot_request"):
|
|
361
|
+
try:
|
|
362
|
+
_launch_snapshot(resp["snapshot_request"])
|
|
363
|
+
except Exception:
|
|
364
|
+
logger.warning(
|
|
365
|
+
"Failed to launch requested snapshot for job %s", id
|
|
366
|
+
)
|
|
367
|
+
if resp.get("status") == "cancelling":
|
|
368
|
+
cancelled.set()
|
|
369
|
+
# Signal the whole process group so the child pipeline is killed.
|
|
370
|
+
import os as _os
|
|
371
|
+
import signal as _signal
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
_os.killpg(_os.getpgid(_os.getpid()), _signal.SIGTERM)
|
|
375
|
+
except (ProcessLookupError, OSError):
|
|
376
|
+
pass
|
|
377
|
+
return
|
|
378
|
+
time.sleep(5)
|
|
379
|
+
|
|
380
|
+
if job_client is not None:
|
|
381
|
+
threading.Thread(target=_watch_cancel, daemon=True).start()
|
|
382
|
+
threading.Thread(
|
|
383
|
+
target=_job_heartbeat_loop,
|
|
384
|
+
args=(job_client, id, cancel_flag),
|
|
385
|
+
name=f"job-heartbeat-{id[:8]}",
|
|
386
|
+
daemon=True,
|
|
387
|
+
).start()
|
|
388
|
+
_start_system_monitor(
|
|
389
|
+
job_dir,
|
|
390
|
+
cancel_flag,
|
|
391
|
+
_post_log,
|
|
392
|
+
slurm_job_id=os.environ.get("SLURM_JOB_ID")
|
|
393
|
+
or os.environ.get("SLURM_JOBID"),
|
|
394
|
+
interval=_system_monitor_interval(server_url),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
info(f"Executing {kind} job {id}")
|
|
398
|
+
try:
|
|
399
|
+
if kind == "studio":
|
|
400
|
+
from ..job._run.studio import run_studio
|
|
401
|
+
|
|
402
|
+
exit_code = run_studio(id, params_dict, on_log=_post_log, job_dir=job_dir)
|
|
403
|
+
else:
|
|
404
|
+
from ..job._run.analysis import run_analysis
|
|
405
|
+
|
|
406
|
+
exit_code = run_analysis(id, params_dict, on_log=_post_log, job_dir=job_dir)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.exception("Job %s failed", id)
|
|
409
|
+
error(f"Execution failed: {e}")
|
|
410
|
+
exit_code = 1
|
|
411
|
+
|
|
412
|
+
cancel_flag.set()
|
|
413
|
+
if client is not None:
|
|
414
|
+
status = (
|
|
415
|
+
"cancelled"
|
|
416
|
+
if (cancelled.is_set() or exit_code == 130)
|
|
417
|
+
else ("completed" if exit_code == 0 else "failed")
|
|
418
|
+
)
|
|
419
|
+
payload = {"status": status}
|
|
420
|
+
elapsed = int((_dt.now() - start).total_seconds())
|
|
421
|
+
payload["running_time"] = elapsed
|
|
422
|
+
try:
|
|
423
|
+
client.post(f"/jobs/{id}/status/", json_data=payload)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.warning("Failed to report final status: %s", e)
|
|
426
|
+
|
|
427
|
+
# Keep the work-dir for non-https (dev/local) servers; job run owns
|
|
428
|
+
# cleanup in production.
|
|
429
|
+
if server_url and server_url.startswith("https://"):
|
|
430
|
+
try:
|
|
431
|
+
from ..job.executor import JobExecutor
|
|
432
|
+
|
|
433
|
+
executor = JobExecutor()
|
|
434
|
+
executor.register_job_dir(id, job_dir)
|
|
435
|
+
executor.cleanup(id)
|
|
436
|
+
except Exception as e:
|
|
437
|
+
logger.warning("Failed to cleanup work-dir: %s", e)
|
|
438
|
+
|
|
439
|
+
if exit_code not in (0, 130):
|
|
440
|
+
raise typer.Exit(exit_code)
|
|
441
|
+
success(f"Job {id} completed")
|