Compare commits
12
Commits
deploy/agent
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bab90eaf4 | ||
|
|
85f0a307d2 | ||
|
|
c1a4a75301 | ||
|
|
2edc576ab1 | ||
|
|
4054f082bf | ||
|
|
54a8de2a88 | ||
|
|
4a5f277c5f | ||
|
|
b2de310ad2 | ||
|
|
fd412a82e9 | ||
|
|
1419c7c6db | ||
|
|
7083a953e1 | ||
|
|
93e7d50845 |
@@ -139,21 +139,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
teardown_external_runtime() {
|
||||
local run_status=$?
|
||||
trap - EXIT
|
||||
if ! vp run e2e:middleware:down; then
|
||||
echo "::error title=E2E teardown failed::External runtime middleware did not shut down cleanly."
|
||||
if [[ "$run_status" -eq 0 ]]; then
|
||||
run_status=1
|
||||
fi
|
||||
fi
|
||||
exit "$run_status"
|
||||
}
|
||||
|
||||
trap teardown_external_runtime EXIT
|
||||
vp run e2e:middleware:up
|
||||
vp run e2e:post-merge:prepare
|
||||
vp run e2e:post-merge
|
||||
|
||||
- name: Upload Cucumber report
|
||||
|
||||
@@ -43,7 +43,7 @@ from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.base import TypeBase
|
||||
|
||||
|
||||
def _patch_redis_clients_on_loaded_modules():
|
||||
def _patch_redis_clients_on_loaded_modules() -> None:
|
||||
"""Ensure any module-level redis_client references point to the shared redis_mock."""
|
||||
|
||||
import sys
|
||||
@@ -51,10 +51,9 @@ def _patch_redis_clients_on_loaded_modules():
|
||||
for module in list(sys.modules.values()):
|
||||
if module is None:
|
||||
continue
|
||||
if hasattr(module, "redis_client"):
|
||||
module.redis_client = redis_mock
|
||||
if hasattr(module, "_pubsub_redis_client"):
|
||||
module.pubsub_redis_client = redis_mock
|
||||
for client_attribute in ("redis_client", "_pubsub_redis_client"):
|
||||
if hasattr(module, client_attribute):
|
||||
setattr(module, client_attribute, redis_mock)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -63,13 +62,13 @@ def app() -> Flask:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _provide_app_context(app: Flask):
|
||||
def _provide_app_context(app: Flask) -> Iterator[None]:
|
||||
with app.app_context():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_redis_clients():
|
||||
def _patch_redis_clients() -> Iterator[None]:
|
||||
"""Patch redis_client to MagicMock only for unit test executions."""
|
||||
|
||||
with (
|
||||
@@ -81,7 +80,7 @@ def _patch_redis_clients():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_redis_mock():
|
||||
def reset_redis_mock() -> None:
|
||||
"""reset the Redis mock before each test"""
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
@@ -91,7 +90,7 @@ def reset_redis_mock():
|
||||
redis_mock.exists.return_value = False
|
||||
redis_mock.set.return_value = None
|
||||
redis_mock.expire.return_value = None
|
||||
redis_mock.hgetall.return_value = {}
|
||||
redis_mock.hgetall.return_value = dict[bytes, bytes]()
|
||||
redis_mock.hdel.return_value = None
|
||||
redis_mock.incr.return_value = 1
|
||||
|
||||
@@ -100,7 +99,7 @@ def reset_redis_mock():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_secret_key():
|
||||
def reset_secret_key() -> Iterator[None]:
|
||||
"""Ensure SECRET_KEY-dependent logic sees an empty config value by default."""
|
||||
|
||||
from configs import dify_config
|
||||
@@ -153,6 +152,18 @@ def _sqlite_session_factory(
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _unbound_session_factory(
|
||||
_sqlite_session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> sessionmaker[Session]:
|
||||
"""Create one unbound factory and install it as the global test factory."""
|
||||
|
||||
factory = sessionmaker()
|
||||
monkeypatch.setattr(session_factory_module, "_session_maker", factory)
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_engine(_sqlite_engine: Engine) -> Engine:
|
||||
"""Expose the pristine full-schema SQLite engine to tests."""
|
||||
@@ -179,6 +190,25 @@ def sqlite_session(_sqlite_session_factory: sessionmaker[Session]) -> Iterator[S
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unbound_session_factory(_unbound_session_factory: sessionmaker[Session]) -> sessionmaker[Session]:
|
||||
"""Expose an unbound factory for paths that must not require persistence."""
|
||||
|
||||
return _unbound_session_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unbound_session(_unbound_session_factory: sessionmaker[Session]) -> Iterator[Session]:
|
||||
"""Yield an unbound Session for paths that must not require persistence.
|
||||
|
||||
Bind-requiring database access fails, while bind-free Session operations can
|
||||
still succeed.
|
||||
"""
|
||||
|
||||
with _unbound_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin:
|
||||
"""Persist the owner identity resolved by service-API app authentication.
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
|
||||
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit
|
||||
from events.event_handlers import update_provider_when_message_created
|
||||
from models import TenantCreditPool
|
||||
from models import Message, TenantCreditPool
|
||||
from models.enums import ProviderQuotaType as ModelProviderQuotaType
|
||||
from models.provider import ProviderType
|
||||
|
||||
|
||||
@@ -30,7 +31,7 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
|
||||
pool_id = str(uuid4())
|
||||
pool = TenantCreditPool(
|
||||
tenant_id=tenant_id,
|
||||
pool_type=ProviderQuotaType.TRIAL,
|
||||
pool_type=ModelProviderQuotaType.TRIAL,
|
||||
quota_limit=10,
|
||||
quota_used=9,
|
||||
)
|
||||
@@ -61,7 +62,7 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
|
||||
),
|
||||
),
|
||||
)
|
||||
message = SimpleNamespace(message_tokens=2, answer_tokens=1)
|
||||
message = Message(message_tokens=2, answer_tokens=1)
|
||||
|
||||
with (
|
||||
patch.object(update_provider_when_message_created, "_execute_provider_updates"),
|
||||
@@ -102,7 +103,7 @@ def test_message_created_paid_credit_accounting_uses_paid_pool() -> None:
|
||||
),
|
||||
),
|
||||
)
|
||||
message = SimpleNamespace(message_tokens=2, answer_tokens=1)
|
||||
message = Message(message_tokens=2, answer_tokens=1)
|
||||
|
||||
with (
|
||||
patch.object(update_provider_when_message_created, "_deduct_credit_pool_quota_capped") as mock_deduct,
|
||||
|
||||
@@ -28,7 +28,6 @@ project-excludes = [
|
||||
"configs/test_dify_config.py",
|
||||
"configs/test_env_consistency.py",
|
||||
"configs/test_nacos_http_client.py",
|
||||
"conftest.py",
|
||||
"controllers/common/test_agent_app_parameters.py",
|
||||
"controllers/common/test_app_access.py",
|
||||
"controllers/common/test_errors.py",
|
||||
@@ -652,7 +651,6 @@ project-excludes = [
|
||||
"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py",
|
||||
"events/test_app_event_signals.py",
|
||||
"events/test_events_package_compat.py",
|
||||
"events/test_update_provider_when_message_created.py",
|
||||
"extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py",
|
||||
"extensions/logstore/test_sql_escape.py",
|
||||
"extensions/otel/conftest.py",
|
||||
@@ -863,7 +861,6 @@ project-excludes = [
|
||||
"services/test_file_service.py",
|
||||
"services/test_human_input_delivery_test_service.py",
|
||||
"services/test_human_input_file_upload_service.py",
|
||||
"services/test_human_input_service.py",
|
||||
"services/test_knowledge_retrieval_inner_service.py",
|
||||
"services/test_knowledge_service.py",
|
||||
"services/test_message_service.py",
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -40,12 +41,6 @@ from services.human_input_service import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unbound_session_factory() -> sessionmaker[Session]:
|
||||
"""Supply the required constructor dependency without enabling database access."""
|
||||
return sessionmaker()
|
||||
|
||||
|
||||
def _make_app(mode: AppMode) -> App:
|
||||
return App(
|
||||
id="app-id",
|
||||
@@ -61,7 +56,7 @@ def _make_app(mode: AppMode) -> App:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_form_record():
|
||||
def sample_form_record() -> HumanInputFormRecord:
|
||||
return HumanInputFormRecord(
|
||||
form_id="form-id",
|
||||
workflow_run_id="workflow-run-id",
|
||||
@@ -95,7 +90,7 @@ def sample_form_record():
|
||||
def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
) -> None:
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
@@ -121,8 +116,10 @@ def test_enqueue_resume_dispatches_task_for_workflow(
|
||||
|
||||
|
||||
def test_ensure_form_active_respects_global_timeout(
|
||||
monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
expired_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -138,7 +135,7 @@ def test_ensure_form_active_respects_global_timeout(
|
||||
def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
) -> None:
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
@@ -166,7 +163,7 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(
|
||||
def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
):
|
||||
) -> None:
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
@@ -190,8 +187,9 @@ def test_enqueue_resume_skips_unsupported_app_mode(
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_uses_repository(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE)
|
||||
repo.get_by_token.return_value = console_record
|
||||
@@ -232,8 +230,10 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str)
|
||||
|
||||
|
||||
def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
configured_input = SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(
|
||||
@@ -270,8 +270,10 @@ def test_resolve_form_inputs_uses_runtime_select_options(
|
||||
|
||||
|
||||
def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
@@ -298,8 +300,10 @@ def test_submit_form_by_token_calls_repository_and_enqueue(
|
||||
|
||||
|
||||
def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
sample_form_record, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
# ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat
|
||||
# resume, not the workflow resume.
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
@@ -327,8 +331,10 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form(
|
||||
|
||||
|
||||
def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
test_record = dataclasses.replace(
|
||||
sample_form_record,
|
||||
@@ -351,8 +357,10 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(
|
||||
|
||||
|
||||
def test_submit_form_by_token_passes_submission_user_id(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
repo.mark_submitted.return_value = sample_form_record
|
||||
@@ -373,7 +381,10 @@ def test_submit_form_by_token_passes_submission_user_id(
|
||||
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_submit_form_by_token_invalid_action(
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
|
||||
service = HumanInputService(unbound_session_factory, form_repository=repo)
|
||||
@@ -390,7 +401,10 @@ def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormR
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_submit_form_by_token_missing_inputs(
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
|
||||
definition_with_input = FormDefinition(
|
||||
@@ -465,12 +479,12 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR
|
||||
],
|
||||
)
|
||||
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
|
||||
sample_form_record,
|
||||
unbound_session_factory,
|
||||
input_definition,
|
||||
submitted_value,
|
||||
expected_message,
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
input_definition: dict[str, JsonValue],
|
||||
submitted_value: JsonValue,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition.model_validate(
|
||||
{
|
||||
@@ -489,14 +503,14 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token",
|
||||
selected_action_id="submit",
|
||||
form_data={input_definition["output_variable_name"]: submitted_value},
|
||||
form_data={definition.inputs[0].output_variable_name: submitted_value},
|
||||
)
|
||||
|
||||
assert expected_message in str(exc_info.value)
|
||||
repo.mark_submitted.assert_not_called()
|
||||
|
||||
|
||||
def test_form_properties(sample_form_record: HumanInputFormRecord):
|
||||
def test_form_properties(sample_form_record: HumanInputFormRecord) -> None:
|
||||
form = Form(sample_form_record)
|
||||
assert form.id == "form-id"
|
||||
assert form.workflow_run_id == "workflow-run-id"
|
||||
@@ -510,20 +524,20 @@ def test_form_properties(sample_form_record: HumanInputFormRecord):
|
||||
assert isinstance(form.expiration_time, datetime)
|
||||
|
||||
|
||||
def test_form_submitted_error_init():
|
||||
def test_form_submitted_error_init() -> None:
|
||||
error = FormSubmittedError(form_id="test-form")
|
||||
assert error.description == "This form has already been submitted by another user, form_id=test-form"
|
||||
assert error.code == 412
|
||||
|
||||
|
||||
def test_human_input_service_init_with_engine(sqlite_engine: Engine):
|
||||
def test_human_input_service_init_with_engine(sqlite_engine: Engine) -> None:
|
||||
service = HumanInputService(session_factory=sqlite_engine)
|
||||
|
||||
assert isinstance(service._session_factory, sessionmaker)
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
|
||||
|
||||
def test_get_form_by_token_none(unbound_session_factory):
|
||||
def test_get_form_by_token_none(unbound_session_factory: sessionmaker[Session]) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
@@ -531,7 +545,10 @@ def test_get_form_by_token_none(unbound_session_factory):
|
||||
assert service.get_form_by_token("invalid") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_get_form_definition_by_token_mismatch(
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -540,7 +557,10 @@ def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFor
|
||||
assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_get_form_definition_by_token_success(
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -551,8 +571,9 @@ def test_get_form_definition_by_token_success(sample_form_record: HumanInputForm
|
||||
|
||||
|
||||
def test_get_form_definition_by_token_for_console_mismatch(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP
|
||||
|
||||
@@ -560,7 +581,9 @@ def test_get_form_definition_by_token_for_console_mismatch(
|
||||
assert service.get_form_definition_by_token_for_console("token") is None
|
||||
|
||||
|
||||
def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory):
|
||||
def test_submit_form_by_token_delivery_not_enabled(
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
@@ -570,8 +593,10 @@ def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory):
|
||||
|
||||
|
||||
def test_submit_form_by_token_no_workflow_run_id(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
):
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
repo.get_by_token.return_value = sample_form_record
|
||||
|
||||
@@ -586,7 +611,10 @@ def test_submit_form_by_token_no_workflow_run_id(
|
||||
enqueue_spy.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_ensure_form_active_errors(
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
# Submitted
|
||||
@@ -607,7 +635,10 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unb
|
||||
service.ensure_form_active(Form(expired_time_record))
|
||||
|
||||
|
||||
def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory):
|
||||
def test_ensure_not_submitted_raises(
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now())
|
||||
|
||||
@@ -615,7 +646,10 @@ def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, u
|
||||
service._ensure_not_submitted(Form(submitted_record))
|
||||
|
||||
|
||||
def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_session_factory):
|
||||
def test_enqueue_resume_workflow_not_found(
|
||||
mocker: MockerFixture,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
workflow_run_repo = MagicMock()
|
||||
@@ -631,10 +665,10 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_sessio
|
||||
|
||||
|
||||
def test_enqueue_resume_app_not_found(
|
||||
mocker,
|
||||
mocker: MockerFixture,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
) -> None:
|
||||
service = HumanInputService(sqlite_session_factory)
|
||||
|
||||
workflow_run = MagicMock()
|
||||
@@ -660,8 +694,10 @@ def test_enqueue_resume_app_not_found(
|
||||
|
||||
|
||||
def test_is_globally_expired_zero_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
):
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = HumanInputService(unbound_session_factory)
|
||||
|
||||
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
@@ -669,7 +705,9 @@ def test_is_globally_expired_zero_timeout(
|
||||
|
||||
|
||||
def test_submit_form_by_token_normalizes_select_and_files(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
@@ -751,7 +789,8 @@ def test_submit_form_by_token_normalizes_select_and_files(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_select_value(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
@@ -779,7 +818,8 @@ def test_submit_form_by_token_invalid_select_value(
|
||||
|
||||
|
||||
def test_submit_form_by_token_invalid_file_list_item(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
@@ -805,7 +845,9 @@ def test_submit_form_by_token_invalid_file_list_item(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
@@ -837,7 +879,9 @@ def test_submit_form_by_token_rejects_cross_tenant_file(
|
||||
|
||||
|
||||
def test_submit_form_by_token_rejects_cross_tenant_file_list(
|
||||
sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture
|
||||
sample_form_record: HumanInputFormRecord,
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
|
||||
definition = FormDefinition(
|
||||
|
||||
@@ -5,6 +5,7 @@ from threading import Barrier
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import URL, Engine
|
||||
from sqlalchemy.exc import UnboundExecutionError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
@@ -58,6 +59,25 @@ def test_core_session_factory_uses_the_shared_sqlite_session_factory(
|
||||
assert session.scalar(text("SELECT value FROM global_factory_probe")) == 42
|
||||
|
||||
|
||||
def test_unbound_session_factory_disables_explicit_and_global_database_access(
|
||||
unbound_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
assert session_factory_module.session_factory.get_session_maker() is unbound_session_factory
|
||||
|
||||
with unbound_session_factory() as session:
|
||||
with pytest.raises(UnboundExecutionError):
|
||||
session.get_bind()
|
||||
|
||||
with session_factory_module.session_factory.create_session() as session:
|
||||
with pytest.raises(UnboundExecutionError):
|
||||
session.execute(text("SELECT 1"))
|
||||
|
||||
|
||||
def test_unbound_session_rejects_database_access(unbound_session: Session) -> None:
|
||||
with pytest.raises(UnboundExecutionError):
|
||||
unbound_session.scalar(text("SELECT 1"))
|
||||
|
||||
|
||||
def test_sqlite_session_factory_shares_one_database_across_worker_sessions(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
|
||||
+6
-665
@@ -21,50 +21,12 @@ x-shared-api-worker-config: &shared-api-worker-config
|
||||
required: false
|
||||
- path: ./envs/vectorstores/weaviate.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/qdrant.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/oceanbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/seekdb.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/couchbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/pgvector.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/vastbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/pgvecto-rs.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/chroma.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/iris.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/oracle.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/opengauss.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/myscale.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/matrixone.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/elasticsearch.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/opensearch.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/milvus.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/nginx.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/certbot.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/ssrf-proxy.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/etcd.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/minio.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/milvus-standalone.env
|
||||
required: false
|
||||
- ./.env
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
@@ -87,50 +49,12 @@ x-shared-worker-config: &shared-worker-config
|
||||
required: false
|
||||
- path: ./envs/vectorstores/weaviate.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/qdrant.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/oceanbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/seekdb.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/couchbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/pgvector.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/vastbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/pgvecto-rs.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/chroma.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/iris.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/oracle.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/opengauss.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/myscale.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/matrixone.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/elasticsearch.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/opensearch.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/milvus.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/nginx.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/certbot.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/ssrf-proxy.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/etcd.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/minio.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/milvus-standalone.env
|
||||
required: false
|
||||
- ./.env
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
@@ -153,50 +77,12 @@ x-shared-worker-beat-config: &shared-worker-beat-config
|
||||
required: false
|
||||
- path: ./envs/vectorstores/weaviate.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/qdrant.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/oceanbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/seekdb.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/couchbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/pgvector.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/vastbase.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/pgvecto-rs.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/chroma.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/iris.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/oracle.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/opengauss.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/myscale.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/matrixone.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/elasticsearch.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/opensearch.env
|
||||
required: false
|
||||
- path: ./envs/vectorstores/milvus.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/nginx.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/certbot.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/ssrf-proxy.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/etcd.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/minio.env
|
||||
required: false
|
||||
- path: ./envs/infrastructure/milvus-standalone.env
|
||||
required: false
|
||||
- ./.env
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
@@ -251,18 +137,11 @@ services:
|
||||
db_mysql:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
oceanbase:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
seekdb:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
agent_backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
# Mount the storage directory to the container, for storing user files.
|
||||
- ./volumes/app/storage:/app/api/storage
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
|
||||
@@ -270,9 +149,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
- default
|
||||
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
@@ -295,12 +171,8 @@ services:
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
- default
|
||||
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
@@ -325,18 +197,11 @@ services:
|
||||
db_mysql:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
oceanbase:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
seekdb:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
agent_backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
# Mount the storage directory to the container, for storing user files.
|
||||
- ./volumes/app/storage:/app/api/storage
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "celery -A celery_healthcheck.celery inspect ping"]
|
||||
@@ -345,12 +210,8 @@ services:
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
disable: ${COMPOSE_WORKER_HEALTHCHECK_DISABLED:-true}
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
- default
|
||||
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.1
|
||||
@@ -365,12 +226,6 @@ services:
|
||||
db_mysql:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
oceanbase:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
seekdb:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
@@ -380,9 +235,6 @@ services:
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
disable: ${COMPOSE_WORKER_HEALTHCHECK_DISABLED:-true}
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
- default
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
@@ -497,9 +349,7 @@ services:
|
||||
environment:
|
||||
REDISCLI_AUTH: ${REDIS_PASSWORD:-difyai123456}
|
||||
volumes:
|
||||
# Mount the redis data directory to the container.
|
||||
- ./volumes/redis/data:/data
|
||||
# Set the redis password when startup redis server.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-difyai123456}
|
||||
healthcheck:
|
||||
test:
|
||||
@@ -519,9 +369,6 @@ services:
|
||||
required: false
|
||||
- ./.env
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
# Make sure you are changing this key for your deployment with a strong key.
|
||||
# You can generate a strong key using `openssl rand -base64 42`.
|
||||
API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
|
||||
GIN_MODE: ${SANDBOX_GIN_MODE:-release}
|
||||
WORKER_TIMEOUT: ${SANDBOX_WORKER_TIMEOUT:-15}
|
||||
@@ -539,14 +386,6 @@ services:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# Local sandbox for Dify Agent shell workspaces.
|
||||
# Network isolation: local_sandbox has NO direct route to `api`. Its only
|
||||
# networks are `agent_sandbox_network` (so agent_backend can reach it on 5004
|
||||
# for shellctl, and it can reach agent_backend directly) and
|
||||
# `local_sandbox_proxy_network` (so its egress is forced through
|
||||
# agent_ssrf_proxy).
|
||||
# All non-agent_backend/localhost traffic goes through the Squid forward proxy
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
@@ -648,12 +487,6 @@ services:
|
||||
db_mysql:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
oceanbase:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
seekdb:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
@@ -684,9 +517,6 @@ services:
|
||||
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES: ${PLUGIN_MAX_FILE_SIZE:-52428800}
|
||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY}
|
||||
DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
|
||||
@@ -698,8 +528,6 @@ services:
|
||||
condition: service_started
|
||||
networks:
|
||||
- default
|
||||
# Shared internal network with local_sandbox so agent_backend can reach it
|
||||
# on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`.
|
||||
- agent_sandbox_network
|
||||
|
||||
# Dedicated SSRF proxy for the dify-agent local_sandbox.
|
||||
@@ -720,15 +548,10 @@ services:
|
||||
HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
|
||||
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
networks:
|
||||
# Needs to reach api and agent_backend as forward-proxy destinations.
|
||||
- default
|
||||
# Only agent_ssrf_proxy and local_sandbox share this internal network, so
|
||||
# the local_sandbox can reach Squid without gaining a direct route to `api`.
|
||||
- local_sandbox_proxy_network
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
ssrf_proxy:
|
||||
image: ubuntu/squid:latest
|
||||
restart: always
|
||||
@@ -743,7 +566,6 @@ services:
|
||||
"cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh",
|
||||
]
|
||||
environment:
|
||||
# pls clearly modify the squid env vars to fit your network environment.
|
||||
HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
|
||||
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}
|
||||
@@ -753,7 +575,6 @@ services:
|
||||
- default
|
||||
|
||||
# Certbot service
|
||||
# use `docker-compose --profile certbot up` to start the certbot service.
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
profiles:
|
||||
@@ -773,7 +594,6 @@ services:
|
||||
command: ["tail", "-f", "/dev/null"]
|
||||
|
||||
# The nginx reverse proxy.
|
||||
# used for reverse proxying the API service and Web service.
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
restart: always
|
||||
@@ -783,8 +603,8 @@ services:
|
||||
- ./nginx/https.conf.template:/etc/nginx/https.conf.template
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||
- ./nginx/docker-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
- ./nginx/ssl:/etc/ssl # cert dir (legacy)
|
||||
- ./volumes/certbot/conf/live:/etc/letsencrypt/live # cert dir (with certbot container)
|
||||
- ./nginx/ssl:/etc/ssl
|
||||
- ./volumes/certbot/conf/live:/etc/letsencrypt/live
|
||||
- ./volumes/certbot/conf:/etc/letsencrypt
|
||||
- ./volumes/certbot/www:/var/www/html
|
||||
entrypoint:
|
||||
@@ -798,8 +618,6 @@ services:
|
||||
NGINX_HTTPS_ENABLED: ${NGINX_HTTPS_ENABLED:-false}
|
||||
NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443}
|
||||
NGINX_PORT: ${NGINX_PORT:-80}
|
||||
# You're required to add your own SSL certificates/keys to the `./nginx/ssl` directory
|
||||
# and modify the env vars below in .env if HTTPS_ENABLED is true.
|
||||
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
|
||||
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
|
||||
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
|
||||
@@ -814,9 +632,9 @@ services:
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
ports:
|
||||
- "${EXPOSE_NGINX_PORT:-80}:${NGINX_PORT:-80}"
|
||||
- "${EXPOSE_NGINX_SSL_PORT:-443}:${NGINX_SSL_PORT:-443}"
|
||||
#ports:
|
||||
# - "${EXPOSE_NGINX_PORT:-80}:${NGINX_PORT:-80}"
|
||||
# - "${EXPOSE_NGINX_SSL_PORT:-443}:${NGINX_SSL_PORT:-443}"
|
||||
|
||||
# The Weaviate vector store.
|
||||
weaviate:
|
||||
@@ -825,11 +643,8 @@ services:
|
||||
- weaviate
|
||||
restart: always
|
||||
volumes:
|
||||
# Mount the Weaviate data directory to the con tainer.
|
||||
- ./volumes/weaviate:/var/lib/weaviate
|
||||
environment:
|
||||
# The Weaviate configurations
|
||||
# You can refer to the [Weaviate](https://weaviate.io/developers/weaviate/config-refs/env-vars) documentation for more information.
|
||||
PERSISTENCE_DATA_PATH: ${WEAVIATE_PERSISTENCE_DATA_PATH:-/var/lib/weaviate}
|
||||
QUERY_DEFAULTS_LIMIT: ${WEAVIATE_QUERY_DEFAULTS_LIMIT:-25}
|
||||
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: ${WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED:-false}
|
||||
@@ -845,487 +660,13 @@ services:
|
||||
ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
|
||||
ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
|
||||
|
||||
# OceanBase vector database
|
||||
oceanbase:
|
||||
image: oceanbase/oceanbase-ce:4.3.5-lts
|
||||
container_name: oceanbase
|
||||
profiles:
|
||||
- oceanbase
|
||||
restart: always
|
||||
volumes:
|
||||
- ./volumes/oceanbase/data:/root/ob
|
||||
- ./volumes/oceanbase/conf:/root/.obd/cluster
|
||||
- ./volumes/oceanbase/init.d:/root/boot/init.d
|
||||
environment:
|
||||
OB_MEMORY_LIMIT: ${OCEANBASE_MEMORY_LIMIT:-6G}
|
||||
OB_SYS_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
|
||||
OB_TENANT_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
|
||||
OB_CLUSTER_NAME: ${OCEANBASE_CLUSTER_NAME:-difyai}
|
||||
OB_SERVER_IP: 127.0.0.1
|
||||
MODE: mini
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
ports:
|
||||
- "${OCEANBASE_VECTOR_PORT:-2881}:2881"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'obclient -h127.0.0.1 -P2881 -uroot@test -p${OCEANBASE_VECTOR_PASSWORD:-difyai123456} -e "SELECT 1;"',
|
||||
]
|
||||
interval: 10s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
|
||||
# seekdb vector database
|
||||
seekdb:
|
||||
image: oceanbase/seekdb:latest
|
||||
container_name: seekdb
|
||||
profiles:
|
||||
- seekdb
|
||||
restart: always
|
||||
volumes:
|
||||
- ./volumes/seekdb:/var/lib/oceanbase
|
||||
environment:
|
||||
ROOT_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
|
||||
MEMORY_LIMIT: ${SEEKDB_MEMORY_LIMIT:-2G}
|
||||
REPORTER: dify-ai-seekdb
|
||||
ports:
|
||||
- "${OCEANBASE_VECTOR_PORT:-2881}:2881"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'mysql -h127.0.0.1 -P2881 -uroot -p${OCEANBASE_VECTOR_PASSWORD:-difyai123456} -e "SELECT 1;"',
|
||||
]
|
||||
interval: 5s
|
||||
retries: 60
|
||||
timeout: 5s
|
||||
|
||||
# Qdrant vector store.
|
||||
# (if used, you need to set VECTOR_STORE to qdrant in the api & worker service.)
|
||||
qdrant:
|
||||
image: langgenius/qdrant:v1.8.3
|
||||
profiles:
|
||||
- qdrant
|
||||
restart: always
|
||||
volumes:
|
||||
- ./volumes/qdrant:/qdrant/storage
|
||||
environment:
|
||||
QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456}
|
||||
|
||||
# The Couchbase vector store.
|
||||
couchbase-server:
|
||||
build: ./couchbase-server
|
||||
profiles:
|
||||
- couchbase
|
||||
restart: always
|
||||
environment:
|
||||
- CLUSTER_NAME=dify_search
|
||||
- COUCHBASE_ADMINISTRATOR_USERNAME=${COUCHBASE_USER:-Administrator}
|
||||
- COUCHBASE_ADMINISTRATOR_PASSWORD=${COUCHBASE_PASSWORD:-password}
|
||||
- COUCHBASE_BUCKET=${COUCHBASE_BUCKET_NAME:-Embeddings}
|
||||
- COUCHBASE_BUCKET_RAMSIZE=512
|
||||
- COUCHBASE_RAM_SIZE=2048
|
||||
- COUCHBASE_EVENTING_RAM_SIZE=512
|
||||
- COUCHBASE_INDEX_RAM_SIZE=512
|
||||
- COUCHBASE_FTS_RAM_SIZE=1024
|
||||
hostname: couchbase-server
|
||||
container_name: couchbase-server
|
||||
working_dir: /opt/couchbase
|
||||
stdin_open: true
|
||||
tty: true
|
||||
entrypoint: [""]
|
||||
command: sh -c "/opt/couchbase/init/init-cbserver.sh"
|
||||
volumes:
|
||||
- ./volumes/couchbase/data:/opt/couchbase/var/lib/couchbase/data
|
||||
healthcheck:
|
||||
# ensure bucket was created before proceeding
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -s -f -u Administrator:password http://localhost:8091/pools/default/buckets | grep -q '\\[{' || exit 1",
|
||||
]
|
||||
interval: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
|
||||
# The pgvector vector database.
|
||||
pgvector:
|
||||
image: pgvector/pgvector:pg16
|
||||
profiles:
|
||||
- pgvector
|
||||
restart: always
|
||||
environment:
|
||||
PGUSER: ${PGVECTOR_PGUSER:-postgres}
|
||||
# The password for the default postgres user.
|
||||
POSTGRES_PASSWORD: ${PGVECTOR_POSTGRES_PASSWORD:-difyai123456}
|
||||
# The name of the default postgres database.
|
||||
POSTGRES_DB: ${PGVECTOR_POSTGRES_DB:-dify}
|
||||
# postgres data directory
|
||||
PGDATA: ${PGVECTOR_PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
# pg_bigm module for full text search
|
||||
PG_BIGM: ${PGVECTOR_PG_BIGM:-false}
|
||||
PG_BIGM_VERSION: ${PGVECTOR_PG_BIGM_VERSION:-1.2-20240606}
|
||||
volumes:
|
||||
- ./volumes/pgvector/data:/var/lib/postgresql/data
|
||||
- ./pgvector/docker-entrypoint.sh:/docker-entrypoint.sh
|
||||
entrypoint: ["/docker-entrypoint.sh"]
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
# get image from https://www.vastdata.com.cn/
|
||||
vastbase:
|
||||
image: vastdata/vastbase-vector
|
||||
profiles:
|
||||
- vastbase
|
||||
restart: always
|
||||
environment:
|
||||
- VB_DBCOMPATIBILITY=PG
|
||||
- VB_DB=dify
|
||||
- VB_USERNAME=dify
|
||||
- VB_PASSWORD=Difyai123456
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- ./vastbase/lic:/home/vastbase/vastbase/lic
|
||||
- ./vastbase/data:/home/vastbase/data
|
||||
- ./vastbase/backup:/home/vastbase/backup
|
||||
- ./vastbase/backup_log:/home/vastbase/backup_log
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
# pgvecto-rs vector store
|
||||
pgvecto-rs:
|
||||
image: tensorchord/pgvecto-rs:pg16-v0.3.0
|
||||
profiles:
|
||||
- pgvecto-rs
|
||||
restart: always
|
||||
environment:
|
||||
PGUSER: ${PGVECTOR_PGUSER:-postgres}
|
||||
# The password for the default postgres user.
|
||||
POSTGRES_PASSWORD: ${PGVECTOR_POSTGRES_PASSWORD:-difyai123456}
|
||||
# The name of the default postgres database.
|
||||
POSTGRES_DB: ${PGVECTOR_POSTGRES_DB:-dify}
|
||||
# postgres data directory
|
||||
PGDATA: ${PGVECTOR_PGDATA:-/var/lib/postgresql/data/pgdata}
|
||||
volumes:
|
||||
- ./volumes/pgvecto_rs/data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
# Chroma vector database
|
||||
chroma:
|
||||
image: ghcr.io/chroma-core/chroma:0.5.20
|
||||
profiles:
|
||||
- chroma
|
||||
restart: always
|
||||
volumes:
|
||||
- ./volumes/chroma:/chroma/chroma
|
||||
environment:
|
||||
CHROMA_SERVER_AUTHN_CREDENTIALS: ${CHROMA_SERVER_AUTHN_CREDENTIALS:-difyai123456}
|
||||
CHROMA_SERVER_AUTHN_PROVIDER: ${CHROMA_SERVER_AUTHN_PROVIDER:-chromadb.auth.token_authn.TokenAuthenticationServerProvider}
|
||||
IS_PERSISTENT: ${CHROMA_IS_PERSISTENT:-TRUE}
|
||||
|
||||
# InterSystems IRIS vector database
|
||||
iris:
|
||||
image: containers.intersystems.com/intersystems/iris-community:2025.3
|
||||
profiles:
|
||||
- iris
|
||||
container_name: iris
|
||||
restart: always
|
||||
init: true
|
||||
ports:
|
||||
- "${IRIS_SUPER_SERVER_PORT:-1972}:1972"
|
||||
- "${IRIS_WEB_SERVER_PORT:-52773}:52773"
|
||||
volumes:
|
||||
- ./volumes/iris:/durable
|
||||
- ./iris/iris-init.script:/iris-init.script
|
||||
- ./iris/docker-entrypoint.sh:/custom-entrypoint.sh
|
||||
entrypoint: ["/custom-entrypoint.sh"]
|
||||
tty: true
|
||||
environment:
|
||||
TZ: ${IRIS_TIMEZONE:-UTC}
|
||||
ISC_DATA_DIRECTORY: /durable/iris
|
||||
|
||||
# Oracle vector database
|
||||
oracle:
|
||||
image: container-registry.oracle.com/database/free:latest
|
||||
profiles:
|
||||
- oracle
|
||||
restart: always
|
||||
volumes:
|
||||
- source: oradata
|
||||
type: volume
|
||||
target: /opt/oracle/oradata
|
||||
- ./startupscripts:/opt/oracle/scripts/startup
|
||||
environment:
|
||||
ORACLE_PWD: ${ORACLE_PWD:-Dify123456}
|
||||
ORACLE_CHARACTERSET: ${ORACLE_CHARACTERSET:-AL32UTF8}
|
||||
|
||||
# Milvus vector database services
|
||||
etcd:
|
||||
container_name: milvus-etcd
|
||||
image: quay.io/coreos/etcd:v3.5.5
|
||||
profiles:
|
||||
- milvus
|
||||
environment:
|
||||
ETCD_AUTO_COMPACTION_MODE: ${ETCD_AUTO_COMPACTION_MODE:-revision}
|
||||
ETCD_AUTO_COMPACTION_RETENTION: ${ETCD_AUTO_COMPACTION_RETENTION:-1000}
|
||||
ETCD_QUOTA_BACKEND_BYTES: ${ETCD_QUOTA_BACKEND_BYTES:-4294967296}
|
||||
ETCD_SNAPSHOT_COUNT: ${ETCD_SNAPSHOT_COUNT:-50000}
|
||||
volumes:
|
||||
- ./volumes/milvus/etcd:/etcd
|
||||
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
|
||||
healthcheck:
|
||||
test: ["CMD", "etcdctl", "endpoint", "health"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- milvus
|
||||
|
||||
minio:
|
||||
container_name: milvus-minio
|
||||
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
|
||||
profiles:
|
||||
- milvus
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||
volumes:
|
||||
- ./volumes/milvus/minio:/minio_data
|
||||
command: minio server /minio_data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- milvus
|
||||
|
||||
milvus-standalone:
|
||||
container_name: milvus-standalone
|
||||
image: milvusdb/milvus:v2.6.3
|
||||
profiles:
|
||||
- milvus
|
||||
command: ["milvus", "run", "standalone"]
|
||||
environment:
|
||||
ETCD_ENDPOINTS: ${ETCD_ENDPOINTS:-etcd:2379}
|
||||
MINIO_ADDRESS: ${MINIO_ADDRESS:-minio:9000}
|
||||
common.security.authorizationEnabled: ${MILVUS_AUTHORIZATION_ENABLED:-true}
|
||||
volumes:
|
||||
- ./volumes/milvus/milvus:/var/lib/milvus
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
|
||||
interval: 30s
|
||||
start_period: 90s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- etcd
|
||||
- minio
|
||||
ports:
|
||||
- 19530:19530
|
||||
- 9091:9091
|
||||
networks:
|
||||
- milvus
|
||||
|
||||
# Opensearch vector database
|
||||
opensearch:
|
||||
container_name: opensearch
|
||||
image: opensearchproject/opensearch:latest
|
||||
profiles:
|
||||
- opensearch
|
||||
environment:
|
||||
discovery.type: ${OPENSEARCH_DISCOVERY_TYPE:-single-node}
|
||||
bootstrap.memory_lock: ${OPENSEARCH_BOOTSTRAP_MEMORY_LOCK:-true}
|
||||
OPENSEARCH_JAVA_OPTS: -Xms${OPENSEARCH_JAVA_OPTS_MIN:-512m} -Xmx${OPENSEARCH_JAVA_OPTS_MAX:-1024m}
|
||||
OPENSEARCH_INITIAL_ADMIN_PASSWORD: ${OPENSEARCH_INITIAL_ADMIN_PASSWORD:-Qazwsxedc!@#123}
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: ${OPENSEARCH_MEMLOCK_SOFT:--1}
|
||||
hard: ${OPENSEARCH_MEMLOCK_HARD:--1}
|
||||
nofile:
|
||||
soft: ${OPENSEARCH_NOFILE_SOFT:-65536}
|
||||
hard: ${OPENSEARCH_NOFILE_HARD:-65536}
|
||||
volumes:
|
||||
- ./volumes/opensearch/data:/usr/share/opensearch/data
|
||||
networks:
|
||||
- opensearch-net
|
||||
|
||||
opensearch-dashboards:
|
||||
container_name: opensearch-dashboards
|
||||
image: opensearchproject/opensearch-dashboards:latest
|
||||
profiles:
|
||||
- opensearch
|
||||
environment:
|
||||
OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
|
||||
volumes:
|
||||
- ./volumes/opensearch/opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml
|
||||
networks:
|
||||
- opensearch-net
|
||||
depends_on:
|
||||
- opensearch
|
||||
|
||||
# opengauss vector database.
|
||||
opengauss:
|
||||
image: opengauss/opengauss:7.0.0-RC1
|
||||
profiles:
|
||||
- opengauss
|
||||
privileged: true
|
||||
restart: always
|
||||
environment:
|
||||
GS_USERNAME: ${OPENGAUSS_USER:-postgres}
|
||||
GS_PASSWORD: ${OPENGAUSS_PASSWORD:-Dify@123}
|
||||
GS_PORT: ${OPENGAUSS_PORT:-6600}
|
||||
GS_DB: ${OPENGAUSS_DATABASE:-dify}
|
||||
volumes:
|
||||
- ./volumes/opengauss/data:/var/lib/opengauss/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "netstat -lntp | grep tcp6 > /dev/null 2>&1"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
ports:
|
||||
- ${OPENGAUSS_PORT:-6600}:${OPENGAUSS_PORT:-6600}
|
||||
|
||||
# MyScale vector database
|
||||
myscale:
|
||||
container_name: myscale
|
||||
image: myscale/myscaledb:1.6.4
|
||||
profiles:
|
||||
- myscale
|
||||
restart: always
|
||||
tty: true
|
||||
volumes:
|
||||
- ./volumes/myscale/data:/var/lib/clickhouse
|
||||
- ./volumes/myscale/log:/var/log/clickhouse-server
|
||||
- ./volumes/myscale/config/users.d/custom_users_config.xml:/etc/clickhouse-server/users.d/custom_users_config.xml
|
||||
ports:
|
||||
- ${MYSCALE_PORT:-8123}:${MYSCALE_PORT:-8123}
|
||||
|
||||
# Matrixone vector store.
|
||||
matrixone:
|
||||
hostname: matrixone
|
||||
image: matrixorigin/matrixone:2.1.1
|
||||
profiles:
|
||||
- matrixone
|
||||
restart: always
|
||||
volumes:
|
||||
- ./volumes/matrixone/data:/mo-data
|
||||
ports:
|
||||
- ${MATRIXONE_PORT:-6001}:${MATRIXONE_PORT:-6001}
|
||||
|
||||
# https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html
|
||||
# https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-prod-prerequisites
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.14.3
|
||||
container_name: elasticsearch
|
||||
profiles:
|
||||
- elasticsearch
|
||||
- elasticsearch-ja
|
||||
restart: always
|
||||
volumes:
|
||||
- ./elasticsearch/docker-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
- dify_es01_data:/usr/share/elasticsearch/data
|
||||
environment:
|
||||
ELASTIC_PASSWORD: ${ELASTICSEARCH_PASSWORD:-elastic}
|
||||
VECTOR_STORE: ${VECTOR_STORE:-}
|
||||
cluster.name: dify-es-cluster
|
||||
node.name: dify-es0
|
||||
discovery.type: single-node
|
||||
xpack.license.self_generated.type: basic
|
||||
xpack.security.enabled: "true"
|
||||
xpack.security.enrollment.enabled: "false"
|
||||
xpack.security.http.ssl.enabled: "false"
|
||||
ports:
|
||||
- ${ELASTICSEARCH_PORT:-9200}:9200
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
entrypoint: ["sh", "-c", "sh /docker-entrypoint-mount.sh"]
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "curl", "-s", "http://localhost:9200/_cluster/health?pretty"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 50
|
||||
|
||||
# https://www.elastic.co/guide/en/kibana/current/docker.html
|
||||
# https://www.elastic.co/guide/en/kibana/current/settings.html
|
||||
kibana:
|
||||
image: docker.elastic.co/kibana/kibana:8.14.3
|
||||
container_name: kibana
|
||||
profiles:
|
||||
- elasticsearch
|
||||
depends_on:
|
||||
- elasticsearch
|
||||
restart: always
|
||||
environment:
|
||||
XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: d1a66dfd-c4d3-4a0a-8290-2abcb83ab3aa
|
||||
NO_PROXY: localhost,127.0.0.1,elasticsearch,kibana
|
||||
XPACK_SECURITY_ENABLED: "true"
|
||||
XPACK_SECURITY_ENROLLMENT_ENABLED: "false"
|
||||
XPACK_SECURITY_HTTP_SSL_ENABLED: "false"
|
||||
XPACK_FLEET_ISAIRGAPPED: "true"
|
||||
I18N_LOCALE: zh-CN
|
||||
SERVER_PORT: "5601"
|
||||
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
|
||||
ports:
|
||||
- ${KIBANA_PORT:-5601}:5601
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -s http://localhost:5601 >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# unstructured .
|
||||
# (if used, you need to set ETL_TYPE to Unstructured in the api & worker service.)
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest
|
||||
profiles:
|
||||
- unstructured
|
||||
restart: always
|
||||
volumes:
|
||||
- ./volumes/unstructured:/app/data
|
||||
|
||||
networks:
|
||||
# create a network between sandbox, api and ssrf_proxy, and can not access outside.
|
||||
ssrf_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# Internal network shared only by agent_ssrf_proxy and local_sandbox.
|
||||
local_sandbox_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# shellctl control channel (agent_backend -> local_sandbox:5004).
|
||||
# sandbox can access agent backend through this network, this is
|
||||
# a known limitation.
|
||||
#
|
||||
# The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution
|
||||
# is still possible through the shellctl channel.
|
||||
agent_sandbox_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
milvus:
|
||||
driver: bridge
|
||||
opensearch-net:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
oradata:
|
||||
dify_es01_data:
|
||||
internal: true
|
||||
@@ -22,6 +22,8 @@ E2E_MODEL_PROVIDER_CREDENTIALS_JSON='{"openai_api_key":"replace-with-real-key"}'
|
||||
# from @external-model because other external-model scenarios may not use dify-agent.
|
||||
# When enabled, the E2E runner also starts the shellctl local sandbox required by
|
||||
# dify-agent's dify.shell/config runtime layer.
|
||||
# Use E2E_AGENT_BACKEND_URL only for an already-running backend; do not set it
|
||||
# together with E2E_START_AGENT_BACKEND.
|
||||
# E2E_START_AGENT_BACKEND=1
|
||||
# E2E_AGENT_BACKEND_PORT=5050
|
||||
# E2E_AGENT_BACKEND_URL=http://127.0.0.1:5050
|
||||
|
||||
+8
-3
@@ -8,9 +8,11 @@ Run commands from the repository root. Install dependencies and browsers once wi
|
||||
|
||||
- Existing initialized instance: `pnpm -C e2e e2e`
|
||||
- Reset, initialize, and run deterministic scenarios: `pnpm -C e2e e2e:full`
|
||||
- Prepare and run scenarios backed by shared fixtures: `E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:prepared`
|
||||
- Tagged subset: `pnpm -C e2e e2e -- --tags @smoke`
|
||||
- Headed debugging: `pnpm -C e2e e2e:headed -- --tags @smoke`
|
||||
- External runtime preparation and run: `pnpm -C e2e e2e:external:prepare`, then `pnpm -C e2e e2e:external`
|
||||
- Prepare and run external runtime scenarios: `E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external`
|
||||
- Seed against existing middleware without running Cucumber: `pnpm -C e2e seed -- --profile <prepared|external-runtime|post-merge>`
|
||||
- Reset persisted E2E state: `pnpm -C e2e e2e:reset`
|
||||
- Middleware lifecycle: `pnpm -C e2e e2e:middleware:up` and `pnpm -C e2e e2e:middleware:down`
|
||||
- Scoped static checks: `vp check e2e`
|
||||
@@ -20,7 +22,8 @@ The runner reuses `web/.next/BUILD_ID` when present. Set `E2E_FORCE_WEB_BUILD=1`
|
||||
## Runtime Ownership
|
||||
|
||||
- `scripts/setup.ts` owns reset, middleware, backend, and frontend startup.
|
||||
- `scripts/run-cucumber.ts` owns E2E orchestration and Cucumber invocation.
|
||||
- `scripts/run-cucumber.ts` is the only E2E runtime orchestrator. It owns service lifetime, optional seed execution, Cucumber invocation, and teardown.
|
||||
- `scripts/seed-runner.ts` owns fixture creation and verification against an already-running runtime; it never starts services.
|
||||
- `support/web-server.ts` owns frontend reuse, readiness, and shutdown.
|
||||
- `features/support/hooks.ts` owns shared auth bootstrap, scenario lifecycle, and diagnostics.
|
||||
- `features/support/world.ts` owns `DifyWorld`, the per-scenario behavior `BrowserContext`, and its authenticated setup and cleanup client. Browser and API identities remain separate so unauthenticated and logout journeys cannot invalidate fixture ownership.
|
||||
@@ -33,12 +36,14 @@ An uninitialized instance is installed and authenticated lazily; an initialized
|
||||
## Tags And External Runtime
|
||||
|
||||
- Default scenarios use shared authenticated storage state. `@unauthenticated` creates a clean context; `@authenticated` is an intent and selection tag only.
|
||||
- `@prepared` requires the strict post-merge seed profile.
|
||||
- `@prepared` requires the prepared fixtures; the post-merge seed profile includes them.
|
||||
- `@external-model` and `@external-tool` identify scenarios that call real external runtimes. Deterministic commands exclude these tags; external commands are opt-in.
|
||||
- `@microphone` uses the checked-in fake audio fixture and an isolated Chromium context.
|
||||
- `@browser-smoke` runs focused keyboard and navigation coverage in Chromium and WebKit CI lanes.
|
||||
- Feature-owned services use their own tags. Agent v2 runtime scenarios use `@agent-backend-runtime` and require the explicit runtime-availability step. Set `E2E_START_AGENT_BACKEND=1` to start it locally, or provide `E2E_AGENT_BACKEND_URL` / `AGENT_BACKEND_BASE_URL`.
|
||||
|
||||
Seed and Cucumber must share one runtime lifecycle. Combined commands own reset, middleware, services, seed, Cucumber, and teardown; CI must not reproduce that lifecycle in workflow YAML. `E2E_START_AGENT_BACKEND=1` starts a managed local backend before the API; it is mutually exclusive with an explicit Agent backend URL.
|
||||
|
||||
Do not overload runtime tags to imply unrelated services or silently skip behavior when a required fixture is missing.
|
||||
|
||||
## Browser, API, And Contract Boundaries
|
||||
|
||||
@@ -48,16 +48,15 @@ Use `the Agent v2 configuration should be saved automatically` for Configure aut
|
||||
|
||||
## Seed and fixture contract
|
||||
|
||||
Seed scripts create or update environment-owned models, plugins, datasets, Agents, and workflows. `fixtures.steps.ts` resolves and validates those resources before a dependent behavior runs. Missing, inactive, unindexed, or drifted fixtures must throw and fail the scenario; never return `skipped`.
|
||||
Seed tasks create or update environment-owned models, plugins, datasets, Agents, and workflows. `fixtures.steps.ts` resolves and validates those resources before a dependent behavior runs. Missing, inactive, unindexed, or drifted fixtures must throw and fail the scenario; never return `skipped`.
|
||||
|
||||
`@prepared` scenarios are excluded from deterministic PR core. Post-merge runs:
|
||||
|
||||
```bash
|
||||
pnpm -C e2e e2e:post-merge:prepare
|
||||
pnpm -C e2e e2e:post-merge
|
||||
E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:post-merge
|
||||
```
|
||||
|
||||
The strict seed must finish without blocked tasks. The concrete resource inventory and defaults belong to the seed profile and environment configuration rather than this guidance.
|
||||
The command owns runtime setup, strict seed, Cucumber, and teardown. The strict seed must finish without blocked tasks. The concrete resource inventory and defaults belong to the seed profile and environment configuration rather than this guidance.
|
||||
|
||||
Organize fixture helpers by the product resource or infrastructure capability they own, not by the feature file that happens to consume them. Keep runtime readiness adapters separate from Console resource fixtures, and keep all fixture state in the current `SeedContext` or scenario `DifyWorld` rather than module globals.
|
||||
|
||||
|
||||
@@ -247,11 +247,12 @@ Then('I should see the Agent v2 tool state fixture tools', async function (this:
|
||||
const toolsSection = page.getByRole('region', { name: 'Tools' })
|
||||
|
||||
await expect(toolsSection).toBeVisible({ timeout: 30_000 })
|
||||
await expect(
|
||||
toolsSection.getByRole('button', { exact: true, name: 'Not authorized' }),
|
||||
).toHaveCount(2)
|
||||
|
||||
const { action: jsonReplaceAction, tool: jsonTool } = await expectProviderToolActionVisible(
|
||||
const {
|
||||
action: jsonReplaceAction,
|
||||
provider: jsonProvider,
|
||||
tool: jsonTool,
|
||||
} = await expectProviderToolActionVisible(
|
||||
toolsSection,
|
||||
agentBuilderPreseededResources.jsonReplaceTool,
|
||||
)
|
||||
@@ -269,10 +270,16 @@ Then('I should see the Agent v2 tool state fixture tools', async function (this:
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await expectProviderToolActionVisible(
|
||||
const { provider: tavilyProvider } = await expectProviderToolActionVisible(
|
||||
toolsSection,
|
||||
agentBuilderPreseededResources.tavilySearchTool,
|
||||
)
|
||||
await expect(
|
||||
tavilyProvider.locator('..').getByRole('button', { exact: true, name: 'Not authorized' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
jsonProvider.locator('..').getByRole('button', { exact: true, name: 'Not authorized' }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
Then('I should see the Agent v2 dual retrieval fixture settings', async function (this: DifyWorld) {
|
||||
|
||||
@@ -276,7 +276,7 @@ export const expectProviderToolActionVisible = async (
|
||||
const action = toolsSection.getByText(tool.actionName, { exact: true })
|
||||
await expect(action).toBeVisible()
|
||||
|
||||
return { action, tool }
|
||||
return { action, provider, tool }
|
||||
}
|
||||
|
||||
export const openAgentKnowledgeRetrievalDialog = async (
|
||||
|
||||
+5
-3
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"e2e": "tsx ./scripts/run-cucumber.ts",
|
||||
"e2e:external": "tsx ./scripts/run-external-runtime.ts",
|
||||
"e2e:external:prepare": "tsx ./scripts/prepare-external-runtime.ts",
|
||||
"e2e:external:prepare": "tsx ./scripts/run-cucumber.ts --seed-only --profile external-runtime",
|
||||
"e2e:full": "tsx ./scripts/run-cucumber.ts --full",
|
||||
"e2e:full:headed": "tsx ./scripts/run-cucumber.ts --full --headed",
|
||||
"e2e:headed": "tsx ./scripts/run-cucumber.ts --headed",
|
||||
@@ -13,9 +13,11 @@
|
||||
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
|
||||
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
|
||||
"e2e:post-merge": "tsx ./scripts/run-post-merge.ts",
|
||||
"e2e:post-merge:prepare": "tsx ./scripts/seed.ts --pack agent-v2 --profile post-merge",
|
||||
"e2e:post-merge:prepare": "tsx ./scripts/run-cucumber.ts --seed-only --profile post-merge",
|
||||
"e2e:prepared": "tsx ./scripts/run-prepared.ts",
|
||||
"e2e:prepared:prepare": "tsx ./scripts/run-cucumber.ts --seed-only --profile prepared",
|
||||
"e2e:reset": "tsx ./scripts/setup.ts reset",
|
||||
"seed": "tsx ./scripts/seed.ts",
|
||||
"seed": "tsx ./scripts/run-cucumber.ts --seed-only",
|
||||
"test:unit": "vitest run",
|
||||
"type-check": "tsc"
|
||||
},
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { e2eDir, isMainModule, runCommandOrThrow } from './common'
|
||||
import './env-register'
|
||||
|
||||
const main = async () => {
|
||||
await runCommandOrThrow({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/seed.ts', '--pack', 'agent-v2', '--profile', 'external-runtime'],
|
||||
cwd: e2eDir,
|
||||
})
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
+125
-173
@@ -7,56 +7,16 @@ import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/p
|
||||
import { startWebServer, stopWebServer } from '../support/web-server'
|
||||
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
|
||||
import { e2eDir, isMainModule, runCommand } from './common'
|
||||
import { parseRunOptions, shouldStartManagedAgentBackend } from './run-options'
|
||||
import { runSeed } from './seed-runner'
|
||||
import { resetState, startMiddleware, stopMiddleware } from './setup'
|
||||
import './env-register'
|
||||
|
||||
type RunOptions = {
|
||||
forwardArgs: string[]
|
||||
full: boolean
|
||||
headed: boolean
|
||||
}
|
||||
|
||||
const parseArgs = (argv: string[]): RunOptions => {
|
||||
let full = false
|
||||
let headed = false
|
||||
const forwardArgs: string[] = []
|
||||
|
||||
for (const [index, arg] of argv.entries()) {
|
||||
if (arg === '--') {
|
||||
forwardArgs.push(...argv.slice(index + 1))
|
||||
return { forwardArgs, full, headed }
|
||||
}
|
||||
|
||||
if (arg === '--full') {
|
||||
full = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--headed') {
|
||||
headed = true
|
||||
continue
|
||||
}
|
||||
|
||||
forwardArgs.push(arg)
|
||||
}
|
||||
|
||||
return { forwardArgs, full, headed }
|
||||
}
|
||||
|
||||
const hasCustomTags = (forwardArgs: string[]) =>
|
||||
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
|
||||
const fullNonExternalTags = 'not @prepared and not @external-model and not @external-tool'
|
||||
|
||||
const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true'
|
||||
|
||||
const shouldStartAgentBackend = () => {
|
||||
if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) return true
|
||||
|
||||
if (process.env.E2E_AGENT_BACKEND_URL || process.env.AGENT_BACKEND_BASE_URL) return false
|
||||
|
||||
return false
|
||||
}
|
||||
const seedCeleryQueues = 'dataset,priority_dataset,workflow_based_app_execution'
|
||||
|
||||
const readLogTail = async (logFilePath: string) => {
|
||||
const content = await readFile(logFilePath, 'utf8').catch(() => '')
|
||||
@@ -87,65 +47,41 @@ const waitForUnexpectedProcessExit = async (
|
||||
throw new Error(`${label} exited before becoming ready. See ${logFilePath}.${logTailMessage}`)
|
||||
}
|
||||
|
||||
const waitForManagedProcess = async ({
|
||||
errorMessage,
|
||||
managedProcess,
|
||||
url,
|
||||
}: {
|
||||
errorMessage: string
|
||||
managedProcess: ManagedProcess
|
||||
url: string
|
||||
}) => {
|
||||
let waiting = true
|
||||
try {
|
||||
await Promise.race([
|
||||
waitForUrl(url, 180_000, 1_000),
|
||||
waitForUnexpectedProcessExit(managedProcess, () => !waiting),
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('exited before becoming ready'))
|
||||
throw error
|
||||
|
||||
throw new Error(`${errorMessage} See ${managedProcess.logFilePath}.`)
|
||||
} finally {
|
||||
waiting = false
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const { forwardArgs, full, headed } = parseArgs(process.argv.slice(2))
|
||||
const startMiddlewareForRun = full
|
||||
const resetStateForRun = full
|
||||
const startAgentBackendForRun = shouldStartAgentBackend()
|
||||
|
||||
if (resetStateForRun) await resetState()
|
||||
|
||||
if (startMiddlewareForRun) await startMiddleware()
|
||||
|
||||
const { forwardArgs, full, headed, seed, seedOnly } = parseRunOptions(process.argv.slice(2))
|
||||
const startAgentBackendForRun = shouldStartManagedAgentBackend()
|
||||
const cucumberReportDir = path.join(e2eDir, 'cucumber-report')
|
||||
const logDir = path.join(e2eDir, '.logs')
|
||||
|
||||
await rm(cucumberReportDir, { force: true, recursive: true })
|
||||
await mkdir(logDir, { recursive: true })
|
||||
|
||||
const shellctlProcess = startAgentBackendForRun
|
||||
? await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'shellctl-sandbox'],
|
||||
cwd: e2eDir,
|
||||
label: 'shellctl sandbox',
|
||||
logFilePath: path.join(logDir, 'cucumber-shellctl-sandbox.log'),
|
||||
})
|
||||
: undefined
|
||||
|
||||
const difyAgentProcess = startAgentBackendForRun
|
||||
? await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'agent-backend'],
|
||||
cwd: e2eDir,
|
||||
env: {
|
||||
E2E_START_AGENT_BACKEND: '1',
|
||||
},
|
||||
label: 'agent backend',
|
||||
logFilePath: path.join(logDir, 'cucumber-agent-backend.log'),
|
||||
})
|
||||
: undefined
|
||||
|
||||
const apiProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'api'],
|
||||
cwd: e2eDir,
|
||||
env: startAgentBackendForRun
|
||||
? {
|
||||
E2E_START_AGENT_BACKEND: '1',
|
||||
}
|
||||
: undefined,
|
||||
label: 'api server',
|
||||
logFilePath: path.join(logDir, 'cucumber-api.log'),
|
||||
})
|
||||
|
||||
const celeryProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'celery'],
|
||||
cwd: e2eDir,
|
||||
label: 'celery worker',
|
||||
logFilePath: path.join(logDir, 'cucumber-celery.log'),
|
||||
})
|
||||
let apiProcess: ManagedProcess | undefined
|
||||
let celeryProcess: ManagedProcess | undefined
|
||||
let difyAgentProcess: ManagedProcess | undefined
|
||||
let middlewareStarted = false
|
||||
let shellctlProcess: ManagedProcess | undefined
|
||||
|
||||
let cleanupPromise: Promise<void> | undefined
|
||||
const cleanup = async () => {
|
||||
@@ -157,7 +93,7 @@ const main = async () => {
|
||||
{ label: 'Stop API server', run: () => stopManagedProcess(apiProcess) },
|
||||
{ label: 'Stop agent backend', run: () => stopManagedProcess(difyAgentProcess) },
|
||||
{ label: 'Stop shellctl sandbox', run: () => stopManagedProcess(shellctlProcess) },
|
||||
...(startMiddlewareForRun ? [{ label: 'Stop middleware', run: stopMiddleware }] : []),
|
||||
...(middlewareStarted ? [{ label: 'Stop middleware', run: stopMiddleware }] : []),
|
||||
])
|
||||
|
||||
if (cleanupErrors.length > 0)
|
||||
@@ -182,60 +118,73 @@ const main = async () => {
|
||||
process.once('SIGTERM', onTerminate)
|
||||
|
||||
try {
|
||||
if (shellctlProcess) {
|
||||
let waitingForShellctl = true
|
||||
try {
|
||||
const shellctlPort = process.env.E2E_SHELLCTL_PORT || '5004'
|
||||
await Promise.race([
|
||||
waitForUrl(`http://127.0.0.1:${shellctlPort}/healthz`, 180_000, 1_000),
|
||||
waitForUnexpectedProcessExit(shellctlProcess, () => !waitingForShellctl),
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('exited before becoming ready'))
|
||||
throw error
|
||||
if (full) await resetState()
|
||||
|
||||
throw new Error(
|
||||
`Shellctl sandbox did not become ready. See ${shellctlProcess.logFilePath}.`,
|
||||
)
|
||||
} finally {
|
||||
waitingForShellctl = false
|
||||
}
|
||||
if (full) {
|
||||
middlewareStarted = true
|
||||
await startMiddleware()
|
||||
}
|
||||
|
||||
if (difyAgentProcess) {
|
||||
let waitingForAgentBackend = true
|
||||
try {
|
||||
const agentBackendPort = process.env.E2E_AGENT_BACKEND_PORT || '5050'
|
||||
await Promise.race([
|
||||
waitForUrl(`http://127.0.0.1:${agentBackendPort}/openapi.json`, 180_000, 1_000),
|
||||
waitForUnexpectedProcessExit(difyAgentProcess, () => !waitingForAgentBackend),
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('exited before becoming ready'))
|
||||
throw error
|
||||
if (!seedOnly) await rm(cucumberReportDir, { force: true, recursive: true })
|
||||
await mkdir(logDir, { recursive: true })
|
||||
|
||||
throw new Error(`Agent backend did not become ready. See ${difyAgentProcess.logFilePath}.`)
|
||||
} finally {
|
||||
waitingForAgentBackend = false
|
||||
}
|
||||
if (startAgentBackendForRun) {
|
||||
shellctlProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'shellctl-sandbox'],
|
||||
cwd: e2eDir,
|
||||
label: 'shellctl sandbox',
|
||||
logFilePath: path.join(logDir, 'cucumber-shellctl-sandbox.log'),
|
||||
})
|
||||
const shellctlPort = process.env.E2E_SHELLCTL_PORT || '5004'
|
||||
await waitForManagedProcess({
|
||||
errorMessage: 'Shellctl sandbox did not become ready.',
|
||||
managedProcess: shellctlProcess,
|
||||
url: `http://127.0.0.1:${shellctlPort}/healthz`,
|
||||
})
|
||||
|
||||
difyAgentProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'agent-backend'],
|
||||
cwd: e2eDir,
|
||||
env: { E2E_START_AGENT_BACKEND: '1' },
|
||||
label: 'agent backend',
|
||||
logFilePath: path.join(logDir, 'cucumber-agent-backend.log'),
|
||||
})
|
||||
const agentBackendPort = process.env.E2E_AGENT_BACKEND_PORT || '5050'
|
||||
await waitForManagedProcess({
|
||||
errorMessage: 'Agent backend did not become ready.',
|
||||
managedProcess: difyAgentProcess,
|
||||
url: `http://127.0.0.1:${agentBackendPort}/openapi.json`,
|
||||
})
|
||||
}
|
||||
|
||||
let waitingForApi = true
|
||||
try {
|
||||
await Promise.race([
|
||||
waitForUrl(`${apiURL}/health`, 180_000, 1_000),
|
||||
waitForUnexpectedProcessExit(apiProcess, () => !waitingForApi),
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('exited before becoming ready'))
|
||||
throw error
|
||||
apiProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'api'],
|
||||
cwd: e2eDir,
|
||||
env: startAgentBackendForRun ? { E2E_START_AGENT_BACKEND: '1' } : undefined,
|
||||
label: 'api server',
|
||||
logFilePath: path.join(logDir, 'cucumber-api.log'),
|
||||
})
|
||||
await waitForManagedProcess({
|
||||
errorMessage: `API did not become ready at ${apiURL}/health.`,
|
||||
managedProcess: apiProcess,
|
||||
url: `${apiURL}/health`,
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
`API did not become ready at ${apiURL}/health. See ${apiProcess.logFilePath}.`,
|
||||
)
|
||||
} finally {
|
||||
waitingForApi = false
|
||||
}
|
||||
celeryProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./scripts/setup.ts',
|
||||
'celery',
|
||||
...(seed ? ['--queues', seedCeleryQueues] : []),
|
||||
],
|
||||
cwd: e2eDir,
|
||||
label: 'celery worker',
|
||||
logFilePath: path.join(logDir, 'cucumber-celery.log'),
|
||||
})
|
||||
|
||||
await startWebServer({
|
||||
baseURL,
|
||||
@@ -247,33 +196,36 @@ const main = async () => {
|
||||
timeoutMs: 300_000,
|
||||
})
|
||||
|
||||
const cucumberEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CUCUMBER_HEADLESS: headed ? '0' : '1',
|
||||
if (seed) await runSeed(seed)
|
||||
|
||||
if (!seedOnly) {
|
||||
const cucumberEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CUCUMBER_HEADLESS: headed ? '0' : '1',
|
||||
}
|
||||
|
||||
if (full && !hasCustomTags(forwardArgs)) cucumberEnv.E2E_CUCUMBER_TAGS = fullNonExternalTags
|
||||
|
||||
const result = await runCommand({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./node_modules/@cucumber/cucumber/bin/cucumber.js',
|
||||
'--config',
|
||||
'./cucumber.config.ts',
|
||||
...forwardArgs,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
env: cucumberEnv,
|
||||
})
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
const messages = await readFile(path.join(cucumberReportDir, 'report.ndjson'), 'utf8')
|
||||
assertCucumberScenariosStarted(messages)
|
||||
}
|
||||
|
||||
process.exitCode = result.exitCode
|
||||
}
|
||||
|
||||
if (startMiddlewareForRun && !hasCustomTags(forwardArgs))
|
||||
cucumberEnv.E2E_CUCUMBER_TAGS = fullNonExternalTags
|
||||
|
||||
const result = await runCommand({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./node_modules/@cucumber/cucumber/bin/cucumber.js',
|
||||
'--config',
|
||||
'./cucumber.config.ts',
|
||||
...forwardArgs,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
env: cucumberEnv,
|
||||
})
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
const messages = await readFile(path.join(cucumberReportDir, 'report.ndjson'), 'utf8')
|
||||
assertCucumberScenariosStarted(messages)
|
||||
}
|
||||
|
||||
process.exitCode = result.exitCode
|
||||
} finally {
|
||||
process.off('SIGINT', onTerminate)
|
||||
process.off('SIGTERM', onTerminate)
|
||||
|
||||
@@ -6,7 +6,16 @@ const defaultExternalRuntimeTags = '@external-model or @external-tool'
|
||||
const main = async () => {
|
||||
await runForegroundProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/run-cucumber.ts', '--', '--tags', defaultExternalRuntimeTags],
|
||||
args: [
|
||||
'tsx',
|
||||
'./scripts/run-cucumber.ts',
|
||||
'--full',
|
||||
'--profile',
|
||||
'external-runtime',
|
||||
'--',
|
||||
'--tags',
|
||||
defaultExternalRuntimeTags,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SeedOptions } from './seed-runner'
|
||||
|
||||
export type RunOptions = {
|
||||
forwardArgs: string[]
|
||||
full: boolean
|
||||
headed: boolean
|
||||
seed?: SeedOptions
|
||||
seedOnly: boolean
|
||||
}
|
||||
|
||||
const readOptionValue = (argv: string[], index: number, option: string) => {
|
||||
const value = argv[index + 1]
|
||||
if (!value || value.startsWith('--')) throw new Error(`${option} requires a value.`)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const parseRunOptions = (argv: string[]): RunOptions => {
|
||||
let allowBlocked = false
|
||||
let dryRun = false
|
||||
let full = false
|
||||
let headed = false
|
||||
let pack = 'agent-v2'
|
||||
let profile: string | undefined
|
||||
let seedOnly = false
|
||||
const forwardArgs: string[] = []
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]!
|
||||
|
||||
if (arg === '--') {
|
||||
forwardArgs.push(...argv.slice(index + 1))
|
||||
break
|
||||
}
|
||||
|
||||
if (arg === '--full') {
|
||||
full = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--headed') {
|
||||
headed = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--seed-only') {
|
||||
seedOnly = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--allow-blocked') {
|
||||
allowBlocked = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--pack') {
|
||||
pack = readOptionValue(argv, index, '--pack')
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith('--pack=')) {
|
||||
pack = arg.slice('--pack='.length)
|
||||
if (!pack) throw new Error('--pack requires a value.')
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--profile') {
|
||||
profile = readOptionValue(argv, index, '--profile')
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith('--profile=')) {
|
||||
profile = arg.slice('--profile='.length)
|
||||
if (!profile) throw new Error('--profile requires a value.')
|
||||
continue
|
||||
}
|
||||
|
||||
forwardArgs.push(arg)
|
||||
}
|
||||
|
||||
const shouldSeed = seedOnly || profile !== undefined
|
||||
if (!shouldSeed && (allowBlocked || dryRun || pack !== 'agent-v2'))
|
||||
throw new Error('Seed options require --seed-only or --profile.')
|
||||
if (dryRun && !seedOnly) throw new Error('--dry-run requires --seed-only.')
|
||||
|
||||
return {
|
||||
forwardArgs,
|
||||
full,
|
||||
headed,
|
||||
seed: shouldSeed
|
||||
? {
|
||||
allowBlocked,
|
||||
dryRun,
|
||||
pack,
|
||||
profile: profile ?? 'post-merge',
|
||||
}
|
||||
: undefined,
|
||||
seedOnly,
|
||||
}
|
||||
}
|
||||
|
||||
const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true'
|
||||
|
||||
export const shouldStartManagedAgentBackend = (env: NodeJS.ProcessEnv = process.env) => {
|
||||
const shouldStart = isTruthyEnv(env.E2E_START_AGENT_BACKEND)
|
||||
const externalUrl = env.E2E_AGENT_BACKEND_URL?.trim() || env.AGENT_BACKEND_BASE_URL?.trim()
|
||||
|
||||
if (shouldStart && externalUrl) {
|
||||
throw new Error(
|
||||
'E2E_START_AGENT_BACKEND cannot be enabled when E2E_AGENT_BACKEND_URL or AGENT_BACKEND_BASE_URL is set.',
|
||||
)
|
||||
}
|
||||
|
||||
return shouldStart
|
||||
}
|
||||
@@ -6,7 +6,16 @@ const postMergeTags = '@prepared or @external-model or @external-tool'
|
||||
const main = async () => {
|
||||
await runForegroundProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/run-cucumber.ts', '--', '--tags', postMergeTags],
|
||||
args: [
|
||||
'tsx',
|
||||
'./scripts/run-cucumber.ts',
|
||||
'--full',
|
||||
'--profile',
|
||||
'post-merge',
|
||||
'--',
|
||||
'--tags',
|
||||
postMergeTags,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { e2eDir, isMainModule, runForegroundProcess } from './common'
|
||||
import './env-register'
|
||||
|
||||
const preparedTags = '@prepared'
|
||||
|
||||
const main = async () => {
|
||||
await runForegroundProcess({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./scripts/run-cucumber.ts',
|
||||
'--full',
|
||||
'--profile',
|
||||
'prepared',
|
||||
'--',
|
||||
'--tags',
|
||||
preparedTags,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
})
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) void main()
|
||||
@@ -0,0 +1,54 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
import { createAgentV2SeedTasks } from '../features/agent-v2/support/seed'
|
||||
import { ensureAuthenticatedState } from '../fixtures/auth'
|
||||
import { createStandaloneConsoleSession } from '../support/api/console-session'
|
||||
import { runSeedTasks, writeSeedReport } from '../support/seed'
|
||||
import { baseURL } from '../test-env'
|
||||
|
||||
export type SeedOptions = {
|
||||
allowBlocked: boolean
|
||||
dryRun: boolean
|
||||
pack: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
const getTasks = (pack: string, profile: string) => {
|
||||
if (pack === 'agent-v2') return createAgentV2SeedTasks(profile)
|
||||
|
||||
throw new Error(`Unknown seed pack "${pack}".`)
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
export const runSeed = async ({ allowBlocked, dryRun, pack, profile }: SeedOptions) => {
|
||||
console.warn(`[seed] bootstrapping auth state against ${baseURL}`)
|
||||
await ensureAuth()
|
||||
|
||||
const consoleSession = await createStandaloneConsoleSession()
|
||||
try {
|
||||
const results = await runSeedTasks(getTasks(pack, profile), {
|
||||
consoleClient: consoleSession.client,
|
||||
dryRun,
|
||||
resources: new Map(),
|
||||
})
|
||||
const reportName = `${pack}-${profile}`
|
||||
const reportPath = await writeSeedReport(reportName, results)
|
||||
const blockedCount = results.filter((result) => result.status === 'blocked').length
|
||||
|
||||
console.warn(`[seed] report ${reportPath}`)
|
||||
if (blockedCount > 0 && !allowBlocked) {
|
||||
throw new Error(
|
||||
`${blockedCount} seed task${blockedCount === 1 ? '' : 's'} blocked. Re-run with --allow-blocked only when partial readiness is intentional.`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await consoleSession.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { ManagedProcess } from '../support/process'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { chromium } from '@playwright/test'
|
||||
import { createAgentV2SeedTasks } from '../features/agent-v2/support/seed'
|
||||
import { ensureAuthenticatedState } from '../fixtures/auth'
|
||||
import { createStandaloneConsoleSession } from '../support/api/console-session'
|
||||
import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process'
|
||||
import { runSeedTasks, writeSeedReport } from '../support/seed'
|
||||
import { startWebServer, stopWebServer } from '../support/web-server'
|
||||
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
|
||||
import { e2eDir, isMainModule } from './common'
|
||||
import './env-register'
|
||||
|
||||
type SeedOptions = {
|
||||
allowBlocked: boolean
|
||||
dryRun: boolean
|
||||
pack: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
const parseArgs = (argv: string[]): SeedOptions => {
|
||||
const options: SeedOptions = {
|
||||
allowBlocked: false,
|
||||
dryRun: false,
|
||||
pack: 'agent-v2',
|
||||
profile: 'post-merge',
|
||||
}
|
||||
|
||||
for (const [index, arg] of argv.entries()) {
|
||||
if (arg === '--pack') {
|
||||
options.pack = argv[index + 1] || options.pack
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--pack=')) {
|
||||
options.pack = arg.slice('--pack='.length)
|
||||
continue
|
||||
}
|
||||
if (arg === '--dry-run') options.dryRun = true
|
||||
if (arg === '--allow-blocked') options.allowBlocked = true
|
||||
if (arg === '--profile') {
|
||||
options.profile = argv[index + 1] || options.profile
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--profile=')) {
|
||||
options.profile = arg.slice('--profile='.length)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const getTasks = (pack: string, profile: string) => {
|
||||
if (pack === 'agent-v2') return createAgentV2SeedTasks(profile)
|
||||
|
||||
throw new Error(`Unknown seed pack "${pack}".`)
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
const startApiProcess = async (logDir: string) => {
|
||||
try {
|
||||
await waitForUrl(`${apiURL}/health`, 1_000, 250, 1_000)
|
||||
return undefined
|
||||
} catch {
|
||||
// Start a local API process below.
|
||||
}
|
||||
|
||||
const apiProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'api'],
|
||||
cwd: e2eDir,
|
||||
label: 'api server',
|
||||
logFilePath: path.join(logDir, 'seed-api.log'),
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForUrl(`${apiURL}/health`, 180_000, 1_000)
|
||||
return apiProcess
|
||||
} catch (error) {
|
||||
await stopManagedProcess(apiProcess)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const startCeleryProcess = async (logDir: string) =>
|
||||
startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./scripts/setup.ts',
|
||||
'celery',
|
||||
'--queues',
|
||||
'dataset,priority_dataset,workflow_based_app_execution',
|
||||
],
|
||||
cwd: e2eDir,
|
||||
label: 'celery worker',
|
||||
logFilePath: path.join(logDir, 'seed-celery.log'),
|
||||
})
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const logDir = path.join(e2eDir, '.logs')
|
||||
let apiProcess: ManagedProcess | undefined
|
||||
let celeryProcess: ManagedProcess | undefined
|
||||
let consoleSession: Awaited<ReturnType<typeof createStandaloneConsoleSession>> | undefined
|
||||
|
||||
await mkdir(logDir, { recursive: true })
|
||||
|
||||
try {
|
||||
apiProcess = await startApiProcess(logDir)
|
||||
celeryProcess = await startCeleryProcess(logDir)
|
||||
await startWebServer({
|
||||
baseURL,
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'web'],
|
||||
cwd: e2eDir,
|
||||
logFilePath: path.join(logDir, 'seed-web.log'),
|
||||
reuseExistingServer: reuseExistingWebServer,
|
||||
timeoutMs: 300_000,
|
||||
})
|
||||
|
||||
console.warn(`[seed] bootstrapping auth state against ${baseURL}`)
|
||||
await ensureAuth()
|
||||
consoleSession = await createStandaloneConsoleSession()
|
||||
|
||||
const results = await runSeedTasks(getTasks(options.pack, options.profile), {
|
||||
consoleClient: consoleSession.client,
|
||||
dryRun: options.dryRun,
|
||||
resources: new Map(),
|
||||
})
|
||||
const reportName = `${options.pack}-${options.profile}`
|
||||
const reportPath = await writeSeedReport(reportName, results)
|
||||
const blockedCount = results.filter((result) => result.status === 'blocked').length
|
||||
|
||||
console.warn(`[seed] report ${reportPath}`)
|
||||
if (blockedCount > 0 && !options.allowBlocked) {
|
||||
throw new Error(
|
||||
`${blockedCount} seed task${blockedCount === 1 ? '' : 's'} blocked. Re-run with --allow-blocked only when partial readiness is intentional.`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await consoleSession?.dispose()
|
||||
await stopWebServer()
|
||||
await stopManagedProcess(celeryProcess)
|
||||
await stopManagedProcess(apiProcess)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
waitForCondition,
|
||||
webDir,
|
||||
} from './common'
|
||||
import './env-register'
|
||||
|
||||
const buildIdPath = path.join(webDir, '.next', 'BUILD_ID')
|
||||
const webBuildStampPath = path.join(webDir, '.next', 'e2e-web-build.sha256')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './scripts/env-register'
|
||||
|
||||
export const defaultBaseURL = 'http://127.0.0.1:3000'
|
||||
export const defaultApiURL = 'http://127.0.0.1:5001'
|
||||
export const defaultLocale = 'en-US'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseRunOptions, shouldStartManagedAgentBackend } from '../scripts/run-options'
|
||||
|
||||
describe('E2E run options', () => {
|
||||
it('forwards Cucumber arguments without requesting seed data', () => {
|
||||
expect(parseRunOptions(['--tags', '@smoke'])).toEqual({
|
||||
forwardArgs: ['--tags', '@smoke'],
|
||||
full: false,
|
||||
headed: false,
|
||||
seed: undefined,
|
||||
seedOnly: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the post-merge profile for the default seed command', () => {
|
||||
expect(parseRunOptions(['--seed-only'])).toMatchObject({
|
||||
forwardArgs: [],
|
||||
seed: {
|
||||
allowBlocked: false,
|
||||
dryRun: false,
|
||||
pack: 'agent-v2',
|
||||
profile: 'post-merge',
|
||||
},
|
||||
seedOnly: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds a named profile before forwarding Cucumber arguments', () => {
|
||||
expect(parseRunOptions(['--profile', 'prepared', '--', '--tags', '@prepared'])).toMatchObject({
|
||||
forwardArgs: ['--tags', '@prepared'],
|
||||
seed: { profile: 'prepared' },
|
||||
seedOnly: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects seed-only options when no seed was requested', () => {
|
||||
expect(() => parseRunOptions(['--allow-blocked'])).toThrow(
|
||||
'Seed options require --seed-only or --profile.',
|
||||
)
|
||||
expect(() => parseRunOptions(['--dry-run', '--profile', 'prepared'])).toThrow(
|
||||
'--dry-run requires --seed-only.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed Agent backend selection', () => {
|
||||
it.each(['1', 'true'])('starts a managed backend for %s', (value) => {
|
||||
expect(shouldStartManagedAgentBackend({ E2E_START_AGENT_BACKEND: value })).toBe(true)
|
||||
})
|
||||
|
||||
it('uses an explicitly configured backend without starting a managed one', () => {
|
||||
expect(
|
||||
shouldStartManagedAgentBackend({ E2E_AGENT_BACKEND_URL: 'http://agent.example.test' }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects two Agent backend owners', () => {
|
||||
expect(() =>
|
||||
shouldStartManagedAgentBackend({
|
||||
AGENT_BACKEND_BASE_URL: 'http://agent.example.test',
|
||||
E2E_START_AGENT_BACKEND: '1',
|
||||
}),
|
||||
).toThrow('E2E_START_AGENT_BACKEND cannot be enabled')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user