From c1eb7be233ff67aee7b31fb4836f3a489d448038 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 7 Aug 2026 22:15:26 +0200 Subject: [PATCH] Builder BE: docker home env + Gitea CI webhook status. Mirror ac-ms-build runner fixes for cluster PHP-FPM (user nobody). Co-authored-by: Cursor --- public/api/build_webhook.php | 32 ++++++++++++ public/index.php | 4 ++ src/BuildRepository.php | 18 +++++++ src/BuildRunner.php | 96 ++++++++++++++++++++++++++++++++++-- src/GiteaCiStatus.php | 77 +++++++++++++++++++++++++++++ src/bootstrap.php | 1 + 6 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 public/api/build_webhook.php create mode 100644 src/GiteaCiStatus.php diff --git a/public/api/build_webhook.php b/public/api/build_webhook.php new file mode 100644 index 0000000..987cf5f --- /dev/null +++ b/public/api/build_webhook.php @@ -0,0 +1,32 @@ + false, 'error' => 'POST required'], 405); +} + +$secret = trim((string) cfg('build.webhook_secret', '')); +$raw = file_get_contents('php://input') ?: ''; +$sig = (string) ($_SERVER['HTTP_X_GITEA_SIGNATURE'] ?? $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''); +if ($secret !== '') { + $expected = hash_hmac('sha256', $raw, $secret); + if ($sig === '' || !hash_equals($expected, $sig)) { + json_out(['ok' => false, 'error' => 'bad_signature'], 403); + } +} + +$payload = json_decode($raw, true); +if (!is_array($payload)) { + json_out(['ok' => false, 'error' => 'invalid_json'], 400); +} + +try { + $build = BuildRunner::enqueueFromWebhook($payload); + json_out(['ok' => true, 'build' => $build]); +} catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 422); +} catch (Throwable $e) { + error_log('build_webhook: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'enqueue_failed'], 500); +} diff --git a/public/index.php b/public/index.php index 03698bb..5200702 100644 --- a/public/index.php +++ b/public/index.php @@ -33,6 +33,10 @@ if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php require __DIR__ . '/api/heartbeat.php'; exit; } +if ($route === '/api/build_webhook.php' || str_ends_with($route, '/api/build_webhook.php')) { + require __DIR__ . '/api/build_webhook.php'; + exit; +} if ($route === '/logout') { Auth::logout(); diff --git a/src/BuildRepository.php b/src/BuildRepository.php index e723eee..f402236 100644 --- a/src/BuildRepository.php +++ b/src/BuildRepository.php @@ -109,6 +109,24 @@ SQL); return $stmt->fetchAll() ?: []; } + /** @return list */ + public static function listActiveForBranch(string $branch): array { + self::ensureSchema(); + $branch = trim($branch); + if ($branch === '') { + return []; + } + $stmt = Database::pdo()->prepare( + "SELECT id FROM builds WHERE branch = ? AND status IN ('queued','running') ORDER BY id ASC" + ); + $stmt->execute([$branch]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $out[] = (int) ($row['id'] ?? 0); + } + return array_values(array_filter($out, static fn (int $id): bool => $id > 0)); + } + public static function healthSummary(): array { self::ensureSchema(); BuildRunner::reconcileRunningBuilds(); diff --git a/src/BuildRunner.php b/src/BuildRunner.php index 982252c..bb6be02 100644 --- a/src/BuildRunner.php +++ b/src/BuildRunner.php @@ -16,15 +16,31 @@ final class BuildRunner { return $which !== '' ? $which : 'docker'; } + /** Writable HOME for docker CLI when PHP-FPM runs as nobody (default HOME=/). */ + public static function dockerHomeDir(): string { + $home = trim((string) cfg('build.docker_home', '/var/www/ac/broadcast')); + return $home !== '' ? rtrim($home, '/') : '/var/www/ac/broadcast'; + } + + public static function dockerEnvPrefix(): string { + $home = self::dockerHomeDir(); + $config = $home . '/.docker'; + if (!is_dir($config)) { + @mkdir($config, 0775, true); + } + return 'HOME=' . escapeshellarg($home) . ' DOCKER_CONFIG=' . escapeshellarg($config) . ' '; + } + public static function dockerAvailable(): bool { $bin = escapeshellarg(self::dockerBinary()); - return trim((string) shell_exec("{$bin} info >/dev/null 2>&1 && echo ok")) === 'ok'; + $env = self::dockerEnvPrefix(); + return trim((string) shell_exec("{$env}{$bin} info >/dev/null 2>&1 && echo ok")) === 'ok'; } public static function dockerCheckDetail(): string { $bin = self::dockerBinary(); $sock = '/var/run/docker.sock'; - $parts = ['bin=' . $bin]; + $parts = ['bin=' . $bin, 'home=' . self::dockerHomeDir()]; if (!is_executable($bin)) { $parts[] = 'bin_not_executable'; } @@ -143,7 +159,11 @@ YAML; )); $mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android')); $scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', '')))); + $dockerHome = self::dockerHomeDir(); $env = [ + 'HOME=' . escapeshellarg($dockerHome), + 'DOCKER_CONFIG=' . escapeshellarg($dockerHome . '/.docker'), + 'BUILDER_HOME=' . escapeshellarg($dockerHome), 'BUILD_ID=' . $id, 'BUILD_LOG_FILE=' . escapeshellarg($logPath), 'BUILD_LOG_STDOUT_ONLY=1', @@ -189,7 +209,65 @@ YAML; 'started_at' => gmdate('Y-m-d H:i:s'), ]); - return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode]; + $build = BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode]; + if (GiteaCiStatus::isEnabled() && !empty($params['git_sha'])) { + GiteaCiStatus::reportForBuild($build, 'pending', 'Build running'); + } + + return $build; + } + + /** Cancel queued/running builds for the same branch (superseded by a newer push). */ + public static function cancelSupersededForBranch(string $branch): void { + $branch = trim($branch); + if ($branch === '') { + return; + } + foreach (BuildRepository::listActiveForBranch($branch) as $id) { + $build = BuildRepository::getById($id); + if (!$build) { + continue; + } + self::stopBuild($id); + if (GiteaCiStatus::isEnabled()) { + GiteaCiStatus::reportForBuild($build, 'error', 'Superseded by newer commit'); + } + } + } + + /** @param array $payload Gitea push webhook JSON */ + public static function enqueueFromWebhook(array $payload): array { + $ref = (string) ($payload['ref'] ?? ''); + $after = (string) ($payload['after'] ?? ''); + if ($after === '' || str_starts_with($after, '0000000')) { + throw new InvalidArgumentException('empty_or_delete_push'); + } + $branch = str_starts_with($ref, 'refs/heads/') ? substr($ref, 13) : $ref; + if ($branch === '') { + throw new InvalidArgumentException('missing_branch'); + } + $repo = is_array($payload['repository'] ?? null) ? $payload['repository'] : []; + $fullName = (string) ($repo['full_name'] ?? cfg('build.gitea_default_repo', 'ac/ac-mobile-android')); + [$owner, $repoName] = array_pad(explode('/', $fullName, 2), 2, 'ac-mobile-android'); + self::cancelSupersededForBranch($branch); + $params = [ + 'git_ref' => $branch, + 'git_sha' => $after, + 'branch' => $branch, + 'trigger_source' => 'ci', + 'gitea_owner' => $owner !== '' ? $owner : 'ac', + 'gitea_repo' => $repoName, + 'run_tests' => true, + 'run_native' => true, + 'run_apk' => true, + 'auto_ota' => false, + 'notify_on_fail' => true, + ]; + $build = self::enqueue($params, null); + if (GiteaCiStatus::isEnabled()) { + GiteaCiStatus::reportForBuild($build, 'pending', 'Build queued'); + } + return $build; } public static function failBuild(int $id, string $message, ?string $logPath = null): void { @@ -205,6 +283,9 @@ YAML; ]); $build = BuildRepository::getById($id); if ($build) { + if (GiteaCiStatus::isEnabled()) { + GiteaCiStatus::reportForBuild($build, 'failure', substr($safe, 0, 120)); + } BuildNotifier::onFailure($id, $build, $safe); } } @@ -544,8 +625,15 @@ YAML; ); } BuildRepository::update($buildId, $update); + $build = BuildRepository::getById($buildId); + if ($build && GiteaCiStatus::isEnabled()) { + if ($status === 'passed') { + GiteaCiStatus::reportForBuild($build, 'success', 'Build passed'); + } else { + GiteaCiStatus::reportForBuild($build, 'failure', 'Build failed'); + } + } if ($status === 'failed') { - $build = BuildRepository::getById($buildId); if ($build) { BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed')); } diff --git a/src/GiteaCiStatus.php b/src/GiteaCiStatus.php new file mode 100644 index 0000000..67ec319 --- /dev/null +++ b/src/GiteaCiStatus.php @@ -0,0 +1,77 @@ + $state, + 'target_url' => $targetUrl ?? self::defaultTargetUrl(), + 'description' => substr($description, 0, 240), + 'context' => self::CONTEXT, + ], JSON_UNESCAPED_SLASHES); + if ($payload === false) { + return; + } + $ch = curl_init($url); + if ($ch === false) { + return; + } + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'Content-Type: application/json', + ], + CURLOPT_POSTFIELDS => $payload, + CURLOPT_TIMEOUT => 12, + ]); + curl_exec($ch); + curl_close($ch); + } + + private static function defaultTargetUrl(): string { + $base = rtrim((string) cfg('build.public_base_url', ''), '/'); + return $base !== '' ? $base . '/build/' : ''; + } + + /** @param array $build */ + public static function reportForBuild(array $build, string $state, string $description = ''): void { + $params = json_decode((string) ($build['params_json'] ?? '{}'), true); + if (!is_array($params)) { + $params = []; + } + $owner = (string) ($params['gitea_owner'] ?? cfg('build.gitea_owner', 'ac')); + $repo = (string) ($params['gitea_repo'] ?? cfg('build.gitea_repo', 'ac-mobile-android')); + $sha = (string) ($build['git_sha'] ?? ''); + $id = (int) ($build['id'] ?? 0); + $target = self::defaultTargetUrl(); + if ($id > 0 && $target !== '') { + $target .= '?build=' . $id; + } + self::report($owner, $repo, $sha, $state, $description, $target); + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index 42bee6f..24e5993 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -77,6 +77,7 @@ foreach ([ require_once __DIR__ . '/BuildRepository.php'; require_once __DIR__ . '/BuildErrorSanitizer.php'; require_once __DIR__ . '/BuildNotifier.php'; +require_once __DIR__ . '/GiteaCiStatus.php'; require_once __DIR__ . '/BuildRunner.php'; function cfg(string $key, $default = null) {