From a64b8453bd494cece5655127f0a60342b968b999 Mon Sep 17 00:00:00 2001 From: Stanley-blik Date: Tue, 30 Jun 2026 10:35:19 +0000 Subject: [PATCH] feat(deploy): pass Modal credentials as args (per-invocation, not ambient env) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flow deploy modal --modal-token-id ... --modal-token-secret ... lets an unattended/multi-account caller (e.g. our always-on cloud VPS) deploy on behalf of a specific account without touching ambient modal auth. DeploySpec gains a credentials dict; ModalDeployer injects MODAL_TOKEN_ID/SECRET into the deploy SUBPROCESS env only — never os.environ — so concurrent per-tenant deploys can't leak tokens between each other. Omitted = ambient modal auth as before. Test asserts the token lands in the subprocess env and not in os.environ. --- src/flow/cli.py | 15 ++++++++++++++- src/flow/deploy/base.py | 5 +++++ src/flow/deploy/modal_deploy.py | 10 ++++++++++ tests/test_deploy.py | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/flow/cli.py b/src/flow/cli.py index c2f9d8a..357e576 100644 --- a/src/flow/cli.py +++ b/src/flow/cli.py @@ -109,19 +109,31 @@ def deploy( model_t2v: str = typer.Option(None, help="T2V model id"), model_i2v: str = typer.Option(None, help="I2V model id"), region: str = typer.Option(None, help="Region (AWS/GCP)"), + modal_token_id: str = typer.Option( + None, help="Modal token id (else uses ambient modal auth / ~/.modal.toml)" + ), + modal_token_secret: str = typer.Option( + None, help="Modal token secret (passed per-invocation, not via global env)" + ), config_path: str = typer.Option("config/config.toml", help="Path to config file"), ) -> None: """Deploy the GPU backend to a provider as a named compute instance. A token alone can't generate — this deploys the open-source Wan backend into your account and reports the endpoint URL to register. CLI flags override the - deploy section of your config file. + deploy section of your config file. Modal credentials may be passed as args + (for unattended/multi-account deploys) or left to ambient modal auth. """ from flow.config import load_config from flow.deploy import DeploySpec, available_providers, get_deployer d = load_config(config_path).deploy provider = provider or d.provider + credentials: dict = {} + if modal_token_id: + credentials["token_id"] = modal_token_id + if modal_token_secret: + credentials["token_secret"] = modal_token_secret spec = DeploySpec( name=name or d.name, gpu=gpu or d.gpu, @@ -129,6 +141,7 @@ def deploy( model_i2v=model_i2v or d.model_i2v, region=region or d.region, scaledown_window=d.scaledown_window, + credentials=credentials, ) try: diff --git a/src/flow/deploy/base.py b/src/flow/deploy/base.py index ca0df23..5104bc1 100644 --- a/src/flow/deploy/base.py +++ b/src/flow/deploy/base.py @@ -24,6 +24,11 @@ class DeploySpec: model_i2v: str = "Wan-AI/Wan2.2-I2V-A14B-Diffusers" region: str = "" scaledown_window: int = 300 + # Provider credentials passed *per invocation* (e.g. Modal token_id/secret). + # Deployers inject these into the deploy subprocess's env only — never the + # ambient process env — so a shared host can deploy for many accounts + # concurrently without leaking tokens between deploys. + credentials: dict = field(default_factory=dict) extra: dict = field(default_factory=dict) diff --git a/src/flow/deploy/modal_deploy.py b/src/flow/deploy/modal_deploy.py index ffd24f0..ef560fb 100644 --- a/src/flow/deploy/modal_deploy.py +++ b/src/flow/deploy/modal_deploy.py @@ -50,6 +50,16 @@ def deploy(self, spec: DeploySpec) -> DeployResult: "FLOW_MODEL_I2V": spec.model_i2v, "FLOW_GPU_SCALEDOWN": str(spec.scaledown_window), } + # Per-invocation Modal auth, if provided as args, scoped to THIS + # subprocess only (never the ambient env) — so a shared host can deploy + # for many accounts concurrently. If omitted, ambient modal auth + # (~/.modal.toml or existing MODAL_TOKEN_* env) is used as before. + creds = spec.credentials or {} + if creds.get("token_id"): + env["MODAL_TOKEN_ID"] = creds["token_id"] + if creds.get("token_secret"): + env["MODAL_TOKEN_SECRET"] = creds["token_secret"] + try: proc = subprocess.run( ["modal", "deploy", str(server)], diff --git a/tests/test_deploy.py b/tests/test_deploy.py index f43f367..be4f66a 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -31,6 +31,40 @@ def test_modal_deploy_fails_cleanly_without_cli(monkeypatch): assert "modal CLI not found" in res.detail +def test_modal_deploy_injects_token_per_invocation(monkeypatch): + """Credentials passed as args land in the subprocess env only, never os.environ.""" + import os + import types + + monkeypatch.setattr( + "flow.deploy.modal_deploy.shutil.which", lambda _: "/usr/bin/modal" + ) + captured: dict = {} + + def fake_run(cmd, env=None, **kw): + captured["env"] = env + return types.SimpleNamespace( + returncode=0, + stdout="Created web endpoint => https://ws--a100.modal.run", + stderr="", + ) + + monkeypatch.setattr("flow.deploy.modal_deploy.subprocess.run", fake_run) + + res = ModalDeployer().deploy( + DeploySpec(name="a100", credentials={"token_id": "ti", "token_secret": "ts"}) + ) + + assert res.status == "deployed" + assert res.endpoint_url == "https://ws--a100.modal.run" + # Token is scoped to the deploy subprocess... + assert captured["env"]["MODAL_TOKEN_ID"] == "ti" + assert captured["env"]["MODAL_TOKEN_SECRET"] == "ts" + assert captured["env"]["FLOW_GPU_APP_NAME"] == "a100" + # ...and never leaks into the ambient process env. + assert "MODAL_TOKEN_ID" not in os.environ + + def test_aws_gcp_are_honest_scaffolds(): for provider in ("aws", "gcp"): res = get_deployer(provider).deploy(DeploySpec(name=f"{provider}-inst"))