1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:57:50 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-05 15:19:48 +02:00
parent 5df496c0a5
commit db91842dd9
12 changed files with 520 additions and 25 deletions

View File

@@ -30,6 +30,7 @@ return [
'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts',
'ota_base_url' => 'http://localhost:8080',
'runner_script' => '/workspace/scripts/docker-build-runner.sh',
// Repo copy preferred; generated YAML used if missing on builder host
'pipeline_config' => '/workspace/build.config.yml',
'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
],

View File

@@ -0,0 +1,43 @@
<?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 === 'rerun_as') {
$override = is_array($body['params'] ?? null) ? $body['params'] : [];
$build = BuildRunner::rerunBuild($buildId, $override, $userId);
json_out(['ok' => true, 'build' => $build]);
}
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);
}

View File

@@ -13,8 +13,7 @@ if (!is_array($params)) {
$params = $_POST;
}
$pipelinePath = (string) cfg('build.pipeline_config', '/workspace/build.config.yml');
$params['pipeline_yaml'] = is_file($pipelinePath) ? (string) file_get_contents($pipelinePath) : null;
$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;

View File

@@ -58,6 +58,89 @@
});
}
function postBuildAction(payload) {
return fetch(base + '/api/build_actions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
}).then(function (r) {
return r.text().then(function (text) {
var j = null;
try { j = JSON.parse(text); } catch (e) {
throw new Error(r.status + ': ' + (text.slice(0, 120) || r.statusText));
}
if (!r.ok || !j.ok) {
throw new Error((j && j.error) || ('HTTP ' + r.status));
}
return j;
});
});
}
var rerunBtn = document.getElementById('build-rerun-btn');
var stopBtn = document.getElementById('build-stop-btn');
var rerunAsToggle = document.getElementById('build-rerun-as-toggle');
var rerunAsForm = document.getElementById('build-rerun-as-form');
var actionStatus = document.getElementById('build-action-status');
if (rerunAsToggle && rerunAsForm) {
rerunAsToggle.addEventListener('click', function () {
rerunAsForm.hidden = !rerunAsForm.hidden;
});
}
if (rerunBtn) {
rerunBtn.addEventListener('click', function () {
var id = Number(rerunBtn.getAttribute('data-build-id'));
if (!id) return;
if (actionStatus) actionStatus.textContent = 'Re-running…';
postBuildAction({ action: 'rerun', build_id: id }).then(function (j) {
if (j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id;
}
}).catch(function (err) {
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
});
});
}
if (stopBtn) {
stopBtn.addEventListener('click', function () {
var id = Number(stopBtn.getAttribute('data-build-id'));
if (!id || !confirm('Stop this build?')) return;
if (actionStatus) actionStatus.textContent = 'Stopping…';
postBuildAction({ action: 'stop', build_id: id }).then(function () {
window.location.reload();
}).catch(function (err) {
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
});
});
}
if (rerunAsForm) {
rerunAsForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var id = Number(rerunBtn && rerunBtn.getAttribute('data-build-id'));
if (!id) return;
var fd = new FormData(rerunAsForm);
var params = {
git_ref: fd.get('git_ref') || 'next',
gradle_task: fd.get('gradle_task') || 'assembleDebug',
ota_channel: fd.get('ota_channel') || 'staging',
run_tests: !!rerunAsForm.querySelector('[name=run_tests]').checked,
run_native: !!rerunAsForm.querySelector('[name=run_native]').checked,
run_apk: !!rerunAsForm.querySelector('[name=run_apk]').checked,
auto_ota: !!rerunAsForm.querySelector('[name=auto_ota]').checked,
auto_deploy: !!rerunAsForm.querySelector('[name=auto_deploy]').checked
};
if (actionStatus) actionStatus.textContent = 'Starting…';
postBuildAction({ action: 'rerun_as', build_id: id, params: params }).then(function (j) {
if (j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id;
}
}).catch(function (err) {
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
});
});
}
var buildId = document.body.getAttribute('data-build-id');
var logEl = document.getElementById('build-log-tail');
if (buildId && logEl) {

View File

@@ -25,6 +25,10 @@ if ($route === '/api/build_log.php' || str_ends_with($route, '/api/build_log.php
require __DIR__ . '/api/build_log.php';
exit;
}
if ($route === '/api/build_actions.php' || str_ends_with($route, '/api/build_actions.php')) {
require __DIR__ . '/api/build_actions.php';
exit;
}
if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
require __DIR__ . '/api/heartbeat.php';
exit;
@@ -55,6 +59,8 @@ if ($route === '/login') {
Auth::check();
BuildRunner::reconcileRunningBuilds();
$view = $_GET['view'] ?? 'home';
if ($view === 'build' && isset($_GET['id'])) {

View File

@@ -111,6 +111,7 @@ SQL);
public static function healthSummary(): array {
self::ensureSchema();
BuildRunner::reconcileRunningBuilds();
$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();

View File

@@ -2,10 +2,62 @@
declare(strict_types=1);
final class BuildRunner {
public static function dockerAvailable(): bool {
return trim((string) shell_exec('docker info >/dev/null 2>&1 && echo ok')) === 'ok';
}
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;
@@ -13,6 +65,7 @@ final class BuildRunner {
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,
@@ -20,7 +73,7 @@ final class BuildRunner {
'phase' => 'queued',
'git_ref' => $params['git_ref'] ?? null,
'git_sha' => $params['git_sha'] ?? null,
'branch' => $params['branch'] ?? 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'),
@@ -35,8 +88,14 @@ final class BuildRunner {
throw new RuntimeException('Cannot create artifact dir: ' . $dir);
}
$logPath = $dir . '/build.log';
BuildRepository::update($id, ['log_path' => $logPath]);
if (!self::dockerAvailable()) {
self::failBuild($id, 'Docker daemon not available', $logPath);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
}
BuildRepository::update($id, [
'log_path' => $logPath,
'status' => 'running',
'phase' => 'docker',
'started_at' => gmdate('Y-m-d H:i:s'),
@@ -67,6 +126,109 @@ final class BuildRunner {
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 || ($build['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');
}
$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';
}
$params['rerun_of'] = $id;
return self::enqueue($params, $userId);
}
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));
}
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 !== '') {
self::failBuild($id, 'Build runner exited unexpectedly', $logPath);
} else {
self::failBuild($id, 'Build runner never started', $logPath);
}
}
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
if (!$logPath || !is_file($logPath)) {
return ['offset' => 0, 'eof' => true, 'text' => ''];

View File

@@ -1,9 +1,50 @@
<?php declare(strict_types=1);
$artifacts = json_decode($build['artifacts_json'] ?? '{}', true) ?: [];
$params = json_decode($build['params_json'] ?? '{}', true) ?: [];
$status = (string) ($build['status'] ?? '');
$isRunning = $status === 'running';
$pipelineYaml = trim((string) ($build['pipeline_yaml'] ?? ''));
if ($pipelineYaml === '') {
$pipelineYaml = '(none — will be generated on next trigger from build.config.yml or params)';
}
?>
<h1>Build <?= h($build['build_code'] ?? '') ?></h1>
<p class="muted">Status: <strong><?= h($build['status'] ?? '') ?></strong> · Phase: <?= h($build['phase'] ?? '') ?></p>
<p class="muted">Status: <strong><?= h($status) ?></strong> · Phase: <?= h($build['phase'] ?? '') ?>
<?php if (!empty($build['error_message'])): ?>
· <span class="error-text"><?= h($build['error_message']) ?></span>
<?php endif; ?>
</p>
<div class="build-actions card card--lift" style="margin-bottom:12px">
<h2>Actions</h2>
<div class="toolbar-actions" style="flex-wrap:wrap;gap:8px">
<button type="button" class="btn" id="build-rerun-btn" data-build-id="<?= (int) $build['id'] ?>">Re-run</button>
<button type="button" class="btn" id="build-rerun-as-toggle">Re-run as…</button>
<?php if ($isRunning): ?>
<button type="button" class="btn btn-danger" id="build-stop-btn" data-build-id="<?= (int) $build['id'] ?>">Stop</button>
<?php endif; ?>
<span id="build-action-status" class="muted"></span>
</div>
<form id="build-rerun-as-form" class="build-form" hidden style="margin-top:12px">
<div class="build-form-grid">
<label>Git ref<input name="git_ref" value="<?= h($params['git_ref'] ?? $build['git_ref'] ?? 'next') ?>"></label>
<label>Gradle task<input name="gradle_task" value="<?= h($params['gradle_task'] ?? 'assembleDebug') ?>"></label>
<label>OTA channel<select name="ota_channel">
<?php foreach (['staging', 'dev', 'nightly', 'stable'] as $ch): ?>
<option value="<?= h($ch) ?>" <?= (($params['ota_channel'] ?? $build['ota_channel'] ?? 'staging') === $ch) ? 'selected' : '' ?>><?= h($ch) ?></option>
<?php endforeach; ?>
</select></label>
</div>
<div class="build-form-checks">
<label><input type="checkbox" name="run_tests" <?= !empty($params['run_tests']) ? 'checked' : '' ?>> Unit tests</label>
<label><input type="checkbox" name="run_native" <?= !isset($params['run_native']) || !empty($params['run_native']) ? 'checked' : '' ?>> Native codecs</label>
<label><input type="checkbox" name="run_apk" <?= !isset($params['run_apk']) || !empty($params['run_apk']) ? 'checked' : '' ?>> APK output</label>
<label><input type="checkbox" name="auto_ota" <?= !empty($params['auto_ota']) ? 'checked' : '' ?>> Create OTA artifacts</label>
<label><input type="checkbox" name="auto_deploy" <?= !empty($params['auto_deploy']) ? 'checked' : '' ?>> Publish OTA to mount</label>
</div>
<button type="submit" class="btn btn-primary">Start parametrized re-run</button>
</form>
</div>
<div class="detail-grid card card--lift">
<dl>
@@ -30,7 +71,8 @@ $params = json_decode($build['params_json'] ?? '{}', true) ?: [];
<section class="card card--lift">
<h2>Pipeline (YAML)</h2>
<pre class="log-preview"><?= h($build['pipeline_yaml'] ?? '(none)') ?></pre>
<p class="muted graphs-footnote">Snapshot stored with this build. New triggers use <code>build.config.yml</code> from the repo when present, otherwise a generated default.</p>
<pre class="log-preview"><?= h($pipelineYaml) ?></pre>
</section>
<section class="card card--lift">