1
0
mirror of git://f0xx.org/ac/ac-ms-build synced 2026-07-29 00:59:05 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:34 +02:00
commit 27e1f7d6c7
12 changed files with 1013 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-build
Remote: `git://f0xx.org/ac/ac-ms-build`

25
composer.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "androidcast/ms-build",
"description": "Build worker API",
"type": "project",
"require": {
"php": ">=8.1",
"androidcast/platform-php": "dev-next",
"androidcast/platform-db": "dev-next"
},
"repositories": [
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-php"
},
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-db"
}
],
"autoload": {
"classmap": [
"src/"
]
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_out(['ok' => false, 'error' => 'POST required'], 405);
}
$raw = file_get_contents('php://input') ?: '{}';
$body = json_decode($raw, true);
if (!is_array($body)) {
$body = $_POST;
}
$action = (string) ($body['action'] ?? '');
$buildId = (int) ($body['build_id'] ?? 0);
if ($buildId <= 0) {
json_out(['ok' => false, 'error' => 'build_id required'], 400);
}
$user = Auth::user();
$userId = isset($user['id']) ? (int) $user['id'] : null;
try {
if ($action === 'stop') {
$ok = BuildRunner::stopBuild($buildId);
json_out(['ok' => $ok, 'build' => BuildRepository::getById($buildId)]);
}
if ($action === 'rerun') {
$build = BuildRunner::rerunBuild($buildId, null, $userId);
json_out(['ok' => true, 'build' => $build]);
}
if ($action === 'force_rerun') {
$build = BuildRunner::forceRerun($buildId, $userId);
json_out(['ok' => true, 'build' => $build]);
}
if ($action === 'rerun_as') {
$override = is_array($body['params'] ?? null) ? $body['params'] : [];
$build = BuildRunner::rerunBuild($buildId, $override, $userId);
json_out(['ok' => true, 'build' => $build]);
}
if ($action === 'rerun_from_failed') {
$build = BuildRunner::rerunFromFailed($buildId, $userId, false);
json_out(['ok' => true, 'build' => $build, 'resume_from' => BuildRunner::detectFailedStep($buildId)]);
}
if ($action === 'rerun_with_ssh') {
$build = BuildRunner::rerunFromFailed($buildId, $userId, true);
json_out(['ok' => true, 'build' => $build, 'resume_from' => BuildRunner::detectFailedStep($buildId)]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
} catch (Throwable $e) {
error_log('build_actions: ' . $e->getMessage());
json_out(['ok' => false, 'error' => $e->getMessage()], 500);
}

39
public/api/build_log.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
$id = (int) ($_GET['id'] ?? 0);
$offset = (int) ($_GET['offset'] ?? 0);
$full = isset($_GET['full']) && $_GET['full'] === '1';
$download = isset($_GET['download']) && $_GET['download'] === '1';
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'id required'], 400);
}
$build = BuildRepository::getById($id);
if (!$build) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
$logPath = $build['log_path'] ?? null;
if ($download) {
if (!$logPath || !is_file($logPath)) {
json_out(['ok' => false, 'error' => 'log not found'], 404);
}
$code = preg_replace('/[^a-zA-Z0-9._-]+/', '_', (string) ($build['build_code'] ?? 'build'));
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $code . '.log"');
readfile($logPath);
exit;
}
$tail = $full
? BuildRunner::readLogFull($logPath)
: BuildRunner::readLogTail($logPath, $offset);
if ($build['status'] === 'running' && $tail['eof'] && is_file(BuildRunner::artifactDir($id) . '/BUILD_INFO.json')) {
BuildRunner::finalizeFromArtifacts($id);
$build = BuildRepository::getById($id);
}
json_out([
'ok' => true,
'build' => $build,
'log' => $tail,
]);

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_out(['ok' => false, 'error' => 'POST required'], 405);
}
$raw = file_get_contents('php://input') ?: '{}';
$params = json_decode($raw, true);
if (!is_array($params)) {
$params = $_POST;
}
$params['pipeline_yaml'] = BuildRunner::resolvePipelineYaml($params);
$params['run_tests'] = !empty($params['run_tests']);
$params['run_native'] = array_key_exists('run_native', $params) ? !empty($params['run_native']) : true;
$params['run_apk'] = array_key_exists('run_apk', $params) ? !empty($params['run_apk']) : true;
$params['auto_ota'] = !empty($params['auto_ota']);
$params['auto_deploy'] = !empty($params['auto_deploy']);
$params['ota_channel'] = $params['ota_channel'] ?? 'staging';
$params['gradle_task'] = $params['gradle_task'] ?? 'assembleDebug';
$user = Auth::user();
$build = BuildRunner::enqueue($params, isset($user['id']) ? (int) $user['id'] : null);
json_out(['ok' => true, 'build' => $build]);

