mirror of
git://f0xx.org/ac/ac-be-builder
synced 2026-07-29 00:58:04 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ac-be-builder
|
||||
|
||||
Builder console. Remote: `git://f0xx.org/ac/ac-be-builder`
|
||||
47
config/config.example.php
Normal file
47
config/config.example.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'Android Cast Builder',
|
||||
'base_path' => '/app/androidcast_project/build',
|
||||
'session_name' => 'ac_crash_sess',
|
||||
'session_cookie_path' => '/app/androidcast_project',
|
||||
'db' => [
|
||||
'driver' => 'mysql',
|
||||
'sqlite_path' => __DIR__ . '/../../crash_reporter/backend/data/crashes.sqlite',
|
||||
'mysql' => [
|
||||
'host' => 'mariadb',
|
||||
'port' => 3306,
|
||||
'socket' => '',
|
||||
'database' => 'androidcast_crashes',
|
||||
'username' => 'androidcast',
|
||||
'password' => 'androidcast',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
],
|
||||
'debug' => true,
|
||||
'rbac' => [
|
||||
'default_company_slug' => 'default',
|
||||
'default_company_id' => 1,
|
||||
],
|
||||
'build' => [
|
||||
'docker_image' => 'androidcast-ci:latest',
|
||||
'docker_bin' => '/usr/bin/docker',
|
||||
'ci_version' => '00.01.00.1000',
|
||||
'repo_root' => '/workspace',
|
||||
'artifacts_root' => '/workspace/out/builds',
|
||||
'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts',
|
||||
'ota_base_url' => 'http://localhost:8080',
|
||||
'runner_script' => '/workspace/scripts/docker-build-runner.sh',
|
||||
// Per-build shallow clone to out/builds/{id}/src (safe for parallel jobs)
|
||||
'isolate_source' => true,
|
||||
'git_clone_depth' => 1,
|
||||
// Repo copy preferred; generated YAML used if missing on builder host
|
||||
'pipeline_config' => '/workspace/build.config.yml',
|
||||
'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
|
||||
],
|
||||
'links' => [
|
||||
'hub' => '/app/androidcast_project/',
|
||||
'crashes' => '/app/androidcast_project/crashes/',
|
||||
'git' => '/app/androidcast_project/git/',
|
||||
'tickets' => '/app/androidcast_project/crashes/?view=tickets',
|
||||
],
|
||||
];
|
||||
55
public/api/build_actions.php
Normal file
55
public/api/build_actions.php
Normal 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
39
public/api/build_log.php
Normal 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,
|
||||
]);
|
||||
27
public/api/build_trigger.php
Normal file
27
public/api/build_trigger.php
Normal 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
7
public/api/builds.php
Normal 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
4
public/api/health.php
Normal 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
51
public/api/heartbeat.php
Normal 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);
|
||||
355
public/assets/js/builder.js
Normal file
355
public/assets/js/builder.js
Normal file
@@ -0,0 +1,355 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var base = document.body.getAttribute('data-base-path') || '';
|
||||
|
||||
function buildStatusIconHtml(status) {
|
||||
var s = String(status || '').toLowerCase();
|
||||
if (s === 'running' || s === 'queued') {
|
||||
return '<span class="build-status-icon build-status-icon--running" role="img" aria-label="In progress">'
|
||||
+ '<span class="build-status-spinner"></span></span>';
|
||||
}
|
||||
if (s === 'passed') {
|
||||
return '<span class="build-status-icon build-status-icon--passed" role="img" aria-label="Succeeded"></span>';
|
||||
}
|
||||
return '<span class="build-status-icon build-status-icon--failed" role="img" aria-label="Failed or cancelled">'
|
||||
+ '<span class="build-status-fail-mark" aria-hidden="true">!</span></span>';
|
||||
}
|
||||
|
||||
function updateBuildStatusCell(cell, status, phase) {
|
||||
if (!cell) return;
|
||||
var icon = cell.querySelector('.build-status-icon');
|
||||
var label = cell.querySelector('.build-status-label');
|
||||
var html = buildStatusIconHtml(status);
|
||||
if (icon) {
|
||||
icon.outerHTML = html;
|
||||
} else {
|
||||
cell.insertAdjacentHTML('afterbegin', html);
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = status || '';
|
||||
}
|
||||
if (phase !== undefined) {
|
||||
var row = cell.closest('tr');
|
||||
if (row) {
|
||||
var phaseCell = row.querySelector('.build-phase-cell');
|
||||
if (phaseCell) {
|
||||
phaseCell.textContent = phase || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateBuildRow(row, build) {
|
||||
if (!row || !build) return;
|
||||
row.setAttribute('data-build-status', build.status || '');
|
||||
var cell = row.querySelector('[data-build-status-cell="' + build.id + '"]');
|
||||
updateBuildStatusCell(cell, build.status, build.phase);
|
||||
var play = row.querySelector('.build-transport-play');
|
||||
var stop = row.querySelector('.build-transport-stop');
|
||||
var active = build.status === 'running' || build.status === 'queued';
|
||||
if (play) {
|
||||
play.setAttribute('data-build-status', build.status || '');
|
||||
}
|
||||
if (stop) {
|
||||
stop.setAttribute('data-build-status', build.status || '');
|
||||
stop.disabled = !active;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRecentBuilds() {
|
||||
var tbody = document.getElementById('builds-recent-tbody');
|
||||
if (!tbody) return;
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', base + '/api/builds.php', true);
|
||||
xhr.onload = function () {
|
||||
var j = null;
|
||||
try { j = JSON.parse(xhr.responseText); } catch (e) { j = null; }
|
||||
if (!j || !j.ok || !j.builds) return;
|
||||
j.builds.forEach(function (b) {
|
||||
var row = tbody.querySelector('tr[data-build-id="' + b.id + '"]');
|
||||
updateBuildRow(row, b);
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
setTimeout(refreshRecentBuilds, 1000);
|
||||
}
|
||||
|
||||
function bindBuildListTransport() {
|
||||
var tbody = document.getElementById('builds-recent-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.addEventListener('click', function (ev) {
|
||||
var play = ev.target.closest('.build-transport-play');
|
||||
var stop = ev.target.closest('.build-transport-stop');
|
||||
if (play) {
|
||||
var pid = Number(play.getAttribute('data-build-id'));
|
||||
var pst = play.getAttribute('data-build-status') || '';
|
||||
if (!pid) return;
|
||||
var msg = pst === 'running' || pst === 'queued'
|
||||
? 'Force re-run? This stops the current run and starts a new build with the same parameters.'
|
||||
: null;
|
||||
if (msg && !confirm(msg)) return;
|
||||
postBuildAction({ action: 'force_rerun', build_id: pid }).then(function (j) {
|
||||
if (j.build && j.build.id) {
|
||||
window.location.href = base + '/?view=build&id=' + j.build.id;
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert(err.message || 'Re-run failed');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stop) {
|
||||
var sid = Number(stop.getAttribute('data-build-id'));
|
||||
if (!sid || stop.disabled) return;
|
||||
if (!confirm('Stop this build now?')) return;
|
||||
postBuildAction({ action: 'stop', build_id: sid }).then(function () {
|
||||
refreshRecentBuilds();
|
||||
}).catch(function (err) {
|
||||
alert(err.message || 'Stop failed');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.body.getAttribute('data-view') === 'home' && document.getElementById('builds-recent-tbody')) {
|
||||
bindBuildListTransport();
|
||||
refreshRecentBuilds();
|
||||
}
|
||||
|
||||
var themeSel = document.getElementById('theme-select');
|
||||
if (themeSel) {
|
||||
var cur = localStorage.getItem('crash_console_theme') || 'dark';
|
||||
themeSel.value = cur;
|
||||
themeSel.addEventListener('change', function () {
|
||||
document.documentElement.setAttribute('data-theme', themeSel.value);
|
||||
localStorage.setItem('crash_console_theme', themeSel.value);
|
||||
});
|
||||
}
|
||||
|
||||
var form = document.getElementById('build-trigger-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
var status = document.getElementById('build-trigger-status');
|
||||
var fd = new FormData(form);
|
||||
var payload = {
|
||||
git_ref: fd.get('git_ref') || 'next',
|
||||
gradle_task: fd.get('gradle_task') || 'assembleDebug',
|
||||
ota_channel: fd.get('ota_channel') || 'staging',
|
||||
run_tests: !!form.querySelector('[name=run_tests]').checked,
|
||||
run_native: !!form.querySelector('[name=run_native]').checked,
|
||||
run_apk: !!form.querySelector('[name=run_apk]').checked,
|
||||
auto_ota: !!form.querySelector('[name=auto_ota]').checked,
|
||||
auto_deploy: !!form.querySelector('[name=auto_deploy]').checked
|
||||
};
|
||||
status.textContent = 'Starting…';
|
||||
fetch(base + '/api/build_trigger.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) {
|
||||
throw new Error((j && j.error) || ('HTTP ' + r.status));
|
||||
}
|
||||
return j;
|
||||
});
|
||||
}).then(function (j) {
|
||||
if (j.ok && j.build && j.build.id) {
|
||||
window.location.href = base + '/?view=build&id=' + j.build.id;
|
||||
} else {
|
||||
status.textContent = j.error || 'Failed';
|
||||
}
|
||||
}).catch(function (err) {
|
||||
status.textContent = err && err.message ? err.message : 'Network error';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 rerunFailedBtn = document.getElementById('build-rerun-failed-btn');
|
||||
var rerunSshBtn = document.getElementById('build-rerun-ssh-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');
|
||||
|
||||
function goToBuild(j) {
|
||||
if (j.build && j.build.id) {
|
||||
window.location.href = base + '/?view=build&id=' + j.build.id;
|
||||
}
|
||||
}
|
||||
|
||||
function bindRerunAction(btn, action, confirmMsg) {
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var id = Number(btn.getAttribute('data-build-id'));
|
||||
if (!id) return;
|
||||
if (confirmMsg && !confirm(confirmMsg)) return;
|
||||
if (actionStatus) actionStatus.textContent = 'Starting…';
|
||||
postBuildAction({ action: action, build_id: id }).then(goToBuild).catch(function (err) {
|
||||
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
|
||||
});
|
||||
});
|
||||
}
|
||||
if (rerunAsToggle && rerunAsForm) {
|
||||
rerunAsToggle.addEventListener('click', function () {
|
||||
rerunAsForm.hidden = !rerunAsForm.hidden;
|
||||
});
|
||||
}
|
||||
bindRerunAction(rerunBtn, 'rerun', null);
|
||||
bindRerunAction(rerunFailedBtn, 'rerun_from_failed', null);
|
||||
bindRerunAction(
|
||||
rerunSshBtn,
|
||||
'rerun_with_ssh',
|
||||
'Start an SSH debug session on the builder? You will need shell access to the builder host.'
|
||||
);
|
||||
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 buildStatus = document.body.getAttribute('data-build-status') || '';
|
||||
var logEl = document.getElementById('build-log-tail');
|
||||
if (buildId && logEl) {
|
||||
var offset = 0;
|
||||
var full = '';
|
||||
var showFull = false;
|
||||
var DISPLAY_TAIL = 50000;
|
||||
|
||||
function renderLog() {
|
||||
logEl.textContent = showFull ? full : full.slice(-DISPLAY_TAIL);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function poll() {
|
||||
var url = base + '/api/build_log.php?id=' + encodeURIComponent(buildId) + '&offset=' + offset;
|
||||
fetch(url)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j.ok) return;
|
||||
if (j.log && j.log.text) {
|
||||
full += j.log.text;
|
||||
offset = j.log.offset || offset;
|
||||
renderLog();
|
||||
}
|
||||
if (j.build) {
|
||||
var iconEl = document.getElementById('build-status-icon');
|
||||
var textEl = document.getElementById('build-status-text');
|
||||
if (iconEl) {
|
||||
iconEl.innerHTML = buildStatusIconHtml(j.build.status);
|
||||
}
|
||||
if (textEl) {
|
||||
var errEl = document.getElementById('build-error-message');
|
||||
var err = j.build.error_message ? (' · <span class="error-text" id="build-error-message">'
|
||||
+ String(j.build.error_message).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
+ '</span>') : '';
|
||||
textEl.innerHTML = 'Status: <strong>' + (j.build.status || '') + '</strong> · Phase: '
|
||||
+ (j.build.phase || '') + err;
|
||||
}
|
||||
document.body.setAttribute('data-build-status', j.build.status || '');
|
||||
}
|
||||
var running = j.build && j.build.status === 'running';
|
||||
var eof = j.log && j.log.eof;
|
||||
if (running || !eof) {
|
||||
setTimeout(poll, running ? 1000 : 250);
|
||||
} else if (j.build && j.build.phase === 'ssh_debug' && !document.getElementById('build-ssh-panel')) {
|
||||
window.location.reload();
|
||||
}
|
||||
}).catch(function () { setTimeout(poll, 2000); });
|
||||
}
|
||||
|
||||
function loadFullLog() {
|
||||
return fetch(base + '/api/build_log.php?id=' + encodeURIComponent(buildId) + '&full=1')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (j.ok && j.log && j.log.text) {
|
||||
full = j.log.text;
|
||||
offset = j.log.offset || 0;
|
||||
renderLog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buildStatus && buildStatus !== 'running' && buildStatus !== 'queued') {
|
||||
loadFullLog().catch(function () { poll(); });
|
||||
} else {
|
||||
poll();
|
||||
}
|
||||
|
||||
var expand = document.getElementById('build-log-expand');
|
||||
if (expand) {
|
||||
expand.addEventListener('click', function () {
|
||||
showFull = true;
|
||||
if (full === '') {
|
||||
loadFullLog().then(function () { renderLog(); });
|
||||
} else {
|
||||
renderLog();
|
||||
}
|
||||
expand.textContent = 'Showing full log';
|
||||
expand.disabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
88
public/index.php
Normal file
88
public/index.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$base = Auth::basePath();
|
||||
if ($base !== '' && str_starts_with($uri, $base)) {
|
||||
$uri = substr($uri, strlen($base)) ?: '/';
|
||||
}
|
||||
$route = rtrim($uri, '/') ?: '/';
|
||||
|
||||
if ($route === '/api/health.php' || str_ends_with($route, '/api/health.php')) {
|
||||
require __DIR__ . '/api/health.php';
|
||||
exit;
|
||||
}
|
||||
if ($route === '/api/builds.php' || str_ends_with($route, '/api/builds.php')) {
|
||||
require __DIR__ . '/api/builds.php';
|
||||
exit;
|
||||
}
|
||||
if ($route === '/api/build_trigger.php' || str_ends_with($route, '/api/build_trigger.php')) {
|
||||
require __DIR__ . '/api/build_trigger.php';
|
||||
exit;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if ($route === '/logout') {
|
||||
Auth::logout();
|
||||
header('Location: ' . $base . '/login');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$user = trim($_POST['username'] ?? '');
|
||||
$pass = $_POST['password'] ?? '';
|
||||
if (Auth::login($user, $pass)) {
|
||||
header('Location: ' . $base . '/');
|
||||
exit;
|
||||
}
|
||||
$loginError = 'Invalid credentials';
|
||||
require __DIR__ . '/../views/login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/login') {
|
||||
require __DIR__ . '/../views/login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
Auth::check();
|
||||
|
||||
BuildRunner::reconcileRunningBuilds();
|
||||
|
||||
$view = $_GET['view'] ?? 'home';
|
||||
|
||||
if ($view === 'build' && isset($_GET['id'])) {
|
||||
$build = BuildRepository::getById((int) $_GET['id']);
|
||||
if (!$build) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
if ($build['status'] === 'running' && is_file(BuildRunner::artifactDir((int) $build['id']) . '/android_cast-latest.apk')) {
|
||||
BuildRunner::finalizeFromArtifacts((int) $build['id']);
|
||||
$build = BuildRepository::getById((int) $_GET['id']);
|
||||
}
|
||||
$health = BuildRepository::healthSummary();
|
||||
$view = 'build';
|
||||
$pageTitle = 'Build ' . ($build['build_code'] ?? '');
|
||||
require __DIR__ . '/../views/layout.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$view = 'home';
|
||||
$health = BuildRepository::healthSummary();
|
||||
$builds = BuildRepository::listRecent(10);
|
||||
$pageTitle = 'Builder';
|
||||
require __DIR__ . '/../views/layout.php';
|
||||
129
src/BuildRepository.php
Normal file
129
src/BuildRepository.php
Normal 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
543
src/BuildRunner.php
Normal 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
99
src/bootstrap.php
Normal 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>';
|
||||
}
|
||||
124
views/build_detail.php
Normal file
124
views/build_detail.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php declare(strict_types=1);
|
||||
$artifacts = json_decode($build['artifacts_json'] ?? '{}', true) ?: [];
|
||||
$params = json_decode($build['params_json'] ?? '{}', true) ?: [];
|
||||
$status = (string) ($build['status'] ?? '');
|
||||
$phase = (string) ($build['phase'] ?? '');
|
||||
$isRunning = $status === 'running';
|
||||
$canRerunFromFailed = in_array($status, ['failed', 'cancelled'], true);
|
||||
$failedStep = $canRerunFromFailed ? BuildRunner::detectFailedStep((int) ($build['id'] ?? 0)) : '';
|
||||
$isSshDebug = $phase === 'ssh_debug' || ($isRunning && !empty($params['ssh_debug']));
|
||||
$sshContainer = BuildRunner::debugContainerName((int) ($build['id'] ?? 0));
|
||||
$builderHost = (string) ($build['builder_id'] ?? gethostname() ?: 'builder');
|
||||
$pipelineYaml = trim((string) ($build['pipeline_yaml'] ?? ''));
|
||||
if ($pipelineYaml === '') {
|
||||
$pipelineYaml = '(none — will be generated on next trigger from build.config.yml or params)';
|
||||
}
|
||||
$stepLabels = [
|
||||
'git' => 'Git checkout / shallow clone',
|
||||
'docker-build' => 'Docker image build',
|
||||
'docker-run' => 'Gradle pipeline (docker run)',
|
||||
];
|
||||
?>
|
||||
<h1>Build <?= h($build['build_code'] ?? '') ?></h1>
|
||||
<p class="muted build-status-line" id="build-status-line">
|
||||
<span id="build-status-icon"><?= build_status_icon($status) ?></span>
|
||||
<span id="build-status-text">Status: <strong><?= h($status) ?></strong> · Phase: <?= h($phase) ?>
|
||||
<?php if (!empty($build['error_message'])): ?>
|
||||
· <span class="error-text" id="build-error-message"><?= h($build['error_message']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</p>
|
||||
<?php
|
||||
$healthDocker = ($health['docker'] ?? '') === 'ok';
|
||||
$dockerFail = str_contains((string) ($build['error_message'] ?? ''), 'Docker daemon not available');
|
||||
if ($status === 'failed' && $dockerFail && $healthDocker):
|
||||
?>
|
||||
<p class="muted graphs-footnote">Ecosystem health reports <strong>Docker: ok</strong> now. The log below is from this failed run — use <strong>Re-run</strong> (do not only refresh).</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($canRerunFromFailed && $failedStep !== ''): ?>
|
||||
<p class="muted graphs-footnote">Failed at step: <strong><?= h($stepLabels[$failedStep] ?? $failedStep) ?></strong> — <em>Re-run from failed</em> skips earlier steps when artifacts allow (like CircleCI).</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="build-actions card card--lift" style="margin-bottom:12px">
|
||||
<h2>Actions</h2>
|
||||
<div class="toolbar-actions" style="flex-wrap:wrap;gap:8px">
|
||||
<a class="btn" href="<?= h(Auth::basePath()) ?>/">← Back</a>
|
||||
<button type="button" class="btn" id="build-rerun-btn" data-build-id="<?= (int) $build['id'] ?>">Re-run</button>
|
||||
<?php if ($canRerunFromFailed): ?>
|
||||
<button type="button" class="btn" id="build-rerun-failed-btn" data-build-id="<?= (int) $build['id'] ?>" title="Resume from <?= h($failedStep) ?>">Re-run from failed</button>
|
||||
<button type="button" class="btn" id="build-rerun-ssh-btn" data-build-id="<?= (int) $build['id'] ?>" title="Resume from failed step, then open an interactive container (CircleCI-style SSH debug)">Re-run with SSH</button>
|
||||
<?php endif; ?>
|
||||
<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'] ?>"><?= $isSshDebug ? 'End SSH session' : 'Stop' ?></button>
|
||||
<?php endif; ?>
|
||||
<span id="build-action-status" class="muted"></span>
|
||||
</div>
|
||||
<?php if ($isSshDebug): ?>
|
||||
<div class="build-ssh-panel" id="build-ssh-panel">
|
||||
<strong>SSH debug session</strong>
|
||||
<p class="muted graphs-footnote" style="margin:6px 0 0">Shell into the CI container on the builder host (same idea as CircleCI “Rerun with SSH”). Stop ends the session.</p>
|
||||
<pre id="build-ssh-command">ssh <user>@<?= h($builderHost) ?>
|
||||
|
||||
docker exec -it <?= h($sshContainer) ?> bash</pre>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<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>
|
||||
<dt>Builder</dt><dd><?= h($build['builder_id'] ?? '') ?></dd>
|
||||
<dt>CI image</dt><dd><code><?= h($build['dockerfile_version'] ?? '') ?></code></dd>
|
||||
<dt>Target OS</dt><dd><?= h($build['target_os'] ?? 'android') ?> (min API <?= (int) ($build['min_api_level'] ?? 24) ?>)</dd>
|
||||
<dt>Git ref</dt><dd><code><?= h($build['git_ref'] ?? '—') ?></code></dd>
|
||||
<dt>Git SHA</dt><dd><code><?= h($build['git_sha'] ?? '—') ?></code></dd>
|
||||
<dt>OTA channel</dt><dd><?= h($build['ota_channel'] ?? '—') ?></dd>
|
||||
<dt>Gradle task</dt><dd><code><?= h($params['gradle_task'] ?? '—') ?></code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<?php if ($artifacts): ?>
|
||||
<section class="card card--lift">
|
||||
<h2>Artifacts</h2>
|
||||
<ul>
|
||||
<?php foreach ($artifacts as $k => $v): ?>
|
||||
<li><strong><?= h($k) ?>:</strong> <code><?= h((string) $v) ?></code></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="card card--lift">
|
||||
<h2>Pipeline (YAML)</h2>
|
||||
<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">
|
||||
<h2>Build log</h2>
|
||||
<p class="muted graphs-footnote">Live tail shows the last ~50 KB while running. Failed builds load the full log automatically. Per-step files (when present): <code>docker-build.log</code>, <code>docker-run.log</code> under this build’s artifact dir.</p>
|
||||
<pre id="build-log-tail" class="log-preview log-preview--live" aria-live="polite"></pre>
|
||||
<div class="build-log-actions">
|
||||
<button type="button" class="btn" id="build-log-expand">Show full log</button>
|
||||
<a class="btn" id="build-log-download" href="<?= h(Auth::basePath()) ?>/api/build_log.php?id=<?= (int) $build['id'] ?>&download=1">Download log</a>
|
||||
</div>
|
||||
</section>
|
||||
83
views/home.php
Normal file
83
views/home.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php declare(strict_types=1); ?>
|
||||
<h1>Android Cast Builder</h1>
|
||||
<p class="muted">Docker CI for APK baking, OTA staging, and pipeline history.</p>
|
||||
|
||||
<section class="card card--lift" style="margin-bottom:16px">
|
||||
<h2>Ecosystem health</h2>
|
||||
<ul id="build-health-list">
|
||||
<li>Docker: <strong id="build-health-docker"><?= h($health['docker'] ?? 'unknown') ?></strong></li>
|
||||
<li>CI image version: <code><?= h($health['ci_version'] ?? '') ?></code></li>
|
||||
<li>Running builds: <span id="build-health-running"><?= (int) ($health['running'] ?? 0) ?></span></li>
|
||||
<li>Passed (7d): <?= (int) ($health['passed_7d'] ?? 0) ?> · Failed (7d): <?= (int) ($health['failed_7d'] ?? 0) ?></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card card--lift" id="build-trigger-panel">
|
||||
<h2>Run pipeline</h2>
|
||||
<form id="build-trigger-form" class="build-form">
|
||||
<div class="build-form-grid">
|
||||
<label>Git ref (branch / tag / commit)<input name="git_ref" placeholder="next" value="next"></label>
|
||||
<label>Gradle task<input name="gradle_task" value="assembleDebug"></label>
|
||||
<label>OTA channel<select name="ota_channel">
|
||||
<option value="staging" selected>staging</option>
|
||||
<option value="dev">dev</option>
|
||||
<option value="nightly">nightly</option>
|
||||
<option value="stable">stable / prod</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="build-form-checks">
|
||||
<label><input type="checkbox" name="run_tests" checked> Unit tests</label>
|
||||
<label><input type="checkbox" name="run_native" checked> Native codecs</label>
|
||||
<label><input type="checkbox" name="run_apk" checked> APK output</label>
|
||||
<label><input type="checkbox" name="auto_ota"> Create OTA artifacts</label>
|
||||
<label><input type="checkbox" name="auto_deploy"> Publish OTA to mount</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Start build</button>
|
||||
<span id="build-trigger-status" class="muted"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card card--lift">
|
||||
<h2>Recent builds (last 10)</h2>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table" id="builds-recent-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="build-col-controls" aria-label="Job controls"></th>
|
||||
<th>Code</th>
|
||||
<th>Status</th>
|
||||
<th>Phase</th>
|
||||
<th>Branch/ref</th>
|
||||
<th>Channel</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="builds-recent-tbody">
|
||||
<?php foreach ($builds as $b):
|
||||
$bStatus = (string) ($b['status'] ?? '');
|
||||
$isActive = in_array($bStatus, ['running', 'queued'], true);
|
||||
?>
|
||||
<tr data-build-id="<?= (int) $b['id'] ?>" data-build-status="<?= h($bStatus) ?>">
|
||||
<td class="build-row-controls">
|
||||
<div class="build-transport-actions">
|
||||
<button type="button" class="build-transport-btn build-transport-play" data-build-id="<?= (int) $b['id'] ?>" data-build-status="<?= h($bStatus) ?>" title="Re-run job" aria-label="Re-run build <?= h($b['build_code']) ?>"></button>
|
||||
<button type="button" class="build-transport-btn build-transport-stop" data-build-id="<?= (int) $b['id'] ?>" data-build-status="<?= h($bStatus) ?>" title="Stop job" aria-label="Stop build <?= h($b['build_code']) ?>"<?= $isActive ? '' : ' disabled' ?>></button>
|
||||
</div>
|
||||
</td>
|
||||
<td><a href="<?= h(Auth::basePath()) ?>/?view=build&id=<?= (int) $b['id'] ?>"><?= h($b['build_code']) ?></a></td>
|
||||
<td>
|
||||
<span class="build-status-cell-inner" data-build-status-cell="<?= (int) $b['id'] ?>">
|
||||
<?= build_status_icon($bStatus) ?>
|
||||
<span class="build-status-label"><?= h($bStatus) ?></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="build-phase-cell" data-build-phase-cell="<?= (int) $b['id'] ?>"><?= h($b['phase']) ?></td>
|
||||
<td><?= h($b['git_ref'] ?? $b['branch'] ?? '—') ?></td>
|
||||
<td><?= h($b['ota_channel'] ?? '—') ?></td>
|
||||
<td><?= h($b['created_at'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
59
views/layout.php
Normal file
59
views/layout.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php declare(strict_types=1);
|
||||
$ab = assets_base();
|
||||
$links = cfg('links', []);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($pageTitle ?? 'Builder') ?> — <?= h(cfg('app_name')) ?></title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('crash_console_theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||
var l = localStorage.getItem('crash_console_lang');
|
||||
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h($ab) ?>/assets/css/app.css">
|
||||
<script src="<?= h($ab) ?>/assets/js/i18n.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/builder.js" defer></script>
|
||||
</head>
|
||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||
data-view="<?= h($view ?? 'home') ?>"
|
||||
<?= (($view ?? '') === 'build' && !empty($build['id'])) ? ' data-build-id="' . (int) $build['id'] . '"' : '' ?>
|
||||
<?= (($view ?? '') === 'build' && !empty($build['status'])) ? ' data-build-status="' . h((string) $build['status']) . '"' : '' ?>>
|
||||
<div class="shell">
|
||||
<nav class="nav-pane" id="nav-pane" aria-label="Builder navigation">
|
||||
<button type="button" class="nav-handle" id="nav-handle" aria-label="Toggle navigation">
|
||||
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="nav-list">
|
||||
<li><a href="<?= h(Auth::basePath()) ?>/" class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"><span class="nav-icon nav-icon--home"></span><span class="nav-label">Builder</span></a></li>
|
||||
<li><a href="<?= h($links['crashes'] ?? '') ?>?view=reports" class="nav-link"><span class="nav-icon nav-icon--reports"></span><span class="nav-label">Crashes</span></a></li>
|
||||
<li><a href="<?= h($links['tickets'] ?? '') ?>" class="nav-link"><span class="nav-icon nav-icon--tickets"></span><span class="nav-label">Tickets</span></a></li>
|
||||
<li><a href="<?= h($links['git'] ?? '') ?>" class="nav-link"><span class="nav-icon nav-icon--git"></span><span class="nav-label">Git</span></a></li>
|
||||
<li><a href="<?= h($links['hub'] ?? '') ?>" class="nav-link"><span class="nav-icon nav-icon--home"></span><span class="nav-label">Hub</span></a></li>
|
||||
</ul>
|
||||
<div class="nav-locale">
|
||||
<label class="toolbar-select"><span>Theme</span>
|
||||
<select id="theme-select" aria-label="UI theme"><option value="dark">Dark</option><option value="light">Light</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="nav-user">
|
||||
<span class="nav-user-name"><?= h(Auth::user()['username'] ?? '') ?></span>
|
||||
<a href="<?= h(Auth::basePath()) ?>/logout" class="nav-link nav-link--logout">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main-pane">
|
||||
<?php if (($view ?? '') === 'build' && !empty($build)): ?>
|
||||
<?php require __DIR__ . '/build_detail.php'; ?>
|
||||
<?php else: ?>
|
||||
<?php require __DIR__ . '/home.php'; ?>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
<?php platform_render_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
32
views/login.php
Normal file
32
views/login.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php declare(strict_types=1);
|
||||
$bp = Auth::basePath();
|
||||
$ab = assets_base();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login — <?= h(cfg('app_name')) ?></title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('crash_console_theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h($ab) ?>/assets/css/app.css">
|
||||
</head>
|
||||
<body class="login-page" data-base-path="<?= h($ab) ?>">
|
||||
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
|
||||
<h1><?= h(cfg('app_name')) ?></h1>
|
||||
<p class="muted">Sign in (same credentials as Crashes / Tickets)</p>
|
||||
<?php if (!empty($loginError)): ?>
|
||||
<div class="alert"><?= h($loginError) ?></div>
|
||||
<?php endif; ?>
|
||||
<label><span>Username</span><input name="username" autocomplete="username" required></label>
|
||||
<label><span>Password</span><input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<?php platform_render_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user