update: create file

This commit is contained in:
tao.chen
2026-08-04 12:40:38 +08:00
parent a27cda5a0c
commit 4919fe0909
6 changed files with 226 additions and 89 deletions
+6 -1
View File
@@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \ PYTHONPATH=/app \
PATH="/app/.venv/bin:${PATH}" PATH="/app/.venv/bin:${PATH}" \
TZ=Asia/Shanghai
WORKDIR /app WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
@@ -25,6 +26,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \ g++ \
python3-dev \ python3-dev \
build-essential \ build-essential \
tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& dpkg-reconfigure --frontend noninteractive tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY common ./common COPY common ./common
+96 -31
View File
@@ -105,30 +105,41 @@ class RuntimeClient:
) )
return ws return ws
async def _jupyter_contents_post( async def _jupyter_request(
self, self,
workspace_id: str, workspace_id: str,
ws: dict[str, Any], ws: dict[str, Any],
body: dict[str, Any], method: str,
contents_path: str,
body: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""POST to the workspace Jupyter's ``/api/contents/`` directly. """Send a request to the workspace Jupyter's contents API.
Builds the URL from the per-workspace ``base_url`` + ``port`` ``contents_path`` is the suffix after ``/api/contents/`` —
and authenticates with the runtime-issued token. We reuse the pass ``""`` for the root, ``"foo.ipynb"`` for a file, or
``"foo.ipynb/checkpoints"`` for the checkpoint subresource.
The URL is built from the per-workspace ``base_url`` + ``port``
and authenticated with the runtime-issued token. We reuse the
existing ``httpx.AsyncClient`` (its ``base_url`` is overridden existing ``httpx.AsyncClient`` (its ``base_url`` is overridden
by the absolute URL we hand it). by the absolute URL we hand it).
""" """
suffix = contents_path.lstrip("/")
url = ( url = (
f"{ws['base_url']}:{ws['port']}" f"{ws['base_url']}:{ws['port']}"
f"/jupyter/{workspace_id}/api/contents/" f"/jupyter/{workspace_id}/api/contents/{suffix}"
) )
logger.debug(url) logger.debug(f"{method} {url}")
logger.debug(body) logger.debug(body)
headers = {"Authorization": f"token {ws['token']}"} headers = {"Authorization": f"token {ws['token']}"}
try: try:
response = await self.client.post( if body is None:
url, json=body, headers=headers response = await self.client.request(
) method, url, headers=headers
)
else:
response = await self.client.request(
method, url, json=body, headers=headers
)
except httpx.RequestError as exc: except httpx.RequestError as exc:
raise _RUNTIME_TRANSPORT_ERROR from exc raise _RUNTIME_TRANSPORT_ERROR from exc
if response.is_error: if response.is_error:
@@ -137,6 +148,8 @@ class RuntimeClient:
except ValueError: except ValueError:
detail = response.text detail = response.text
raise RuntimeClientError(response.status_code, detail) raise RuntimeClientError(response.status_code, detail)
if response.status_code == 204 or not response.content:
return {}
return response.json() return response.json()
async def create_notebook( async def create_notebook(
@@ -145,24 +158,29 @@ class RuntimeClient:
*, *,
name: str, name: str,
cells: list[dict[str, Any]] | None = None, cells: list[dict[str, Any]] | None = None,
checkpoint: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a notebook in the workspace root. """Create a notebook at the given path in the workspace.
Auto-starts the workspace's Jupyter if it is not yet running, Uses Jupyter's ``PUT /api/contents/{name}`` (the
then posts to the Jupyter contents API "save / create at path" verb — ``POST /api/contents/`` only
(``/jupyter/{ws_id}/api/contents/``) directly using the creates an auto-incremented ``Untitled.ipynb``). The body
runtime-issued token. Returns the Jupyter contents descriptor carries ``type``, ``format`` and the full notebook ``content``.
(``path``, ``type``, ``created``, ``writable``...).
``name`` must be a safe basename (no path separators); the Auto-starts the workspace's Jupyter if it is not yet running.
Jupyter side will reject anything that escapes the workspace When ``checkpoint`` is true (default), follows up with a
root. ``POST /api/contents/{name}/checkpoints`` so the file is
visible in the live notebook tree without an extra refresh —
checkpoint failures are logged but do not fail the create,
because the underlying ``PUT`` already persisted the bytes.
``name`` must be a safe basename or relative path; the Jupyter
side will reject anything that escapes the workspace root.
""" """
ws = await self._ensure_workspace(workspace_id) ws = await self._ensure_workspace(workspace_id)
logger.debug(ws)
body = { body = {
"type": "notebook", "type": "notebook",
"path": name, "format": "json",
"content": { "content": {
"cells": cells if cells is not None else [], "cells": cells if cells is not None else [],
"metadata": {}, "metadata": {},
@@ -170,8 +188,20 @@ class RuntimeClient:
"nbformat_minor": 5, "nbformat_minor": 5,
}, },
} }
result = await self._jupyter_request(
return await self._jupyter_contents_post(workspace_id, ws, body) workspace_id, ws, "PUT", name, body=body
)
if checkpoint:
try:
await self._jupyter_request(
workspace_id, ws, "POST", f"{name}/checkpoints"
)
except RuntimeClientError as exc:
logger.warning(
f"checkpoint after create_notebook({name}) failed: "
f"{exc.status_code} {exc.detail}"
)
return result
async def upload_file( async def upload_file(
self, self,
@@ -180,13 +210,20 @@ class RuntimeClient:
name: str, name: str,
content: str, content: str,
content_type: str = "text/plain", content_type: str = "text/plain",
checkpoint: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Upload a text file to the workspace root. """Upload a text file to the workspace.
``content_type`` selects the Jupyter ``format`` field: anything Uses ``PUT /api/contents/{name}`` with ``type="file"`` and
under ``text/`` or ``application/json`` is sent as ``text``. ``format="text"``. ``content_type`` is accepted for API
Binary uploads are not yet supported and raise ``ValueError`` symmetry with :meth:`create_notebook`; only ``text/*`` and
— the Jupyter contents API base64 pathway is not wired up here. ``application/json`` are supported here — binary uploads
require the base64 pathway which is not yet wired up and
raise :class:`ValueError`.
When ``checkpoint`` is true (default), the same follow-up
checkpoint call as :meth:`create_notebook` is made; failures
are swallowed.
""" """
if not ( if not (
content_type.startswith("text/") content_type.startswith("text/")
@@ -199,11 +236,39 @@ class RuntimeClient:
ws = await self._ensure_workspace(workspace_id) ws = await self._ensure_workspace(workspace_id)
body = { body = {
"type": "file", "type": "file",
"path": name,
"content": content,
"format": "text", "format": "text",
"content": content,
} }
return await self._jupyter_contents_post(workspace_id, ws, body) result = await self._jupyter_request(
workspace_id, ws, "PUT", name, body=body
)
if checkpoint:
try:
await self._jupyter_request(
workspace_id, ws, "POST", f"{name}/checkpoints"
)
except RuntimeClientError as exc:
logger.warning(
f"checkpoint after upload_file({name}) failed: "
f"{exc.status_code} {exc.detail}"
)
return result
async def delete_file(
self,
workspace_id: str,
*,
name: str,
) -> None:
"""Delete a file from the workspace Jupyter.
Uses ``DELETE /api/contents/{name}`` on the workspace Jupyter.
Jupyter returns ``204 No Content`` on success; any 4xx/5xx is
surfaced as :class:`RuntimeClientError`. Non-recursive — Jupyter
rejects directory deletes unless the directory is empty.
"""
ws = await self._ensure_workspace(workspace_id)
await self._jupyter_request(workspace_id, ws, "DELETE", name)
__all__ = ["RuntimeClient", "RuntimeClientError"] __all__ = ["RuntimeClient", "RuntimeClientError"]
+108 -55
View File
@@ -586,29 +586,46 @@ async def delete_workspace_directory(
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> 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)
# The original query JOINed StorageObjects to filter scripts by
# ``relative_path`` prefix. Jupyter-only scripts have no
# StorageObject row, and the Scripts schema does not track
# directory membership, so we cannot honour the directory filter
# for jupyter-created files. We therefore enumerate all active
# scripts the user owns in the workspace and forward each delete
# to the runtime. ``directory_path`` is still returned in the
# response for API compatibility.
rows = ( rows = (
await session.execute( await session.execute(
select(Scripts, StorageObjects) select(Scripts)
.join(
StorageObjects,
StorageObjects.storage_object_id == Scripts.current_object_id,
)
.where( .where(
Scripts.workspace_id == context.workspace.workspace_id, Scripts.workspace_id == context.workspace.workspace_id,
Scripts.owner_user_id == context.user.user_id, Scripts.owner_user_id == context.user.user_id,
Scripts.status == "active", Scripts.status == "active",
StorageObjects.relative_path.startswith(
f"{relative_prefix}/"
),
) )
) )
).all() ).scalars().all()
for script, _storage in rows: runtime_client = request.app.state.runtime_client
await request.app.state.storage_client.delete_object( workspace_id = context.workspace.workspace_id
script.current_object_id for script in rows:
) if not script.script_name:
continue
# Best-effort: try to delete from jupyter, but do not let one
# failure abort the rest. The jupyter call will 404 if the
# file is not actually in the workspace root (e.g. legacy
# scripts that were never mirrored to jupyter); we treat that
# as a no-op and still mark the row deleted.
try:
await runtime_client.delete_file(
workspace_id, name=script.script_name
)
except RuntimeClientError as exc:
if exc.status_code != 404:
logger.warning(
f"delete_workspace_directory: jupyter delete "
f"failed for {script.script_id} ({script.script_name}): "
f"{exc.status_code} {exc.detail}"
)
script.status = "deleted" script.status = "deleted"
script.deleted_at = datetime.now(UTC).replace(tzinfo=None) script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
return { return {
@@ -677,48 +694,64 @@ async def update_script(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
script, storage_object = await get_script_row( # Look up the Scripts row on its own: jupyter-only scripts do not
script_id, # have a StorageObjects row to JOIN against, and update is an
context, # in-place overwrite of the same jupyter path, so we do not need
session, # any object-store metadata.
for_update=True, script = await session.scalar(
select(Scripts)
.where(
Scripts.script_id == script_id,
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
is_admin=context.is_admin, is_admin=context.is_admin,
) )
content = validate_script_content(payload.content, script.script_type) content = validate_script_content(payload.content, script.script_type)
if not storage_object.relative_path: runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
try:
if script.script_type == "notebook":
notebook = json.loads(content.decode("utf-8"))
jupyter_resp = await runtime_client.create_notebook(
workspace_id,
name=script.script_name,
cells=notebook.get("cells"),
)
else:
jupyter_resp = await runtime_client.upload_file(
workspace_id,
name=script.script_name,
content=content.decode("utf-8"),
content_type=(
mimetypes.guess_type(script.script_name)[0]
or "text/plain"
),
)
except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status_code=exc.status_code,
"script has no workspace path", detail=exc.detail,
) ) from exc
old_object_id = script.current_object_id
storage_data = await request.app.state.storage_client.create_server_object(
workspace_id=context.workspace.workspace_id,
user_id=script.owner_user_id,
usage_type="working_copy",
file_name=storage_object.file_name or script.script_name,
content_type=storage_object.mime_type
or mimetypes.guess_type(script.script_name)[0]
or "application/octet-stream",
content=content,
visibility=script.visibility,
is_immutable=False,
idempotency_key=(
f"script-update:{script.script_id}:"
f"{hashlib.sha256(content).hexdigest()}"
),
relative_path=storage_object.relative_path,
)
script.current_object_id = storage_data["storage_object_id"]
script.updated_at = datetime.now(UTC).replace(tzinfo=None) script.updated_at = datetime.now(UTC).replace(tzinfo=None)
if old_object_id != script.current_object_id: storage_data = {
try: "storage_object_id": script.current_object_id,
await request.app.state.storage_client.delete_object(old_object_id) "relative_path": script.script_name,
except Exception: "object_key": f"{workspace_id}/{script.script_name}",
pass "content_hash": hashlib.sha256(content).hexdigest(),
"size_bytes": len(content),
}
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": script_payload(script, storage_data), "data": script_payload(script, storage_data),
@@ -733,20 +766,40 @@ async def delete_script(
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
script, storage_object = await get_script_row( # Scripts created via the jupyter path do not have a corresponding
script_id, # StorageObjects row, so we look up the Scripts row on its own and
context, # forward the delete to the workspace's live Jupyter instance.
session, script = await session.scalar(
for_update=True, select(Scripts)
.where(
Scripts.script_id == script_id,
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
)
.with_for_update()
) )
if script is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"script not found",
)
require_script_modify_access( require_script_modify_access(
script, script,
user_id=context.user.user_id, user_id=context.user.user_id,
is_admin=context.is_admin, is_admin=context.is_admin,
) )
await request.app.state.storage_client.delete_object(
script.current_object_id runtime_client = request.app.state.runtime_client
) try:
await runtime_client.delete_file(
context.workspace.workspace_id, name=script.script_name
)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
script.status = "deleted" script.status = "deleted"
script.deleted_at = datetime.now(UTC).replace(tzinfo=None) script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
return { return {
+4
View File
@@ -7,6 +7,10 @@ COPY frontend/ ./
RUN pnpm typecheck && pnpm build RUN pnpm typecheck && pnpm build
FROM nginx:alpine FROM nginx:alpine
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone
COPY ./default.conf /etc/nginx/conf.d/default.conf.template COPY ./default.conf /etc/nginx/conf.d/default.conf.template
COPY ./scripts/nginx-entrypoint.sh /usr/local/bin/model-platform-entrypoint.sh COPY ./scripts/nginx-entrypoint.sh /usr/local/bin/model-platform-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/model-platform-entrypoint.sh \ RUN sed -i 's/\r$//' /usr/local/bin/model-platform-entrypoint.sh \
+6 -1
View File
@@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \ PYTHONPATH=/app \
PATH="/app/.venv/bin:${PATH}" PATH="/app/.venv/bin:${PATH}" \
TZ=Asia/Shanghai
WORKDIR /app WORKDIR /app
# 从官方 Rclone 镜像直接复制 rclone 二进制文件(高效、稳定) # 从官方 Rclone 镜像直接复制 rclone 二进制文件(高效、稳定)
COPY --from=rclone/rclone:latest /usr/local/bin/rclone /usr/local/bin/rclone COPY --from=rclone/rclone:latest /usr/local/bin/rclone /usr/local/bin/rclone
@@ -28,6 +29,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
procps \ procps \
build-essential \ build-essential \
python3-dev \ python3-dev \
tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& dpkg-reconfigure --frontend noninteractive tzdata \
&& sed -i 's/#user_allow_other/user_allow_other/g' /etc/fuse.conf \ && sed -i 's/#user_allow_other/user_allow_other/g' /etc/fuse.conf \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+6 -1
View File
@@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \ PYTHONPATH=/app \
PATH="/app/.venv/bin:${PATH}" PATH="/app/.venv/bin:${PATH}" \
TZ=Asia/Shanghai
WORKDIR /app WORKDIR /app
RUN ( \ RUN ( \
@@ -24,6 +25,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \ g++ \
python3-dev \ python3-dev \
build-essential \ build-essential \
tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& dpkg-reconfigure --frontend noninteractive tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv