From b8000a712f6b313403ddc95c27f824b594f157c6 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 12 Jul 2026 13:53:15 +0200 Subject: [PATCH] feat(build): error sanitizer and failed-build notifier Signed-off-by: Anton Afanasyeu --- src/BuildErrorSanitizer.php | 28 +++++ src/BuildNotifier.php | 236 ++++++++++++++++++++++++++++++++++++ src/BuildRunner.php | 132 +++++++++++++++++--- 3 files changed, 381 insertions(+), 15 deletions(-) create mode 100644 src/BuildErrorSanitizer.php create mode 100644 src/BuildNotifier.php diff --git a/src/BuildErrorSanitizer.php b/src/BuildErrorSanitizer.php new file mode 100644 index 0000000..5825574 --- /dev/null +++ b/src/BuildErrorSanitizer.php @@ -0,0 +1,28 @@ +]+#i', '***', $out) ?? $out; + $out = preg_replace('#git://[^\s\'"<>]+#i', '***', $out) ?? $out; + $out = preg_replace('#ssh://[^\s\'"<>]+#i', '***', $out) ?? $out; + $out = preg_replace( + '/\b(password|passwd|pass|token|secret|api[_-]?key|bearer|authorization)\s*[:=]\s*\S+/i', + '$1=***', + $out + ) ?? $out; + $out = preg_replace( + '/(?:\/[^\s]+)?\/(?:\.env|credentials(?:\.json)?|secrets?\.(?:json|ya?ml|env))[^\s]*/i', + '***', + $out + ) ?? $out; + $out = preg_replace('#/var/www/[^\s\'"]+#', '***', $out) ?? $out; + $out = preg_replace('#/home/[^\s\'"]+#', '***', $out) ?? $out; + return trim(preg_replace('/\s{2,}/', ' ', $out) ?? $out); + } +} diff --git a/src/BuildNotifier.php b/src/BuildNotifier.php new file mode 100644 index 0000000..18b40de --- /dev/null +++ b/src/BuildNotifier.php @@ -0,0 +1,236 @@ + $build */ + public static function onFailure(int $buildId, array $build, string $errorMessage): void { + $params = json_decode((string) ($build['params_json'] ?? '{}'), true); + if (!is_array($params)) { + $params = []; + } + $trigger = (string) ($params['trigger_source'] ?? 'manual'); + $safeError = BuildErrorSanitizer::sanitize($errorMessage); + $code = (string) ($build['build_code'] ?? ('#' . $buildId)); + $ref = (string) ($build['git_ref'] ?? $build['branch'] ?? '—'); + $basePath = rtrim((string) cfg('base_path', '/app/androidcast_project/build'), '/'); + $detailUrl = self::publicBaseUrl() . $basePath . '/?view=build&id=' . $buildId; + $subject = 'Android Cast build failed: ' . $code; + $bodyText = "Build {$code} failed (ref: {$ref}).\n\n" + . $safeError . "\n\n" + . "Details: {$detailUrl}\n"; + + if (in_array($trigger, ['autobuild', 'agent', 'ci'], true)) { + self::notifyAdmin($subject, $bodyText, $code, $safeError, $detailUrl); + return; + } + + if (empty($params['notify_on_fail'])) { + return; + } + + $channel = (string) ($params['notify_channel'] ?? 'email'); + $userId = (int) ($build['created_by_user_id'] ?? 0); + $username = self::usernameFor($userId); + $userPrefix = $username !== '' ? "User {$username}: " : ''; + + if ($channel === 'telegram' || $channel === 'both') { + self::sendTelegram( + $userPrefix . $subject, + $bodyText, + self::telegramConfig() + ); + } + if ($channel === 'email' || $channel === 'both') { + $to = self::emailForUser($userId); + if ($to !== '') { + self::sendPlainEmail($to, $subject, nl2br(h($bodyText)), self::smtpConfig()); + } + } + } + + private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void { + $tg = self::telegramConfig(); + if ($tg['token'] !== '' && $tg['chat_id'] !== '') { + self::sendTelegram($subject, $bodyText, $tg); + } + $admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com'); + if ($admin !== '') { + $html = '

' . h($code) . ' failed.

' + . '
' . h($safeError) . '
' + . '

Open build log

