```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
608
tests/test_runtime_activation.py
Normal file
608
tests/test_runtime_activation.py
Normal file
@@ -0,0 +1,608 @@
|
||||
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
|
||||
Reference in New Issue
Block a user