1
0
mirror of git://f0xx.org/ac/ac-be-builder synced 2026-08-12 18:12:50 +03:00

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 <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-08-07 22:15:26 +02:00
parent 49183ea8d3
commit c1eb7be233
6 changed files with 224 additions and 4 deletions

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_out(['ok' => 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);
}

View File

@@ -33,6 +33,10 @@ if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php
require __DIR__ . '/api/heartbeat.php'; require __DIR__ . '/api/heartbeat.php';
exit; 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') { if ($route === '/logout') {
Auth::logout(); Auth::logout();

View File

@@ -109,6 +109,24 @@ SQL);
return $stmt->fetchAll() ?: []; return $stmt->fetchAll() ?: [];
} }
/** @return list<int> */
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 { public static function healthSummary(): array {
self::ensureSchema(); self::ensureSchema();
BuildRunner::reconcileRunningBuilds(); BuildRunner::reconcileRunningBuilds();

View File

@@ -16,15 +16,31 @@ final class BuildRunner {
return $which !== '' ? $which : 'docker'; 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 { public static function dockerAvailable(): bool {
$bin = escapeshellarg(self::dockerBinary()); $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 { public static function dockerCheckDetail(): string {
$bin = self::dockerBinary(); $bin = self::dockerBinary();
$sock = '/var/run/docker.sock'; $sock = '/var/run/docker.sock';
$parts = ['bin=' . $bin]; $parts = ['bin=' . $bin, 'home=' . self::dockerHomeDir()];
if (!is_executable($bin)) { if (!is_executable($bin)) {
$parts[] = 'bin_not_executable'; $parts[] = 'bin_not_executable';
} }
@@ -143,7 +159,11 @@ YAML;
)); ));
$mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android')); $mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
$scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', '')))); $scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
$dockerHome = self::dockerHomeDir();
$env = [ $env = [
'HOME=' . escapeshellarg($dockerHome),
'DOCKER_CONFIG=' . escapeshellarg($dockerHome . '/.docker'),
'BUILDER_HOME=' . escapeshellarg($dockerHome),
'BUILD_ID=' . $id, 'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath), 'BUILD_LOG_FILE=' . escapeshellarg($logPath),
'BUILD_LOG_STDOUT_ONLY=1', 'BUILD_LOG_STDOUT_ONLY=1',
@@ -189,7 +209,65 @@ YAML;
'started_at' => gmdate('Y-m-d H:i:s'), '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<string, mixed> $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 { public static function failBuild(int $id, string $message, ?string $logPath = null): void {
@@ -205,6 +283,9 @@ YAML;
]); ]);
$build = BuildRepository::getById($id); $build = BuildRepository::getById($id);
if ($build) { if ($build) {
if (GiteaCiStatus::isEnabled()) {
GiteaCiStatus::reportForBuild($build, 'failure', substr($safe, 0, 120));
}
BuildNotifier::onFailure($id, $build, $safe); BuildNotifier::onFailure($id, $build, $safe);
} }
} }
@@ -544,8 +625,15 @@ YAML;
); );
} }
BuildRepository::update($buildId, $update); 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') { if ($status === 'failed') {
$build = BuildRepository::getById($buildId);
if ($build) { if ($build) {
BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed')); BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
} }

77
src/GiteaCiStatus.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/** Posts commit statuses to Gitea for merge gating (context: androidcast/build). */
final class GiteaCiStatus {
public const CONTEXT = 'androidcast/build';
public static function isEnabled(): bool {
return trim((string) cfg('build.gitea_api_url', '')) !== ''
&& trim((string) cfg('build.gitea_token', '')) !== '';
}
/** @param 'pending'|'success'|'failure'|'error' $state */
public static function report(
string $owner,
string $repo,
string $sha,
string $state,
string $description = '',
?string $targetUrl = null
): void {
if (!self::isEnabled() || $sha === '') {
return;
}
$api = rtrim((string) cfg('build.gitea_api_url'), '/');
$token = (string) cfg('build.gitea_token');
$url = $api . '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo)
. '/statuses/' . rawurlencode($sha);
$payload = json_encode([
'state' => $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<string, mixed> $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);
}
}

View File

@@ -77,6 +77,7 @@ foreach ([
require_once __DIR__ . '/BuildRepository.php'; require_once __DIR__ . '/BuildRepository.php';
require_once __DIR__ . '/BuildErrorSanitizer.php'; require_once __DIR__ . '/BuildErrorSanitizer.php';
require_once __DIR__ . '/BuildNotifier.php'; require_once __DIR__ . '/BuildNotifier.php';
require_once __DIR__ . '/GiteaCiStatus.php';
require_once __DIR__ . '/BuildRunner.php'; require_once __DIR__ . '/BuildRunner.php';
function cfg(string $key, $default = null) { function cfg(string $key, $default = null) {