Compare commits

...

2 Commits

Author SHA1 Message Date
-LAN-
59639ca9b2 chore: bump Dify to 1.13.3 and sandbox to 0.2.13 (#34079)
Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-25 20:03:15 +08:00
Xin Zhang
66b8c42a25 feat: add inner API endpoints for admin DSL import/export (#34059) 2026-03-25 19:48:53 +08:00
14 changed files with 382 additions and 24 deletions

View File

@@ -16,12 +16,14 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from .app import dsl as _app_dsl
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
__all__ = [
"_app_dsl",
"_mail",
"_plugin",
"_workspace",

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,110 @@
"""Inner API endpoints for app DSL import/export.
Called by the enterprise admin-api service. Import requires ``creator_email``
to attribute the created app; workspace/membership validation is done by the
Go admin-api caller.
"""
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from extensions.ext_database import db
from models import Account, App
from models.account import AccountStatus
from services.app_dsl_service import AppDslService, ImportMode, ImportStatus
class InnerAppDSLImportPayload(BaseModel):
yaml_content: str = Field(description="YAML DSL content")
creator_email: str = Field(description="Email of the workspace member who will own the imported app")
name: str | None = Field(default=None, description="Override app name from DSL")
description: str | None = Field(default=None, description="Override app description from DSL")
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
@inner_api_ns.route("/enterprise/workspaces/<string:workspace_id>/dsl/import")
class EnterpriseAppDSLImport(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc("enterprise_app_dsl_import")
@inner_api_ns.expect(inner_api_ns.models[InnerAppDSLImportPayload.__name__])
@inner_api_ns.doc(
responses={
200: "Import completed",
202: "Import pending (DSL version mismatch requires confirmation)",
400: "Import failed (business error)",
404: "Creator account not found or inactive",
}
)
def post(self, workspace_id: str):
"""Import a DSL into a workspace on behalf of a specified creator."""
args = InnerAppDSLImportPayload.model_validate(inner_api_ns.payload or {})
account = _get_active_account(args.creator_email)
if account is None:
return {"message": f"account '{args.creator_email}' not found or inactive"}, 404
account.set_tenant_id(workspace_id)
with Session(db.engine) as session:
dsl_service = AppDslService(session)
result = dsl_service.import_app(
account=account,
import_mode=ImportMode.YAML_CONTENT,
yaml_content=args.yaml_content,
name=args.name,
description=args.description,
)
session.commit()
if result.status == ImportStatus.FAILED:
return result.model_dump(mode="json"), 400
if result.status == ImportStatus.PENDING:
return result.model_dump(mode="json"), 202
return result.model_dump(mode="json"), 200
@inner_api_ns.route("/enterprise/apps/<string:app_id>/dsl")
class EnterpriseAppDSLExport(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_app_dsl_export",
responses={
200: "Export successful",
404: "App not found",
},
)
def get(self, app_id: str):
"""Export an app's DSL as YAML."""
include_secret = request.args.get("include_secret", "false").lower() == "true"
app_model = db.session.query(App).filter_by(id=app_id).first()
if not app_model:
return {"message": "app not found"}, 404
data = AppDslService.export_dsl(
app_model=app_model,
include_secret=include_secret,
)
return {"data": data}, 200
def _get_active_account(email: str) -> Account | None:
"""Look up an active account by email.
Workspace membership is already validated by the Go admin-api caller.
"""
account = db.session.query(Account).filter_by(email=email).first()
if account is None or account.status != AccountStatus.ACTIVE:
return None
return account

View File

@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.13.2"
version = "1.13.3"
requires-python = ">=3.11,<3.13"
dependencies = [

View File

@@ -163,11 +163,9 @@ class DifyTestContainers:
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
logger.info("Redis container is ready and accepting connections")
# Start Dify Sandbox container for code execution environment
# Dify Sandbox provides a secure environment for executing user code
# Use pinned version 0.2.12 to match production docker-compose configuration
# Start Dify Sandbox container for code execution environment.
logger.info("Initializing Dify Sandbox container...")
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.12").with_network(self.network)
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.14").with_network(self.network)
self.dify_sandbox.with_exposed_ports(8194)
self.dify_sandbox.env = {
"API_KEY": "test_api_key",
@@ -187,7 +185,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...")
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.3-local").with_network(
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,245 @@
"""Unit tests for inner_api app DSL import/export endpoints.
Tests Pydantic model validation, endpoint handler logic, and the
_get_active_account helper. Auth/setup decorators are tested separately
in test_auth_wraps.py; handler tests use inspect.unwrap() to bypass them.
"""
import inspect
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.app.dsl import (
EnterpriseAppDSLExport,
EnterpriseAppDSLImport,
InnerAppDSLImportPayload,
_get_active_account,
)
from services.app_dsl_service import ImportStatus
class TestInnerAppDSLImportPayload:
"""Test InnerAppDSLImportPayload Pydantic model validation."""
def test_valid_payload_all_fields(self):
data = {
"yaml_content": "version: 0.6.0\nkind: app\n",
"creator_email": "user@example.com",
"name": "My App",
"description": "A test app",
}
payload = InnerAppDSLImportPayload.model_validate(data)
assert payload.yaml_content == data["yaml_content"]
assert payload.creator_email == "user@example.com"
assert payload.name == "My App"
assert payload.description == "A test app"
def test_valid_payload_optional_fields_omitted(self):
data = {
"yaml_content": "version: 0.6.0\n",
"creator_email": "user@example.com",
}
payload = InnerAppDSLImportPayload.model_validate(data)
assert payload.name is None
assert payload.description is None
def test_missing_yaml_content_fails(self):
with pytest.raises(ValidationError) as exc_info:
InnerAppDSLImportPayload.model_validate({"creator_email": "a@b.com"})
assert "yaml_content" in str(exc_info.value)
def test_missing_creator_email_fails(self):
with pytest.raises(ValidationError) as exc_info:
InnerAppDSLImportPayload.model_validate({"yaml_content": "test"})
assert "creator_email" in str(exc_info.value)
class TestGetActiveAccount:
"""Test the _get_active_account helper function."""
@patch("controllers.inner_api.app.dsl.db")
def test_returns_active_account(self, mock_db):
mock_account = MagicMock()
mock_account.status = "active"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
result = _get_active_account("user@example.com")
assert result is mock_account
mock_db.session.query.return_value.filter_by.assert_called_once_with(email="user@example.com")
@patch("controllers.inner_api.app.dsl.db")
def test_returns_none_for_inactive_account(self, mock_db):
mock_account = MagicMock()
mock_account.status = "banned"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
result = _get_active_account("banned@example.com")
assert result is None
@patch("controllers.inner_api.app.dsl.db")
def test_returns_none_for_nonexistent_email(self, mock_db):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
result = _get_active_account("missing@example.com")
assert result is None
class TestEnterpriseAppDSLImport:
"""Test EnterpriseAppDSLImport endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseAppDSLImport()
@pytest.fixture
def _mock_import_deps(self):
"""Patch db, Session, and AppDslService for import handler tests."""
with (
patch("controllers.inner_api.app.dsl.db"),
patch("controllers.inner_api.app.dsl.Session") as mock_session,
patch("controllers.inner_api.app.dsl.AppDslService") as mock_dsl_cls,
):
mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_session.return_value.__exit__ = MagicMock(return_value=False)
self._mock_dsl = MagicMock()
mock_dsl_cls.return_value = self._mock_dsl
yield
def _make_import_result(self, status: ImportStatus, **kwargs) -> "Import":
from services.app_dsl_service import Import
result = Import(
id="import-id",
status=status,
app_id=kwargs.get("app_id", "app-123"),
app_mode=kwargs.get("app_mode", "workflow"),
)
return result
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_success_returns_200(self, mock_get_account, api_instance, app: Flask):
mock_account = MagicMock()
mock_get_account.return_value = mock_account
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.COMPLETED)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {
"yaml_content": "version: 0.6.0\n",
"creator_email": "user@example.com",
}
result = unwrapped(api_instance, workspace_id="ws-123")
body, status_code = result
assert status_code == 200
assert body["status"] == "completed"
mock_account.set_tenant_id.assert_called_once_with("ws-123")
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_pending_returns_202(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = MagicMock()
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.PENDING)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
assert status_code == 202
assert body["status"] == "pending"
@pytest.mark.usefixtures("_mock_import_deps")
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_failed_returns_400(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = MagicMock()
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.FAILED)
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
assert status_code == 400
assert body["status"] == "failed"
@patch("controllers.inner_api.app.dsl._get_active_account")
def test_import_account_not_found_returns_404(self, mock_get_account, api_instance, app: Flask):
mock_get_account.return_value = None
unwrapped = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
mock_ns.payload = {"yaml_content": "test", "creator_email": "missing@e.com"}
result = unwrapped(api_instance, workspace_id="ws-123")
body, status_code = result
assert status_code == 404
assert "missing@e.com" in body["message"]
class TestEnterpriseAppDSLExport:
"""Test EnterpriseAppDSLExport endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseAppDSLExport()
@patch("controllers.inner_api.app.dsl.AppDslService")
@patch("controllers.inner_api.app.dsl.db")
def test_export_success_returns_200(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
mock_app = MagicMock()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
mock_dsl_cls.export_dsl.return_value = "version: 0.6.0\nkind: app\n"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=false"):
result = unwrapped(api_instance, app_id="app-123")
body, status_code = result
assert status_code == 200
assert body["data"] == "version: 0.6.0\nkind: app\n"
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=False)
@patch("controllers.inner_api.app.dsl.AppDslService")
@patch("controllers.inner_api.app.dsl.db")
def test_export_with_secret(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
mock_app = MagicMock()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
mock_dsl_cls.export_dsl.return_value = "yaml-data"
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=true"):
result = unwrapped(api_instance, app_id="app-123")
body, status_code = result
assert status_code == 200
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=True)
@patch("controllers.inner_api.app.dsl.db")
def test_export_app_not_found_returns_404(self, mock_db, api_instance, app: Flask):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
unwrapped = inspect.unwrap(api_instance.get)
with app.test_request_context("?include_secret=false"):
result = unwrapped(api_instance, app_id="nonexistent")
body, status_code = result
assert status_code == 404
assert "app not found" in body["message"]

2
api/uv.lock generated
View File

@@ -1457,7 +1457,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.13.2"
version = "1.13.3"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.2
image: langgenius/dify-web:1.13.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -245,7 +245,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
# The DifySandbox configurations
@@ -269,7 +269,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@@ -97,7 +97,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
env_file:
- ./middleware.env
@@ -123,7 +123,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
env_file:
- ./middleware.env

View File

@@ -731,7 +731,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -773,7 +773,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -812,7 +812,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.2
image: langgenius/dify-api:1.13.3
restart: always
environment:
# Use the shared environment variables.
@@ -842,7 +842,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.2
image: langgenius/dify-web:1.13.3
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -955,7 +955,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.12
image: langgenius/dify-sandbox:0.2.14
restart: always
environment:
# The DifySandbox configurations
@@ -979,7 +979,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@@ -5,7 +5,8 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
python_path: /opt/python/bin/python3
nodejs_path: /usr/local/bin/node
enable_network: True # please make sure there is no network risk in your environment
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
proxy:

View File

@@ -5,7 +5,7 @@ app:
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
python_path: /opt/python/bin/python3
python_lib_path:
- /usr/local/lib/python3.10
- /usr/lib/python3.10

View File

@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.13.2",
"version": "1.13.3",
"private": true,
"packageManager": "pnpm@10.32.1",
"imports": {