557 lines
18 KiB
PHP
557 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Clients;
|
|
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class JenkinsClient
|
|
{
|
|
private ?string $host;
|
|
|
|
private ?string $username;
|
|
|
|
private ?string $apiToken;
|
|
|
|
private int $timeout;
|
|
|
|
public function __construct()
|
|
{
|
|
$config = config('jenkins', []);
|
|
$this->host = rtrim($config['host'] ?? '', '/');
|
|
$this->username = $config['username'] ?? null;
|
|
$this->apiToken = $config['api_token'] ?? null;
|
|
$this->timeout = $config['timeout'] ?? 30;
|
|
}
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
return ! empty($this->host) && ! empty($this->username) && ! empty($this->apiToken);
|
|
}
|
|
|
|
public function getJobInfo(string $jobName): ?array
|
|
{
|
|
return $this->request($this->getJobPath($jobName).'/api/json');
|
|
}
|
|
|
|
public function getBuildInfo(string $jobName, int $buildNumber): ?array
|
|
{
|
|
return $this->request($this->getJobPath($jobName)."/{$buildNumber}/api/json");
|
|
}
|
|
|
|
public function getLastBuild(string $jobName): ?array
|
|
{
|
|
return $this->request($this->getJobPath($jobName).'/lastBuild/api/json');
|
|
}
|
|
|
|
public function getParameterDefinitions(string $jobName): array
|
|
{
|
|
$jobInfo = $this->getJobInfo($jobName);
|
|
if (! $jobInfo || empty($jobInfo['property'])) {
|
|
return [];
|
|
}
|
|
|
|
$buildFormParameters = $this->getBuildFormParameters($jobName);
|
|
|
|
foreach ($jobInfo['property'] as $property) {
|
|
if (empty($property['parameterDefinitions']) || ! is_array($property['parameterDefinitions'])) {
|
|
continue;
|
|
}
|
|
|
|
return array_map(function (array $definition) use ($buildFormParameters) {
|
|
$name = $definition['name'] ?? '';
|
|
$formParameter = $buildFormParameters[$name] ?? [];
|
|
$default = $definition['defaultParameterValue']['value'] ?? null;
|
|
if (($formParameter['multiple'] ?? false) && isset($formParameter['default'])) {
|
|
$default = $formParameter['default'];
|
|
}
|
|
|
|
return [
|
|
'name' => $name,
|
|
'type' => $definition['type'] ?? $definition['_class'] ?? 'StringParameterDefinition',
|
|
'description' => $definition['description'] ?? '',
|
|
'default' => $default,
|
|
'choices' => $definition['choices'] ?? $formParameter['choices'] ?? [],
|
|
'multiple' => (bool) ($formParameter['multiple'] ?? false),
|
|
];
|
|
}, array_values(array_filter($property['parameterDefinitions'], fn ($definition) => ! empty($definition['name']))));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
public function triggerBuild(string $jobName, array $parameters = []): array
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
Log::warning('Jenkins client is not configured');
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Jenkins not configured',
|
|
];
|
|
}
|
|
|
|
$path = $this->getJobPath($jobName).'/build?delay=0sec';
|
|
$url = $this->host.$path;
|
|
|
|
try {
|
|
$jobInfo = $this->getJobInfo($jobName);
|
|
$nextBuildNumber = isset($jobInfo['nextBuildNumber']) ? (int) $jobInfo['nextBuildNumber'] : null;
|
|
$request = $this->http();
|
|
$crumb = $this->getCrumb();
|
|
if ($crumb) {
|
|
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
|
|
}
|
|
|
|
$response = empty($parameters)
|
|
? $request->post($url)
|
|
: $request->asForm()->post($url, [
|
|
'json' => json_encode([
|
|
'parameter' => $this->buildFormParameters($parameters),
|
|
'statusCode' => '303',
|
|
'redirectTo' => '.',
|
|
], JSON_UNESCAPED_UNICODE),
|
|
'Submit' => 'Build',
|
|
]);
|
|
|
|
if ($response->successful() || $response->status() === 201) {
|
|
return [
|
|
'success' => true,
|
|
'queue_url' => $response->header('Location'),
|
|
'build_number' => $nextBuildNumber,
|
|
'status' => $response->status(),
|
|
];
|
|
}
|
|
|
|
Log::warning('Jenkins build trigger failed', [
|
|
'url' => $url,
|
|
'status' => $response->status(),
|
|
'body' => $response->body(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Jenkins 返回状态码 '.$response->status(),
|
|
'status' => $response->status(),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('Jenkins build trigger error', [
|
|
'url' => $url,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
public function getBuilds(string $jobName, int $limit = 10): array
|
|
{
|
|
$jobInfo = $this->getJobInfo($jobName);
|
|
if (! $jobInfo || empty($jobInfo['builds'])) {
|
|
return [];
|
|
}
|
|
|
|
$builds = array_slice($jobInfo['builds'], 0, $limit);
|
|
$result = [];
|
|
|
|
foreach ($builds as $build) {
|
|
$buildInfo = $this->getBuildInfo($jobName, $build['number']);
|
|
if ($buildInfo) {
|
|
$result[] = $buildInfo;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function getBuildStatus(string $jobName, ?string $queueUrl = null, ?int $buildNumber = null): array
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
Log::warning('Jenkins client is not configured');
|
|
|
|
return [
|
|
'success' => false,
|
|
'status' => 'UNKNOWN',
|
|
'message' => 'Jenkins not configured',
|
|
];
|
|
}
|
|
|
|
$queueItem = null;
|
|
if ($queueUrl && ! $buildNumber) {
|
|
$queueItem = $this->getQueueItem($queueUrl);
|
|
if (! $queueItem) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'UNKNOWN',
|
|
'queue_url' => $queueUrl,
|
|
'message' => '无法获取 Jenkins 队列状态',
|
|
];
|
|
}
|
|
|
|
if ($queueItem['cancelled'] ?? false) {
|
|
return [
|
|
'success' => true,
|
|
'status' => 'ABORTED',
|
|
'result' => 'ABORTED',
|
|
'completed' => true,
|
|
'queue_url' => $queueUrl,
|
|
];
|
|
}
|
|
|
|
if (empty($queueItem['executable']['number'])) {
|
|
return [
|
|
'success' => true,
|
|
'status' => 'PENDING',
|
|
'building' => true,
|
|
'completed' => false,
|
|
'queue_url' => $queueUrl,
|
|
'message' => $queueItem['why'] ?? null,
|
|
];
|
|
}
|
|
|
|
$buildNumber = (int) $queueItem['executable']['number'];
|
|
}
|
|
|
|
if (! $buildNumber) {
|
|
return [
|
|
'success' => false,
|
|
'status' => 'UNKNOWN',
|
|
'queue_url' => $queueUrl,
|
|
'message' => '缺少 Jenkins 构建号',
|
|
];
|
|
}
|
|
|
|
$buildInfo = $this->getBuildInfo($jobName, $buildNumber);
|
|
if (! $buildInfo) {
|
|
return [
|
|
'success' => true,
|
|
'status' => 'PENDING',
|
|
'building' => true,
|
|
'completed' => false,
|
|
'build_number' => $buildNumber,
|
|
'queue_url' => $queueUrl,
|
|
'message' => $this->findQueuedItem($jobName) ? '等待 Jenkins 开始构建' : '等待 Jenkins 创建构建',
|
|
];
|
|
}
|
|
|
|
$building = (bool) ($buildInfo['building'] ?? false);
|
|
$result = $buildInfo['result'] ?? null;
|
|
|
|
return [
|
|
'success' => true,
|
|
'status' => $building ? 'BUILDING' : ($result ?? 'UNKNOWN'),
|
|
'result' => $result,
|
|
'building' => $building,
|
|
'completed' => ! $building && ! empty($result),
|
|
'build_number' => $buildNumber,
|
|
'build_url' => $buildInfo['url'] ?? ($queueItem['executable']['url'] ?? null),
|
|
'queue_url' => $queueUrl,
|
|
];
|
|
}
|
|
|
|
public function cancelBuild(string $jobName, ?string $queueUrl = null, ?int $buildNumber = null): array
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
Log::warning('Jenkins client is not configured');
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Jenkins not configured',
|
|
];
|
|
}
|
|
|
|
if ($queueUrl && ! $buildNumber) {
|
|
$queueItem = $this->getQueueItem($queueUrl);
|
|
if (! empty($queueItem['executable']['number'])) {
|
|
$buildNumber = (int) $queueItem['executable']['number'];
|
|
} else {
|
|
$queueId = $this->extractQueueId($queueUrl);
|
|
if (! $queueId) {
|
|
return [
|
|
'success' => false,
|
|
'message' => '无法识别 Jenkins 队列 ID',
|
|
];
|
|
}
|
|
|
|
return [
|
|
...$this->post('/queue/cancelItem?id='.rawurlencode($queueId), 'Jenkins queue cancel'),
|
|
'cancelled_queue' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
$queueItem = $this->findQueuedItem($jobName);
|
|
if (! empty($queueItem['id'])) {
|
|
return [
|
|
...$this->post('/queue/cancelItem?id='.rawurlencode((string) $queueItem['id']), 'Jenkins queue cancel'),
|
|
'cancelled_queue' => true,
|
|
];
|
|
}
|
|
|
|
if (! $buildNumber) {
|
|
return [
|
|
'success' => false,
|
|
'message' => '缺少 Jenkins 构建号',
|
|
];
|
|
}
|
|
|
|
return [
|
|
...$this->post($this->getJobPath($jobName)."/{$buildNumber}/stop", 'Jenkins build stop'),
|
|
'stopping_build' => true,
|
|
];
|
|
}
|
|
|
|
private function request(string $path): ?array
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
Log::warning('Jenkins client is not configured');
|
|
|
|
return null;
|
|
}
|
|
|
|
$url = $this->host.$path;
|
|
|
|
try {
|
|
$response = $this->http()->get($url);
|
|
|
|
if ($response->successful()) {
|
|
return $response->json();
|
|
}
|
|
|
|
Log::warning('Jenkins API request failed', [
|
|
'url' => $url,
|
|
'status' => $response->status(),
|
|
]);
|
|
|
|
return null;
|
|
} catch (\Throwable $e) {
|
|
Log::error('Jenkins API request error', [
|
|
'url' => $url,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function post(string $path, string $operation): array
|
|
{
|
|
$url = $this->host.$path;
|
|
|
|
try {
|
|
$request = $this->http();
|
|
$crumb = $this->getCrumb();
|
|
if ($crumb) {
|
|
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
|
|
}
|
|
|
|
$response = $request->post($url);
|
|
if ($response->successful() || in_array($response->status(), [201, 302], true)) {
|
|
return [
|
|
'success' => true,
|
|
'status' => $response->status(),
|
|
];
|
|
}
|
|
|
|
Log::warning($operation.' failed', [
|
|
'url' => $url,
|
|
'status' => $response->status(),
|
|
'body' => $response->body(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Jenkins 返回状态码 '.$response->status(),
|
|
'status' => $response->status(),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error($operation.' error', [
|
|
'url' => $url,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
private function buildFormParameters(array $parameters): array
|
|
{
|
|
return collect($parameters)
|
|
->map(fn ($value, $name) => [
|
|
'name' => $name,
|
|
'value' => $value,
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string
|
|
{
|
|
if (! $this->isConfigured()) {
|
|
Log::warning('Jenkins client is not configured');
|
|
|
|
return null;
|
|
}
|
|
|
|
$url = $this->host.$path;
|
|
|
|
try {
|
|
$response = $this->http()->get($url);
|
|
|
|
if ($response->successful() || ($allowMethodNotAllowed && $response->status() === 405)) {
|
|
return $response->body();
|
|
}
|
|
|
|
Log::warning('Jenkins page request failed', [
|
|
'url' => $url,
|
|
'status' => $response->status(),
|
|
]);
|
|
|
|
return null;
|
|
} catch (\Throwable $e) {
|
|
Log::error('Jenkins page request error', [
|
|
'url' => $url,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function getBuildFormParameters(string $jobName): array
|
|
{
|
|
$html = $this->requestBody($this->getJobPath($jobName).'/build?delay=0sec', allowMethodNotAllowed: true);
|
|
if (! $html) {
|
|
return [];
|
|
}
|
|
|
|
$parameters = [];
|
|
foreach (array_slice(preg_split('/<div name="parameter"[^>]*>/s', $html) ?: [], 1) as $parameterHtml) {
|
|
if (! preg_match('/<input name="name" type="hidden" value="([^"]+)"/s', $parameterHtml, $nameMatch)) {
|
|
continue;
|
|
}
|
|
|
|
if (! preg_match('/<select([^>]*)>(.*?)<\/select>/s', $parameterHtml, $selectMatch)) {
|
|
continue;
|
|
}
|
|
|
|
$name = html_entity_decode($nameMatch[1], ENT_QUOTES | ENT_HTML5);
|
|
$selectAttributes = $selectMatch[1];
|
|
$optionsHtml = $selectMatch[2];
|
|
$multiple = str_contains($selectAttributes, 'multiple');
|
|
|
|
preg_match_all('/<option([^>]*) value="([^"]*)"[^>]*>(.*?)<\/option>/s', $optionsHtml, $optionMatches, PREG_SET_ORDER);
|
|
|
|
$choices = [];
|
|
$defaults = [];
|
|
foreach ($optionMatches as $optionMatch) {
|
|
$attributes = $optionMatch[1];
|
|
$value = html_entity_decode($optionMatch[2], ENT_QUOTES | ENT_HTML5);
|
|
$label = trim(strip_tags(html_entity_decode($optionMatch[3], ENT_QUOTES | ENT_HTML5)));
|
|
$selected = str_contains($attributes, 'selected') || str_contains($label, '√');
|
|
$label = trim(str_replace('√', '', $label));
|
|
|
|
$choices[] = [
|
|
'value' => $value,
|
|
'label' => $label ?: $value,
|
|
'selected' => $selected,
|
|
];
|
|
|
|
if ($selected) {
|
|
$defaults[] = $value;
|
|
}
|
|
}
|
|
|
|
$parameters[$name] = [
|
|
'choices' => $choices,
|
|
'default' => $multiple ? $defaults : ($defaults[0] ?? null),
|
|
'multiple' => $multiple,
|
|
];
|
|
}
|
|
|
|
return $parameters;
|
|
}
|
|
|
|
private function getQueueItem(string $queueUrl): ?array
|
|
{
|
|
$path = $this->normalizeJenkinsPath($queueUrl);
|
|
$path = rtrim($path, '/').'/api/json';
|
|
|
|
return $this->request($path);
|
|
}
|
|
|
|
private function findQueuedItem(string $jobName): ?array
|
|
{
|
|
$queue = $this->request('/queue/api/json');
|
|
if (empty($queue['items']) || ! is_array($queue['items'])) {
|
|
return null;
|
|
}
|
|
|
|
$normalizedJobName = trim($jobName, '/');
|
|
$lastSegment = basename(str_replace('\\', '/', $normalizedJobName));
|
|
|
|
foreach ($queue['items'] as $item) {
|
|
$task = $item['task'] ?? [];
|
|
$taskName = $task['fullName'] ?? $task['name'] ?? '';
|
|
|
|
if ($taskName === $normalizedJobName || $taskName === $lastSegment) {
|
|
return $item;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function normalizeJenkinsPath(string $pathOrUrl): string
|
|
{
|
|
$path = parse_url($pathOrUrl, PHP_URL_PATH) ?: $pathOrUrl;
|
|
$query = parse_url($pathOrUrl, PHP_URL_QUERY);
|
|
|
|
return $query ? "{$path}?{$query}" : $path;
|
|
}
|
|
|
|
private function extractQueueId(string $queueUrl): ?string
|
|
{
|
|
if (preg_match('#/queue/item/(\d+)#', $queueUrl, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
parse_str(parse_url($queueUrl, PHP_URL_QUERY) ?: '', $query);
|
|
|
|
return isset($query['id']) ? (string) $query['id'] : null;
|
|
}
|
|
|
|
private function http(): PendingRequest
|
|
{
|
|
return Http::timeout($this->timeout)
|
|
->withBasicAuth($this->username, $this->apiToken);
|
|
}
|
|
|
|
private function getCrumb(): ?array
|
|
{
|
|
$crumb = $this->request('/crumbIssuer/api/json');
|
|
if (! $crumb || empty($crumb['crumb']) || empty($crumb['crumbRequestField'])) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'field' => $crumb['crumbRequestField'],
|
|
'crumb' => $crumb['crumb'],
|
|
];
|
|
}
|
|
|
|
private function getJobPath(string $jobName): string
|
|
{
|
|
$segments = array_filter(explode('/', trim($jobName, '/')), fn ($segment) => $segment !== '');
|
|
|
|
return '/job/'.implode('/job/', array_map('rawurlencode', $segments));
|
|
}
|
|
}
|