mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 17:44:36 +08:00
340 lines
16 KiB
PHP
340 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Enums\BuildPackTypes;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\GiteaApp;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GitlabApp;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\Rule;
|
|
use OpenApi\Attributes as OA;
|
|
|
|
class ApplicationPreviewsController extends Controller
|
|
{
|
|
private const GIT_TYPES = ['github', 'gitlab', 'gitea', 'bitbucket'];
|
|
|
|
#[OA\Get(
|
|
summary: 'List Preview Deployments',
|
|
description: 'List the preview deployments of an application, newest pull request first.',
|
|
path: '/applications/{uuid}/previews',
|
|
operationId: 'list-preview-deployments-by-application-uuid',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Applications'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', description: 'UUID of the application.', required: true, schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'List of preview deployments.', content: new OA\JsonContent(
|
|
type: 'array',
|
|
items: new OA\Items(ref: '#/components/schemas/ApplicationPreview'),
|
|
)),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
],
|
|
)]
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
|
if (! $application) {
|
|
return response()->json(['message' => 'Application not found.'], 404);
|
|
}
|
|
|
|
$this->authorize('view', $application);
|
|
|
|
$previews = $application->previews()
|
|
->get()
|
|
->map(fn (ApplicationPreview $preview): array => $this->formatPreview($preview));
|
|
|
|
return response()->json($previews);
|
|
}
|
|
|
|
#[OA\Get(
|
|
summary: 'Get Preview Deployment',
|
|
description: 'Get a preview deployment by pull request ID.',
|
|
path: '/applications/{uuid}/previews/{pull_request_id}',
|
|
operationId: 'get-preview-deployment-by-pull-request-id',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Applications'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', description: 'UUID of the application.', required: true, schema: new OA\Schema(type: 'string')),
|
|
new OA\Parameter(name: 'pull_request_id', in: 'path', description: 'Pull request ID of the preview.', required: true, schema: new OA\Schema(type: 'integer')),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Preview deployment.', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationPreview')),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
|
],
|
|
)]
|
|
public function show(Request $request): JsonResponse
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
|
if (! $application) {
|
|
return response()->json(['message' => 'Application not found.'], 404);
|
|
}
|
|
|
|
$this->authorize('view', $application);
|
|
|
|
$pullRequestIdRaw = $request->route('pull_request_id');
|
|
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0 || (int) $pullRequestIdRaw > 2147483647) {
|
|
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
|
}
|
|
|
|
$preview = $application->previews()->where('pull_request_id', (int) $pullRequestIdRaw)->first();
|
|
if (! $preview) {
|
|
return response()->json(['message' => 'Preview not found.'], 404);
|
|
}
|
|
|
|
return response()->json($this->formatPreview($preview));
|
|
}
|
|
|
|
#[OA\Post(
|
|
summary: 'Create Preview Deployment',
|
|
description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.',
|
|
path: '/applications/{uuid}/previews',
|
|
operationId: 'create-preview-deployment-by-application-uuid',
|
|
security: [['bearerAuth' => []]],
|
|
tags: ['Applications'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', description: 'UUID of the application.', required: true, schema: new OA\Schema(type: 'string')),
|
|
],
|
|
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(
|
|
required: ['pull_request_id'],
|
|
properties: [
|
|
new OA\Property(property: 'pull_request_id', type: 'integer', minimum: 1, maximum: 2147483647, example: 42),
|
|
new OA\Property(property: 'pull_request_html_url', type: 'string', nullable: true, example: 'https://github.com/org/repo/pull/42'),
|
|
new OA\Property(property: 'git_type', type: 'string', nullable: true, enum: self::GIT_TYPES, description: 'Git provider that hosts the pull request. Sets the ref Coolify fetches (pull/{id}/head or merge-requests/{id}/head).'),
|
|
new OA\Property(property: 'commit', type: 'string', nullable: true, description: 'Commit SHA to deploy. Required for Bitbucket.'),
|
|
new OA\Property(property: 'docker_tag', type: 'string', nullable: true, description: 'Docker image tag. Docker Image applications only.'),
|
|
new OA\Property(property: 'force', type: 'boolean', default: false, description: 'Rebuild without cache.'),
|
|
new OA\Property(property: 'instant_deploy', type: 'boolean', default: true, description: 'Queue a deployment. Set to false to only create the preview, for example to set its domains first.'),
|
|
],
|
|
)),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Existing preview deployment updated.', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationPreviewDeployment')),
|
|
new OA\Response(response: 201, description: 'Preview deployment created.', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationPreviewDeployment')),
|
|
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
|
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
|
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
|
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
|
new OA\Response(response: 429, description: 'Deployment queue is full.'),
|
|
],
|
|
)]
|
|
#[OA\Schema(
|
|
schema: 'ApplicationPreview',
|
|
type: 'object',
|
|
properties: [
|
|
new OA\Property(property: 'uuid', type: 'string'),
|
|
new OA\Property(property: 'pull_request_id', type: 'integer'),
|
|
new OA\Property(property: 'pull_request_html_url', type: 'string', nullable: true),
|
|
new OA\Property(property: 'git_type', type: 'string', nullable: true),
|
|
new OA\Property(property: 'status', type: 'string'),
|
|
new OA\Property(property: 'domains', type: 'string', nullable: true),
|
|
new OA\Property(property: 'docker_compose_domains', type: 'array', nullable: true, items: new OA\Items(properties: [
|
|
new OA\Property(property: 'name', type: 'string'),
|
|
new OA\Property(property: 'domain', type: 'string', nullable: true),
|
|
new OA\Property(property: 'redirect', type: 'string', nullable: true),
|
|
], type: 'object')),
|
|
new OA\Property(property: 'domain_port_overrides', type: 'object', nullable: true),
|
|
new OA\Property(property: 'docker_registry_image_tag', type: 'string', nullable: true),
|
|
new OA\Property(property: 'last_online_at', type: 'string', nullable: true),
|
|
new OA\Property(property: 'created_at', type: 'string'),
|
|
new OA\Property(property: 'updated_at', type: 'string'),
|
|
],
|
|
)]
|
|
#[OA\Schema(
|
|
schema: 'ApplicationPreviewDeployment',
|
|
type: 'object',
|
|
properties: [
|
|
new OA\Property(property: 'message', type: 'string'),
|
|
new OA\Property(property: 'deployment_uuid', type: 'string', nullable: true),
|
|
new OA\Property(property: 'preview', ref: '#/components/schemas/ApplicationPreview'),
|
|
],
|
|
)]
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
|
if (! $application) {
|
|
return response()->json(['message' => 'Application not found.'], 404);
|
|
}
|
|
|
|
$this->authorize('deploy', $application);
|
|
|
|
$isDockerImage = $application->build_pack === 'dockerimage';
|
|
if (! $isDockerImage && ! $application->git_based()) {
|
|
return response()->json(['message' => 'Preview deployments need a Git based or Docker Image application.'], 400);
|
|
}
|
|
|
|
$rules = [
|
|
'pull_request_id' => 'required|integer|min:1|max:2147483647',
|
|
'pull_request_html_url' => 'nullable|url|max:2048',
|
|
'force' => 'boolean',
|
|
'instant_deploy' => 'boolean',
|
|
];
|
|
if ($isDockerImage) {
|
|
$rules['git_type'] = 'missing';
|
|
$rules['commit'] = 'missing';
|
|
$rules['docker_tag'] = ['nullable', 'string', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/'];
|
|
} else {
|
|
$rules['git_type'] = ['nullable', 'string', Rule::in(self::GIT_TYPES)];
|
|
$rules['commit'] = ['nullable', 'string', 'regex:/^[0-9a-fA-F]{7,40}$/', 'required_if:git_type,bitbucket'];
|
|
$rules['docker_tag'] = 'missing';
|
|
}
|
|
|
|
$validator = Validator::make($request->all(), $rules);
|
|
if ($validator->fails()) {
|
|
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
|
}
|
|
|
|
$pullRequestId = (int) $request->input('pull_request_id');
|
|
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
|
|
$preview = $application->previews()->where('pull_request_id', $pullRequestId)->first();
|
|
$gitType = $request->input('git_type') ?? $preview?->git_type ?? $this->sourceGitType($application);
|
|
|
|
if (! $isDockerImage && $gitType === null) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['git_type' => ['The git type field is required for applications without a GitHub, GitLab, or Gitea App source.']],
|
|
], 422);
|
|
}
|
|
if ($isDockerImage && $dockerTag === null && blank($preview?->docker_registry_image_tag)) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['docker_tag' => ['The docker tag field is required for a new Docker Image preview.']],
|
|
], 422);
|
|
}
|
|
|
|
$created = ! $preview;
|
|
if ($created) {
|
|
$isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value;
|
|
$preview = ApplicationPreview::create([
|
|
'application_id' => $application->id,
|
|
'pull_request_id' => $pullRequestId,
|
|
'pull_request_html_url' => $request->input('pull_request_html_url') ?? '',
|
|
'git_type' => $isDockerImage ? null : $gitType,
|
|
'docker_compose_domains' => $isCompose ? $application->docker_compose_domains : null,
|
|
'docker_registry_image_tag' => $dockerTag,
|
|
]);
|
|
$isCompose
|
|
? $preview->generate_preview_fqdn_compose()
|
|
: $preview->generate_preview_fqdn();
|
|
} else {
|
|
$preview->fill(array_filter([
|
|
'pull_request_html_url' => $request->input('pull_request_html_url'),
|
|
'git_type' => $isDockerImage ? null : $gitType,
|
|
'docker_registry_image_tag' => $dockerTag,
|
|
], fn (mixed $value): bool => $value !== null));
|
|
$preview->save();
|
|
}
|
|
|
|
if ($created) {
|
|
auditLog('api.application.preview_created', [
|
|
'team_id' => $teamId,
|
|
'application_uuid' => $application->uuid,
|
|
'pull_request_id' => $pullRequestId,
|
|
]);
|
|
}
|
|
|
|
$status = $created ? 201 : 200;
|
|
if (! $request->boolean('instant_deploy', true)) {
|
|
return response()->json([
|
|
'message' => $created ? 'Preview created.' : 'Preview updated.',
|
|
'deployment_uuid' => null,
|
|
'preview' => $this->formatPreview($preview->refresh()),
|
|
], $status);
|
|
}
|
|
|
|
$deploymentUuid = new_public_id();
|
|
$result = queue_application_deployment(
|
|
application: $application,
|
|
deployment_uuid: $deploymentUuid,
|
|
pull_request_id: $pullRequestId,
|
|
commit: $request->input('commit'),
|
|
force_rebuild: $request->boolean('force'),
|
|
is_api: true,
|
|
git_type: $preview->git_type,
|
|
docker_registry_image_tag: $preview->docker_registry_image_tag,
|
|
);
|
|
if ($result['status'] === 'queue_full') {
|
|
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
|
}
|
|
|
|
$queued = $result['status'] !== 'skipped';
|
|
if ($queued) {
|
|
auditLog('api.deployment.triggered', [
|
|
'resource_type' => 'application',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'deployment_uuid' => $deploymentUuid,
|
|
'force_rebuild' => $request->boolean('force'),
|
|
'pull_request_id' => $pullRequestId,
|
|
]);
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => $queued ? 'Preview deployment queued.' : $result['message'],
|
|
'deployment_uuid' => $queued ? $deploymentUuid : null,
|
|
'preview' => $this->formatPreview($preview->refresh()),
|
|
], $status);
|
|
}
|
|
|
|
private function sourceGitType(Application $application): ?string
|
|
{
|
|
return match ($application->source?->getMorphClass()) {
|
|
GithubApp::class => 'github',
|
|
GitlabApp::class => 'gitlab',
|
|
GiteaApp::class => 'gitea',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function formatPreview(ApplicationPreview $preview): array
|
|
{
|
|
$composeDomains = json_decode($preview->docker_compose_domains ?: 'null', true);
|
|
|
|
return [
|
|
'uuid' => $preview->uuid,
|
|
'pull_request_id' => $preview->pull_request_id,
|
|
'pull_request_html_url' => $preview->pull_request_html_url ?: null,
|
|
'git_type' => $preview->git_type,
|
|
'status' => $preview->status,
|
|
'domains' => $preview->fqdn,
|
|
'docker_compose_domains' => is_array($composeDomains)
|
|
? collect($composeDomains)
|
|
->map(fn (mixed $entry, string|int $name): array => ['name' => (string) $name, 'domain' => data_get($entry, 'domain') ?: null, 'redirect' => data_get($entry, 'redirect')])
|
|
->values()
|
|
->all()
|
|
: null,
|
|
'domain_port_overrides' => $preview->domain_port_overrides,
|
|
'docker_registry_image_tag' => $preview->docker_registry_image_tag,
|
|
'last_online_at' => $preview->last_online_at,
|
|
'created_at' => $preview->created_at,
|
|
'updated_at' => $preview->updated_at,
|
|
];
|
|
}
|
|
}
|