mirror of
git://f0xx.org/android_cast
synced 2026-07-29 11:38:12 +03:00
sync BE
This commit is contained in:
@@ -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'],
|
||||
],
|
||||
|
||||
43
examples/build_console/backend/public/api/build_actions.php
Normal file
43
examples/build_console/backend/public/api/build_actions.php
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' => ''];
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user