7
public/api/builds.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
BuildRunner::reconcileRunningBuilds();
BuildRunner::reconcileQueuedBuilds();
json_out(['ok' => true, 'builds' => BuildRepository::listRecent(10)]);

4
public/api/health.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
json_out(['ok' => true, 'health' => BuildRepository::healthSummary()]);

51
public/api/heartbeat.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
$raw = file_get_contents('php://input') ?: '';
$req = json_decode($raw, true);
if (!is_array($req)) {
http_response_code(400);
echo json_encode(['error' => 'invalid_json']);
exit;
}
$heartbeat = $req['heartbeat'] ?? [];
if (!is_array($heartbeat)) {
$heartbeat = [];
}
$type = (string)($heartbeat['type'] ?? 'generic');
$srvEpoch = time();
$srvTz = date('O');
$resp = [
'heartbeat' => [
'type' => $type,
'server_date' => $srvEpoch,
'server_tz' => $srvTz,
],
];
if ($type === 'ntp') {
$clientDate = (int)($heartbeat['date'] ?? 0);
$clientTz = (string)($heartbeat['tz'] ?? '');
$resp['heartbeat']['client_date'] = $clientDate;
$resp['heartbeat']['client_tz'] = $clientTz;
$resp['heartbeat']['correction_seconds'] = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
$resp['heartbeat']['enabled'] = true;
}
if ($type === 'ip') {
$resp['heartbeat']['external_ip'] = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
}
echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

View File

