feat(applications): add DELETE endpoint for preview deployments by PR id
Add `DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}` to
cancel active deployments, stop containers, and delete the preview
record via `CleanupPreviewDeployment`. Includes OpenAPI annotations,
input validation, and full feature test coverage.
This commit is contained in:
parent
3a8f52ce16
commit
bceb5f28dc
5 changed files with 313 additions and 2 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Actions\Application\LoadComposeFile;
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Service\StartService;
|
||||
|
|
@ -9,6 +10,7 @@
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\LocalFileVolume;
|
||||
|
|
@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type)
|
|||
$connectToDockerNetwork = $request->connect_to_docker_network;
|
||||
$customNginxConfiguration = $request->custom_nginx_configuration;
|
||||
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
|
||||
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
|
||||
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
|
||||
|
||||
if (! is_null($customNginxConfiguration)) {
|
||||
if (! isBase64Encoded($customNginxConfiguration)) {
|
||||
|
|
@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
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.',
|
||||
path: '/applications/{uuid}/previews/{pull_request_id}',
|
||||
operationId: 'delete-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 to delete.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
|
||||
properties: [new OA\Property(property: 'message', type: 'string')],
|
||||
)),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function delete_preview_by_pull_request_id(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('delete', $application);
|
||||
|
||||
$pullRequestIdRaw = $request->route('pull_request_id');
|
||||
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
||||
}
|
||||
$pullRequestId = (int) $pullRequestIdRaw;
|
||||
|
||||
$preview = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pullRequestId)
|
||||
->first();
|
||||
|
||||
if (! $preview) {
|
||||
return response()->json(['message' => 'Preview not found.'], 404);
|
||||
}
|
||||
|
||||
$preview->delete();
|
||||
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
|
||||
|
||||
return response()->json(['message' => 'Preview deletion request queued.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
openapi.json
64
openapi.json
|
|
@ -3788,6 +3788,70 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/cloud-tokens": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
|
|||
42
openapi.yaml
42
openapi.yaml
|
|
@ -2398,6 +2398,48 @@ 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: []
|
||||
/cloud-tokens:
|
||||
get:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@
|
|||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
|
||||
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
|
||||
Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']);
|
||||
|
|
@ -218,7 +220,7 @@
|
|||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||
|
|
|
|||
132
tests/Feature/ApplicationPreviewApiTest.php
Normal file
132
tests/Feature/ApplicationPreviewApiTest.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
CleanupPreviewDeployment::shouldRun()->andReturn([
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
function previewAuthHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function createTeamApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createPreview(Application $application, int $pullRequestId): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
|
||||
'fqdn' => "pr-{$pullRequestId}.example.com",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
|
||||
test('returns 401 when no bearer token provided', function () {
|
||||
$response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid does not exist', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when preview does not exist for the application', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Preview not found.']);
|
||||
});
|
||||
|
||||
test('returns 422 when pull_request_id is not a positive integer', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['message' => 'Invalid pull_request_id.']);
|
||||
});
|
||||
|
||||
test('soft-deletes the preview and returns 200 on success', function () {
|
||||
$preview = createPreview($this->application, 42);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Preview deletion request queued.']);
|
||||
|
||||
expect($preview->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns 403 when token lacks write ability', function () {
|
||||
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
|
||||
createPreview($this->application, 7);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue