Skip to content

Commit c95caf7

Browse files
Add custom env vars support (#1)
* Add custom env vars support * Implement custom env vars whitelist
1 parent 2e40ab8 commit c95caf7

16 files changed

Lines changed: 145 additions & 5 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ __pycache__/
1313
.pytest_cache/
1414
.ruff_cache/
1515
/data
16+
.idea

app/backends/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ async def launch(
177177
work_dir: str,
178178
configfile: str | None,
179179
snakemake_args: list[str] | None = None,
180+
env_vars: dict[str, str] | None = None,
180181
) -> None:
181182
"""
182183
Write the .run.sh wrapper script, launch it via nohup/disown,

app/backends/local.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ async def launch(
8888
work_dir: str,
8989
configfile: str | None,
9090
snakemake_args: list[str] | None = None,
91+
env_vars: dict[str, str] | None = None,
9192
) -> None:
9293
self._validate_configfile(configfile)
9394
snkmt_db_path = str(Path(work_dir).resolve() / SNKMT_DB_FILENAME)
9495
wrapper_content = build_wrapper_script(
95-
self._config.pixi_path, snkmt_db_path, configfile, snakemake_args
96+
self._config.pixi_path, snkmt_db_path, configfile, snakemake_args, env_vars
9697
)
9798

9899
wd = Path(work_dir)

app/backends/slurm_ssh.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,13 @@ async def launch(
284284
work_dir: str,
285285
configfile: str | None,
286286
snakemake_args: list[str] | None = None,
287+
env_vars: dict[str, str] | None = None,
287288
) -> None:
288289
cfg = self._config
289290
self._validate_configfile(configfile)
290291
snkmt_db_path = f"{work_dir}/{SNKMT_DB_FILENAME}"
291292
wrapper_content = build_wrapper_script(
292-
cfg.pixi_path, snkmt_db_path, configfile, snakemake_args
293+
cfg.pixi_path, snkmt_db_path, configfile, snakemake_args, env_vars
293294
)
294295

295296
# Write wrapper script via SFTP, then make executable and launch detached

app/config.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
from __future__ import annotations
22

3+
import re
34
from pathlib import Path
45

56
import yaml
6-
from pydantic import BaseModel
7+
from pydantic import BaseModel, field_validator
78
from pydantic_settings import BaseSettings
89

910
BACKEND_KEYS: frozenset[str] = frozenset({"slurm_ssh", "local"})
1011

12+
_VALID_ENV_VAR_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
13+
14+
15+
def _validate_env_var_names(v: list[str] | None) -> list[str] | None:
16+
if v is not None:
17+
for name in v:
18+
if not _VALID_ENV_VAR_NAME.match(name):
19+
msg = f"allowed_env_vars entry is not a valid env var name: {name!r}"
20+
raise ValueError(msg)
21+
return v
22+
1123

1224
class Settings(BaseSettings):
1325
"""Application settings loaded from environment variables."""
@@ -40,6 +52,12 @@ class SlurmSSHConfig(BaseModel):
4052
scratch_dir: str
4153
default_snakemake_args: list[str] = []
4254
snkmt_db_sync_interval: float = 30.0
55+
allowed_env_vars: list[str] | None = None
56+
57+
@field_validator("allowed_env_vars")
58+
@classmethod
59+
def _validate_allowed_env_vars(cls, v: list[str] | None) -> list[str] | None:
60+
return _validate_env_var_names(v)
4361

4462

4563
class LocalConfig(BaseModel):
@@ -50,6 +68,12 @@ class LocalConfig(BaseModel):
5068
poll_interval: float = 5.0
5169
default_snakemake_args: list[str] = []
5270
snkmt_db_sync_interval: float = 30.0
71+
allowed_env_vars: list[str] | None = None
72+
73+
@field_validator("allowed_env_vars")
74+
@classmethod
75+
def _validate_allowed_env_vars(cls, v: list[str] | None) -> list[str] | None:
76+
return _validate_env_var_names(v)
5377

5478

5579
def load_config(

app/deps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class AppState:
2727
settings: Settings
2828
health_cache: HealthCache
2929
default_snakemake_args: list[str]
30+
allowed_env_vars: list[str] | None = None
3031
background_tasks: set[asyncio.Task[None]] = field(default_factory=set)
3132

3233

@@ -46,6 +47,10 @@ def get_default_snakemake_args(request: Request) -> list[str]:
4647
return app_state(request).default_snakemake_args
4748

4849

50+
def get_allowed_env_vars(request: Request) -> list[str] | None:
51+
return app_state(request).allowed_env_vars
52+
53+
4954
def provide_background_tasks(request: Request) -> set[asyncio.Task[None]]:
5055
"""Return the shared background-task set."""
5156
return app_state(request).background_tasks

app/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
4040
settings=settings,
4141
health_cache={"backend_ok": None, "checked_at": 0.0},
4242
default_snakemake_args=backend_config.default_snakemake_args,
43+
allowed_env_vars=backend_config.allowed_env_vars,
4344
)
4445

4546
store.restore_from_disk()

app/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ class JobCreate(BaseModel):
7575
description="Relative paths within the work directory to cache, "
7676
"e.g. ['data', 'resources']. Requires cache_key.",
7777
)
78+
env_vars: dict[str, str] | None = Field(
79+
None,
80+
description="Environment variables to merge into the Snakemake process "
81+
"environment. Only keys listed in the server's allowed_env_vars config are accepted.",
82+
)
7883

7984
@field_validator("cache_key")
8085
@classmethod

app/routes/jobs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from app.backends.base import ComputeBackend
1616
from app.deps import (
17+
get_allowed_env_vars,
1718
get_backend,
1819
get_default_snakemake_args,
1920
get_store,
@@ -100,8 +101,22 @@ async def create_job(
100101
store: Annotated[JobStore, Depends(get_store)],
101102
backend: Annotated[ComputeBackend, Depends(get_backend)],
102103
default_snakemake_args: Annotated[list[str], Depends(get_default_snakemake_args)],
104+
allowed_env_vars: Annotated[list[str] | None, Depends(get_allowed_env_vars)],
103105
) -> JobResponse:
104106
"""Submit a new Snakemake job for execution."""
107+
if body.env_vars:
108+
if allowed_env_vars is None:
109+
raise HTTPException(
110+
status_code=422,
111+
detail="env_vars are not enabled; set allowed_env_vars in server config",
112+
)
113+
disallowed = set(body.env_vars) - set(allowed_env_vars)
114+
if disallowed:
115+
raise HTTPException(
116+
status_code=422,
117+
detail=f"env_vars keys not in allowed list: {sorted(disallowed)}",
118+
)
119+
105120
source = body.workflow
106121
is_url = source.startswith(("http://", "https://"))
107122
if not is_url:
@@ -133,6 +148,7 @@ async def create_job(
133148
extra_files=body.extra_files,
134149
cache_key=body.cache_key,
135150
cache_dirs=body.cache_dirs,
151+
env_vars=body.env_vars,
136152
),
137153
),
138154
name=f"execute-{job_id}",

app/tasks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class ExecuteJobParams:
2727
extra_files: dict[str, str] | None = None
2828
cache_key: str | None = None
2929
cache_dirs: list[str] | None = None
30+
env_vars: dict[str, str] | None = None
3031

3132

3233
logger = logging.getLogger(__name__)
@@ -198,7 +199,7 @@ async def execute_job(
198199
await _restore_cache(backend, store, job_id, work_dir, params)
199200

200201
store.mark_running(job_id)
201-
await backend.launch(job_id, work_dir, params.configfile, params.snakemake_args)
202+
await backend.launch(job_id, work_dir, params.configfile, params.snakemake_args, params.env_vars)
202203

203204
def log_callback(line: str) -> None:
204205
store.push_log(job_id, line)

0 commit comments

Comments
 (0)