update: create file
This commit is contained in:
+6
-1
@@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PATH="/app/.venv/bin:${PATH}"
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
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++ \
|
||||
python3-dev \
|
||||
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/*
|
||||
|
||||
COPY common ./common
|
||||
|
||||
@@ -105,30 +105,41 @@ class RuntimeClient:
|
||||
)
|
||||
return ws
|
||||
|
||||
async def _jupyter_contents_post(
|
||||
async def _jupyter_request(
|
||||
self,
|
||||
workspace_id: str,
|
||||
ws: dict[str, Any],
|
||||
body: dict[str, Any],
|
||||
method: str,
|
||||
contents_path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> 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``
|
||||
and authenticates with the runtime-issued token. We reuse the
|
||||
``contents_path`` is the suffix after ``/api/contents/`` —
|
||||
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
|
||||
by the absolute URL we hand it).
|
||||
"""
|
||||
suffix = contents_path.lstrip("/")
|
||||
url = (
|
||||
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)
|
||||
headers = {"Authorization": f"token {ws['token']}"}
|
||||
try:
|
||||
response = await self.client.post(
|
||||
url, json=body, headers=headers
|
||||
)
|
||||
if body is None:
|
||||
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:
|
||||
raise _RUNTIME_TRANSPORT_ERROR from exc
|
||||
if response.is_error:
|
||||
@@ -137,6 +148,8 @@ class RuntimeClient:
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise RuntimeClientError(response.status_code, detail)
|
||||
if response.status_code == 204 or not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
async def create_notebook(
|
||||
@@ -145,24 +158,29 @@ class RuntimeClient:
|
||||
*,
|
||||
name: str,
|
||||
cells: list[dict[str, Any]] | None = None,
|
||||
checkpoint: bool = True,
|
||||
) -> 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,
|
||||
then posts to the Jupyter contents API
|
||||
(``/jupyter/{ws_id}/api/contents/``) directly using the
|
||||
runtime-issued token. Returns the Jupyter contents descriptor
|
||||
(``path``, ``type``, ``created``, ``writable``...).
|
||||
Uses Jupyter's ``PUT /api/contents/{name}`` (the
|
||||
"save / create at path" verb — ``POST /api/contents/`` only
|
||||
creates an auto-incremented ``Untitled.ipynb``). The body
|
||||
carries ``type``, ``format`` and the full notebook ``content``.
|
||||
|
||||
``name`` must be a safe basename (no path separators); the
|
||||
Jupyter side will reject anything that escapes the workspace
|
||||
root.
|
||||
Auto-starts the workspace's Jupyter if it is not yet running.
|
||||
When ``checkpoint`` is true (default), follows up with a
|
||||
``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)
|
||||
logger.debug(ws)
|
||||
body = {
|
||||
"type": "notebook",
|
||||
"path": name,
|
||||
"format": "json",
|
||||
"content": {
|
||||
"cells": cells if cells is not None else [],
|
||||
"metadata": {},
|
||||
@@ -170,8 +188,20 @@ class RuntimeClient:
|
||||
"nbformat_minor": 5,
|
||||
},
|
||||
}
|
||||
|
||||
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 create_notebook({name}) failed: "
|
||||
f"{exc.status_code} {exc.detail}"
|
||||
)
|
||||
return result
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
@@ -180,13 +210,20 @@ class RuntimeClient:
|
||||
name: str,
|
||||
content: str,
|
||||
content_type: str = "text/plain",
|
||||
checkpoint: bool = True,
|
||||
) -> 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
|
||||
under ``text/`` or ``application/json`` is sent as ``text``.
|
||||
Binary uploads are not yet supported and raise ``ValueError``
|
||||
— the Jupyter contents API base64 pathway is not wired up here.
|
||||
Uses ``PUT /api/contents/{name}`` with ``type="file"`` and
|
||||
``format="text"``. ``content_type`` is accepted for API
|
||||
symmetry with :meth:`create_notebook`; only ``text/*`` and
|
||||
``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 (
|
||||
content_type.startswith("text/")
|
||||
@@ -199,11 +236,39 @@ class RuntimeClient:
|
||||
ws = await self._ensure_workspace(workspace_id)
|
||||
body = {
|
||||
"type": "file",
|
||||
"path": name,
|
||||
"content": content,
|
||||
"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"]
|
||||
|
||||
+108
-55
@@ -586,29 +586,46 @@ async def delete_workspace_directory(
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
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 = (
|
||||
await session.execute(
|
||||
select(Scripts, StorageObjects)
|
||||
.join(
|
||||
StorageObjects,
|
||||
StorageObjects.storage_object_id == Scripts.current_object_id,
|
||||
)
|
||||
select(Scripts)
|
||||
.where(
|
||||
Scripts.workspace_id == context.workspace.workspace_id,
|
||||
Scripts.owner_user_id == context.user.user_id,
|
||||
Scripts.status == "active",
|
||||
StorageObjects.relative_path.startswith(
|
||||
f"{relative_prefix}/"
|
||||
),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for script, _storage in rows:
|
||||
await request.app.state.storage_client.delete_object(
|
||||
script.current_object_id
|
||||
)
|
||||
).scalars().all()
|
||||
runtime_client = request.app.state.runtime_client
|
||||
workspace_id = context.workspace.workspace_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.deleted_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
return {
|
||||
@@ -677,48 +694,64 @@ async def update_script(
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
script, storage_object = await get_script_row(
|
||||
script_id,
|
||||
context,
|
||||
session,
|
||||
for_update=True,
|
||||
# Look up the Scripts row on its own: jupyter-only scripts do not
|
||||
# have a StorageObjects row to JOIN against, and update is an
|
||||
# in-place overwrite of the same jupyter path, so we do not need
|
||||
# any object-store metadata.
|
||||
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(
|
||||
script,
|
||||
user_id=context.user.user_id,
|
||||
is_admin=context.is_admin,
|
||||
)
|
||||
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(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"script has no workspace path",
|
||||
)
|
||||
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"]
|
||||
status_code=exc.status_code,
|
||||
detail=exc.detail,
|
||||
) from exc
|
||||
|
||||
script.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
if old_object_id != script.current_object_id:
|
||||
try:
|
||||
await request.app.state.storage_client.delete_object(old_object_id)
|
||||
except Exception:
|
||||
pass
|
||||
storage_data = {
|
||||
"storage_object_id": script.current_object_id,
|
||||
"relative_path": script.script_name,
|
||||
"object_key": f"{workspace_id}/{script.script_name}",
|
||||
"content_hash": hashlib.sha256(content).hexdigest(),
|
||||
"size_bytes": len(content),
|
||||
}
|
||||
return {
|
||||
"request_id": context.request_id,
|
||||
"data": script_payload(script, storage_data),
|
||||
@@ -733,20 +766,40 @@ async def delete_script(
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
script, storage_object = await get_script_row(
|
||||
script_id,
|
||||
context,
|
||||
session,
|
||||
for_update=True,
|
||||
# Scripts created via the jupyter path do not have a corresponding
|
||||
# StorageObjects row, so we look up the Scripts row on its own and
|
||||
# forward the delete to the workspace's live Jupyter instance.
|
||||
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(
|
||||
script,
|
||||
user_id=context.user.user_id,
|
||||
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.deleted_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,10 @@ COPY frontend/ ./
|
||||
RUN pnpm typecheck && pnpm build
|
||||
|
||||
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 ./scripts/nginx-entrypoint.sh /usr/local/bin/model-platform-entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/model-platform-entrypoint.sh \
|
||||
|
||||
+6
-1
@@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PATH="/app/.venv/bin:${PATH}"
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
# 从官方 Rclone 镜像直接复制 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 \
|
||||
build-essential \
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
+6
-1
@@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PATH="/app/.venv/bin:${PATH}"
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
|
||||
RUN ( \
|
||||
@@ -24,6 +25,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
g++ \
|
||||
python3-dev \
|
||||
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/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
Reference in New Issue
Block a user