feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
609 lines
21 KiB
Python
609 lines
21 KiB
Python
import shutil
|
|
import subprocess
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine, text
|
|
from sqlalchemy.engine import Engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.application.scheduling import create_scheduler
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.database import Base
|
|
from app.core.database.safety import validate_platform_migration_target
|
|
from app.core.utils.time import utc_now
|
|
from app.modules.events.constants import EventStatus
|
|
from app.modules.events.models import DomainEvent
|
|
from app.modules.feishu.constants import FeishuInboundStatus
|
|
from app.modules.feishu.models import FeishuEventReceipt
|
|
from app.modules.observability.constants import (
|
|
HeartbeatComponent,
|
|
ObservabilityKey,
|
|
ObservabilityStatus,
|
|
)
|
|
from app.modules.observability.models import SystemHeartbeat
|
|
from app.modules.observability import runtime as observability_runtime
|
|
from app.modules.observability.service import ObservabilityService
|
|
from app.modules.workflows.constants import WorkflowStatus
|
|
from app.modules.workflows.models import WorkflowInstance
|
|
from app.tools import run_scheduler, runtime_preflight
|
|
|
|
|
|
def _production_settings(**overrides: object) -> Settings:
|
|
values: dict[str, object] = {
|
|
"app_env": "production",
|
|
"database_url": (
|
|
"postgresql+psycopg://app:runtime-database-password-2026@db/app"
|
|
),
|
|
"api_key": "runtime-service-key-2026-primary",
|
|
"audit_api_key": "runtime-audit-key-2026-independent",
|
|
"cors_origins": ["https://internal.example.com"],
|
|
"debug": False,
|
|
"mask_sensitive_responses": True,
|
|
"read_only_mode": True,
|
|
"feishu_app_id": None,
|
|
"feishu_app_secret": None,
|
|
"feishu_event_transport": "disabled",
|
|
"feishu_verification_token": None,
|
|
"feishu_user_features_enabled": False,
|
|
}
|
|
values.update(overrides)
|
|
return Settings(_env_file=None, **values)
|
|
|
|
|
|
def _stamp_alembic_head(engine: Engine) -> None:
|
|
expected = runtime_preflight.expected_alembic_heads()
|
|
assert len(expected) == 1
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("create table alembic_version (version_num varchar(32))")
|
|
)
|
|
connection.execute(
|
|
text("insert into alembic_version (version_num) values (:revision)"),
|
|
{"revision": expected[0]},
|
|
)
|
|
|
|
|
|
def test_production_feishu_transport_fails_closed() -> None:
|
|
with pytest.raises(ValueError, match="FEISHU_EVENT_TRANSPORT"):
|
|
_production_settings(
|
|
feishu_user_features_enabled=True,
|
|
feishu_admin_identities=["tenant:open-id"],
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="FEISHU_VERIFICATION_TOKEN"):
|
|
_production_settings(
|
|
feishu_event_transport="webhook",
|
|
feishu_app_id="app-id",
|
|
feishu_app_secret="feishu-app-secret-2026-secure-value",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="FEISHU_APP_ID"):
|
|
_production_settings(feishu_event_transport="long_connection")
|
|
|
|
settings = _production_settings(
|
|
feishu_event_transport="long_connection",
|
|
feishu_app_id="app-id",
|
|
feishu_app_secret="feishu-app-secret-2026-secure-value",
|
|
feishu_user_features_enabled=True,
|
|
feishu_admin_identities=["tenant:open-id"],
|
|
)
|
|
assert settings.feishu_event_transport == "long_connection"
|
|
|
|
|
|
def test_production_database_target_fails_closed() -> None:
|
|
with pytest.raises(ValueError, match="must use PostgreSQL"):
|
|
_production_settings(
|
|
database_url=(
|
|
"mysql+pymysql://app:runtime-database-password-2026@legacy/business"
|
|
),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="must target different databases"):
|
|
_production_settings(
|
|
database_url=(
|
|
"postgresql+psycopg://platform:platform-password-2026-secure@db/platform"
|
|
),
|
|
legacy_database_url=(
|
|
"postgresql://readonly:readonly-password-2026-secure@db:5432/platform"
|
|
),
|
|
)
|
|
|
|
settings = _production_settings(
|
|
database_url=(
|
|
"postgresql+psycopg://platform:platform-password-2026-secure@db/platform"
|
|
),
|
|
legacy_database_url=(
|
|
"mysql+pymysql://readonly:readonly-password-2026-secure@legacy/business"
|
|
),
|
|
)
|
|
assert settings.database_url.startswith("postgresql")
|
|
|
|
|
|
def test_production_placeholders_and_invalid_admin_identities_fail_closed() -> None:
|
|
placeholder_key = "replace-with-a-random-service-key"
|
|
with pytest.raises(ValueError, match="API_KEY/API_KEYS") as error:
|
|
_production_settings(api_key=placeholder_key)
|
|
assert placeholder_key not in str(error.value)
|
|
with pytest.raises(ValueError, match="DATABASE_URL password"):
|
|
_production_settings(
|
|
database_url="postgresql+psycopg://app:change-me@db/app",
|
|
)
|
|
with pytest.raises(ValueError, match="tenant_key:open_id"):
|
|
_production_settings(
|
|
feishu_event_transport="long_connection",
|
|
feishu_app_id="app-id",
|
|
feishu_app_secret="feishu-app-secret-2026-secure-value",
|
|
feishu_user_features_enabled=True,
|
|
feishu_admin_identities=["not-a-valid-identity"],
|
|
)
|
|
|
|
|
|
def test_platform_migration_target_never_accepts_mysql_or_legacy_database() -> None:
|
|
with pytest.raises(RuntimeError, match="PostgreSQL or local SQLite"):
|
|
validate_platform_migration_target(
|
|
"mysql+pymysql://platform:secret@legacy/business",
|
|
None,
|
|
)
|
|
with pytest.raises(RuntimeError, match="matches LEGACY_DATABASE_URL"):
|
|
validate_platform_migration_target(
|
|
"postgresql+psycopg://platform:one@db/platform",
|
|
"postgresql://readonly:two@db:5432/platform",
|
|
)
|
|
|
|
|
|
def test_environment_overrides_dotenv(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
base = tmp_path / ".env"
|
|
base.write_text("SCHEDULER_ENABLED=true\n", encoding="utf-8")
|
|
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
|
|
|
|
assert Settings(_env_file=base).scheduler_enabled is True
|
|
|
|
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
|
|
assert Settings(_env_file=base).scheduler_enabled is False
|
|
|
|
|
|
def test_scheduler_process_refuses_disabled_flag(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
run_scheduler,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(scheduler_enabled=False),
|
|
)
|
|
monkeypatch.setattr(
|
|
run_scheduler,
|
|
"create_scheduler",
|
|
lambda: pytest.fail("disabled scheduler must not be created"),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="SCHEDULER_ENABLED"):
|
|
run_scheduler.main()
|
|
|
|
|
|
def test_runtime_preflight_requires_exact_alembic_head(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "runtime-preflight.db"
|
|
database_url = f"sqlite:///{database_path.as_posix()}"
|
|
settings = Settings(
|
|
_env_file=None,
|
|
database_url=database_url,
|
|
feishu_event_transport="disabled",
|
|
)
|
|
expected = runtime_preflight.expected_alembic_heads()
|
|
assert len(expected) == 1
|
|
engine = create_engine(database_url)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("create table alembic_version (version_num varchar(32))")
|
|
)
|
|
connection.execute(
|
|
text("insert into alembic_version (version_num) values ('old')")
|
|
)
|
|
with pytest.raises(
|
|
runtime_preflight.RuntimePreflightError,
|
|
match="current Alembic head",
|
|
):
|
|
runtime_preflight.run_preflight(settings)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(text("delete from alembic_version"))
|
|
connection.execute(
|
|
text(
|
|
"insert into alembic_version (version_num) values (:revision)"
|
|
),
|
|
{"revision": expected[0]},
|
|
)
|
|
result = runtime_preflight.run_preflight(settings)
|
|
assert result.revisions == expected
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_scheduler_registers_immediate_runtime_heartbeats(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("TASK_QUEUE_ENABLED", "true")
|
|
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
|
|
get_settings.cache_clear()
|
|
try:
|
|
scheduler = create_scheduler()
|
|
|
|
assert scheduler.get_job("scheduler_heartbeat").next_run_time is not None
|
|
assert scheduler.get_job("worker_heartbeat").next_run_time is not None
|
|
assert scheduler.get_job("event_dispatch").next_run_time is not None
|
|
assert scheduler.get_job("feishu_inbound_event_cycle") is not None
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_required_component_heartbeats_control_readiness(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
|
|
monkeypatch.setenv("FEISHU_ADMIN_IDENTITIES", "")
|
|
monkeypatch.setenv("FEISHU_EVENT_TRANSPORT", "long_connection")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "app-id")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret")
|
|
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
|
|
get_settings.cache_clear()
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
_stamp_alembic_head(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
service = ObservabilityService(db)
|
|
missing = service.ready()
|
|
assert missing[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.SCHEDULER][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.DEGRADED
|
|
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.FEISHU_EVENTS][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.DEGRADED
|
|
|
|
service.record_heartbeat(
|
|
HeartbeatComponent.SCHEDULER,
|
|
"scheduler-test",
|
|
)
|
|
service.record_heartbeat(
|
|
HeartbeatComponent.FEISHU_EVENTS,
|
|
"feishu-events-test",
|
|
)
|
|
ready = service.ready()
|
|
assert ready[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
|
|
heartbeat = db.query(SystemHeartbeat).filter_by(
|
|
component=HeartbeatComponent.SCHEDULER,
|
|
).one()
|
|
heartbeat.last_seen_at = utc_now() - timedelta(minutes=10)
|
|
db.commit()
|
|
stale = service.ready()
|
|
assert stale[ObservabilityKey.CHECKS][ObservabilityKey.SCHEDULER][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.DEGRADED
|
|
finally:
|
|
get_settings.cache_clear()
|
|
engine.dispose()
|
|
|
|
|
|
def test_api_lifecycle_records_immediate_heartbeat(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine = create_engine(
|
|
"sqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
monkeypatch.setattr(observability_runtime, "SessionLocal", factory)
|
|
monkeypatch.setattr(
|
|
observability_runtime,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(heartbeat_interval_seconds=3600),
|
|
)
|
|
app = FastAPI()
|
|
observability_runtime.attach_api_heartbeat(app)
|
|
|
|
try:
|
|
with TestClient(app):
|
|
thread = app.state.api_heartbeat_thread
|
|
assert thread.is_alive()
|
|
with Session(engine) as db:
|
|
heartbeat = db.query(SystemHeartbeat).filter_by(
|
|
component=HeartbeatComponent.API,
|
|
instance_id=app.state.api_heartbeat_instance_id,
|
|
).one()
|
|
assert heartbeat.status == ObservabilityStatus.OK
|
|
|
|
assert not thread.is_alive()
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_production_readiness_requires_api_heartbeat(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = SimpleNamespace(
|
|
app_env="production",
|
|
scheduler_enabled=False,
|
|
task_queue_enabled=False,
|
|
feishu_user_features_enabled=False,
|
|
feishu_event_transport="disabled",
|
|
event_dispatch_enabled=True,
|
|
heartbeat_interval_seconds=60,
|
|
heartbeat_retention_seconds=86400,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.modules.observability.service.get_settings",
|
|
lambda: settings,
|
|
)
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
_stamp_alembic_head(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
service = ObservabilityService(db)
|
|
service.record_heartbeat(
|
|
HeartbeatComponent.SCHEDULER,
|
|
"scheduler-test",
|
|
)
|
|
|
|
missing = service.ready()
|
|
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.API][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.DEGRADED
|
|
assert missing[ObservabilityKey.CHECKS][ObservabilityKey.API][
|
|
"reason"
|
|
] == "heartbeat_missing"
|
|
|
|
service.record_heartbeat(
|
|
HeartbeatComponent.API,
|
|
"api-test",
|
|
)
|
|
ready = service.ready()
|
|
assert ready[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
assert ready[ObservabilityKey.CHECKS][ObservabilityKey.API][
|
|
ObservabilityKey.STATUS
|
|
] == ObservabilityStatus.OK
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_production_schema_readiness_requires_alembic_head(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"app.modules.observability.service.get_settings",
|
|
lambda: SimpleNamespace(app_env="production"),
|
|
)
|
|
engine = create_engine("sqlite://")
|
|
try:
|
|
with Session(engine) as db:
|
|
db.execute(text("create table alembic_version (version_num varchar(32))"))
|
|
db.execute(
|
|
text("insert into alembic_version (version_num) values ('old-revision')")
|
|
)
|
|
db.commit()
|
|
service = ObservabilityService(db)
|
|
mismatch = service._schema_check()
|
|
assert mismatch[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
|
expected = mismatch["expected"]
|
|
assert len(expected) == 1
|
|
|
|
db.execute(text("delete from alembic_version"))
|
|
db.execute(
|
|
text("insert into alembic_version (version_num) values (:revision)"),
|
|
{"revision": expected[0]},
|
|
)
|
|
db.commit()
|
|
assert service._schema_check()[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_historical_terminal_failures_do_not_block_readiness() -> None:
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
db.add(
|
|
DomainEvent(
|
|
event_id="terminal-event",
|
|
event_type="test.terminal",
|
|
aggregate_type="test",
|
|
status=EventStatus.FAILED,
|
|
max_attempts=1,
|
|
)
|
|
)
|
|
db.add(
|
|
WorkflowInstance(
|
|
code="WF-TERMINAL",
|
|
workflow_type="test",
|
|
aggregate_type="test",
|
|
status=WorkflowStatus.FAILED,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
service = ObservabilityService(db)
|
|
events = service._events_check()
|
|
workflows = service._workflows_check()
|
|
assert events[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
assert events["failed"] == 1
|
|
assert workflows[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
|
assert workflows["failed"] == 1
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_feishu_inbound_metrics_expose_counts_without_payloads() -> None:
|
|
engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(engine)
|
|
try:
|
|
with Session(engine) as db:
|
|
for index, status_value in enumerate(
|
|
(
|
|
FeishuInboundStatus.PENDING,
|
|
FeishuInboundStatus.RETRY,
|
|
FeishuInboundStatus.PROCESSING,
|
|
FeishuInboundStatus.FAILED,
|
|
)
|
|
):
|
|
db.add(
|
|
FeishuEventReceipt(
|
|
event_key=f"metric-event-{index}",
|
|
source="webhook",
|
|
status=status_value,
|
|
payload={"text": "must not appear in metrics"},
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
metrics = ObservabilityService(db)._feishu_inbound_metrics()
|
|
assert metrics == {
|
|
"pending": 1,
|
|
"retry": 1,
|
|
"processing": 1,
|
|
"failed": 1,
|
|
"reply_pending": 0,
|
|
"reply_retry": 0,
|
|
"reply_processing": 0,
|
|
"reply_failed": 0,
|
|
}
|
|
assert "payload" not in metrics
|
|
assert "must not appear" not in str(metrics)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_runtime_deployment_uses_single_dotenv_file() -> None:
|
|
compose = Path("docker-compose.yml").read_text(encoding="utf-8")
|
|
external_compose = Path("docker-compose.external-db.yml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
gitignore = Path(".gitignore").read_text(encoding="utf-8")
|
|
start_script = Path("scripts/start_runtime.ps1").read_text(encoding="utf-8")
|
|
|
|
assert "feishu-events:" in compose
|
|
assert 'profiles: ["long-connection"]' in compose
|
|
assert "/api/v1/health/ready" in compose
|
|
assert "/api/v1/health/live" not in compose
|
|
assert "${COMPOSE_DATABASE_URL:-postgresql+psycopg://" in compose
|
|
assert "@db:5432/" in compose
|
|
assert "${DATABASE_URL:" not in compose
|
|
assert compose.count("APP_ENV: production") == 5
|
|
assert 'profiles: ["internal-database"]' in external_compose
|
|
assert external_compose.count("depends_on: !override") == 5
|
|
assert external_compose.count(
|
|
"${COMPOSE_DATABASE_URL:?COMPOSE_DATABASE_URL is required "
|
|
"for external PostgreSQL mode}"
|
|
) == 5
|
|
assert "${POSTGRES_PASSWORD" not in external_compose
|
|
assert compose.count("restart: unless-stopped") >= 6
|
|
assert compose.count("path: .env") == 1
|
|
assert compose.count("required: true") == 1
|
|
assert ".env.runtime" not in compose
|
|
assert ".env" in gitignore.splitlines()
|
|
assert ".env.runtime" not in gitignore.splitlines()
|
|
assert "-WindowStyle Hidden" in start_script
|
|
assert "TASK_QUEUE_ENABLED = \"false\"" in start_script
|
|
assert "app.tools.runtime_preflight" in start_script
|
|
assert "/api/v1/health/ready" in start_script
|
|
|
|
|
|
def test_default_and_external_compose_configs_validate_without_project_dotenv(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
docker = shutil.which("docker")
|
|
if docker is None:
|
|
pytest.skip("Docker CLI is not installed")
|
|
|
|
base_compose = tmp_path / "docker-compose.yml"
|
|
external_compose = tmp_path / "docker-compose.external-db.yml"
|
|
shutil.copyfile("docker-compose.yml", base_compose)
|
|
shutil.copyfile("docker-compose.external-db.yml", external_compose)
|
|
(tmp_path / ".env").write_text(
|
|
"\n".join(
|
|
(
|
|
"API_KEY=config-test-service-key",
|
|
"AUDIT_API_KEY=config-test-audit-key",
|
|
"CORS_ORIGINS=[]",
|
|
)
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
default_interpolation = tmp_path / "default.compose.env"
|
|
default_interpolation.write_text(
|
|
"POSTGRES_PASSWORD=config-test-database-password\n",
|
|
encoding="utf-8",
|
|
)
|
|
default_command = [
|
|
docker,
|
|
"compose",
|
|
"--project-directory",
|
|
str(tmp_path),
|
|
"--env-file",
|
|
str(default_interpolation),
|
|
"-f",
|
|
str(base_compose),
|
|
]
|
|
_run_compose_config([*default_command, "config", "--quiet"], tmp_path)
|
|
default_services = _run_compose_config(
|
|
[*default_command, "config", "--services"],
|
|
tmp_path,
|
|
)
|
|
assert "db" in default_services.splitlines()
|
|
|
|
external_interpolation = tmp_path / "external.compose.env"
|
|
external_interpolation.write_text(
|
|
(
|
|
"COMPOSE_DATABASE_URL="
|
|
"postgresql+psycopg://config-user:config-password@"
|
|
"external.invalid:5432/platform\n"
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
external_command = [
|
|
docker,
|
|
"compose",
|
|
"--project-directory",
|
|
str(tmp_path),
|
|
"--env-file",
|
|
str(external_interpolation),
|
|
"-f",
|
|
str(base_compose),
|
|
"-f",
|
|
str(external_compose),
|
|
]
|
|
_run_compose_config([*external_command, "config", "--quiet"], tmp_path)
|
|
external_services = _run_compose_config(
|
|
[*external_command, "config", "--services"],
|
|
tmp_path,
|
|
)
|
|
assert "db" not in external_services.splitlines()
|
|
|
|
|
|
def _run_compose_config(command: list[str], cwd: Path) -> str:
|
|
result = subprocess.run(
|
|
command,
|
|
cwd=cwd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout
|