commit 27e1f7d6c7642cc0a0faff701f2be30d15925c2b Author: Anton Afanasyeu Date: Tue Jun 23 12:29:34 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..08df6e1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-ms-build + +Remote: `git://f0xx.org/ac/ac-ms-build` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..11d1edf --- /dev/null +++ b/composer.json @@ -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/" + ] + } +} diff --git a/public/api/build_actions.php b/public/api/build_actions.php new file mode 100644 index 0000000..077c834 --- /dev/null +++ b/public/api/build_actions.php @@ -0,0 +1,55 @@ + 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); +} diff --git a/public/api/build_log.php b/public/api/build_log.php new file mode 100644 index 0000000..d4587ac --- /dev/null +++ b/public/api/build_log.php @@ -0,0 +1,39 @@ + 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, +]); diff --git a/public/api/build_trigger.php b/public/api/build_trigger.php new file mode 100644 index 0000000..be05f77 --- /dev/null +++ b/public/api/build_trigger.php @@ -0,0 +1,27 @@ + 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]); diff --git a/public/api/builds.php b/public/api/builds.php new file mode 100644 index 0000000..49a04cb --- /dev/null +++ b/public/api/builds.php @@ -0,0 +1,7 @@ + true, 'builds' => BuildRepository::listRecent(10)]); diff --git a/public/api/health.php b/public/api/health.php new file mode 100644 index 0000000..e289d91 --- /dev/null +++ b/public/api/health.php @@ -0,0 +1,4 @@ + true, 'health' => BuildRepository::healthSummary()]); diff --git a/public/api/heartbeat.php b/public/api/heartbeat.php new file mode 100644 index 0000000..f88a5d7 --- /dev/null +++ b/public/api/heartbeat.php @@ -0,0 +1,51 @@ + '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); diff --git a/sql/060_build_ecosystem.sql b/sql/060_build_ecosystem.sql new file mode 100644 index 0000000..95228cb --- /dev/null +++ b/sql/060_build_ecosystem.sql @@ -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; diff --git a/src/BuildRepository.php b/src/BuildRepository.php new file mode 100644 index 0000000..e723eee --- /dev/null +++ b/src/BuildRepository.php @@ -0,0 +1,129 @@ +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'), + ]; + } +} diff --git a/src/BuildRunner.php b/src/BuildRunner.php new file mode 100644 index 0000000..e9ed74c --- /dev/null +++ b/src/BuildRunner.php @@ -0,0 +1,543 @@ +/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 << $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 */ + 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 */ + 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')); + } + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..cc202d5 --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,99 @@ + 'In progress', + 'passed' => 'Succeeded', + 'failed' => 'Failed or cancelled', + ]; + $label = h($labels[$kind] ?? 'Unknown'); + if ($kind === 'running') { + return '' + . ''; + } + if ($kind === 'passed') { + return ''; + } + return '' + . ''; +}