'; + self::sendPlainEmail($admin, $subject, $html, self::smtpConfig()); + } + } + + /** @return array{token:string,chat_id:string} */ + private static function telegramConfig(): array { + $token = trim((string) cfg('build.notify.telegram_token', '')); + $chatId = trim((string) cfg('build.notify.telegram_chat_id', '')); + if ($token === '' || $chatId === '') { + $bc = self::loadBroadcastConfig(); + $token = $token !== '' ? $token : trim((string) ($bc['telegram_token'] ?? '')); + $chatId = $chatId !== '' ? $chatId : trim((string) ($bc['telegram_chat_id'] ?? '')); + } + return ['token' => $token, 'chat_id' => $chatId]; + } + + /** @return array */ + private static function smtpConfig(): array { + $mail = cfg('mail', []); + if (is_array($mail) && !empty($mail['smtp'])) { + $smtp = $mail['smtp']; + return [ + 'host' => (string) ($smtp['host'] ?? 'smtp.gmail.com'), + 'port' => (int) ($smtp['port'] ?? 587), + 'user' => (string) ($smtp['user'] ?? $smtp['username'] ?? ''), + 'pass' => (string) ($smtp['password'] ?? $smtp['pass'] ?? ''), + 'from' => (string) ($mail['from'] ?? $smtp['user'] ?? ''), + 'from_name' => (string) ($mail['from_name'] ?? 'AndroidCast Builder'), + ]; + } + $bc = self::loadBroadcastConfig(); + return [ + 'host' => (string) ($bc['smtp_host'] ?? 'smtp.gmail.com'), + 'port' => (int) ($bc['smtp_port'] ?? 587), + 'user' => (string) ($bc['smtp_user'] ?? ''), + 'pass' => (string) ($bc['smtp_password'] ?? ''), + 'from' => (string) ($bc['smtp_from'] ?? $bc['smtp_user'] ?? ''), + 'from_name' => (string) ($bc['smtp_from_name'] ?? 'AndroidCast Builder'), + ]; + } + + /** @return array */ + private static function loadBroadcastConfig(): array { + static $cached = null; + if (is_array($cached)) { + return $cached; + } + $path = (string) cfg( + 'build.notify.broadcast_config', + '/var/www/ac/broadcast/config/config.php' + ); + if ($path !== '' && is_file($path)) { + $cfg = require $path; + $cached = is_array($cfg) ? $cfg : []; + return $cached; + } + $cached = []; + return $cached; + } + + private static function publicBaseUrl(): string { + $host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? 'apps.f0xx.org'; + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') + ? 'https' : 'http'; + return $scheme . '://' . explode(',', (string) $host)[0]; + } + + private static function emailForUser(int $userId): string { + if ($userId <= 0) { + return ''; + } + $stmt = Database::pdo()->prepare( + 'SELECT email_normalized, username FROM users WHERE id = ? LIMIT 1' + ); + $stmt->execute([$userId]); + $row = $stmt->fetch(); + if (!$row) { + return ''; + } + $email = trim((string) ($row['email_normalized'] ?? '')); + if ($email !== '' && str_contains($email, '@')) { + return $email; + } + return ''; + } + + private static function usernameFor(int $userId): string { + if ($userId <= 0) { + return ''; + } + $stmt = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$userId]); + $row = $stmt->fetch(); + return $row ? (string) ($row['username'] ?? '') : ''; + } + + /** @param array{token:string,chat_id:string} $tg */ + private static function sendTelegram(string $title, string $body, array $tg): void { + if ($tg['token'] === '' || $tg['chat_id'] === '') { + return; + } + $text = '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n\n" + . htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $payload = http_build_query([ + 'chat_id' => $tg['chat_id'], + 'text' => $text, + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => '1', + ]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", + 'content' => $payload, + 'timeout' => 15, + 'ignore_errors' => true, + ], + ]); + @file_get_contents( + 'https://api.telegram.org/bot' . $tg['token'] . '/sendMessage', + false, + $ctx + ); + } + + /** @param array $smtp */ + private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void { + if ($to === '' || empty($smtp['user']) || empty($smtp['pass'])) { + return; + } + $host = (string) ($smtp['host'] ?? 'smtp.gmail.com'); + $port = (int) ($smtp['port'] ?? 587); + $from = (string) ($smtp['from'] ?? $smtp['user']); + $fromName = (string) ($smtp['from_name'] ?? 'AndroidCast Builder'); + $boundary = bin2hex(random_bytes(8)); + $headers = [ + "From: {$fromName} <{$from}>", + "To: {$to}", + 'Subject: =?UTF-8?B?' . base64_encode($subject) . '?=', + 'MIME-Version: 1.0', + "Content-Type: multipart/alternative; boundary=\"{$boundary}\"", + ]; + $plain = strip_tags(str_replace(['
', '
', '
'], "\n", $htmlBody)); + $body = "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$plain}\r\n" + . "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n" + . "--{$boundary}--"; + $sock = @fsockopen("tls://{$host}", $port, $errno, $errstr, 15); + if (!$sock) { + return; + } + $read = static fn(): string => (string) fgets($sock, 1024); + $send = static function (string $cmd) use ($sock): void { + fwrite($sock, $cmd . "\r\n"); + }; + $read(); + $send('EHLO builder.local'); + $read(); + $send('AUTH LOGIN'); + $read(); + $send(base64_encode((string) $smtp['user'])); + $read(); + $send(base64_encode((string) $smtp['pass'])); + $read(); + $send("MAIL FROM:<{$from}>"); + $read(); + $send("RCPT TO:<{$to}>"); + $read(); + $send('DATA'); + $read(); + fwrite($sock, implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n"); + $send('QUIT'); + fclose($sock); + } +} diff --git a/src/BuildRunner.php b/src/BuildRunner.php index f10f56f..982252c 100644 --- a/src/BuildRunner.php +++ b/src/BuildRunner.php @@ -94,6 +94,9 @@ YAML; public static function enqueue(array $params, ?int $userId): array { BuildRepository::ensureSchema(); + if (!isset($params['trigger_source'])) { + $params['trigger_source'] = $userId !== null ? 'manual' : 'agent'; + } $params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params); $buildCode = self::generateBuildCode(); $id = BuildRepository::create([ @@ -138,6 +141,8 @@ YAML; . ' -C ' . escapeshellarg($repo) . ' remote get-url origin 2>/dev/null' )); + $mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android')); + $scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', '')))); $env = [ 'BUILD_ID=' . $id, 'BUILD_LOG_FILE=' . escapeshellarg($logPath), @@ -146,6 +151,9 @@ YAML; 'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)), 'BUILD_WORK_DIR=' . escapeshellarg($repo), 'BUILD_OUT_DIR=' . escapeshellarg($dir), + 'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub), + 'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)), + 'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')), 'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')), 'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')), 'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')), @@ -185,15 +193,20 @@ YAML; } public static function failBuild(int $id, string $message, ?string $logPath = null): void { + $safe = BuildErrorSanitizer::sanitize($message); if ($logPath) { - file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND); + file_put_contents($logPath, "\n[builder] ERROR: {$safe}\n", FILE_APPEND); } BuildRepository::update($id, [ 'status' => 'failed', 'phase' => 'error', - 'error_message' => substr($message, 0, 512), + 'error_message' => substr($safe, 0, 512), 'finished_at' => gmdate('Y-m-d H:i:s'), ]); + $build = BuildRepository::getById($id); + if ($build) { + BuildNotifier::onFailure($id, $build, $safe); + } } public static function stopBuild(int $id): bool { @@ -276,6 +289,14 @@ YAML; /** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */ public static function detectFailedStep(int $buildId): string { + $build = BuildRepository::getById($buildId); + if ($build) { + $err = (string) ($build['error_message'] ?? ''); + $started = (string) ($build['started_at'] ?? ''); + if ($started === '' && str_contains($err, 'Docker daemon not available')) { + return 'docker-setup'; + } + } $dir = self::artifactDir($buildId); $logTail = ''; $logPath = $dir . '/build.log'; @@ -456,7 +477,7 @@ YAML; if (strlen($summary) > 480) { $summary = '…' . substr($summary, -477); } - return 'Build runner exited unexpectedly: ' . $summary; + return BuildErrorSanitizer::sanitize('Build runner exited unexpectedly: ' . $summary); } public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array { @@ -508,13 +529,27 @@ YAML; $versionApp = $info['gitSha'] ?? null; } } - BuildRepository::update($buildId, [ + $update = [ 'status' => $status, 'phase' => 'done', 'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES), 'version_app' => $versionApp, 'finished_at' => gmdate('Y-m-d H:i:s'), - ]); + ]; + if ($status === 'failed') { + $update['error_message'] = substr( + BuildErrorSanitizer::sanitize('Build finished without APK artifact'), + 0, + 512 + ); + } + BuildRepository::update($buildId, $update); + if ($status === 'failed') { + $build = BuildRepository::getById($buildId); + if ($build) { + BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed')); + } + } if (!empty($artifacts['ota']) && cfg('build.ota_mount')) { $build = BuildRepository::getById($buildId); if (!empty($build['auto_deploy'])) { @@ -533,19 +568,86 @@ YAML; return; } $build = BuildRepository::getById($buildId); - $channel = $build['ota_channel'] ?? 'staging'; + $channel = (string) ($build['ota_channel'] ?? 'staging'); $dest = $mount . '/v0'; shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/')); - if ($channel !== '' && $channel !== 'staging') { - $channelJson = $src . '/ota/channel/' . $channel . '.json'; - if (is_file($channelJson)) { - shell_exec( - 'cp -a ' - . escapeshellarg($channelJson) - . ' ' - . escapeshellarg($dest . '/ota/channel/' . $channel . '.json') - ); + $channelJson = $src . '/ota/channel/' . $channel . '.json'; + if (is_file($channelJson)) { + shell_exec( + 'mkdir -p ' + . escapeshellarg($dest . '/ota/channel') + . ' && cp -a ' + . escapeshellarg($channelJson) + . ' ' + . escapeshellarg($dest . '/ota/channel/' . $channel . '.json') + ); + } + self::publishBrowserApk($src, $channel); + } + + /** Copy installable .apk to hub /v0/downloads/ for browser sideload. */ + private static function publishBrowserApk(string $otaV0Dir, string $channel): void { + $downloads = rtrim((string) cfg('build.downloads_mount', ''), '/'); + if ($downloads === '') { + $parent = dirname(rtrim((string) cfg('build.ota_mount', ''), '/')); + $downloads = $parent !== '' && $parent !== '.' + ? $parent . '/downloads' + : '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads'; + } + if (!is_dir($downloads) && !@mkdir($downloads, 0775, true) && !is_dir($downloads)) { + return; + } + $apkPath = self::resolveBrowserApkPath($otaV0Dir, $channel); + if ($apkPath === null || !is_file($apkPath)) { + return; + } + $latest = $downloads . '/android_cast-latest.apk'; + $versioned = $downloads . '/' . basename($apkPath); + @copy($apkPath, $latest); + @copy($apkPath, $versioned); + @chown($latest, 'nginx'); + @chgrp($latest, 'nginx'); + @chown($versioned, 'nginx'); + @chgrp($versioned, 'nginx'); + } + + private static function resolveBrowserApkPath(string $otaV0Dir, string $channel): ?string { + $channelFile = $otaV0Dir . '/ota/channel/' . $channel . '.json'; + if (is_file($channelFile)) { + $channelData = json_decode((string) file_get_contents($channelFile), true); + $manifestUrl = is_array($channelData) ? (string) ($channelData['manifestUrl'] ?? '') : ''; + if ($manifestUrl !== '') { + $manifestPath = self::otaUrlToLocalPath($manifestUrl, $otaV0Dir); + if ($manifestPath !== null && is_file($manifestPath)) { + $manifest = json_decode((string) file_get_contents($manifestPath), true); + if (is_array($manifest)) { + $browser = (string) ($manifest['browserApkUrl'] ?? ''); + if ($browser !== '') { + $local = self::otaUrlToLocalPath($browser, $otaV0Dir); + if ($local !== null && is_file($local)) { + return $local; + } + } + } + } } } + $glob = glob($otaV0Dir . '/ota/*/*/*/*.apk') ?: []; + if ($glob !== []) { + usort($glob, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a)); + return $glob[0]; + } + return null; + } + + private static function otaUrlToLocalPath(string $url, string $otaV0Dir): ?string { + $path = (string) parse_url($url, PHP_URL_PATH); + $prefix = '/v0/ota/'; + if (!str_starts_with($path, $prefix)) { + return null; + } + $rel = substr($path, strlen($prefix)); + $local = $otaV0Dir . '/ota/' . $rel; + return is_file($local) ? $local : null; } }