@@ -0,0 +1,31 @@
USE `androidcast_crashes`;
CREATE TABLE IF NOT EXISTS builds (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
build_code VARCHAR(64) NOT NULL,
status ENUM('queued','running','passed','failed','cancelled') NOT NULL DEFAULT 'queued',
phase VARCHAR(64) NOT NULL DEFAULT 'init',
git_ref VARCHAR(256) NULL,
git_sha VARCHAR(64) NULL,
branch VARCHAR(128) NULL,
pipeline_yaml LONGTEXT NULL,
params_json LONGTEXT NOT NULL,
dockerfile_version VARCHAR(32) NOT NULL DEFAULT '00.01.00.1000',
builder_id VARCHAR(128) NOT NULL DEFAULT 'local-docker',
version_app VARCHAR(32) NULL,
version_code INT NULL,
target_os VARCHAR(32) NOT NULL DEFAULT 'android',
min_api_level INT NOT NULL DEFAULT 24,
ota_channel VARCHAR(64) NULL,
auto_ota TINYINT(1) NOT NULL DEFAULT 0,
auto_deploy TINYINT(1) NOT NULL DEFAULT 0,
log_path VARCHAR(512) NULL,
artifacts_json LONGTEXT NULL,
error_message VARCHAR(512) NULL,
created_by_user_id INT UNSIGNED NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_builds_code (build_code),
KEY idx_builds_status (status),
KEY idx_builds_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

129
src/BuildRepository.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
final class BuildRepository {
public static function ensureSchema(): void {
$pdo = Database::pdo();
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS builds (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
build_code VARCHAR(64) NOT NULL,
status ENUM('queued','running','passed','failed','cancelled') NOT NULL DEFAULT 'queued',
phase VARCHAR(64) NOT NULL DEFAULT 'init',
git_ref VARCHAR(256) NULL,
git_sha VARCHAR(64) NULL,
branch VARCHAR(128) NULL,
pipeline_yaml LONGTEXT NULL,
params_json LONGTEXT NOT NULL,
dockerfile_version VARCHAR(32) NOT NULL DEFAULT '00.01.00.1000',
builder_id VARCHAR(128) NOT NULL DEFAULT 'local-docker',
version_app VARCHAR(32) NULL,
version_code INT NULL,
target_os VARCHAR(32) NOT NULL DEFAULT 'android',
min_api_level INT NOT NULL DEFAULT 24,
ota_channel VARCHAR(64) NULL,
auto_ota TINYINT(1) NOT NULL DEFAULT 0,
auto_deploy TINYINT(1) NOT NULL DEFAULT 0,
log_path VARCHAR(512) NULL,
artifacts_json LONGTEXT NULL,
error_message VARCHAR(512) NULL,
created_by_user_id INT UNSIGNED NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_builds_code (build_code),
KEY idx_builds_status (status),
KEY idx_builds_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public static function create(array $row): int {
self::ensureSchema();
$pdo = Database::pdo();
$stmt = $pdo->prepare(<<<'SQL'
INSERT INTO builds (
build_code, status, phase, git_ref, git_sha, branch, pipeline_yaml, params_json,
dockerfile_version, builder_id, target_os, min_api_level, ota_channel, auto_ota, auto_deploy,
log_path, created_by_user_id
) VALUES (
:build_code, :status, :phase, :git_ref, :git_sha, :branch, :pipeline_yaml, :params_json,
:dockerfile_version, :builder_id, :target_os, :min_api_level, :ota_channel, :auto_ota, :auto_deploy,
:log_path, :created_by_user_id
)
SQL);
$stmt->execute([
':build_code' => $row['build_code'],
':status' => $row['status'] ?? 'queued',
':phase' => $row['phase'] ?? 'queued',
':git_ref' => $row['git_ref'] ?? null,
':git_sha' => $row['git_sha'] ?? null,
':branch' => $row['branch'] ?? null,
':pipeline_yaml' => $row['pipeline_yaml'] ?? null,
':params_json' => $row['params_json'] ?? '{}',
':dockerfile_version' => $row['dockerfile_version'] ?? '00.01.00.1000',
':builder_id' => $row['builder_id'] ?? 'local-docker',
':target_os' => $row['target_os'] ?? 'android',
':min_api_level' => (int) ($row['min_api_level'] ?? 24),
':ota_channel' => $row['ota_channel'] ?? null,
':auto_ota' => !empty($row['auto_ota']) ? 1 : 0,
':auto_deploy' => !empty($row['auto_deploy']) ? 1 : 0,
':log_path' => $row['log_path'] ?? null,
':created_by_user_id' => $row['created_by_user_id'] ?? null,
]);
return (int) $pdo->lastInsertId();
}
public static function update(int $id, array $fields): void {
self::ensureSchema();
$allowed = ['status', 'phase', 'version_app', 'version_code', 'artifacts_json', 'error_message', 'started_at', 'finished_at', 'git_sha', 'log_path'];
$sets = [];
$params = [':id' => $id];
foreach ($fields as $k => $v) {
if (!in_array($k, $allowed, true)) {
continue;
}
$sets[] = "$k = :$k";
$params[":$k"] = $v;
}
if (!$sets) {
return;
}
$sql = 'UPDATE builds SET ' . implode(', ', $sets) . ' WHERE id = :id';
Database::pdo()->prepare($sql)->execute($params);
}
public static function getById(int $id): ?array {
self::ensureSchema();
$stmt = Database::pdo()->prepare('SELECT * FROM builds WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function listRecent(int $limit = 10): array {
self::ensureSchema();
$stmt = Database::pdo()->prepare('SELECT * FROM builds ORDER BY id DESC LIMIT ?');
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll() ?: [];
}
public static function healthSummary(): array {
self::ensureSchema();
BuildRunner::reconcileRunningBuilds();
BuildRunner::reconcileQueuedBuilds();
$pdo = Database::pdo();
$running = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'running'")->fetchColumn();
$failed = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'failed' AND created_at > (NOW() - INTERVAL 7 DAY)")->fetchColumn();
$passed = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'passed' AND created_at > (NOW() - INTERVAL 7 DAY)")->fetchColumn();
$dockerOk = BuildRunner::dockerAvailable();
return [
'docker' => $dockerOk ? 'ok' : 'down',
'running' => $running,
'passed_7d' => $passed,
'failed_7d' => $failed,
'ci_version' => cfg('build.ci_version', '00.01.00.1000'),
];
}
}

543
src/BuildRunner.php Normal file
View File

@@ -0,0 +1,543 @@
<?php
declare(strict_types=1);
final class BuildRunner {
public static function dockerBinary(): string {
$configured = trim((string) cfg('build.docker_bin', ''));
if ($configured !== '' && is_executable($configured)) {
return $configured;
}
foreach (['/usr/bin/docker', '/usr/local/bin/docker'] as $path) {
if (is_executable($path)) {
return $path;
}
}
$which = trim((string) shell_exec('command -v docker 2>/dev/null'));
return $which !== '' ? $which : 'docker';
}
public static function dockerAvailable(): bool {
$bin = escapeshellarg(self::dockerBinary());
return trim((string) shell_exec("{$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];
if (!is_executable($bin)) {
$parts[] = 'bin_not_executable';
}
if (!is_readable($sock)) {
$parts[] = 'sock_not_readable';
}
$parts[] = self::dockerAvailable() ? 'info_ok' : 'info_failed';
return implode(', ', $parts);
}
public static function generateBuildCode(): string {
return 'bld-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
}
public static function resolvePipelineYaml(array $params): string {
$candidates = array_filter([
(string) cfg('build.pipeline_config', ''),
rtrim((string) cfg('build.repo_root', ''), '/') . '/build.config.yml',
]);
foreach ($candidates as $path) {
if ($path !== '' && is_file($path)) {
$text = (string) file_get_contents($path);
if ($text !== '') {
return $text;
}
}
}
return self::generatePipelineYaml($params);
}
public static function generatePipelineYaml(array $params): string {
$gitRef = (string) ($params['git_ref'] ?? 'next');
$gradle = (string) ($params['gradle_task'] ?? 'assembleDebug');
$channel = (string) ($params['ota_channel'] ?? 'staging');
$runTests = !empty($params['run_tests']) ? 'true' : 'false';
$runNative = !empty($params['run_native']) ? 'true' : 'false';
$runApk = !empty($params['run_apk']) ? 'true' : 'false';
$autoOta = !empty($params['auto_ota']) ? 'true' : 'false';
$ciVer = (string) cfg('build.ci_version', '00.01.00.1000');
return <<<YAML
# Generated pipeline snapshot (build.config.yml missing on builder host)
version: 2.1
parameters:
git_ref: {$gitRef}
gradle_task: {$gradle}
run_tests: {$runTests}
run_native: {$runNative}
run_apk: {$runApk}
auto_ota: {$autoOta}
ota_channel: {$channel}
jobs:
android-build:
docker:
- image: androidcast-ci:{$ciVer}
steps:
- checkout
- run:
name: Gradle pipeline
command: ./scripts/ci-build-and-publish-ota.sh
YAML;
}
public static function artifactDir(int $buildId): string {
$root = rtrim((string) cfg('build.artifacts_root', '/workspace/out/builds'), '/');
return $root . '/' . $buildId;
}
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
'build_code' => $buildCode,
'status' => 'queued',
'phase' => 'queued',
'git_ref' => $params['git_ref'] ?? null,
'git_sha' => $params['git_sha'] ?? null,
'branch' => $params['branch'] ?? ($params['git_ref'] ?? null),
'pipeline_yaml' => $params['pipeline_yaml'] ?? null,
'params_json' => json_encode($params, JSON_UNESCAPED_SLASHES),
'dockerfile_version' => cfg('build.ci_version', '00.01.00.1000'),
'builder_id' => gethostname() ?: 'builder',
'ota_channel' => $params['ota_channel'] ?? 'staging',
'auto_ota' => !empty($params['auto_ota']),
'auto_deploy' => !empty($params['auto_deploy']),
'created_by_user_id' => $userId,
]);
$dir = self::artifactDir($id);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Cannot create artifact dir: ' . $dir);
}
$logPath = $dir . '/build.log';
BuildRepository::update($id, ['log_path' => $logPath]);
if (!self::dockerAvailable()) {
$detail = self::dockerCheckDetail();
self::failBuild(
$id,
'Docker daemon not available (' . $detail . '). '
. 'Ensure docker service is running and PHP-FPM user (nginx) is in group docker, then restart php-fpm.',
$logPath
);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
}
$phase = !empty($params['ssh_debug']) ? 'ssh_debug' : 'docker';
$runner = (string) cfg('build.runner_script', '/workspace/scripts/docker-build-runner.sh');
$repo = (string) cfg('build.repo_root', '/workspace');
$gitRemote = trim((string) shell_exec(
'git -c safe.directory=' . escapeshellarg($repo)
. ' -C ' . escapeshellarg($repo)
. ' remote get-url origin 2>/dev/null'
));
$env = [
'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
'BUILD_LOG_STDOUT_ONLY=1',
'BUILD_ISOLATE_SOURCE=' . (cfg('build.isolate_source', true) ? '1' : '0'),
'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
'BUILD_WORK_DIR=' . escapeshellarg($repo),
'BUILD_OUT_DIR=' . escapeshellarg($dir),
'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'] ?? '')),
'GIT_REMOTE=' . escapeshellarg($gitRemote),
'GRADLE_TASK=' . escapeshellarg((string) ($params['gradle_task'] ?? 'assembleDebug')),
'RUN_UNIT_TESTS=' . (!empty($params['run_tests']) ? '1' : '0'),
'RUN_NATIVE=' . (!empty($params['run_native']) ? '1' : '0'),
'RUN_APK=' . (!empty($params['run_apk']) ? '1' : '0'),
'OTA_BASE_URL=' . escapeshellarg((string) ($params['ota_base_url'] ?? cfg('build.ota_base_url', ''))),
'OTA_CHANNEL=' . escapeshellarg((string) ($params['ota_channel'] ?? 'staging')),
'AUTO_OTA=' . (!empty($params['auto_ota']) ? '1' : '0'),
];
if (!empty($params['resume_from'])) {
$env[] = 'BUILD_RESUME_FROM=' . escapeshellarg((string) $params['resume_from']);
}
if (!empty($params['reuse_build_id'])) {
$reuseSrc = self::artifactDir((int) $params['reuse_build_id']) . '/src';
$env[] = 'BUILD_REUSE_SRC_DIR=' . escapeshellarg($reuseSrc);
}
if (!empty($params['ssh_debug'])) {
$env[] = 'BUILD_SSH_DEBUG=1';
}
$cmd = implode(' ', $env) . ' nohup ' . escapeshellarg($runner) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
$pid = trim((string) shell_exec($cmd));
if ($pid === '' || !ctype_digit($pid)) {
self::failBuild($id, 'Build runner failed to start', $logPath);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
}
file_put_contents($dir . '/runner.pid', $pid);
BuildRepository::update($id, [
'status' => 'running',
'phase' => $phase,
'started_at' => gmdate('Y-m-d H:i:s'),
]);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
}
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
if ($logPath) {
file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'failed',
'phase' => 'error',
'error_message' => substr($message, 0, 512),
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
}
public static function stopBuild(int $id): bool {
$build = BuildRepository::getById($id);
if (!$build) {
return false;
}
$status = (string) ($build['status'] ?? '');
if ($status === 'queued') {
$dir = self::artifactDir($id);
$logPath = $build['log_path'] ?? ($dir . '/build.log');
if ($logPath) {
file_put_contents($logPath, "\n[builder] Stopped while queued\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'cancelled',
'phase' => 'stopped',
'error_message' => 'Stopped while queued',
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
return true;
}
if ($status !== 'running') {
return false;
}
$dir = self::artifactDir($id);
$pidFile = $dir . '/runner.pid';
$pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : '';
if ($pid !== '' && ctype_digit($pid)) {
shell_exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
self::stopDebugContainer($id);
$logPath = $build['log_path'] ?? ($dir . '/build.log');
if ($logPath) {
file_put_contents($logPath, "\n[builder] Stopped by operator\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'cancelled',
'phase' => 'stopped',
'error_message' => 'Stopped by operator',
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
return true;
}
/** @return array<string, mixed> */
public static function rerunBuild(int $id, ?array $overrideParams = null, ?int $userId = null): array {
$build = BuildRepository::getById($id);
if (!$build) {
throw new InvalidArgumentException('build_not_found');
}
$params = json_decode((string) ($build['params_json'] ?? '{}'), true);
if (!is_array($params)) {
$params = [];
}
if ($overrideParams) {
$params = array_merge($params, $overrideParams);
}
if (empty($params['git_ref'])) {
$params['git_ref'] = $build['git_ref'] ?? $build['branch'] ?? 'next';
}
if (!isset($params['rerun_of'])) {
$params['rerun_of'] = $id;
}
return self::enqueue($params, $userId);
}
/** Stop an in-flight build (if any) and enqueue a fresh run with the same params. */
public static function forceRerun(int $id, ?int $userId = null): array {
$build = BuildRepository::getById($id);
if (!$build) {
throw new InvalidArgumentException('build_not_found');
}
$status = (string) ($build['status'] ?? '');
if (in_array($status, ['running', 'queued'], true)) {
self::stopBuild($id);
}
return self::rerunBuild($id, null, $userId);
}
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
public static function detectFailedStep(int $buildId): string {
$dir = self::artifactDir($buildId);
$logTail = '';
$logPath = $dir . '/build.log';
if (is_file($logPath)) {
$size = filesize($logPath) ?: 0;
$logTail = (string) file_get_contents($logPath, false, null, max(0, $size - 65536));
}
$hasRun = is_file($dir . '/docker-run.log') && (filesize($dir . '/docker-run.log') ?: 0) > 0;
$hasBuild = is_file($dir . '/docker-build.log') && (filesize($dir . '/docker-build.log') ?: 0) > 0;
if ($hasRun && !is_file($dir . '/android_cast-latest.apk')) {
return 'docker-run';
}
if ($hasBuild || str_contains($logTail, '[runner] step docker-build')) {
if (!str_contains($logTail, '[runner] step docker-run') && !str_contains($logTail, '[runner] finished')) {
return 'docker-build';
}
$buildStep = $hasBuild ? (string) file_get_contents($dir . '/docker-build.log') : '';
if (str_contains($logTail, 'ERROR: failed to build') || str_contains($buildStep, 'ERROR: failed to build')) {
return 'docker-build';
}
}
if (str_contains($logTail, 'isolate:') || str_contains($logTail, 'syncing shared checkout')
|| str_contains($logTail, 'dubious ownership') || is_file($dir . '/git-clone.log')) {
return 'git';
}
return $hasRun ? 'docker-run' : ($hasBuild ? 'docker-build' : 'git');
}
/** @return array<string, mixed> */
public static function rerunFromFailed(int $id, ?int $userId = null, bool $sshDebug = false): array {
$build = BuildRepository::getById($id);
if (!$build) {
throw new InvalidArgumentException('build_not_found');
}
$status = (string) ($build['status'] ?? '');
if (!in_array($status, ['failed', 'cancelled'], true)) {
throw new InvalidArgumentException('rerun_from_failed_requires_failed_build');
}
$step = self::detectFailedStep($id);
return self::rerunBuild($id, [
'resume_from' => $step,
'reuse_build_id' => $id,
'ssh_debug' => $sshDebug,
'rerun_of' => $id,
], $userId);
}
public static function debugContainerName(int $buildId): string {
return 'androidcast-bld-' . $buildId;
}
public static function debugContainerAlive(int $buildId): bool {
$bin = escapeshellarg(self::dockerBinary());
$name = escapeshellarg(self::debugContainerName($buildId));
return trim((string) shell_exec("{$bin} inspect -f '{{.State.Running}}' {$name} 2>/dev/null")) === 'true';
}
public static function stopDebugContainer(int $buildId): void {
$bin = escapeshellarg(self::dockerBinary());
$name = escapeshellarg(self::debugContainerName($buildId));
shell_exec("{$bin} rm -f {$name} 2>/dev/null");
}
public static function runnerAlive(int $buildId): bool {
$pidFile = self::artifactDir($buildId) . '/runner.pid';
$pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : '';
if ($pid === '' || !ctype_digit($pid)) {
return false;
}
$out = trim((string) shell_exec('kill -0 ' . escapeshellarg($pid) . ' 2>/dev/null && echo alive'));
return $out === 'alive';
}
public static function reconcileRunningBuilds(): void {
BuildRepository::ensureSchema();
$stmt = Database::pdo()->query("SELECT id, log_path FROM builds WHERE status = 'running'");
if (!$stmt) {
return;
}
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$dir = self::artifactDir($id);
$logPath = (string) ($row['log_path'] ?? ($dir . '/build.log'));
if (is_file($dir . '/android_cast-latest.apk')) {
self::finalizeFromArtifacts($id);
continue;
}
$tail = '';
if (is_file($logPath)) {
$size = filesize($logPath) ?: 0;
$tail = (string) file_get_contents($logPath, false, null, max(0, $size - 8192));
}
$modeFile = $dir . '/runner.mode';
if (is_file($modeFile) && trim((string) file_get_contents($modeFile)) === 'ssh_debug') {
if (self::debugContainerAlive($id)) {
BuildRepository::update($id, ['status' => 'running', 'phase' => 'ssh_debug']);
continue;
}
self::failBuild($id, 'SSH debug container ended', $logPath);
continue;
}
if (self::runnerAlive($id)) {
continue;
}
if (str_contains($tail, 'Cannot connect to the Docker daemon') || !self::dockerAvailable()) {
self::failBuild($id, 'Docker daemon not available', $logPath);
} elseif (str_contains($tail, '[runner] finished')) {
self::finalizeFromArtifacts($id);
} elseif ($tail !== '') {
$hint = self::summarizeLogFailure($logPath);
$msg = $hint !== '' ? $hint : 'Build runner exited unexpectedly';
self::failBuild($id, $msg, $logPath);
} else {
self::failBuild($id, 'Build runner never started', $logPath);
}
}
}
public static function reconcileQueuedBuilds(): void {
BuildRepository::ensureSchema();
$stmt = Database::pdo()->query("SELECT id, log_path, created_at FROM builds WHERE status = 'queued'");
if (!$stmt) {
return;
}
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$logPath = (string) ($row['log_path'] ?? (self::artifactDir($id) . '/build.log'));
if (self::runnerAlive($id)) {
BuildRepository::update($id, [
'status' => 'running',
'phase' => 'docker',
'started_at' => gmdate('Y-m-d H:i:s'),
]);
continue;
}
$created = strtotime((string) ($row['created_at'] ?? '')) ?: 0;
if ($created > 0 && $created < time() - 120) {
self::failBuild($id, 'Build stuck in queued (runner never started)', $logPath);
}
}
}
/** Last lines / markers from build.log for reconcile error_message (max 512 chars). */
public static function summarizeLogFailure(?string $logPath): string {
if (!$logPath || !is_file($logPath)) {
return '';
}
$size = filesize($logPath) ?: 0;
$chunk = (string) file_get_contents($logPath, false, null, max(0, $size - 32768));
if ($chunk === '') {
return '';
}
$lines = preg_split('/\r\n|\r|\n/', $chunk) ?: [];
$markers = [];
foreach ($lines as $line) {
$trim = trim($line);
if ($trim === '') {
continue;
}
if (preg_match('/\[runner\] ERROR:|\[builder\] ERROR:|fatal:|BUILD FAILED|Cannot connect to the Docker daemon/i', $trim)) {
$markers[] = $trim;
}
}
$tailLines = array_values(array_filter($lines, static fn(string $l): bool => trim($l) !== ''));
$last = array_slice($tailLines, -4);
$parts = $markers !== [] ? array_slice($markers, -2) : $last;
$summary = implode(' | ', $parts);
if ($summary === '') {
return '';
}
if (strlen($summary) > 480) {
$summary = '…' . substr($summary, -477);
}
return 'Build runner exited unexpectedly: ' . $summary;
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
if (!$logPath || !is_file($logPath)) {
return ['offset' => 0, 'eof' => true, 'text' => '', 'size' => 0];
}
$size = filesize($logPath) ?: 0;
$offset = max(0, min($offset, $size));
$fh = fopen($logPath, 'rb');
if (!$fh) {
return ['offset' => $offset, 'eof' => true, 'text' => '', 'size' => $size];
}
fseek($fh, $offset);
$chunk = fread($fh, $maxBytes) ?: '';
$newOffset = $offset + strlen($chunk);
fclose($fh);
return ['offset' => $newOffset, 'eof' => $newOffset >= $size, 'text' => $chunk, 'size' => $size];
}
public static function readLogFull(?string $logPath, int $maxBytes = 2097152): array {
if (!$logPath || !is_file($logPath)) {
return ['offset' => 0, 'eof' => true, 'text' => '', 'size' => 0, 'truncated' => false];
}
$size = filesize($logPath) ?: 0;
$truncated = $size > $maxBytes;
$start = $truncated ? $size - $maxBytes : 0;
$text = (string) file_get_contents($logPath, false, null, $start);
if ($truncated) {
$text = "[log truncated: showing last {$maxBytes} of {$size} bytes]\n" . $text;
}
return ['offset' => $size, 'eof' => true, 'text' => $text, 'size' => $size, 'truncated' => $truncated];
}
public static function finalizeFromArtifacts(int $buildId): void {
$dir = self::artifactDir($buildId);
$infoPath = $dir . '/BUILD_INFO.json';
$status = is_file($dir . '/android_cast-latest.apk') ? 'passed' : 'failed';
$artifacts = [];
if (is_file($dir . '/android_cast-latest.apk')) {
$artifacts['apk'] = 'android_cast-latest.apk';
}
if (is_dir($dir . '/ota/v0')) {
$artifacts['ota'] = 'ota/v0';
}
$versionApp = null;
if (is_file($infoPath)) {
$info = json_decode((string) file_get_contents($infoPath), true);
if (is_array($info)) {
$versionApp = $info['gitSha'] ?? null;
}
}
BuildRepository::update($buildId, [
'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 (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
$build = BuildRepository::getById($buildId);
if (!empty($build['auto_deploy'])) {
self::publishOta($buildId, $dir);
}
}
}
private static function publishOta(int $buildId, string $dir): void {
$mount = rtrim((string) cfg('build.ota_mount', ''), '/');
if ($mount === '' || !is_dir($mount)) {
return;
}
$src = $dir . '/ota/v0';
if (!is_dir($src)) {
return;
}
$build = BuildRepository::getById($buildId);
$channel = $build['ota_channel'] ?? 'staging';
$dest = $mount . '/v0';
shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'));
if ($channel !== '' && $channel !== 'staging') {
shell_exec('cp -a ' . escapeshellarg($src . '/ota/channel/' . $channel . '.json') . ' ' . escapeshellarg($dest . '/ota/channel/' . $channel . '.json 2>/dev/null'));
}
}
}

99
src/bootstrap.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
$examplesRoot = dirname(__DIR__, 3);
require_once $examplesRoot . '/platform/shared_session.php';
require_once $examplesRoot . '/platform/footer.php';
$crashSrc = $examplesRoot . '/crash_reporter/backend/src';
$configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) {
$configPath = __DIR__ . '/../config/config.example.php';
}
$config = require $configPath;
platform_start_session(
$config['session_name'] ?? 'ac_crash_sess',
$config['session_cookie_path'] ?? '/app/androidcast_project'
);
foreach ([
'Database.php',
'Rbac.php',
'Auth.php',
'AuthEmailSchema.php',
'AuthCrypto.php',
'AuthTotp.php',
'AuthFactors.php',
'AuthAttempts.php',
'UserRepository.php',
'AnalyticsHead.php',
] as $f) {
require_once $crashSrc . '/' . $f;
}
require_once __DIR__ . '/BuildRepository.php';
require_once __DIR__ . '/BuildRunner.php';
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);
$v = $config;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) {
return $default;
}
$v = $v[$p];
}
return $v;
}
function h(mixed $s): string {
if ($s === null || $s === false) {
return '';
}
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function json_out(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function assets_base(): string {
return rtrim((string) cfg('links.crashes', '/app/androidcast_project/crashes/'), '/');
}
/** @return 'running'|'passed'|'failed' */
function build_status_kind(string $status): string {
$s = strtolower(trim($status));
if (in_array($s, ['running', 'queued'], true)) {
return 'running';
}
if ($s === 'passed') {
return 'passed';
}
return 'failed';
}
/** Inline status glyph: spinner (in progress), green check, or red alert triangle. */
function build_status_icon(string $status): string {
$kind = build_status_kind($status);
$labels = [
'running' => 'In progress',
'passed' => 'Succeeded',
'failed' => 'Failed or cancelled',
];
$label = h($labels[$kind] ?? 'Unknown');
if ($kind === 'running') {
return '<span class="build-status-icon build-status-icon--running" role="img" aria-label="' . $label . '">'
. '<span class="build-status-spinner"></span></span>';
}
if ($kind === 'passed') {
return '<span class="build-status-icon build-status-icon--passed" role="img" aria-label="' . $label . '"></span>';
}
return '<span class="build-status-icon build-status-icon--failed" role="img" aria-label="' . $label . '">'
. '<span class="build-status-fail-mark" aria-hidden="true">!</span></span>';
}