From 248b2e4eca515a7e30e3c4dd4c8dc3a2fea28733 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 2 Jul 2026 10:42:13 +0200 Subject: [PATCH] Fix API CI failures --- .github/workflows/tests.yml | 6 +-- .gitignore | 5 ++- scripts/ci-docker-compose-up.sh | 45 +++++++++++++++++++ scripts/php-ci-test.sh | 8 +--- .../tests/Api/LimitedBackofficeApiTest.php | 4 +- ...EdgeGatewayAgentLoopResponsivenessTest.php | 41 +++++++++++++++++ .../EdgeGatewayMachineSignalBrokerTest.php | 15 +++++++ 7 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 scripts/ci-docker-compose-up.sh create mode 100644 services/nginx/app/tests/Unit/Selfserve/EdgeGatewayAgentLoopResponsivenessTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMachineSignalBrokerTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ee7f8bc..06d3945d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: > diff --git a/.gitignore b/.gitignore index e1f233f2..87b44db6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ .env.old /.tmp/ /.env.staging -/services/nginx/app/storage/replication-bootstrap.json \ No newline at end of file +/services/nginx/app/storage/replication-bootstrap.json +/.env_old_2 +/.openclaw/ +/services/nginx/app/build/phpstan/ diff --git a/scripts/ci-docker-compose-up.sh b/scripts/ci-docker-compose-up.sh new file mode 100644 index 00000000..6c325306 --- /dev/null +++ b/scripts/ci-docker-compose-up.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 [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 diff --git a/scripts/php-ci-test.sh b/scripts/php-ci-test.sh index 7e61b8bd..3f2c162d 100644 --- a/scripts/php-ci-test.sh +++ b/scripts/php-ci-test.sh @@ -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 diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 58050705..b002643b 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -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(); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayAgentLoopResponsivenessTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayAgentLoopResponsivenessTest.php new file mode 100644 index 00000000..721edc7e --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayAgentLoopResponsivenessTest.php @@ -0,0 +1,41 @@ +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"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMachineSignalBrokerTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMachineSignalBrokerTest.php new file mode 100644 index 00000000..1a4d6908 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayMachineSignalBrokerTest.php @@ -0,0 +1,15 @@ +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'); +});