diff --git a/app/Http/Controllers/Api/ApplicationPreviewsController.php b/app/Http/Controllers/Api/ApplicationPreviewsController.php new file mode 100644 index 0000000000..1e82df5816 --- /dev/null +++ b/app/Http/Controllers/Api/ApplicationPreviewsController.php @@ -0,0 +1,337 @@ + []]], + 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 + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c9029de94a..33bd21da4f 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -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') { diff --git a/openapi.json b/openapi.json index f7a5c98442..313fefa63a 100644 --- a/openapi.json +++ b/openapi.json @@ -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" diff --git a/openapi.yaml b/openapi.yaml index 5ad716cc32..dc2b3aebf9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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' diff --git a/routes/api.php b/routes/api.php index 1f4eff104c..a44b95a1d2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ 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']); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php index 0078b5538c..f79fd1919f 100644 --- a/tests/Feature/ApplicationPreviewApiTest.php +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -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'); + }); +});