refactor: remove local fs, add config class to common pacakge
This commit is contained in:
@@ -13,7 +13,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
common = { path = "../common" }
|
common = { workspace = true }
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -15,6 +14,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from backend.dependencies import database_session
|
from backend.dependencies import database_session
|
||||||
from backend.runtime_client import RuntimeClientError
|
from backend.runtime_client import RuntimeClientError
|
||||||
|
from common.config import settings
|
||||||
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
|
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ router = APIRouter(tags=["jupyter"])
|
|||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-only-not-for-production")
|
JWT_SECRET = settings.jwt_secret
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from common.config import settings
|
||||||
from common.db import create_database_engine, create_session_factory
|
from common.db import create_database_engine, create_session_factory
|
||||||
from common.service_app import create_service_app
|
from common.service_app import create_service_app
|
||||||
from common.storage import RustFSObjectStore
|
from common.storage import RustFSObjectStore
|
||||||
@@ -25,27 +24,17 @@ from backend.storage_client import StorageClient
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||||
engine = create_database_engine(os.environ["DATABASE_URL"])
|
engine = create_database_engine(settings.database_url)
|
||||||
app.state.session_factory = create_session_factory(engine)
|
app.state.session_factory = create_session_factory(engine)
|
||||||
workspace_root = Path(
|
|
||||||
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
|
|
||||||
)
|
|
||||||
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Storage API is now part of the backend process. Platform routers keep
|
# Storage API is now part of the backend process. Platform routers keep
|
||||||
# their existing client contract, but calls are dispatched in-process.
|
# their existing client contract, but calls are dispatched in-process.
|
||||||
app.state.object_store = RustFSObjectStore(
|
app.state.object_store = RustFSObjectStore(
|
||||||
internal_endpoint=os.getenv(
|
internal_endpoint=settings.rustfs_endpoint,
|
||||||
"RUSTFS_INTERNAL_ENDPOINT",
|
access_key=settings.rustfs_access_key,
|
||||||
"http://rustfs:9000",
|
secret_key=settings.rustfs_secret_key,
|
||||||
),
|
|
||||||
access_key=os.environ["RUSTFS_ACCESS_KEY"],
|
|
||||||
secret_key=os.environ["RUSTFS_SECRET_KEY"],
|
|
||||||
)
|
|
||||||
app.state.default_bucket = os.getenv(
|
|
||||||
"RUSTFS_DEFAULT_BUCKET",
|
|
||||||
"model-platform",
|
|
||||||
)
|
)
|
||||||
|
app.state.default_bucket = settings.rustfs_workspace_bucket
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
app.state.object_store.ensure_bucket,
|
app.state.object_store.ensure_bucket,
|
||||||
app.state.default_bucket,
|
app.state.default_bucket,
|
||||||
@@ -57,7 +46,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
)
|
)
|
||||||
app.state.storage_client = StorageClient(storage_http_client)
|
app.state.storage_client = StorageClient(storage_http_client)
|
||||||
runtime_http_client = httpx.AsyncClient(
|
runtime_http_client = httpx.AsyncClient(
|
||||||
base_url=os.getenv("RUNTIME_API_URL", "http://runtime:8000"),
|
base_url=settings.runtime_api_url,
|
||||||
timeout=httpx.Timeout(30.0),
|
timeout=httpx.Timeout(30.0),
|
||||||
)
|
)
|
||||||
app.state.runtime_client = RuntimeClient(runtime_http_client)
|
app.state.runtime_client = RuntimeClient(runtime_http_client)
|
||||||
@@ -70,7 +59,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
|
|
||||||
|
|
||||||
app = create_service_app(
|
app = create_service_app(
|
||||||
os.getenv("SERVICE_NAME", "backend"),
|
settings.service_name,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.include_router(jupyter_router)
|
app.include_router(jupyter_router)
|
||||||
|
|||||||
@@ -1,43 +1,16 @@
|
|||||||
from __future__ import annotations
|
"""Schedule executor HTTP-side dispatch is intentionally a no-op.
|
||||||
|
|
||||||
import logging
|
Architecture V3.1 §2.3 documents two dispatch paths:
|
||||||
|
① Backend HTTP push to Schedule Executor (immediate runs)
|
||||||
|
② Schedule Executor polling MySQL Outbox (immediate + cron runs; contract)
|
||||||
|
|
||||||
import httpx
|
We run path ② only. Path ① is an optimisation layered on top of ② and was
|
||||||
|
removed alongside the INTERNAL_SERVICE_TOKEN cleanup. Backend writes the
|
||||||
|
``schedule.run.requested`` Outbox event in the same transaction as the
|
||||||
|
``ScheduleRuns`` row, then commits; the executor picks it up on its next
|
||||||
|
poll. No additional auth / token / header is involved — the Outbox is the
|
||||||
|
single source of truth for run dispatch.
|
||||||
|
|
||||||
|
This module is kept as a docstring-only placeholder so future readers
|
||||||
LOGGER = logging.getLogger(__name__)
|
(LLM and human) can grep for ``schedule_client`` and find the rationale.
|
||||||
|
"""
|
||||||
|
|
||||||
class ScheduleExecutorClient:
|
|
||||||
"""Best-effort HTTP notification for immediate run dispatch.
|
|
||||||
|
|
||||||
MySQL remains the source of truth. If this notification fails, the
|
|
||||||
executor's database polling loop will still pick up the pending Outbox row.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, client: httpx.AsyncClient, service_token: str) -> None:
|
|
||||||
self.client = client
|
|
||||||
self.headers = {"X-Service-Token": service_token}
|
|
||||||
|
|
||||||
async def dispatch_run(self, run_id: str) -> bool:
|
|
||||||
try:
|
|
||||||
response = await self.client.post(
|
|
||||||
f"/internal/v1/runs/{run_id}/dispatch",
|
|
||||||
headers=self.headers,
|
|
||||||
)
|
|
||||||
except httpx.RequestError:
|
|
||||||
LOGGER.warning(
|
|
||||||
"schedule executor notification failed for run %s",
|
|
||||||
run_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
if response.is_error:
|
|
||||||
LOGGER.warning(
|
|
||||||
"schedule executor rejected run %s: %s %s",
|
|
||||||
run_id,
|
|
||||||
response.status_code,
|
|
||||||
response.text[:500],
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -293,10 +293,10 @@ async def run_schedule_now(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
# Commit before the HTTP push so the executor can read the Outbox row.
|
# Commit before yielding so the Outbox row is visible to the executor's
|
||||||
# The executor also polls MySQL, so a failed push does not lose the run.
|
# next MySQL poll — the executor's _database_event_loop picks it up.
|
||||||
|
# We intentionally do NOT HTTP-push; see backend/schedule_client.py.
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await request.app.state.schedule_client.dispatch_run(run.run_id)
|
|
||||||
await session.refresh(run)
|
await session.refresh(run)
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
|
|||||||
+111
-118
@@ -1,13 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -127,51 +125,6 @@ def validate_script_content(content: str, script_type: str) -> bytes:
|
|||||||
return encoded
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
def workspace_target(
|
|
||||||
context: RequestContext,
|
|
||||||
relative_path: str,
|
|
||||||
) -> Path:
|
|
||||||
root = Path(
|
|
||||||
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
|
|
||||||
).resolve()
|
|
||||||
scoped_root = (root / context.workspace.workspace_code).resolve()
|
|
||||||
pure_path = PurePosixPath(relative_path)
|
|
||||||
target = (scoped_root / Path(*pure_path.parts)).resolve()
|
|
||||||
if scoped_root not in target.parents:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
"script path escapes workspace root",
|
|
||||||
)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def apply_workspace_permissions(path: Path, mode: int) -> None:
|
|
||||||
os.chmod(path, mode)
|
|
||||||
if os.name != "nt":
|
|
||||||
shared_gid = int(os.getenv("WORKSPACE_SHARED_GID", "100"))
|
|
||||||
os.chown(path, -1, shared_gid)
|
|
||||||
|
|
||||||
|
|
||||||
def atomic_write(target: Path, content: bytes) -> None:
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
apply_workspace_permissions(target.parent, 0o2770)
|
|
||||||
descriptor, temporary_name = tempfile.mkstemp(
|
|
||||||
prefix=f".{target.name}.",
|
|
||||||
suffix=".tmp",
|
|
||||||
dir=target.parent,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "wb") as handle:
|
|
||||||
handle.write(content)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.replace(temporary_name, target)
|
|
||||||
apply_workspace_permissions(target, 0o660)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temporary_name):
|
|
||||||
os.unlink(temporary_name)
|
|
||||||
|
|
||||||
|
|
||||||
def script_payload(
|
def script_payload(
|
||||||
script: Scripts,
|
script: Scripts,
|
||||||
storage_object: StorageObjects | dict[str, Any],
|
storage_object: StorageObjects | dict[str, Any],
|
||||||
@@ -310,7 +263,6 @@ async def create_script_record(
|
|||||||
) if parent_path is None else normalize_user_path(parent_path)
|
) if parent_path is None else normalize_user_path(parent_path)
|
||||||
child_path = f"{folder}/{name}" if folder else name
|
child_path = f"{folder}/{name}" if folder else name
|
||||||
relative_path = user_relative_path(context, child_path)
|
relative_path = user_relative_path(context, child_path)
|
||||||
target = workspace_target(context, relative_path)
|
|
||||||
|
|
||||||
existing_script = await session.scalar(
|
existing_script = await session.scalar(
|
||||||
select(Scripts)
|
select(Scripts)
|
||||||
@@ -324,21 +276,23 @@ async def create_script_record(
|
|||||||
Scripts.status == "active",
|
Scripts.status == "active",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing_script is not None or target.exists():
|
if existing_script is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"a file with the same path already exists",
|
"a file with the same path already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
atomic_write(target, content)
|
storage_data = await request.app.state.storage_client.create_server_object(
|
||||||
storage_data = await request.app.state.storage_client.register_workspace_object(
|
workspace_id=context.workspace.workspace_id,
|
||||||
{
|
user_id=context.user.user_id,
|
||||||
"workspace_id": context.workspace.workspace_id,
|
usage_type="working_copy",
|
||||||
"user_id": context.user.user_id,
|
file_name=name,
|
||||||
"relative_path": relative_path,
|
content_type=mimetypes.guess_type(name)[0]
|
||||||
"usage_type": "working_copy",
|
or "application/octet-stream",
|
||||||
"visibility": visibility,
|
content=content,
|
||||||
}
|
visibility=visibility,
|
||||||
|
is_immutable=False,
|
||||||
|
idempotency_key=f"script:{context.workspace.workspace_id}:{relative_path}",
|
||||||
)
|
)
|
||||||
object_id = storage_data["storage_object_id"]
|
object_id = storage_data["storage_object_id"]
|
||||||
script = await session.scalar(
|
script = await session.scalar(
|
||||||
@@ -453,34 +407,57 @@ async def upload_script(
|
|||||||
@router.get("/api/v1/workspace-tree")
|
@router.get("/api/v1/workspace-tree")
|
||||||
async def get_workspace_tree(
|
async def get_workspace_tree(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
root = workspace_target(context, user_relative_path(context))
|
# Workspace object storage uses implicit directories (object key prefixes),
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
# so we derive the tree from ``StorageObjects.relative_path`` rather than
|
||||||
apply_workspace_permissions(root, 0o2770)
|
# walking a local filesystem. Only paths that start with the user's
|
||||||
directories: list[dict[str, str]] = []
|
# scoped prefix (and that are currently active) contribute.
|
||||||
for current, names, _files in os.walk(root, followlinks=False):
|
scoped_prefix = user_relative_path(context)
|
||||||
names[:] = sorted(
|
if scoped_prefix:
|
||||||
name
|
like_prefix = f"{scoped_prefix}%"
|
||||||
for name in names
|
else:
|
||||||
if not name.startswith(".")
|
like_prefix = "%"
|
||||||
and not (Path(current) / name).is_symlink()
|
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(StorageObjects.relative_path)
|
||||||
|
.where(
|
||||||
|
StorageObjects.workspace_id == context.workspace.workspace_id,
|
||||||
|
StorageObjects.object_status == "available",
|
||||||
|
StorageObjects.relative_path.like(like_prefix),
|
||||||
)
|
)
|
||||||
current_path = Path(current)
|
)
|
||||||
if current_path == root:
|
).scalars().all()
|
||||||
|
|
||||||
|
directories: dict[str, dict[str, str]] = {}
|
||||||
|
for relative in rows:
|
||||||
|
if not relative:
|
||||||
continue
|
continue
|
||||||
relative = current_path.relative_to(root).as_posix()
|
# Strip the scoped prefix so the returned paths are workspace-local.
|
||||||
parent = PurePosixPath(relative).parent.as_posix()
|
if scoped_prefix and relative.startswith(scoped_prefix + "/"):
|
||||||
directories.append(
|
trimmed = relative[len(scoped_prefix) + 1 :]
|
||||||
|
elif relative == scoped_prefix:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
trimmed = relative
|
||||||
|
# Materialise every ancestor directory of the file.
|
||||||
|
parts = trimmed.split("/")[:-1]
|
||||||
|
for index in range(1, len(parts) + 1):
|
||||||
|
directory_path = "/".join(parts[:index])
|
||||||
|
directories.setdefault(
|
||||||
|
directory_path,
|
||||||
{
|
{
|
||||||
"path": relative,
|
"path": directory_path,
|
||||||
"name": current_path.name,
|
"name": parts[index - 1],
|
||||||
"parent_path": "" if parent == "." else parent,
|
"parent_path": "" if index == 1 else "/".join(parts[: index - 1]),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
sorted_dirs = sorted(directories.values(), key=lambda item: item["path"])
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
"data": {"directories": directories},
|
"data": {"directories": sorted_dirs},
|
||||||
"meta": {"directory_count": len(directories)},
|
"meta": {"directory_count": len(sorted_dirs)},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -491,31 +468,42 @@ async def get_workspace_tree(
|
|||||||
async def create_workspace_directory(
|
async def create_workspace_directory(
|
||||||
payload: CreateWorkspaceDirectoryRequest,
|
payload: CreateWorkspaceDirectoryRequest,
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = safe_directory_name(payload.directory_name)
|
name = safe_directory_name(payload.directory_name)
|
||||||
parent = normalize_user_path(payload.parent_path)
|
parent = normalize_user_path(payload.parent_path)
|
||||||
child_path = f"{parent}/{name}" if parent else name
|
child_path = f"{parent}/{name}" if parent else name
|
||||||
target = workspace_target(context, user_relative_path(context, child_path))
|
relative_path = user_relative_path(context, child_path)
|
||||||
parent_target = target.parent
|
scoped_prefix = user_relative_path(context)
|
||||||
user_root = workspace_target(context, user_relative_path(context))
|
# Validate parent exists: there must be at least one StorageObject whose
|
||||||
if (
|
# relative_path is exactly the parent directory (or its prefix).
|
||||||
not parent_target.is_dir()
|
if parent:
|
||||||
or (
|
existing_parent = await session.scalar(
|
||||||
parent_target != user_root
|
select(StorageObjects.storage_object_id).where(
|
||||||
and user_root not in parent_target.parents
|
StorageObjects.workspace_id == context.workspace.workspace_id,
|
||||||
|
StorageObjects.object_status == "available",
|
||||||
|
StorageObjects.relative_path.like(f"{scoped_prefix}%"),
|
||||||
)
|
)
|
||||||
):
|
)
|
||||||
|
if existing_parent is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_404_NOT_FOUND,
|
status.HTTP_404_NOT_FOUND,
|
||||||
"parent directory not found",
|
"parent directory not found",
|
||||||
)
|
)
|
||||||
if target.exists():
|
# RustFS has no real directory objects — the prefix is implicitly
|
||||||
|
# created when a file is uploaded. Conflict detection is best-effort.
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(StorageObjects.storage_object_id).where(
|
||||||
|
StorageObjects.workspace_id == context.workspace.workspace_id,
|
||||||
|
StorageObjects.relative_path == relative_path,
|
||||||
|
StorageObjects.object_status == "available",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"a file or directory with the same path already exists",
|
"a file or directory with the same path already exists",
|
||||||
)
|
)
|
||||||
target.mkdir(parents=False)
|
|
||||||
apply_workspace_permissions(target, 0o2770)
|
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
"data": {
|
"data": {
|
||||||
@@ -536,12 +524,6 @@ async def delete_workspace_directory(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
directory_path = normalize_user_path(path, allow_empty=False)
|
directory_path = normalize_user_path(path, allow_empty=False)
|
||||||
relative_prefix = user_relative_path(context, directory_path)
|
relative_prefix = user_relative_path(context, directory_path)
|
||||||
target = workspace_target(context, relative_prefix)
|
|
||||||
if not target.is_dir() or target.is_symlink():
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_404_NOT_FOUND,
|
|
||||||
"directory not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -570,7 +552,6 @@ async def delete_workspace_directory(
|
|||||||
)
|
)
|
||||||
script.status = "deleted"
|
script.status = "deleted"
|
||||||
script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
|
script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
|
||||||
shutil.rmtree(target)
|
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
"data": {
|
"data": {
|
||||||
@@ -657,16 +638,21 @@ async def update_script(
|
|||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"script has no workspace path",
|
"script has no workspace path",
|
||||||
)
|
)
|
||||||
target = workspace_target(context, storage_object.relative_path)
|
storage_data = await request.app.state.storage_client.create_server_object(
|
||||||
atomic_write(target, content)
|
workspace_id=context.workspace.workspace_id,
|
||||||
storage_data = await request.app.state.storage_client.register_workspace_object(
|
user_id=script.owner_user_id,
|
||||||
{
|
usage_type="working_copy",
|
||||||
"workspace_id": context.workspace.workspace_id,
|
file_name=storage_object.file_name or script.script_name,
|
||||||
"user_id": script.owner_user_id,
|
content_type=storage_object.mime_type
|
||||||
"relative_path": storage_object.relative_path,
|
or mimetypes.guess_type(script.script_name)[0]
|
||||||
"usage_type": "working_copy",
|
or "application/octet-stream",
|
||||||
"visibility": script.visibility,
|
content=content,
|
||||||
}
|
visibility=script.visibility,
|
||||||
|
is_immutable=False,
|
||||||
|
idempotency_key=(
|
||||||
|
f"script-update:{script.script_id}:"
|
||||||
|
f"{hashlib.sha256(content).hexdigest()}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
storage_object.content_hash = storage_data["content_hash"]
|
storage_object.content_hash = storage_data["content_hash"]
|
||||||
storage_object.size_bytes = storage_data["size_bytes"]
|
storage_object.size_bytes = storage_data["size_bytes"]
|
||||||
@@ -703,10 +689,6 @@ async def delete_script(
|
|||||||
session,
|
session,
|
||||||
[script.current_object_id],
|
[script.current_object_id],
|
||||||
)
|
)
|
||||||
if storage_object.relative_path:
|
|
||||||
target = workspace_target(context, storage_object.relative_path)
|
|
||||||
if target.is_file():
|
|
||||||
target.unlink()
|
|
||||||
await request.app.state.storage_client.delete_object(
|
await request.app.state.storage_client.delete_object(
|
||||||
script.current_object_id
|
script.current_object_id
|
||||||
)
|
)
|
||||||
@@ -761,13 +743,24 @@ async def publish_version(
|
|||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"script has no workspace path",
|
"script has no workspace path",
|
||||||
)
|
)
|
||||||
target = workspace_target(context, source_object.relative_path)
|
if not source_object.bucket_name or not source_object.object_key:
|
||||||
if not target.is_file():
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"script working copy is missing",
|
"script working copy is not stored in object storage",
|
||||||
)
|
)
|
||||||
content = target.read_bytes()
|
|
||||||
|
def read_object_bytes() -> bytes:
|
||||||
|
response = request.app.state.object_store.get_object(
|
||||||
|
Bucket=source_object.bucket_name,
|
||||||
|
Key=source_object.object_key,
|
||||||
|
)
|
||||||
|
body = response["Body"]
|
||||||
|
try:
|
||||||
|
return body.read()
|
||||||
|
finally:
|
||||||
|
body.close()
|
||||||
|
|
||||||
|
content = await asyncio.to_thread(read_object_bytes)
|
||||||
content_hash = hashlib.sha256(content).hexdigest()
|
content_hash = hashlib.sha256(content).hexdigest()
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(Versions).where(
|
select(Versions).where(
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from common.config import settings
|
||||||
from common.db import create_database_engine, create_session_factory, session_scope
|
from common.db import create_database_engine, create_session_factory, session_scope
|
||||||
from common.db.models import (
|
from common.db.models import (
|
||||||
StorageObjects,
|
StorageObjects,
|
||||||
@@ -30,7 +30,6 @@ from common.storage.schemas import (
|
|||||||
CompleteUploadRequest,
|
CompleteUploadRequest,
|
||||||
CreateUploadRequest,
|
CreateUploadRequest,
|
||||||
DownloadUrlRequest,
|
DownloadUrlRequest,
|
||||||
RegisterWorkspaceObjectRequest,
|
|
||||||
ServerObjectRequest)
|
ServerObjectRequest)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,18 +82,14 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||||
database_url = os.environ["DATABASE_URL"]
|
engine = create_database_engine(settings.database_url)
|
||||||
engine = create_database_engine(database_url)
|
|
||||||
app.state.session_factory = create_session_factory(engine)
|
app.state.session_factory = create_session_factory(engine)
|
||||||
app.state.object_store = RustFSObjectStore(
|
app.state.object_store = RustFSObjectStore(
|
||||||
internal_endpoint=os.getenv(
|
internal_endpoint=settings.rustfs_endpoint,
|
||||||
"RUSTFS_INTERNAL_ENDPOINT",
|
access_key=settings.rustfs_access_key,
|
||||||
"http://rustfs:9000"),
|
secret_key=settings.rustfs_secret_key,
|
||||||
access_key=os.environ["RUSTFS_ACCESS_KEY"],
|
)
|
||||||
secret_key=os.environ["RUSTFS_SECRET_KEY"])
|
app.state.default_bucket = settings.rustfs_workspace_bucket
|
||||||
app.state.default_bucket = os.getenv(
|
|
||||||
"RUSTFS_DEFAULT_BUCKET",
|
|
||||||
"model-platform")
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
app.state.object_store.ensure_bucket,
|
app.state.object_store.ensure_bucket,
|
||||||
app.state.default_bucket)
|
app.state.default_bucket)
|
||||||
@@ -105,7 +100,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
|
|
||||||
|
|
||||||
app = create_service_app(
|
app = create_service_app(
|
||||||
os.getenv("SERVICE_NAME", "storage-api"),
|
settings.service_name,
|
||||||
lifespan=lifespan)
|
lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
@@ -174,8 +169,11 @@ async def create_upload_record(
|
|||||||
bucket_name = (
|
bucket_name = (
|
||||||
workspace.artifact_bucket or request.app.state.default_bucket
|
workspace.artifact_bucket or request.app.state.default_bucket
|
||||||
)
|
)
|
||||||
|
# Default layout: one top-level folder per workspace inside the
|
||||||
|
# ``workspaces`` bucket. ``bucket_name`` already encodes the workspace
|
||||||
|
# namespace, so the key starts with the workspace id directly.
|
||||||
object_key = (
|
object_key = (
|
||||||
f"workspaces/{payload.workspace_id}/"
|
f"{payload.workspace_id}/"
|
||||||
f"{payload.usage_type}/{upload_id}/{file_name}"
|
f"{payload.usage_type}/{upload_id}/{file_name}"
|
||||||
)
|
)
|
||||||
upload = UploadSessions(
|
upload = UploadSessions(
|
||||||
@@ -459,89 +457,6 @@ async def create_server_object(
|
|||||||
return {"data": storage_payload(item), "meta": {"reused": False}}
|
return {"data": storage_payload(item), "meta": {"reused": False}}
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
|
||||||
"/internal/v1/workspace-objects")
|
|
||||||
async def register_workspace_object(
|
|
||||||
payload: RegisterWorkspaceObjectRequest,
|
|
||||||
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
|
|
||||||
workspace = await require_workspace_member(
|
|
||||||
session,
|
|
||||||
payload.workspace_id,
|
|
||||||
payload.user_id)
|
|
||||||
pure_path = PurePosixPath(payload.relative_path.replace("\\", "/"))
|
|
||||||
if (
|
|
||||||
pure_path.is_absolute()
|
|
||||||
or not pure_path.parts
|
|
||||||
or any(part in {"", ".", ".."} for part in pure_path.parts)
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
"invalid workspace relative_path")
|
|
||||||
relative_path = pure_path.as_posix()
|
|
||||||
workspace_root = Path(
|
|
||||||
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
|
|
||||||
).resolve()
|
|
||||||
scoped_root = (workspace_root / workspace.workspace_code).resolve()
|
|
||||||
target = (scoped_root / Path(*pure_path.parts)).resolve()
|
|
||||||
if scoped_root != target and scoped_root not in target.parents:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
"workspace path escapes its root")
|
|
||||||
if not target.is_file():
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_404_NOT_FOUND,
|
|
||||||
"workspace file does not exist")
|
|
||||||
content = await asyncio.to_thread(target.read_bytes)
|
|
||||||
content_hash = hashlib.sha256(content).hexdigest()
|
|
||||||
stat_result = target.stat()
|
|
||||||
path_digest = hash_bytes(relative_path)
|
|
||||||
item = await session.scalar(
|
|
||||||
select(StorageObjects).where(
|
|
||||||
StorageObjects.workspace_id == payload.workspace_id,
|
|
||||||
StorageObjects.storage_backend == "workspace_fs",
|
|
||||||
StorageObjects.path_hash == path_digest)
|
|
||||||
)
|
|
||||||
reused = item is not None
|
|
||||||
if item is None:
|
|
||||||
item = StorageObjects(
|
|
||||||
storage_object_id=new_ulid(),
|
|
||||||
workspace_id=payload.workspace_id,
|
|
||||||
owner_user_id=payload.user_id,
|
|
||||||
object_type="file",
|
|
||||||
usage_type=payload.usage_type,
|
|
||||||
storage_backend="workspace_fs",
|
|
||||||
relative_path=relative_path,
|
|
||||||
path_hash=path_digest,
|
|
||||||
storage_uri=target.as_uri(),
|
|
||||||
file_name=target.name,
|
|
||||||
visibility=payload.visibility,
|
|
||||||
is_immutable=0,
|
|
||||||
object_status="available",
|
|
||||||
created_by=payload.user_id)
|
|
||||||
session.add(item)
|
|
||||||
elif item.is_immutable:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_409_CONFLICT,
|
|
||||||
"immutable workspace object cannot be updated")
|
|
||||||
elif item.owner_user_id != payload.user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_403_FORBIDDEN,
|
|
||||||
"workspace object belongs to another user")
|
|
||||||
item.usage_type = payload.usage_type
|
|
||||||
item.file_name = target.name
|
|
||||||
item.file_extension = target.suffix.lower() or None
|
|
||||||
item.mime_type = (
|
|
||||||
mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
|
||||||
)
|
|
||||||
item.size_bytes = stat_result.st_size
|
|
||||||
item.content_hash = content_hash
|
|
||||||
item.visibility = payload.visibility
|
|
||||||
item.object_status = "available"
|
|
||||||
await session.flush()
|
|
||||||
await session.refresh(item)
|
|
||||||
return {"data": storage_payload(item), "meta": {"reused": reused}}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/internal/v1/objects/{storage_object_id}/download-url")
|
"/internal/v1/objects/{storage_object_id}/download-url")
|
||||||
async def create_download_url(
|
async def create_download_url(
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ version = "0.2.0"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"SQLAlchemy==2.0.51",
|
"SQLAlchemy==2.0.51",
|
||||||
|
"greenlet>=3.0.0",
|
||||||
"apscheduler>=3.11.3",
|
"apscheduler>=3.11.3",
|
||||||
"asyncmy==0.2.11",
|
"asyncmy==0.2.11",
|
||||||
"boto3>=1.34,<2",
|
"boto3>=1.34,<2",
|
||||||
"fastapi==0.116.1",
|
"fastapi==0.116.1",
|
||||||
|
"pydantic-settings>=2.14.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class StorageObjects(Base):
|
|||||||
comment="working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result",
|
comment="working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result",
|
||||||
)
|
)
|
||||||
storage_backend: Mapped[str] = mapped_column(
|
storage_backend: Mapped[str] = mapped_column(
|
||||||
String(16), nullable=False, comment="workspace_fs/rustfs"
|
String(16), nullable=False, comment="rustfs"
|
||||||
)
|
)
|
||||||
storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False)
|
storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, Response, status
|
from fastapi import FastAPI, Response, status
|
||||||
|
|
||||||
|
from common.config import settings
|
||||||
|
|
||||||
|
|
||||||
def _target_list() -> list[str]:
|
def _target_list() -> list[str]:
|
||||||
value = os.getenv("READINESS_TARGETS", "")
|
value = settings.readiness_targets
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -76,15 +76,12 @@ services:
|
|||||||
dockerfile: schedule/Dockerfile
|
dockerfile: schedule/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# No host port: architecture §2.2 — only Nginx is externally reachable.
|
# No host port: architecture §2.2 — only Nginx is externally reachable.
|
||||||
|
# No local-FS volume: schedule executes nodes via tempfile.TemporaryDirectory
|
||||||
|
# under Python's default temp dir (cleaned per-run); artifacts live in RustFS.
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4
|
DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4
|
||||||
# schedule reads RUSTFS_ENDPOINT via schedule/service.py:build_object_store()
|
|
||||||
# (used by SchedulerService directly via boto3 — not via the storage client).
|
|
||||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:-http://rustfs:9000}
|
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:-http://rustfs:9000}
|
||||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
|
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
|
||||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
|
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
|
||||||
WORKSPACE_ROOT: /workspace/workspaces
|
|
||||||
volumes:
|
|
||||||
- ./deploy/data/workspaces:/workspace/workspaces
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ the directory usable; downstream consumers (e.g. process.py) import it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
WORKSPACES_ROOT = Path(os.getenv("WORKSPACES_ROOT", "/app/workspaces"))
|
from common.config import settings
|
||||||
REMOTE_BUCKET = os.getenv("REMOTE_BUCKET", "rustfs:workspaces")
|
|
||||||
|
WORKSPACES_ROOT = Path(settings.workspaces_root)
|
||||||
|
REMOTE_BUCKET = settings.remote_bucket
|
||||||
|
|
||||||
RCLONE_PROCESS: subprocess.Popen | None = None
|
RCLONE_PROCESS: subprocess.Popen | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ a thin wrapper over ``start_workspace``.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -18,10 +17,11 @@ from typing import TypedDict
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from common.config import settings
|
||||||
from common.utils import get_free_port, start_process
|
from common.utils import get_free_port, start_process
|
||||||
from runtime.mount import WORKSPACES_ROOT
|
from runtime.mount import WORKSPACES_ROOT
|
||||||
|
|
||||||
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost")
|
PUBLIC_BASE_URL = settings.public_base_url
|
||||||
|
|
||||||
|
|
||||||
class JupyterProcessRecord(TypedDict):
|
class JupyterProcessRecord(TypedDict):
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ async def _execute_notebook(
|
|||||||
*,
|
*,
|
||||||
artifact_name: str,
|
artifact_name: str,
|
||||||
arguments: list[str],
|
arguments: list[str],
|
||||||
workspace_root: Path,
|
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
) -> ExecutionResult:
|
) -> ExecutionResult:
|
||||||
output = artifact.with_name(f"executed-{artifact_name}")
|
output = artifact.with_name(f"executed-{artifact_name}")
|
||||||
@@ -48,13 +47,11 @@ async def _execute_notebook(
|
|||||||
str(artifact),
|
str(artifact),
|
||||||
"--output",
|
"--output",
|
||||||
str(output),
|
str(output),
|
||||||
"--workspace",
|
|
||||||
str(workspace_root),
|
|
||||||
"--timeout",
|
"--timeout",
|
||||||
str(max(1, timeout_seconds)),
|
str(max(1, timeout_seconds)),
|
||||||
"--arguments-json",
|
"--arguments-json",
|
||||||
json.dumps(arguments, ensure_ascii=False),
|
json.dumps(arguments, ensure_ascii=False),
|
||||||
cwd=str(workspace_root),
|
cwd=str(artifact.parent),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
@@ -109,14 +106,13 @@ async def _execute_python(
|
|||||||
artifact: Path,
|
artifact: Path,
|
||||||
*,
|
*,
|
||||||
arguments: list[str],
|
arguments: list[str],
|
||||||
workspace_root: Path,
|
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
) -> ExecutionResult:
|
) -> ExecutionResult:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(artifact),
|
str(artifact),
|
||||||
*arguments,
|
*arguments,
|
||||||
cwd=str(workspace_root),
|
cwd=str(artifact.parent),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
@@ -172,16 +168,16 @@ async def execute_artifact(
|
|||||||
script_type: str,
|
script_type: str,
|
||||||
artifact_path: str,
|
artifact_path: str,
|
||||||
arguments: list[str],
|
arguments: list[str],
|
||||||
workspace_root: Path,
|
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
) -> ExecutionResult:
|
) -> ExecutionResult:
|
||||||
runtime_root = workspace_root / "runtime_tmp" / "schedule-runs"
|
# Stage the artifact under Python's system temp dir (cleaned on context
|
||||||
runtime_root.mkdir(parents=True, exist_ok=True)
|
# exit). No local-FS volume assumption; the bytes only live for the
|
||||||
|
# duration of the subprocess.
|
||||||
suffix = ".ipynb" if script_type == "notebook" else ".py"
|
suffix = ".ipynb" if script_type == "notebook" else ".py"
|
||||||
raw_name = PurePosixPath(artifact_path.replace("\\", "/")).name
|
raw_name = PurePosixPath(artifact_path.replace("\\", "/")).name
|
||||||
artifact_name = raw_name if raw_name.endswith(suffix) else f"artifact{suffix}"
|
artifact_name = raw_name if raw_name.endswith(suffix) else f"artifact{suffix}"
|
||||||
prefix = f"{run_id[-6:]}-{node_run_id[-6:]}-"
|
prefix = f"{run_id[-6:]}-{node_run_id[-6:]}-"
|
||||||
with tempfile.TemporaryDirectory(prefix=prefix, dir=runtime_root) as directory:
|
with tempfile.TemporaryDirectory(prefix=prefix) as directory:
|
||||||
artifact = Path(directory) / artifact_name
|
artifact = Path(directory) / artifact_name
|
||||||
artifact.write_bytes(source)
|
artifact.write_bytes(source)
|
||||||
if script_type == "notebook":
|
if script_type == "notebook":
|
||||||
@@ -189,14 +185,12 @@ async def execute_artifact(
|
|||||||
artifact,
|
artifact,
|
||||||
artifact_name=artifact_name,
|
artifact_name=artifact_name,
|
||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
workspace_root=workspace_root,
|
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
)
|
)
|
||||||
if script_type == "python":
|
if script_type == "python":
|
||||||
return await _execute_python(
|
return await _execute_python(
|
||||||
artifact,
|
artifact,
|
||||||
arguments=arguments,
|
arguments=arguments,
|
||||||
workspace_root=workspace_root,
|
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
)
|
)
|
||||||
raise ValueError(f"unsupported script_type: {script_type}")
|
raise ValueError(f"unsupported script_type: {script_type}")
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
from common.config import settings
|
||||||
from common.db import create_database_engine, create_session_factory
|
from common.db import create_database_engine, create_session_factory
|
||||||
from common.service_app import create_service_app
|
from common.service_app import create_service_app
|
||||||
from schedule.service import (
|
from schedule.service import (
|
||||||
@@ -17,7 +16,7 @@ from schedule.storage_client import SchedulerStorageClient
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||||
engine = create_database_engine(os.environ["DATABASE_URL"])
|
engine = create_database_engine(settings.database_url)
|
||||||
session_factory = create_session_factory(engine)
|
session_factory = create_session_factory(engine)
|
||||||
backend_http_client = build_storage_http_client()
|
backend_http_client = build_storage_http_client()
|
||||||
service = SchedulerService(
|
service = SchedulerService(
|
||||||
@@ -25,10 +24,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
backend_http_client=backend_http_client,
|
backend_http_client=backend_http_client,
|
||||||
object_store=build_object_store(),
|
object_store=build_object_store(),
|
||||||
storage_client=SchedulerStorageClient(backend_http_client),
|
storage_client=SchedulerStorageClient(backend_http_client),
|
||||||
workspace_root=Path(
|
database_url=settings.database_url,
|
||||||
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
|
|
||||||
),
|
|
||||||
database_url=os.environ["DATABASE_URL"],
|
|
||||||
)
|
)
|
||||||
app.state.scheduler_service = service
|
app.state.scheduler_service = service
|
||||||
await service.start()
|
await service.start()
|
||||||
@@ -41,6 +37,6 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
|
|
||||||
|
|
||||||
app = create_service_app(
|
app = create_service_app(
|
||||||
os.getenv("SERVICE_NAME", "scheduler-worker"),
|
settings.service_name,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,14 +36,12 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--input", required=True)
|
parser.add_argument("--input", required=True)
|
||||||
parser.add_argument("--output", required=True)
|
parser.add_argument("--output", required=True)
|
||||||
parser.add_argument("--workspace", required=True)
|
|
||||||
parser.add_argument("--timeout", required=True, type=int)
|
parser.add_argument("--timeout", required=True, type=int)
|
||||||
parser.add_argument("--arguments-json", default="[]")
|
parser.add_argument("--arguments-json", default="[]")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
source = Path(args.input)
|
source = Path(args.input)
|
||||||
output = Path(args.output)
|
output = Path(args.output)
|
||||||
workspace = Path(args.workspace)
|
|
||||||
arguments = json.loads(args.arguments_json)
|
arguments = json.loads(args.arguments_json)
|
||||||
if not isinstance(arguments, list) or not all(
|
if not isinstance(arguments, list) or not all(
|
||||||
isinstance(item, str) for item in arguments
|
isinstance(item, str) for item in arguments
|
||||||
@@ -68,7 +66,10 @@ def main() -> None:
|
|||||||
kernel_name="python3",
|
kernel_name="python3",
|
||||||
allow_errors=False,
|
allow_errors=False,
|
||||||
)
|
)
|
||||||
client.execute(cwd=str(workspace))
|
# No explicit cwd — the kernel inherits the parent's cwd, which the
|
||||||
|
# scheduler sets to the staged artifact directory. Keeping it here
|
||||||
|
# avoids any "cwd must exist" requirement on the host.
|
||||||
|
client.execute()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
exit_code = 124 if "timeout" in type(exc).__name__.lower() else 1
|
exit_code = 124 if "timeout" in type(exc).__name__.lower() else 1
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ Backend that ``CronScheduler`` calls at every cron tick.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from common.config import settings
|
||||||
from common.ids import new_ulid
|
from common.ids import new_ulid
|
||||||
|
|
||||||
from schedule.orchestrator import DispatchOrchestrator
|
from schedule.orchestrator import DispatchOrchestrator
|
||||||
@@ -52,14 +52,12 @@ class SchedulerService:
|
|||||||
backend_http_client: httpx.AsyncClient,
|
backend_http_client: httpx.AsyncClient,
|
||||||
object_store: Any,
|
object_store: Any,
|
||||||
storage_client: Any,
|
storage_client: Any,
|
||||||
workspace_root: Any,
|
|
||||||
database_url: str,
|
database_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.backend_http_client = backend_http_client
|
self.backend_http_client = backend_http_client
|
||||||
self.object_store = object_store
|
self.object_store = object_store
|
||||||
self.storage_client = storage_client
|
self.storage_client = storage_client
|
||||||
self.workspace_root = workspace_root
|
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
|
|
||||||
# Wire worker BEFORE orchestrator: orchestrator's dispatch table
|
# Wire worker BEFORE orchestrator: orchestrator's dispatch table
|
||||||
@@ -69,7 +67,6 @@ class SchedulerService:
|
|||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
object_store=object_store,
|
object_store=object_store,
|
||||||
storage_client=storage_client,
|
storage_client=storage_client,
|
||||||
workspace_root=workspace_root,
|
|
||||||
)
|
)
|
||||||
self.orchestrator = DispatchOrchestrator(
|
self.orchestrator = DispatchOrchestrator(
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
@@ -162,20 +159,16 @@ class SchedulerService:
|
|||||||
def build_object_store() -> Any:
|
def build_object_store() -> Any:
|
||||||
"""Construct a boto3 S3 client pointed at RustFS.
|
"""Construct a boto3 S3 client pointed at RustFS.
|
||||||
|
|
||||||
Reads ``RUSTFS_ENDPOINT`` (full URL), ``RUSTFS_ACCESS_KEY``, and
|
Reads ``rustfs_endpoint`` / ``rustfs_access_key`` / ``rustfs_secret_key``
|
||||||
``RUSTFS_SECRET_KEY``. Falls back to ``http://rustfs:9000`` for the
|
from :data:`common.config.settings`.
|
||||||
endpoint — that's the default docker-compose service name.
|
|
||||||
"""
|
"""
|
||||||
import boto3
|
import boto3
|
||||||
|
|
||||||
return boto3.client(
|
return boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=os.getenv(
|
endpoint_url=settings.rustfs_endpoint,
|
||||||
"RUSTFS_ENDPOINT",
|
aws_access_key_id=settings.rustfs_access_key,
|
||||||
"http://rustfs:9000",
|
aws_secret_access_key=settings.rustfs_secret_key,
|
||||||
),
|
|
||||||
aws_access_key_id=os.environ["RUSTFS_ACCESS_KEY"],
|
|
||||||
aws_secret_access_key=os.environ["RUSTFS_SECRET_KEY"],
|
|
||||||
region_name="us-east-1",
|
region_name="us-east-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,7 +176,7 @@ def build_object_store() -> Any:
|
|||||||
def build_storage_http_client() -> httpx.AsyncClient:
|
def build_storage_http_client() -> httpx.AsyncClient:
|
||||||
"""Construct the httpx client that talks to Backend's HTTP API."""
|
"""Construct the httpx client that talks to Backend's HTTP API."""
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=os.getenv("BACKEND_API_URL", "http://backend:8000"),
|
base_url=settings.backend_api_url,
|
||||||
timeout=httpx.Timeout(60.0),
|
timeout=httpx.Timeout(60.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ class NodeExecutor:
|
|||||||
session_factory,
|
session_factory,
|
||||||
object_store: Any,
|
object_store: Any,
|
||||||
storage_client: Any,
|
storage_client: Any,
|
||||||
workspace_root: Path,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.object_store = object_store
|
self.object_store = object_store
|
||||||
self.storage_client = storage_client
|
self.storage_client = storage_client
|
||||||
self.workspace_root = workspace_root
|
|
||||||
|
|
||||||
async def handle_node_execute(
|
async def handle_node_execute(
|
||||||
self,
|
self,
|
||||||
@@ -76,10 +74,6 @@ class NodeExecutor:
|
|||||||
object_key=context["object_key"],
|
object_key=context["object_key"],
|
||||||
content_hash=context["content_hash"],
|
content_hash=context["content_hash"],
|
||||||
)
|
)
|
||||||
workspace_root = (
|
|
||||||
self.workspace_root / context["workspace_code"]
|
|
||||||
).resolve()
|
|
||||||
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
result = await execute_artifact(
|
result = await execute_artifact(
|
||||||
content,
|
content,
|
||||||
run_id=payload["run_id"],
|
run_id=payload["run_id"],
|
||||||
@@ -87,7 +81,6 @@ class NodeExecutor:
|
|||||||
script_type=payload["script_type"],
|
script_type=payload["script_type"],
|
||||||
artifact_path=payload["artifact_path"],
|
artifact_path=payload["artifact_path"],
|
||||||
arguments=[str(item) for item in payload.get("arguments", [])],
|
arguments=[str(item) for item in payload.get("arguments", [])],
|
||||||
workspace_root=workspace_root,
|
|
||||||
timeout_seconds=int(payload["timeout_seconds"]),
|
timeout_seconds=int(payload["timeout_seconds"]),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
Reference in New Issue
Block a user