feat(api): add endpoints to list, get, and open preview deployments

- GET /applications/{uuid}/previews and GET /applications/{uuid}/previews/{pull_request_id}
  return preview deployments (read ability).
- POST /applications/{uuid}/previews opens a preview for a pull request and queues its
  deployment, or redeploys an existing preview (deploy ability). It accepts git_type,
  commit (required for Bitbucket), docker_tag (Docker Image apps), force, and
  instant_deploy. GitHub App and GitLab App sources set git_type automatically.
- Fix POST /deploy with pull_request_id: pass the preview git_type to the deployment, so
  deploy-key and public-repository applications build the pull request instead of the
  base branch.
- Regenerate the OpenAPI spec (also adds the missing service_name logs parameter).
This commit is contained in:
Andras Bacsai
2026-10-04 00:52:17 +02:00
parent c2f63c8f10
commit e172beaa30
6 changed files with 1397 additions and 272 deletions
@@ -0,0 +1,337 @@
<?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\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) {
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, 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',
'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 or GitLab 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',
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,
];
}
}
@@ -432,8 +432,8 @@ class DeployController extends Controller
$resource = getResourceByUuid($uuid, $teamId);
if ($resource) {
$dockerTagForResource = $dockerTag;
$preview = null;
if ($pr !== 0) {
$preview = null;
if ($resource instanceof Application && $resource->build_pack === 'dockerimage') {
$preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag);
$dockerTagForResource = $preview?->docker_registry_image_tag;
@@ -446,7 +446,7 @@ class DeployController extends Controller
continue;
}
}
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource);
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource, $preview?->git_type);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
@@ -519,7 +519,7 @@ class DeployController extends Controller
return response()->json(['message' => 'No resources found with this tag.'], 404);
}
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null, ?string $gitType = null): array
{
$message = null;
$deployment_uuid = null;
@@ -544,6 +544,7 @@ class DeployController extends Controller
force_rebuild: $force,
pull_request_id: $pr,
is_api: true,
git_type: $gitType,
docker_registry_image_tag: $dockerTag,
);
if ($result['status'] === 'queue_full') {
+516 -173
View File
@@ -11,6 +11,401 @@
}
],
"paths": {
"\/applications\/{uuid}\/previews": {
"get": {
"tags": [
"Applications"
],
"summary": "List Preview Deployments",
"description": "List the preview deployments of an application, newest pull request first.",
"operationId": "list-preview-deployments-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of preview deployments.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/ApplicationPreview"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Applications"
],
"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.",
"operationId": "create-preview-deployment-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"pull_request_id"
],
"properties": {
"pull_request_id": {
"type": "integer",
"minimum": 1,
"example": 42
},
"pull_request_html_url": {
"type": [
"string",
"null"
],
"example": "https:\/\/github.com\/org\/repo\/pull\/42"
},
"git_type": {
"description": "Git provider that hosts the pull request. Sets the ref Coolify fetches (pull\/{id}\/head or merge-requests\/{id}\/head).",
"type": [
"string",
"null"
],
"enum": [
"github",
"gitlab",
"gitea",
"bitbucket"
]
},
"commit": {
"description": "Commit SHA to deploy. Required for Bitbucket.",
"type": [
"string",
"null"
]
},
"docker_tag": {
"description": "Docker image tag. Docker Image applications only.",
"type": [
"string",
"null"
]
},
"force": {
"description": "Rebuild without cache.",
"type": "boolean",
"default": false
},
"instant_deploy": {
"description": "Queue a deployment. Set to false to only create the preview, for example to set its domains first.",
"type": "boolean",
"default": true
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Existing preview deployment updated.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ApplicationPreviewDeployment"
}
}
}
},
"201": {
"description": "Preview deployment created.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ApplicationPreviewDeployment"
}
}
}
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
},
"429": {
"description": "Deployment queue is full."
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/previews\/{pull_request_id}": {
"get": {
"tags": [
"Applications"
],
"summary": "Get Preview Deployment",
"description": "Get a preview deployment by pull request ID.",
"operationId": "get-preview-deployment-by-pull-request-id",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "pull_request_id",
"in": "path",
"description": "Pull request ID of the preview.",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Preview deployment.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/ApplicationPreview"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"Applications"
],
"summary": "Delete Preview Deployment",
"description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
"operationId": "delete-preview-deployment-by-pull-request-id",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "pull_request_id",
"in": "path",
"description": "Pull request ID of the preview to delete.",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Preview deletion queued.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Applications"
],
"summary": "Update Preview Domains",
"description": "Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.",
"operationId": "update-preview-domains-by-pull-request-id",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "pull_request_id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"domains": {
"type": [
"string",
"null"
],
"example": "https:\/\/pr.example.com:3000"
},
"docker_compose_domains": {
"type": [
"array",
"null"
],
"items": {
"properties": {
"name": {
"type": "string"
},
"domain": {
"type": [
"string",
"null"
]
},
"redirect": {
"type": [
"string",
"null"
],
"enum": [
"www",
"non-www",
"both"
]
}
},
"type": "object"
}
},
"force_domain_override": {
"type": "boolean",
"default": false
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Preview domains updated."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"$ref": "#\/components\/responses\/403"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"409": {
"description": "Domain conflict."
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/secret-manager": {
"patch": {
"tags": [
@@ -3575,6 +3970,15 @@
"type": "boolean",
"default": false
}
},
{
"name": "service_name",
"in": "query",
"description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -3670,6 +4074,15 @@
"type": "boolean",
"default": false
}
},
{
"name": "service_name",
"in": "query",
"description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -3708,175 +4121,6 @@
]
}
},
"\/applications\/{uuid}\/previews\/{pull_request_id}": {
"delete": {
"tags": [
"Applications"
],
"summary": "Delete Preview Deployment",
"description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
"operationId": "delete-preview-deployment-by-pull-request-id",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "pull_request_id",
"in": "path",
"description": "Pull request ID of the preview to delete.",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Preview deletion queued.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Applications"
],
"summary": "Update Preview Domains",
"description": "Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.",
"operationId": "update-preview-domains-by-pull-request-id",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "pull_request_id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"domains": {
"type": [
"string",
"null"
],
"example": "https:\/\/pr.example.com:3000"
},
"docker_compose_domains": {
"type": [
"array",
"null"
],
"items": {
"properties": {
"name": {
"type": "string"
},
"domain": {
"type": [
"string",
"null"
]
},
"redirect": {
"type": [
"string",
"null"
],
"enum": [
"www",
"non-www",
"both"
]
}
},
"type": "object"
}
},
"force_domain_override": {
"type": "boolean",
"default": false
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Preview domains updated."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"$ref": "#\/components\/responses\/403"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"409": {
"description": "Domain conflict."
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/envs": {
"get": {
"tags": [
@@ -22592,6 +22836,105 @@
},
"type": "object"
},
"ApplicationPreview": {
"properties": {
"uuid": {
"type": "string"
},
"pull_request_id": {
"type": "integer"
},
"pull_request_html_url": {
"type": [
"string",
"null"
]
},
"git_type": {
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
},
"domains": {
"type": [
"string",
"null"
]
},
"docker_compose_domains": {
"type": [
"array",
"null"
],
"items": {
"properties": {
"name": {
"type": "string"
},
"domain": {
"type": [
"string",
"null"
]
},
"redirect": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
},
"domain_port_overrides": {
"type": [
"object",
"null"
]
},
"docker_registry_image_tag": {
"type": [
"string",
"null"
]
},
"last_online_at": {
"type": [
"string",
"null"
]
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
},
"ApplicationPreviewDeployment": {
"properties": {
"message": {
"type": "string"
},
"deployment_uuid": {
"type": [
"string",
"null"
]
},
"preview": {
"$ref": "#\/components\/schemas\/ApplicationPreview"
}
},
"type": "object"
},
"VolumeBackupScheduleRequest": {
"required": [
"frequency"
@@ -24227,14 +24570,14 @@
}
},
"tags": [
{
"name": "Secret Managers",
"description": "Secret Managers"
},
{
"name": "Applications",
"description": "Applications"
},
{
"name": "Secret Managers",
"description": "Secret Managers"
},
{
"name": "Cloud-init Scripts",
"description": "Cloud-init Scripts"
+317 -96
View File
@@ -7,6 +7,241 @@ servers:
url: 'https://app.coolify.io/api/v1'
description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.'
paths:
'/applications/{uuid}/previews':
get:
tags:
- Applications
summary: 'List Preview Deployments'
description: 'List the preview deployments of an application, newest pull request first.'
operationId: list-preview-deployments-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
responses:
'200':
description: 'List of preview deployments.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApplicationPreview'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Applications
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.'
operationId: create-preview-deployment-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- pull_request_id
properties:
pull_request_id:
type: integer
minimum: 1
example: 42
pull_request_html_url:
type: [string, 'null']
example: 'https://github.com/org/repo/pull/42'
git_type:
description: 'Git provider that hosts the pull request. Sets the ref Coolify fetches (pull/{id}/head or merge-requests/{id}/head).'
type: [string, 'null']
enum: [github, gitlab, gitea, bitbucket]
commit:
description: 'Commit SHA to deploy. Required for Bitbucket.'
type: [string, 'null']
docker_tag:
description: 'Docker image tag. Docker Image applications only.'
type: [string, 'null']
force:
description: 'Rebuild without cache.'
type: boolean
default: false
instant_deploy:
description: 'Queue a deployment. Set to false to only create the preview, for example to set its domains first.'
type: boolean
default: true
type: object
responses:
'200':
description: 'Existing preview deployment updated.'
content:
application/json:
schema:
$ref: '#/components/schemas/ApplicationPreviewDeployment'
'201':
description: 'Preview deployment created.'
content:
application/json:
schema:
$ref: '#/components/schemas/ApplicationPreviewDeployment'
'400':
$ref: '#/components/responses/400'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
'429':
description: 'Deployment queue is full.'
security:
-
bearerAuth: []
'/applications/{uuid}/previews/{pull_request_id}':
get:
tags:
- Applications
summary: 'Get Preview Deployment'
description: 'Get a preview deployment by pull request ID.'
operationId: get-preview-deployment-by-pull-request-id
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: pull_request_id
in: path
description: 'Pull request ID of the preview.'
required: true
schema:
type: integer
responses:
'200':
description: 'Preview deployment.'
content:
application/json:
schema:
$ref: '#/components/schemas/ApplicationPreview'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
delete:
tags:
- Applications
summary: 'Delete Preview Deployment'
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
operationId: delete-preview-deployment-by-pull-request-id
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: pull_request_id
in: path
description: 'Pull request ID of the preview to delete.'
required: true
schema:
type: integer
responses:
'200':
description: 'Preview deletion queued.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Applications
summary: 'Update Preview Domains'
description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.'
operationId: update-preview-domains-by-pull-request-id
parameters:
-
name: uuid
in: path
required: true
schema:
type: string
-
name: pull_request_id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
properties:
domains:
type: [string, 'null']
example: 'https://pr.example.com:3000'
docker_compose_domains:
type: [array, 'null']
items: { properties: { name: { type: string }, domain: { type: [string, 'null'] }, redirect: { type: [string, 'null'], enum: [www, non-www, both] } }, type: object }
force_domain_override:
type: boolean
default: false
type: object
responses:
'200':
description: 'Preview domains updated.'
'401':
$ref: '#/components/responses/401'
'403':
$ref: '#/components/responses/403'
'404':
$ref: '#/components/responses/404'
'409':
description: 'Domain conflict.'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/secret-manager':
patch:
tags:
@@ -2360,6 +2595,13 @@ paths:
schema:
type: boolean
default: false
-
name: service_name
in: query
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.'
required: false
schema:
type: string
responses:
'200':
description: 'Get application logs by UUID.'
@@ -2426,6 +2668,13 @@ paths:
schema:
type: boolean
default: false
-
name: service_name
in: query
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.'
required: false
schema:
type: string
responses:
'200':
description: 'Preview runtime logs.'
@@ -2446,99 +2695,6 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/previews/{pull_request_id}':
delete:
tags:
- Applications
summary: 'Delete Preview Deployment'
description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
operationId: delete-preview-deployment-by-pull-request-id
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: pull_request_id
in: path
description: 'Pull request ID of the preview to delete.'
required: true
schema:
type: integer
responses:
'200':
description: 'Preview deletion queued.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Applications
summary: 'Update Preview Domains'
description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.'
operationId: update-preview-domains-by-pull-request-id
parameters:
-
name: uuid
in: path
required: true
schema:
type: string
-
name: pull_request_id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
properties:
domains:
type: [string, 'null']
example: 'https://pr.example.com:3000'
docker_compose_domains:
type: [array, 'null']
items: { properties: { name: { type: string }, domain: { type: [string, 'null'] }, redirect: { type: [string, 'null'], enum: [www, non-www, both] } }, type: object }
force_domain_override:
type: boolean
default: false
type: object
responses:
'200':
description: 'Preview domains updated.'
'401':
$ref: '#/components/responses/401'
'403':
$ref: '#/components/responses/403'
'404':
$ref: '#/components/responses/404'
'409':
description: 'Domain conflict.'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/envs':
get:
tags:
@@ -14347,6 +14503,71 @@ components:
- 'null'
format: date-time
type: object
ApplicationPreview:
properties:
uuid:
type: string
pull_request_id:
type: integer
pull_request_html_url:
type:
- string
- 'null'
git_type:
type:
- string
- 'null'
status:
type: string
domains:
type:
- string
- 'null'
docker_compose_domains:
type:
- array
- 'null'
items:
properties:
name:
type: string
domain:
type:
- string
- 'null'
redirect:
type:
- string
- 'null'
type: object
domain_port_overrides:
type:
- object
- 'null'
docker_registry_image_tag:
type:
- string
- 'null'
last_online_at:
type:
- string
- 'null'
created_at:
type: string
updated_at:
type: string
type: object
ApplicationPreviewDeployment:
properties:
message:
type: string
deployment_uuid:
type:
- string
- 'null'
preview:
$ref: '#/components/schemas/ApplicationPreview'
type: object
VolumeBackupScheduleRequest:
required:
- frequency
@@ -15530,12 +15751,12 @@ components:
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.'
scheme: bearer
tags:
-
name: 'Secret Managers'
description: 'Secret Managers'
-
name: Applications
description: Applications
-
name: 'Secret Managers'
description: 'Secret Managers'
-
name: 'Cloud-init Scripts'
description: 'Cloud-init Scripts'
+4
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\ApplicationPreviewsController;
use App\Http\Controllers\Api\ApplicationsController;
use App\Http\Controllers\Api\ApplicationSecretManagerController;
use App\Http\Controllers\Api\AuditEventsController;
@@ -290,6 +291,9 @@ Route::group([
Route::post('/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::post('/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'index'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'store'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/previews/{pull_request_id}', [ApplicationPreviewsController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
+219
View File
@@ -4,6 +4,7 @@ use App\Actions\Application\CleanupPreviewDeployment;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -500,3 +501,221 @@ describe('GET /api/v1/applications/{uuid}/logs service_name', function () {
->assertJson(['message' => "No running container found for service_name 'work'."]);
});
});
describe('GET /api/v1/applications/{uuid}/previews', function () {
test('lists only the previews of the application', function () {
createPreview($this->application, 12);
createPreview($this->application, 3);
$otherApplication = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
createPreview($otherApplication, 99);
$this->withHeaders(previewAuthHeaders(createTeamApiToken($this->user, $this->team, ['read'])))
->getJson("/api/v1/applications/{$this->application->uuid}/previews")
->assertOk()
->assertJsonCount(2)
->assertJsonPath('0.pull_request_id', 12)
->assertJsonPath('1.pull_request_id', 3)
->assertJsonPath('1.pull_request_html_url', 'https://github.com/example/repo/pull/3');
});
test('returns 404 for an application of another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
createPreview($this->application, 3);
$this->withHeaders(previewAuthHeaders(createTeamApiToken($otherUser, $otherTeam, ['*'])))
->getJson("/api/v1/applications/{$this->application->uuid}/previews")
->assertNotFound();
});
});
describe('GET /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
test('returns the preview', function () {
$preview = createPreview($this->application, 42);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/previews/42")
->assertOk()
->assertJsonPath('uuid', $preview->uuid)
->assertJsonPath('pull_request_html_url', 'https://github.com/example/repo/pull/42');
});
test('returns 404 when the preview does not exist', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/previews/42")
->assertNotFound()
->assertJson(['message' => 'Preview not found.']);
});
});
describe('POST /api/v1/applications/{uuid}/previews', function () {
test('creates a preview and queues a deployment of the merge request ref', function () {
$this->application->update(['fqdn' => 'https://app.example.com']);
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 42,
'pull_request_html_url' => 'https://gitlab.com/example/repo/-/merge_requests/42',
'git_type' => 'gitlab',
])
->assertCreated()
->assertJsonPath('preview.pull_request_id', 42)
->assertJsonPath('preview.git_type', 'gitlab');
expect($response->json('preview.domains'))->toContain('app.example.com');
$deployment = $this->application->deployment_queue()->latest('id')->first();
expect($deployment->deployment_uuid)->toBe($response->json('deployment_uuid'))
->and($deployment->pull_request_id)->toBe(42)
->and($deployment->git_type)->toBe('gitlab');
});
test('uses the GitHub App source to choose the pull request ref', function () {
$githubApp = GithubApp::create([
'name' => 'preview-api-app',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => false,
'app_id' => 111,
'installation_id' => 222,
'client_id' => 'client',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
]);
$this->application->update(['source_id' => $githubApp->id, 'source_type' => GithubApp::class]);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", ['pull_request_id' => 7])
->assertCreated()
->assertJsonPath('preview.git_type', 'github');
expect($this->application->deployment_queue()->latest('id')->first()->git_type)->toBe('github');
});
test('requires git_type when the application has no Git App source', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", ['pull_request_id' => 7])
->assertUnprocessable()
->assertJsonValidationErrors('git_type');
expect(ApplicationPreview::where('application_id', $this->application->id)->exists())->toBeFalse();
});
test('requires a commit for Bitbucket pull requests', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 7,
'git_type' => 'bitbucket',
])
->assertUnprocessable()
->assertJsonValidationErrors('commit');
});
test('redeploys an existing preview with its stored git type', function () {
$preview = createPreview($this->application, 42);
$preview->update(['git_type' => 'gitea']);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", ['pull_request_id' => 42])
->assertOk()
->assertJsonPath('preview.uuid', $preview->uuid);
expect(ApplicationPreview::where('application_id', $this->application->id)->count())->toBe(1)
->and($this->application->deployment_queue()->latest('id')->first()->git_type)->toBe('gitea');
});
test('creates the preview without a deployment when instant_deploy is false', function () {
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 42,
'git_type' => 'github',
'instant_deploy' => false,
])
->assertCreated()
->assertJsonPath('deployment_uuid', null);
expect($this->application->deployment_queue()->exists())->toBeFalse();
});
test('requires a docker tag for a new Docker Image preview and stores it', function () {
$this->application->update([
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'ghcr.io/example/app',
'docker_registry_image_tag' => 'latest',
]);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", ['pull_request_id' => 5])
->assertUnprocessable()
->assertJsonValidationErrors('docker_tag');
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 5,
'docker_tag' => 'pr-5',
])
->assertCreated()
->assertJsonPath('preview.docker_registry_image_tag', 'pr-5');
expect($this->application->deployment_queue()->latest('id')->first()->docker_registry_image_tag)->toBe('pr-5');
});
test('rejects applications without a Git repository or Docker image', function () {
$this->application->update(['dockerfile' => 'FROM nginx']);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 5,
'git_type' => 'github',
])
->assertBadRequest();
});
test('returns 403 when token lacks deploy ability', function (array $abilities) {
$this->withHeaders(previewAuthHeaders(createTeamApiToken($this->user, $this->team, $abilities)))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 5,
'git_type' => 'github',
])
->assertForbidden();
expect(ApplicationPreview::where('application_id', $this->application->id)->exists())->toBeFalse();
})->with([
'read' => [['read']],
'write' => [['write']],
]);
test('returns 404 for an application of another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$this->withHeaders(previewAuthHeaders(createTeamApiToken($otherUser, $otherTeam, ['*'])))
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 5,
'git_type' => 'github',
])
->assertNotFound();
expect(ApplicationPreview::where('application_id', $this->application->id)->exists())->toBeFalse();
});
});
describe('POST /api/v1/deploy with pull_request_id', function () {
test('deploys the preview with its stored git type', function () {
$preview = createPreview($this->application, 42);
$preview->update(['git_type' => 'gitlab']);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->postJson('/api/v1/deploy', ['uuid' => $this->application->uuid, 'pull_request_id' => 42])
->assertOk();
expect($this->application->deployment_queue()->latest('id')->first()->git_type)->toBe('gitlab');
});
});