Skip to content

Commit 4d31b8c

Browse files
authored
Merge branch 'main' into update/partial_dependabot_06182026
2 parents 926c230 + 8d5ea5c commit 4d31b8c

57 files changed

Lines changed: 6174 additions & 2628 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/api.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ async def lifespan(app: FastAPI):
9999
# Do the following at API Startup:
100100
print_launch_message()
101101
_print_storage_banner()
102+
# Start the local JuiceFS S3 gateway before any storage access
103+
# (no-op unless TFL_STORAGE_PROVIDER=juicefs). Blocking subprocess/HTTP
104+
# work runs off the event loop.
105+
from transformerlab.services.juicefs_gateway import ensure_juicefs_gateway, stop_juicefs_gateway
106+
107+
await asyncio.to_thread(ensure_juicefs_gateway)
102108
# Initialize directories early
103109
from transformerlab.shared import dirs as shared_dirs
104110

@@ -174,6 +180,7 @@ async def lifespan(app: FastAPI):
174180
from transformerlab.services.process_registry import get_registry
175181

176182
get_registry().kill_all()
183+
stop_juicefs_gateway()
177184
await db.close()
178185
# Run the clean up function
179186
cleanup_at_exit()
@@ -435,8 +442,9 @@ def _effective_storage_provider() -> str:
435442
"""
436443
from lab.storage import STORAGE_PROVIDER
437444

438-
if STORAGE_PROVIDER == "localfs":
439-
return "localfs"
445+
# juicefs and localfs are always active when configured — no TFL_REMOTE_STORAGE_ENABLED gate.
446+
if STORAGE_PROVIDER in ("localfs", "juicefs"):
447+
return STORAGE_PROVIDER
440448
remote_enabled = os.getenv("TFL_REMOTE_STORAGE_ENABLED", "false").lower() == "true"
441449
return STORAGE_PROVIDER if remote_enabled else "localfs"
442450

api/localprovider_pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"soundfile==0.13.1",
3333
"tensorboardX==2.6.2.2",
3434
"timm==1.0.15",
35-
"transformerlab==0.1.43",
35+
"transformerlab==0.1.44",
3636
"transformerlab-inference==0.2.52",
3737
"transformers==4.57.1",
3838
"wandb==0.23.1",

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ dependencies = [
2727
"python-dotenv==1.2.2",
2828
"python-multipart==0.0.32",
2929
"sqlalchemy[asyncio]==2.0.50",
30-
"transformerlab==0.1.43",
30+
"transformerlab==0.1.44",
3131
"transformerlab-inference==0.2.52",
3232
"uvicorn==0.35.0",
3333
"watchfiles==1.2.0",

api/scripts/on_server_start.py

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from transformerlab.db.session import async_session
99
from transformerlab.db.session import run_alembic_migrations
1010
from transformerlab.services.experiment_init import seed_default_admin_user, seed_default_experiments
11+
from transformerlab.services.juicefs_gateway import ensure_juicefs_gateway, stop_juicefs_gateway
1112
from transformerlab.shared.remote_workspace import create_buckets_for_all_teams, get_default_aws_profile
1213
from transformerlab.shared import dirs, galleries
1314

@@ -17,36 +18,48 @@ async def main() -> None:
1718
# prestart_once runs standalone (e.g. in Docker build), so set it here too.
1819
os.environ["_TFL_SOURCE_CODE_DIR"] = dirs.TFL_SOURCE_CODE_DIR
1920

20-
# Ensure expected directory structure exists before other startup steps.
21-
await dirs.initialize_dirs()
22-
23-
print("✅ Running one-time startup tasks")
24-
await run_alembic_migrations()
25-
await seed_default_admin_user()
26-
await galleries.update_gallery_cache()
27-
28-
# Buckets/containers/local workspace dirs must exist before seed_default_experiments(),
29-
# which writes experiment metadata to remote storage (e.g. Azure block upload).
30-
tfl_remote_storage_enabled = os.getenv("TFL_REMOTE_STORAGE_ENABLED", "false").lower() == "true"
31-
if tfl_remote_storage_enabled or (os.getenv("TFL_STORAGE_PROVIDER") == "localfs" and os.getenv("TFL_STORAGE_URI")):
32-
print("✅ CHECKING STORAGE FOR EXISTING TEAMS")
33-
try:
34-
async with async_session() as session:
35-
success_count, failure_count, error_messages = await create_buckets_for_all_teams(
36-
session, profile_name=get_default_aws_profile()
37-
)
38-
if success_count > 0:
39-
print(f"✅ Created/verified storage for {success_count} team(s)")
40-
if failure_count > 0:
41-
print(f"⚠️ Failed to create storage for {failure_count} team(s)")
42-
for error in error_messages:
43-
print(f" - {error}")
44-
except Exception as e:
45-
print(f"⚠️ Error creating storage for existing teams: {e}")
46-
47-
await seed_default_experiments()
48-
49-
print("✅ One-time startup tasks complete")
21+
# Start the local JuiceFS S3 gateway before any storage access (no-op unless
22+
# TFL_STORAGE_PROVIDER=juicefs). This prestart script runs in its own process,
23+
# so it needs its own gateway; the API lifespan starts a supervised one later.
24+
await asyncio.to_thread(ensure_juicefs_gateway)
25+
try:
26+
# Ensure expected directory structure exists before other startup steps.
27+
await dirs.initialize_dirs()
28+
29+
print("✅ Running one-time startup tasks")
30+
await run_alembic_migrations()
31+
await seed_default_admin_user()
32+
await galleries.update_gallery_cache()
33+
34+
# Buckets/containers/local workspace dirs must exist before seed_default_experiments(),
35+
# which writes experiment metadata to remote storage (e.g. Azure block upload).
36+
tfl_remote_storage_enabled = os.getenv("TFL_REMOTE_STORAGE_ENABLED", "false").lower() == "true"
37+
if (
38+
tfl_remote_storage_enabled
39+
or os.getenv("TFL_STORAGE_PROVIDER") == "juicefs"
40+
or (os.getenv("TFL_STORAGE_PROVIDER") == "localfs" and os.getenv("TFL_STORAGE_URI"))
41+
):
42+
print("✅ CHECKING STORAGE FOR EXISTING TEAMS")
43+
try:
44+
async with async_session() as session:
45+
success_count, failure_count, error_messages = await create_buckets_for_all_teams(
46+
session, profile_name=get_default_aws_profile()
47+
)
48+
if success_count > 0:
49+
print(f"✅ Created/verified storage for {success_count} team(s)")
50+
if failure_count > 0:
51+
print(f"⚠️ Failed to create storage for {failure_count} team(s)")
52+
for error in error_messages:
53+
print(f" - {error}")
54+
except Exception as e:
55+
print(f"⚠️ Error creating storage for existing teams: {e}")
56+
57+
await seed_default_experiments()
58+
59+
print("✅ One-time startup tasks complete")
60+
finally:
61+
# No-op unless this process spawned the gateway.
62+
stop_juicefs_gateway()
5063

5164

5265
if __name__ == "__main__":
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import subprocess
2+
import pytest
3+
from unittest.mock import MagicMock, patch
4+
5+
from transformerlab.services import juicefs_gateway
6+
7+
8+
@pytest.fixture(autouse=True)
9+
def reset_gateway_state():
10+
juicefs_gateway._gateway_process = None
11+
juicefs_gateway._shutdown_event.clear()
12+
yield
13+
juicefs_gateway._shutdown_event.set()
14+
juicefs_gateway._gateway_process = None
15+
16+
17+
@pytest.fixture
18+
def juicefs_env(monkeypatch):
19+
monkeypatch.setenv("TFL_STORAGE_PROVIDER", "juicefs")
20+
monkeypatch.setenv("TFL_JUICEFS_METADATA_URL", "redis://localhost:6379/1")
21+
monkeypatch.setenv("TFL_JUICEFS_VOLUME_NAME", "myvol")
22+
monkeypatch.setenv("TFL_JUICEFS_STORAGE_BACKEND", "aws")
23+
monkeypatch.setenv("TFL_JUICEFS_TOKEN", "hosted-token")
24+
monkeypatch.setenv("TFL_JUICEFS_GATEWAY_ACCESS_KEY", "gw-access")
25+
monkeypatch.setenv("TFL_JUICEFS_GATEWAY_SECRET_KEY", "gw-secret-123")
26+
monkeypatch.delenv("TFL_JUICEFS_GATEWAY_ENDPOINT", raising=False)
27+
monkeypatch.delenv("TFL_JUICEFS_CONSOLE_URL", raising=False)
28+
29+
30+
def test_noop_for_other_providers(monkeypatch):
31+
monkeypatch.setenv("TFL_STORAGE_PROVIDER", "aws")
32+
33+
with patch.object(juicefs_gateway, "_spawn_gateway") as mock_spawn:
34+
juicefs_gateway.ensure_juicefs_gateway()
35+
36+
mock_spawn.assert_not_called()
37+
38+
39+
def test_reuses_already_running_gateway(juicefs_env):
40+
with (
41+
patch.object(juicefs_gateway, "is_gateway_ready", return_value=True),
42+
patch.object(juicefs_gateway, "_run_juicefs_auth") as mock_auth,
43+
patch.object(juicefs_gateway, "_spawn_gateway") as mock_spawn,
44+
):
45+
juicefs_gateway.ensure_juicefs_gateway()
46+
47+
# Auth runs even when the gateway is reused: it is idempotent and writes the on-disk
48+
# config that later `juicefs quota set` calls depend on (e.g. for a second API worker
49+
# that reuses a gateway it did not spawn).
50+
mock_auth.assert_called_once()
51+
mock_spawn.assert_not_called()
52+
53+
54+
def test_starts_gateway_and_waits_for_ready(juicefs_env):
55+
with (
56+
patch.object(juicefs_gateway, "is_gateway_ready", side_effect=[False, True]),
57+
patch.object(juicefs_gateway, "_run_juicefs_auth") as mock_auth,
58+
patch.object(juicefs_gateway, "_spawn_gateway", return_value=MagicMock()) as mock_spawn,
59+
patch.object(juicefs_gateway, "_start_supervisor") as mock_supervisor,
60+
):
61+
juicefs_gateway.ensure_juicefs_gateway()
62+
63+
mock_auth.assert_called_once()
64+
mock_spawn.assert_called_once()
65+
mock_supervisor.assert_called_once()
66+
67+
68+
def test_exits_when_auth_fails(juicefs_env):
69+
auth_error = subprocess.CalledProcessError(1, ["juicefs", "auth"], stderr="bad token")
70+
with (
71+
patch.object(juicefs_gateway, "is_gateway_ready", return_value=False),
72+
patch.object(juicefs_gateway, "_run_juicefs_auth", side_effect=auth_error),
73+
patch.object(juicefs_gateway, "_spawn_gateway") as mock_spawn,
74+
):
75+
with pytest.raises(SystemExit):
76+
juicefs_gateway.ensure_juicefs_gateway()
77+
78+
mock_spawn.assert_not_called()
79+
80+
81+
def test_exits_when_juicefs_binary_missing(juicefs_env):
82+
with (
83+
patch.object(juicefs_gateway, "is_gateway_ready", return_value=False),
84+
patch.object(juicefs_gateway, "_run_juicefs_auth", side_effect=FileNotFoundError("juicefs")),
85+
):
86+
with pytest.raises(SystemExit):
87+
juicefs_gateway.ensure_juicefs_gateway()
88+
89+
90+
def test_exits_when_gateway_never_ready(juicefs_env):
91+
with (
92+
patch.object(juicefs_gateway, "is_gateway_ready", return_value=False),
93+
patch.object(juicefs_gateway, "_run_juicefs_auth"),
94+
patch.object(juicefs_gateway, "_spawn_gateway", return_value=MagicMock()),
95+
patch.object(juicefs_gateway, "_wait_until_ready", return_value=False),
96+
):
97+
with pytest.raises(SystemExit):
98+
juicefs_gateway.ensure_juicefs_gateway()
99+
100+
101+
def test_exits_when_required_env_missing(juicefs_env, monkeypatch):
102+
monkeypatch.delenv("TFL_JUICEFS_TOKEN", raising=False)
103+
104+
with patch.object(juicefs_gateway, "is_gateway_ready", return_value=False):
105+
with pytest.raises(SystemExit):
106+
juicefs_gateway.ensure_juicefs_gateway()
107+
108+
109+
def test_run_juicefs_auth_builds_command(juicefs_env, monkeypatch):
110+
monkeypatch.setenv("TFL_JUICEFS_CONSOLE_URL", "http://juicefs-console:8080")
111+
112+
with (
113+
patch("transformerlab.services.juicefs_gateway.subprocess.run") as mock_run,
114+
patch(
115+
"transformerlab.services.compute_provider.launch_credentials.get_aws_credentials_from_file",
116+
return_value=("AKID", "SECRET"),
117+
),
118+
patch(
119+
"transformerlab.shared.remote_workspace.get_default_aws_profile",
120+
return_value="transformerlab-s3",
121+
),
122+
):
123+
juicefs_gateway._run_juicefs_auth()
124+
125+
cmd = mock_run.call_args[0][0]
126+
assert cmd[:4] == ["juicefs", "auth", "myvol", "--token"]
127+
assert "hosted-token" in cmd
128+
assert "--console-url" in cmd and "http://juicefs-console:8080" in cmd
129+
assert "--access-key" in cmd and "AKID" in cmd
130+
assert "--secret-key" in cmd and "SECRET" in cmd
131+
132+
133+
def test_spawn_gateway_uses_minio_root_creds(juicefs_env, tmp_path, monkeypatch):
134+
monkeypatch.setenv("TFL_HOME_DIR", str(tmp_path))
135+
136+
with patch("transformerlab.services.juicefs_gateway.subprocess.Popen") as mock_popen:
137+
juicefs_gateway._spawn_gateway()
138+
139+
args, kwargs = mock_popen.call_args
140+
assert args[0] == [
141+
"juicefs",
142+
"gateway",
143+
"myvol",
144+
"127.0.0.1:9000",
145+
"--multi-buckets",
146+
"--keep-etag",
147+
"--umask",
148+
"000",
149+
]
150+
assert kwargs["env"]["MINIO_ROOT_USER"] == "gw-access"
151+
assert kwargs["env"]["MINIO_ROOT_PASSWORD"] == "gw-secret-123"
152+
153+
154+
def test_stop_gateway_terminates_process(juicefs_env):
155+
mock_proc = MagicMock()
156+
mock_proc.poll.return_value = None
157+
juicefs_gateway._gateway_process = mock_proc
158+
159+
juicefs_gateway.stop_juicefs_gateway()
160+
161+
mock_proc.terminate.assert_called_once()
162+
assert juicefs_gateway._gateway_process is None
163+
164+
165+
def test_supervise_respawns_after_crash(juicefs_env):
166+
crashed_proc = MagicMock()
167+
crashed_proc.returncode = 1
168+
# wait() returns immediately (crash); after respawn the loop must observe
169+
# the shutdown event and exit.
170+
crashed_proc.wait.return_value = 1
171+
juicefs_gateway._gateway_process = crashed_proc
172+
173+
respawned_proc = MagicMock()
174+
175+
def fake_spawn():
176+
# Stop the loop after the first respawn.
177+
juicefs_gateway._shutdown_event.set()
178+
return respawned_proc
179+
180+
with (
181+
patch.object(juicefs_gateway, "_spawn_gateway", side_effect=fake_spawn) as mock_spawn,
182+
patch.object(juicefs_gateway.time, "sleep"),
183+
):
184+
juicefs_gateway._supervise()
185+
186+
mock_spawn.assert_called_once()
187+
assert juicefs_gateway._gateway_process is respawned_proc
188+
189+
190+
def test_supervise_does_not_respawn_on_shutdown(juicefs_env):
191+
proc = MagicMock()
192+
193+
def wait_then_shutdown():
194+
# Simulate stop_juicefs_gateway(): the shutdown event is set before the
195+
# process exits, so the supervisor must NOT respawn.
196+
juicefs_gateway._shutdown_event.set()
197+
return 0
198+
199+
proc.wait.side_effect = wait_then_shutdown
200+
juicefs_gateway._gateway_process = proc
201+
202+
with patch.object(juicefs_gateway, "_spawn_gateway") as mock_spawn:
203+
juicefs_gateway._supervise()
204+
205+
mock_spawn.assert_not_called()

0 commit comments

Comments
 (0)