mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
DSL export returns a draft_hash covering the graph, features, environment and conversation variables; an import that sends an older hash fails instead of wiping newer edits. A blank secret imported over an app keeps the stored value. The incompatible-node error names the nodes. New openapi ops: draft test runs, run history with node steps, publish, version list and restore, and environment variables (secrets masked on read). Publish shares one service method with the console.
228 lines
9.3 KiB
Python
228 lines
9.3 KiB
Python
from __future__ import annotations
|
|
|
|
from http import HTTPStatus
|
|
from typing import Final
|
|
from uuid import UUID
|
|
|
|
from flask_restx import Resource
|
|
from werkzeug.exceptions import Forbidden
|
|
|
|
from constants.oauth_bearer import Scope
|
|
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
|
|
from controllers.openapi import openapi_ns
|
|
from controllers.openapi._contract import Example, Kind, endpoint, op_of
|
|
from controllers.openapi._models import (
|
|
AppDslExportQuery,
|
|
AppDslExportResponse,
|
|
AppDslImportPayload,
|
|
AppDslImportResponse,
|
|
Hint,
|
|
)
|
|
from controllers.openapi.auth.context import Context
|
|
from controllers.openapi.auth.requirements import (
|
|
EDITOR_ROLES,
|
|
CheckRBACPermission,
|
|
CheckScope,
|
|
CheckSubject,
|
|
CheckWorkspaceMember,
|
|
CheckWorkspaceRole,
|
|
account_app_guards,
|
|
)
|
|
from controllers.openapi.auth.subjects import AccountSubject
|
|
from extensions.ext_application_services import application_services
|
|
from extensions.ext_database import db
|
|
from machinery.context import RequestContext
|
|
from services.app_dsl_service import AppDslService
|
|
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
|
|
from services.errors.account import NoPermissionError
|
|
from services.errors.app import WorkflowNotFoundError
|
|
from services.workflow_service import WorkflowService
|
|
|
|
_DSL_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_IMPORT_EXPORT_DSL, scope=Scope.APPS_READ, editor=True)
|
|
|
|
|
|
def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse:
|
|
hints: list[Hint] = []
|
|
if result.status == ImportStatus.PENDING:
|
|
hints.append(
|
|
Hint(
|
|
summary="Confirm the pending import",
|
|
op=op_of(AppDslImportConfirmApi.post),
|
|
input={"workspace_id": workspace_id, "import_id": result.id},
|
|
)
|
|
)
|
|
return AppDslImportResponse(**result.model_dump(), hints=hints)
|
|
|
|
|
|
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports")
|
|
class AppDslImportApi(Resource):
|
|
"""Import a DSL YAML string into the specified workspace.
|
|
|
|
Use ``mode=yaml-content`` with ``yaml_content`` for inline YAML, or
|
|
``mode=yaml-url`` with ``yaml_url`` for a remote URL. Provide ``app_id``
|
|
to overwrite an existing workflow or advanced-chat app; omit it to create
|
|
a new app.
|
|
|
|
Returns 202 when the DSL version requires an explicit confirmation step
|
|
(major version mismatch). Callers must then POST to the imports :confirm method.
|
|
Returns 400 when the import failed due to invalid DSL or a business error.
|
|
"""
|
|
|
|
@endpoint(
|
|
account_context=True,
|
|
op="import.console_app.dsl",
|
|
kind=Kind.OBJECT,
|
|
summary="Import an app from DSL text or URL",
|
|
examples=(
|
|
Example(
|
|
title="Import an app from inline YAML",
|
|
input={"mode": "yaml-content", "yaml_content": "<yaml text of the app DSL>"},
|
|
),
|
|
Example(
|
|
title="Import an app from a URL",
|
|
input={"mode": "yaml-url", "yaml_url": "https://example.com/apps/release-summary.yml"},
|
|
),
|
|
Example(
|
|
title="Overwrite an existing workflow app from YAML",
|
|
input={"mode": "yaml-content", "yaml_content": "<yaml text of the app DSL>", "app_id": "<app_id>"},
|
|
),
|
|
),
|
|
requirements=(
|
|
CheckSubject(allowed=(AccountSubject,)),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
|
CheckWorkspaceRole(EDITOR_ROLES),
|
|
),
|
|
body=AppDslImportPayload,
|
|
returns=(
|
|
(HTTPStatus.OK, AppDslImportResponse, "Import completed"),
|
|
(HTTPStatus.ACCEPTED, AppDslImportResponse, "Import pending confirmation"),
|
|
(HTTPStatus.BAD_REQUEST, AppDslImportResponse, "Import failed"),
|
|
),
|
|
)
|
|
def post(self, ctx: RequestContext, workspace_id: str, *, body: AppDslImportPayload):
|
|
try:
|
|
result = application_services().apps.imports.import_app(
|
|
ctx, AppImportParams.model_validate(body.model_dump())
|
|
)
|
|
except NoPermissionError as exc:
|
|
raise Forbidden(str(exc)) from exc
|
|
|
|
response = _import_response(result, workspace_id=workspace_id)
|
|
match result.status:
|
|
case ImportStatus.FAILED:
|
|
return response, HTTPStatus.BAD_REQUEST
|
|
case ImportStatus.PENDING:
|
|
return response, HTTPStatus.ACCEPTED
|
|
case _:
|
|
return response, HTTPStatus.OK
|
|
|
|
|
|
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>:confirm")
|
|
class AppDslImportConfirmApi(Resource):
|
|
"""Confirm a pending DSL import identified by ``import_id``.
|
|
|
|
Required only when the initial import returned 202 (major DSL version
|
|
mismatch that requires explicit acknowledgement). The pending state is
|
|
stored in Redis for 10 minutes; this call retrieves it and completes the
|
|
import under the given workspace.
|
|
|
|
Returns 400 when the pending data has expired or the import fails.
|
|
"""
|
|
|
|
@endpoint(
|
|
account_context=True,
|
|
op="confirm.console_app.dsl_import",
|
|
kind=Kind.OBJECT,
|
|
summary="Confirm a pending DSL import",
|
|
examples=(Example(title="Confirm an import that is pending confirmation", input={"import_id": "<import_id>"}),),
|
|
requirements=(
|
|
CheckSubject(allowed=(AccountSubject,)),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
|
CheckWorkspaceRole(EDITOR_ROLES),
|
|
),
|
|
returns=((HTTPStatus.OK, Import, "Import confirmed"), (HTTPStatus.BAD_REQUEST, Import, "Import failed")),
|
|
)
|
|
def post(self, ctx: RequestContext, workspace_id: str, import_id: str):
|
|
try:
|
|
result = application_services().apps.imports.confirm_import(ctx, import_id)
|
|
except NoPermissionError as exc:
|
|
raise Forbidden(str(exc)) from exc
|
|
|
|
if result.status == ImportStatus.FAILED:
|
|
return result, HTTPStatus.BAD_REQUEST
|
|
return result, HTTPStatus.OK
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/dsl")
|
|
class AppDslExportApi(Resource):
|
|
"""Export an app's current draft configuration as a DSL YAML string.
|
|
|
|
The auth pipeline resolves the app and its tenant from ``app_id``. Pass
|
|
``include_secret=true`` to embed encrypted credential values (e.g. tool
|
|
node secrets); omit it to produce a portable, sharable DSL safe to share.
|
|
|
|
Note: the pipeline enforces ``app.enable_api`` for all ``/apps/<app_id>``
|
|
routes in the openapi group. Apps with the service API disabled will
|
|
receive a 403; enable the API in the console first if needed.
|
|
"""
|
|
|
|
@endpoint(
|
|
op="export.console_app.dsl",
|
|
kind=Kind.OBJECT,
|
|
summary="Export app DSL as YAML text inside a JSON object",
|
|
examples=(
|
|
Example(title="Export the current draft as YAML", input={"app_id": "<app_id>"}),
|
|
Example(
|
|
title="Export a published version including secrets",
|
|
input={"app_id": "<app_id>", "workflow_id": "<workflow_id>", "include_secret": True},
|
|
),
|
|
),
|
|
requirements=_DSL_READ_GUARDS,
|
|
query=AppDslExportQuery,
|
|
returns=(200, AppDslExportResponse, "Export successful"),
|
|
)
|
|
def get(self, ctx: Context, app_id: str, *, query: AppDslExportQuery):
|
|
# Capture the draft hash before building the DSL: an edit that commits in between would
|
|
# otherwise be described by a hash newer than the exported YAML, defeating the guard
|
|
# draft_hash exists for (a later import silently overwriting that edit).
|
|
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=db.session())
|
|
try:
|
|
data = AppDslService.export_dsl(
|
|
app_model=ctx.app,
|
|
session=db.session(),
|
|
include_secret=query.include_secret,
|
|
workflow_id=query.workflow_id,
|
|
)
|
|
except WorkflowNotFoundError as exc:
|
|
return str(exc), 404
|
|
return AppDslExportResponse(data=data, draft_hash=draft.content_hash if draft else None), 200
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/dependencies:check")
|
|
class AppDslCheckDependenciesApi(Resource):
|
|
"""Check for leaked plugin dependencies after a DSL import.
|
|
|
|
Call this after an import that reported ``COMPLETED_WITH_WARNINGS`` to
|
|
find which plugin dependencies referenced in the DSL are not yet installed
|
|
in the workspace. Returns an empty ``leaked_dependencies`` list when all
|
|
dependencies are satisfied.
|
|
"""
|
|
|
|
@endpoint(
|
|
account_context=True,
|
|
op="check.console_app.dependency",
|
|
kind=Kind.OBJECT,
|
|
summary="Check plugin dependencies of an app",
|
|
examples=(Example(title="Check which plugins an app needs", input={"app_id": "<app_id>"}),),
|
|
requirements=_DSL_READ_GUARDS,
|
|
returns=(HTTPStatus.OK, CheckDependenciesResult, "Dependencies checked"),
|
|
)
|
|
def get(self, ctx: RequestContext, app_id: str):
|
|
result = application_services().apps.imports.check_dependencies(ctx, str(UUID(app_id)))
|
|
|
|
return result, HTTPStatus.OK
|