update: create file
This commit is contained in:
+6
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user