Isolate database credentials for agent tests

This commit is contained in:
2026-08-27 18:17:39 +02:00
parent 2df37b671b
commit efce42b967
8 changed files with 110 additions and 3 deletions

View File

@@ -39,6 +39,11 @@ als privilegierter Containerprozess, startet Aider aber als separaten Benutzer `
Dieser Unterprozess erhaelt nur Modell-Credentials, nicht die Failure-DB- oder Dieser Unterprozess erhaelt nur Modell-Credentials, nicht die Failure-DB- oder
Gitea-Zugangsdaten. Gitea-Zugangsdaten.
Die nachgelagerten Testprozesse erhalten separat konfigurierte `TEST_DB_*`-Zugangsdaten.
Dieser Benutzer darf ausschliesslich die isolierten Testschemas bearbeiten. Fuer Laravel
werden daraus `DB_*`, `ERP_DB_*` und `COCKPIT_CONFIG_DATABASE` gesetzt; Aider selbst sieht
diese Werte nie. Produktive ERP- oder Kundenzugangsdaten duerfen hier nicht verwendet werden.
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90 des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an. Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
@@ -48,6 +53,7 @@ Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
```bash ```bash
cp .env.example .env cp .env.example .env
# FAILURE_DB_PASSWORD und bei Bedarf MISTRAL_API_KEY eintragen # FAILURE_DB_PASSWORD und bei Bedarf MISTRAL_API_KEY eintragen
# TEST_DB_PASSWORD fuer den Benutzer der isolierten Testschemas eintragen
docker compose build docker compose build
docker compose run --rm automation check docker compose run --rm automation check
docker compose run --rm automation once docker compose run --rm automation once

View File

@@ -23,6 +23,13 @@ services:
POLL_SECONDS: ${POLL_SECONDS:-60} POLL_SECONDS: ${POLL_SECONDS:-60}
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90} STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test} TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test}
TEST_DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
TEST_DB_PORT: ${TEST_DB_PORT:-33060}
TEST_DB_NAME: ${TEST_DB_NAME:-laravel_ai_chat_agent_test}
TEST_DB_USER: ${TEST_DB_USER:-lai_test_runner}
TEST_DB_PASSWORD: ${TEST_DB_PASSWORD:-agent_test_secret}
TEST_CUSTOMER_DB: ${TEST_CUSTOMER_DB:-customer_agent_test}
TEST_CONFIG_DB: ${TEST_CONFIG_DB:-cockpit_agent_test}
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
volumes: volumes:

View File

@@ -33,6 +33,13 @@ class Settings:
agent_command: str agent_command: str
agent_model: str agent_model: str
test_commands: tuple[str, ...] test_commands: tuple[str, ...]
test_database_host: str
test_database_port: int
test_database_name: str
test_database_user: str
test_database_password: str
test_customer_database: str
test_config_database: str
poll_seconds: int poll_seconds: int
max_changed_files: int max_changed_files: int
max_changed_lines: int max_changed_lines: int
@@ -71,6 +78,13 @@ class Settings:
"composer install --no-interaction --prefer-dist;;php artisan test", "composer install --no-interaction --prefer-dist;;php artisan test",
) )
), ),
test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"),
test_database_port=int(os.getenv("TEST_DB_PORT", "33060")),
test_database_name=os.getenv("TEST_DB_NAME", "laravel_ai_chat_agent_test"),
test_database_user=os.getenv("TEST_DB_USER", "lai_test_runner"),
test_database_password=os.getenv("TEST_DB_PASSWORD", ""),
test_customer_database=os.getenv("TEST_CUSTOMER_DB", "customer_agent_test"),
test_config_database=os.getenv("TEST_CONFIG_DB", "cockpit_agent_test"),
poll_seconds=int(os.getenv("POLL_SECONDS", "60")), poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")), max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")), max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
@@ -87,3 +101,5 @@ class Settings:
raise ValueError(f"{self.mode} requires GITEA_TOKEN") raise ValueError(f"{self.mode} requires GITEA_TOKEN")
if not self.database_password: if not self.database_password:
raise ValueError("FAILURE_DB_PASSWORD is required") raise ValueError("FAILURE_DB_PASSWORD is required")
if not self.test_database_password:
raise ValueError("TEST_DB_PASSWORD is required")

View File

@@ -5,6 +5,7 @@ import pwd
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -46,8 +47,9 @@ class GitWorkspace:
command: str, command: str,
check: bool = True, check: bool = True,
username: str | None = None, username: str | None = None,
extra_environment: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
environment = None environment = dict(os.environ)
drop_privileges = None drop_privileges = None
if username: if username:
account = pwd.getpwnam(username) account = pwd.getpwnam(username)
@@ -61,6 +63,8 @@ class GitWorkspace:
os.setgid(account.pw_gid) os.setgid(account.pw_gid)
os.setuid(account.pw_uid) os.setuid(account.pw_uid)
environment.update(extra_environment or {})
return subprocess.run( return subprocess.run(
shlex.split(command), shlex.split(command),
cwd=self.path, cwd=self.path,

View File

@@ -99,8 +99,35 @@ class AutomationWorker:
def _run_tests(self, workspace: GitWorkspace) -> list[dict]: def _run_tests(self, workspace: GitWorkspace) -> list[dict]:
results = [] results = []
test_environment = {
"AGENT_TEST_DB_ENABLED": "true",
"TEST_DB_HOST": self.settings.test_database_host,
"TEST_DB_PORT": str(self.settings.test_database_port),
"TEST_DB_NAME": self.settings.test_database_name,
"TEST_DB_USER": self.settings.test_database_user,
"TEST_DB_PASSWORD": self.settings.test_database_password,
"TEST_CUSTOMER_DB": self.settings.test_customer_database,
"TEST_CONFIG_DB": self.settings.test_config_database,
"DB_CONNECTION": "mysql",
"DB_HOST": self.settings.test_database_host,
"DB_PORT": str(self.settings.test_database_port),
"DB_DATABASE": self.settings.test_database_name,
"DB_USERNAME": self.settings.test_database_user,
"DB_PASSWORD": self.settings.test_database_password,
"ERP_DB_HOST": self.settings.test_database_host,
"ERP_DB_PORT": str(self.settings.test_database_port),
"ERP_DATABASE": self.settings.test_customer_database,
"ERP_DB_USERNAME": self.settings.test_database_user,
"ERP_DB_PASSWORD": self.settings.test_database_password,
"COCKPIT_CONFIG_DATABASE": self.settings.test_config_database,
}
for command in self.settings.test_commands: for command in self.settings.test_commands:
result = workspace.run(command, check=False, username="coder") result = workspace.run(
command,
check=False,
username="coder",
extra_environment=test_environment,
)
results.append({ results.append({
"command": command, "command": command,
"returncode": result.returncode, "returncode": result.returncode,

View File

@@ -13,3 +13,26 @@ def test_aider_command_cannot_mutate_git_configuration_or_send_analytics() -> No
assert "--no-auto-commits" in command assert "--no-auto-commits" in command
assert command.count("/dev/null") == 2 assert command.count("/dev/null") == 2
assert command[-4:] == ["--edit-format", "diff", "--timeout", "60"] assert command[-4:] == ["--edit-format", "diff", "--timeout", "60"]
def test_aider_environment_does_not_include_database_credentials(tmp_path, monkeypatch) -> None:
config = settings(workspace_root=tmp_path)
workspace = tmp_path / "repo"
(workspace / ".git").mkdir(parents=True)
monkeypatch.setenv("TEST_DB_PASSWORD", "must-not-reach-aider")
captured = {}
def run(command, **kwargs):
captured.update(kwargs["env"])
return type("Result", (), {"returncode": 0, "stdout": "ok", "stderr": ""})()
monkeypatch.setattr("lai_automation.agent.subprocess.run", run)
monkeypatch.setattr("lai_automation.agent.os.chown", lambda *args, **kwargs: None)
monkeypatch.setattr("lai_automation.agent.pwd.getpwnam", lambda _: type(
"Account", (), {"pw_dir": str(tmp_path), "pw_uid": 10001, "pw_gid": 10001}
)())
AiderAgent(config).run(workspace, "fix it")
assert "TEST_DB_PASSWORD" not in captured
assert "DB_PASSWORD" not in captured

View File

@@ -25,6 +25,13 @@ def settings(**overrides) -> Settings:
"agent_command": "aider", "agent_command": "aider",
"agent_model": "mistral/test", "agent_model": "mistral/test",
"test_commands": ("php artisan test",), "test_commands": ("php artisan test",),
"test_database_host": "test-db",
"test_database_port": 3306,
"test_database_name": "lai_agent_test",
"test_database_user": "test_runner",
"test_database_password": "test-secret",
"test_customer_database": "customer_agent_test",
"test_config_database": "cockpit_agent_test",
"poll_seconds": 60, "poll_seconds": 60,
"max_changed_files": 12, "max_changed_files": 12,
"max_changed_lines": 800, "max_changed_lines": 800,

View File

@@ -35,3 +35,20 @@ def test_stop_signal_interrupts_worker_wait() -> None:
worker._stop(15, None) worker._stop(15, None)
assert worker.stop_event.is_set() assert worker.stop_event.is_set()
def test_database_credentials_are_only_passed_to_test_process() -> None:
worker = AutomationWorker(settings())
workspace = Mock()
workspace.run.return_value = Mock(returncode=0, stdout="ok", stderr="")
results = worker._run_tests(workspace)
assert results[0]["returncode"] == 0
environment = workspace.run.call_args.kwargs["extra_environment"]
assert environment["AGENT_TEST_DB_ENABLED"] == "true"
assert environment["TEST_DB_USER"] == "test_runner"
assert environment["TEST_DB_PASSWORD"] == "test-secret"
assert environment["DB_DATABASE"] == "lai_agent_test"
assert environment["ERP_DATABASE"] == "customer_agent_test"
assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test"