Fix API CI failures

This commit is contained in:
Jeppe Bundgaard
2026-07-02 10:42:13 +02:00
parent 1d1ebd2176
commit 248b2e4eca
7 changed files with 112 additions and 12 deletions
+2 -4
View File
@@ -157,6 +157,7 @@ jobs:
env:
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev
TRAEFIK_WEB_PORT: "18080"
TRAEFIK_WEBSECURE_PORT: "18443"
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
@@ -235,11 +236,8 @@ jobs:
with:
node-version: 22
- name: Prune stale Docker networks
run: docker network prune -f || true
- name: Boot local stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
- name: Sync PHP app checkout
run: >
+3
View File
@@ -14,3 +14,6 @@
/.tmp/
/.env.staging
/services/nginx/app/storage/replication-bootstrap.json
/.env_old_2
/.openclaw/
/services/nginx/app/build/phpstan/
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -eq 0 ]; then
echo "Usage: $0 <service> [service ...]" >&2
exit 2
fi
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
compose_up() {
attempt=1
while :; do
docker network prune -f >/dev/null 2>&1 || true
if docker compose $compose_files up -d "$@"; then
return 0
fi
status="$?"
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
sleep_seconds=$((attempt * 5))
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
if command -v flock >/dev/null 2>&1; then
(
flock 9
compose_up "$@"
) 9>"$lock_file"
else
echo "flock is not available; running Docker compose startup without a host lock." >&2
compose_up "$@"
fi
+2 -6
View File
@@ -18,6 +18,7 @@ cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
@@ -84,10 +85,6 @@ composer_install() {
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
}
prune_unused_docker_networks() {
docker network prune -f >/dev/null 2>&1 || true
}
configure_ci_docker_subnet() {
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
return
@@ -119,8 +116,7 @@ cleanup() {
trap cleanup EXIT INT TERM
configure_ci_docker_subnet
prune_unused_docker_networks
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
@@ -314,7 +314,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect($deactivated->data()['active'] ?? true)->toBeFalse();
$userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id`, `deleted_at` FROM `users` WHERE `id` = ' . $employeeId);
expect($userRow['password'] ?? 'not-null')->toBeNull();
expect($userRow)->not->toBeNull();
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
expect($userRow['password'])->toBeNull();
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
expect($userRow['deleted_at'] ?? null)->not->toBeNull();
});
@@ -0,0 +1,41 @@
<?php
it('services broker transport before replaying queued outbox items', function (): void {
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
$runOffset = strpos($agentSource, 'public function run(): void');
expect($runOffset)->not->toBeFalse();
$loopSource = substr($agentSource, (int)$runOffset, 1800);
$configureOffset = strpos($loopSource, '$this->configureBrokerClient();');
$pumpOffset = strpos($loopSource, '$this->pumpBrokerTransport();');
$flushOffset = strpos($loopSource, '$this->flushOutbox();');
expect($configureOffset)->not->toBeFalse()
->and($pumpOffset)->not->toBeFalse()
->and($flushOffset)->not->toBeFalse()
->and((int)$configureOffset)->toBeLessThan((int)$pumpOffset)
->and((int)$pumpOffset)->toBeLessThan((int)$flushOffset);
});
it('bounds stale outbox replay so it cannot monopolize the gateway loop', function (): void {
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($agentSource)->toContain('private const OUTBOX_REPLAY_BATCH_LIMIT = 3;')
->and($agentSource)->toContain('private const OUTBOX_REPLAY_TIMEOUT_SECONDS = 3;')
->and($agentSource)->toContain('private const OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS = 10;')
->and($agentSource)->toContain('private const OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS = 15;')
->and($agentSource)->toContain('private int $lastOutboxFailureAt = 0;')
->and($agentSource)->toContain('$this->stateStore->queuedItems(self::OUTBOX_REPLAY_BATCH_LIMIT)')
->and($agentSource)->toContain('private function shouldSkipOutboxReplay(): bool')
->and($agentSource)->toContain('$this->lastOutboxFailureAt = time();')
->and($agentSource)->toContain('? self::OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS')
->and($agentSource)->toContain(': self::OUTBOX_REPLAY_TIMEOUT_SECONDS;');
});
it('uses a short control-plane timeout for self-serve machine signals before queueing', function (): void {
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($agentSource)->toContain('private const MACHINE_SIGNAL_TIMEOUT_SECONDS = 3;')
->and($agentSource)->toContain('private function sendControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): bool')
->and($agentSource)->toContain("'machine_signal',\n self::MACHINE_SIGNAL_TIMEOUT_SECONDS");
});
@@ -0,0 +1,15 @@
<?php
it('wires self-serve machine signals through the broker control plane', function (): void {
$routeSource = (string)file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
$agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
$machineSignalSource = (string)file_get_contents(app_path('modules/selfserve/classes/selfserve_machine_signal.php'));
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/selfserve/machine-signal'")
->and($routeSource)->toContain('private function handleBrokerSelfserveMachineSignal(): void')
->and($routeSource)->toContain('recordBrokerEdgeGatewaySignal($gatewayId, $payload)')
->and($agentSource)->toContain("'type' => 'MACHINE_SIGNAL'")
->and($agentSource)->toContain('#/edge-agent/gateways/\d+/selfserve/machine-signal$#')
->and($machineSignalSource)->toContain('public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array')
->and($machineSignalSource)->toContain('private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array');
});