Files
dify/api/services/app_package_service.py

104 lines
4.7 KiB
Python

"""Package ordinary App DSLs while retaining the existing Roster Agent format."""
from __future__ import annotations
import hashlib
import io
import re
import tempfile
import zipfile
import zlib
from typing import BinaryIO, Literal, cast
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from services.agent.errors import InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError
from services.agent.roster_package_entities import RosterAgentPackageApp, RosterAgentPackageExport
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.dsl_content import DSL_MAX_SIZE
class AppPackageManifest(BaseModel):
model_config = ConfigDict(extra="forbid")
format: Literal["dify.app"]
format_version: Literal[1]
apps: list[RosterAgentPackageApp] = Field(min_length=1, max_length=1)
class AppPackageService(RosterAgentPackageReader):
"""Reuse the bounded ZIP reader; ordinary Apps retain DSL import semantics."""
def read_dsl(self, source: BinaryIO) -> str | None:
"""Return an ordinary DSL, or rewind a Roster package for its importer."""
try:
with tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b") as spool:
self._copy_bounded(source, cast(BinaryIO, spool))
spool.seek(0)
with zipfile.ZipFile(spool) as archive:
infos = self._index_archive(archive)
data, _ = self._read_yaml_document(archive, infos, "manifest.yaml")
if isinstance(data, dict) and data.get("format") == "dify.roster-agent":
return None
try:
manifest = AppPackageManifest.model_validate(data)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("App package manifest is invalid") from exc
resource = manifest.apps[0]
if set(infos) != {"manifest.yaml", resource.path}:
raise InvalidRosterAgentPackageError("App package members do not match the manifest")
app_data, _ = self._read_yaml_document(
archive, infos, resource.path, resource=resource, max_bytes=DSL_MAX_SIZE
)
if (
not isinstance(app_data, dict)
or app_data.get("kind") != "app"
or not isinstance(app_data.get("app"), dict)
or app_data["app"].get("mode")
not in {
"workflow",
"advanced-chat",
"chat",
"completion",
"agent-chat",
"channel",
"rag-pipeline",
}
):
raise InvalidRosterAgentPackageError("App package DSL is invalid")
# Preserve the original text for version confirmation and legacy DSL handling.
return archive.read(resource.path).decode("utf-8")
except (InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError):
raise
except (OSError, zipfile.BadZipFile, EOFError, RuntimeError, ValueError, zlib.error) as exc:
raise InvalidRosterAgentPackageError("App package is not a valid ZIP") from exc
finally:
source.seek(0)
def export(self, *, dsl: str, name: str) -> RosterAgentPackageExport:
payload = dsl.encode("utf-8")
if len(payload) > DSL_MAX_SIZE:
raise RosterAgentPackageTooLargeError("App package DSL exceeds the size limit")
manifest = AppPackageManifest(
format="dify.app",
format_version=1,
apps=[
RosterAgentPackageApp(path="app.yaml", size=len(payload), sha256=hashlib.sha256(payload).hexdigest())
],
)
archive = io.BytesIO()
try:
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package:
package.writestr("manifest.yaml", yaml.safe_dump(manifest.model_dump(mode="json")))
package.writestr("app.yaml", payload)
size = archive.tell()
archive.seek(0)
# Validate exported containers against the same limits as imports.
self.read_dsl(archive)
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80] or "app"
return RosterAgentPackageExport(archive=archive, filename=f"{slug}.ifpkg", size=size)
except Exception:
archive.close()
raise