mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-04 20:17:49 +00:00
Merge remote-tracking branch 'origin/next' into feat/database-service-logs-endpoint
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,569 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class CloudProviderTokensController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($token)
|
||||
{
|
||||
$token->makeHidden([
|
||||
'id',
|
||||
'token',
|
||||
]);
|
||||
|
||||
return serializeApiResponse($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a provider token against the provider's API.
|
||||
*
|
||||
* @return array{valid: bool, error: string|null}
|
||||
*/
|
||||
private function validateProviderToken(string $provider, string $token): array
|
||||
{
|
||||
try {
|
||||
$response = match ($provider) {
|
||||
'hetzner' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'),
|
||||
'digitalocean' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($response === null) {
|
||||
return ['valid' => false, 'error' => 'Unsupported provider.'];
|
||||
}
|
||||
|
||||
if ($response->successful()) {
|
||||
return ['valid' => true, 'error' => null];
|
||||
}
|
||||
|
||||
return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to validate cloud provider token', [
|
||||
'provider' => $provider,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['valid' => false, 'error' => 'Failed to validate token with provider API.'];
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Cloud Provider Tokens',
|
||||
description: 'List all cloud provider tokens for the authenticated team.',
|
||||
path: '/cloud-tokens',
|
||||
operationId: 'list-cloud-tokens',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all cloud provider tokens.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tokens = CloudProviderToken::whereTeamId($teamId)
|
||||
->withCount('servers')
|
||||
->get()
|
||||
->map(function ($token) {
|
||||
return $this->removeSensitiveData($token);
|
||||
});
|
||||
|
||||
return response()->json($tokens);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Cloud Provider Token',
|
||||
description: 'Get cloud provider token by UUID.',
|
||||
path: '/cloud-tokens/{uuid}',
|
||||
operationId: 'get-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get cloud provider token by UUID',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($request->uuid)
|
||||
->withCount('servers')
|
||||
->first();
|
||||
|
||||
if (is_null($token)) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return response()->json($this->removeSensitiveData($token));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Cloud Provider Token',
|
||||
description: 'Create a new cloud provider token. The token will be validated before being stored.',
|
||||
path: '/cloud-tokens',
|
||||
operationId: 'create-cloud-token',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Cloud provider token details',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['provider', 'token', 'name'],
|
||||
properties: [
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Cloud provider token created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the token.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function store(Request $request)
|
||||
{
|
||||
$allowedFields = ['provider', 'token', 'name'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [CloudProviderToken::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
// Use request body only (excludes any route parameters)
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validator = customApiValidator($body, [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate token with the provider's API
|
||||
$validation = $this->validateProviderToken($body['provider'], $body['token']);
|
||||
|
||||
if (! $validation['valid']) {
|
||||
return response()->json(['message' => $validation['error']], 400);
|
||||
}
|
||||
|
||||
$cloudProviderToken = CloudProviderToken::create([
|
||||
'team_id' => $teamId,
|
||||
'provider' => $body['provider'],
|
||||
'token' => $body['token'],
|
||||
'name' => $body['name'],
|
||||
]);
|
||||
|
||||
auditLog('api.cloud_token.created', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $cloudProviderToken->name,
|
||||
'provider' => $cloudProviderToken->provider,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $cloudProviderToken->uuid,
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Cloud Provider Token',
|
||||
description: 'Update cloud provider token name.',
|
||||
path: '/cloud-tokens/{uuid}',
|
||||
operationId: 'update-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Cloud provider token updated.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Cloud provider token updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
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 update(Request $request)
|
||||
{
|
||||
$allowedFields = ['name'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
// Use request body only (excludes route parameters like uuid)
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validator = customApiValidator($body, [
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Use route parameter for UUID lookup
|
||||
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $token);
|
||||
|
||||
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
|
||||
auditLog('api.cloud_token.updated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $token->uuid,
|
||||
'cloud_token_name' => $token->name,
|
||||
'provider' => $token->provider,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $token->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Cloud Provider Token',
|
||||
description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.',
|
||||
path: '/cloud-tokens/{uuid}',
|
||||
operationId: 'delete-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the cloud provider token.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Cloud provider token deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
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',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
if ($token->hasServers()) {
|
||||
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||
}
|
||||
|
||||
$tokenUuid = $token->uuid;
|
||||
$tokenName = $token->name;
|
||||
$tokenProvider = $token->provider;
|
||||
$token->delete();
|
||||
|
||||
auditLog('api.cloud_token.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $tokenUuid,
|
||||
'cloud_token_name' => $tokenName,
|
||||
'provider' => $tokenProvider,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Validate Cloud Provider Token',
|
||||
description: 'Validate a cloud provider token against the provider API.',
|
||||
path: '/cloud-tokens/{uuid}/validate',
|
||||
operationId: 'validate-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Token validation result.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'valid' => ['type' => 'boolean', 'example' => true],
|
||||
'message' => ['type' => 'string', 'example' => 'Token is valid.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function validateToken(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
|
||||
if (! $cloudToken) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $cloudToken);
|
||||
|
||||
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||
|
||||
auditLog('api.cloud_token.validated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudToken->uuid,
|
||||
'cloud_token_name' => $cloudToken->name,
|
||||
'provider' => $cloudToken->provider,
|
||||
'valid' => $validation['valid'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,17 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class DeployController extends Controller
|
||||
{
|
||||
@@ -127,10 +129,177 @@ class DeployController extends Controller
|
||||
if (! $deployment) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
$application = $deployment->application;
|
||||
if (! $application || data_get($application->team(), 'id') !== (int) $teamId) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json($this->removeSensitiveData($deployment));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Cancel',
|
||||
description: 'Cancel a deployment by UUID.',
|
||||
path: '/deployments/{uuid}/cancel',
|
||||
operationId: 'cancel-deployment-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Deployments'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Deployment cancelled successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Deployment cancelled successfully.'],
|
||||
'deployment_uuid' => ['type' => 'string', 'example' => 'cm37r6cqj000008jm0veg5tkm'],
|
||||
'status' => ['type' => 'string', 'example' => 'cancelled-by-user'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
description: 'Deployment cannot be cancelled (already finished/failed/cancelled).',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Deployment cannot be cancelled. Current status: finished'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'User doesn\'t have permission to cancel this deployment.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'You do not have permission to cancel this deployment.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function cancel_deployment(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
|
||||
// Find the deployment by UUID
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first();
|
||||
if (! $deployment) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
|
||||
// Check if the deployment belongs to the user's team
|
||||
$servers = Server::whereTeamId($teamId)->pluck('id');
|
||||
if (! $servers->contains($deployment->server_id)) {
|
||||
return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403);
|
||||
}
|
||||
|
||||
// Check if deployment can be cancelled (must be queued or in_progress)
|
||||
$cancellableStatuses = [
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
];
|
||||
|
||||
if (! in_array($deployment->status, $cancellableStatuses)) {
|
||||
return response()->json([
|
||||
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Perform the cancellation
|
||||
try {
|
||||
$deployment_uuid = $deployment->deployment_uuid;
|
||||
$kill_command = "docker rm -f {$deployment_uuid}";
|
||||
$build_server_id = $deployment->build_server_id ?? $deployment->server_id;
|
||||
|
||||
// Mark deployment as cancelled
|
||||
$deployment->update([
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Get the server
|
||||
$server = Server::whereTeamId($teamId)->find($build_server_id);
|
||||
|
||||
if ($server) {
|
||||
// Add cancellation log entry
|
||||
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
|
||||
|
||||
// Check if container exists and kill it
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process([$kill_command], $server);
|
||||
$deployment->addLogEntry('Deployment container stopped.');
|
||||
} else {
|
||||
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
// Kill running process if process ID exists
|
||||
if ($deployment->current_process_id) {
|
||||
try {
|
||||
$processKillCommand = "kill -9 {$deployment->current_process_id}";
|
||||
instant_remote_process([$processKillCommand], $server);
|
||||
} catch (\Throwable $e) {
|
||||
// Process might already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditLog('api.deployment.cancelled', [
|
||||
'team_id' => $teamId,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
'application_id' => $application?->id,
|
||||
'application_uuid' => $application?->uuid,
|
||||
'server_id' => $deployment->server_id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment cancelled successfully.',
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
'status' => $deployment->status,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Failed to cancel deployment: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Deploy',
|
||||
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',
|
||||
@@ -145,6 +314,8 @@ class DeployController extends Controller
|
||||
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
|
||||
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
|
||||
responses: [
|
||||
@@ -195,7 +366,9 @@ class DeployController extends Controller
|
||||
$uuids = $request->input('uuid');
|
||||
$tags = $request->input('tag');
|
||||
$force = $request->input('force') ?? false;
|
||||
$pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0;
|
||||
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
|
||||
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
|
||||
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
|
||||
|
||||
if ($uuids && $tags) {
|
||||
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
|
||||
@@ -203,16 +376,22 @@ class DeployController extends Controller
|
||||
if ($tags && $pr) {
|
||||
return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
|
||||
}
|
||||
if ($dockerTag && $pr === 0) {
|
||||
return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400);
|
||||
}
|
||||
if ($dockerTag && $tags) {
|
||||
return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400);
|
||||
}
|
||||
if ($tags) {
|
||||
return $this->by_tags($tags, $teamId, $force);
|
||||
} elseif ($uuids) {
|
||||
return $this->by_uuids($uuids, $teamId, $force, $pr);
|
||||
return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
|
||||
}
|
||||
|
||||
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
|
||||
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null)
|
||||
{
|
||||
$uuids = explode(',', $uuid);
|
||||
$uuids = collect(array_filter($uuids));
|
||||
@@ -225,9 +404,28 @@ class DeployController extends Controller
|
||||
foreach ($uuids as $uuid) {
|
||||
$resource = getResourceByUuid($uuid, $teamId);
|
||||
if ($resource) {
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
|
||||
$dockerTagForResource = $dockerTag;
|
||||
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;
|
||||
} else {
|
||||
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
|
||||
}
|
||||
if (! $preview) {
|
||||
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource);
|
||||
if (isset($result['status']) && $result['status'] === 429) {
|
||||
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
|
||||
} else {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
|
||||
}
|
||||
@@ -267,9 +465,13 @@ class DeployController extends Controller
|
||||
continue;
|
||||
}
|
||||
foreach ($applications as $resource) {
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force);
|
||||
$result = $this->deploy_resource($resource, $force);
|
||||
if (isset($result['status']) && $result['status'] === 429) {
|
||||
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
|
||||
}
|
||||
$message = $message->merge($return_message);
|
||||
}
|
||||
@@ -290,7 +492,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): array
|
||||
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array
|
||||
{
|
||||
$message = null;
|
||||
$deployment_uuid = null;
|
||||
@@ -299,37 +501,106 @@ class DeployController extends Controller
|
||||
}
|
||||
switch ($resource?->getMorphClass()) {
|
||||
case Application::class:
|
||||
$deployment_uuid = new Cuid2;
|
||||
// Check authorization for application deployment
|
||||
try {
|
||||
$this->authorize('deploy', $resource);
|
||||
} catch (AuthorizationException $e) {
|
||||
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
|
||||
}
|
||||
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
|
||||
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $resource,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: $force,
|
||||
pull_request_id: $pr,
|
||||
is_api: true,
|
||||
docker_registry_image_tag: $dockerTag,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
if ($result['status'] === 'queue_full') {
|
||||
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
|
||||
} elseif ($result['status'] === 'skipped') {
|
||||
$message = $result['message'];
|
||||
} else {
|
||||
$message = "Application {$resource->name} deployment queued.";
|
||||
auditLog('api.deployment.triggered', [
|
||||
'resource_type' => 'application',
|
||||
'application_uuid' => $resource->uuid,
|
||||
'application_name' => $resource->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'force_rebuild' => $force,
|
||||
'pull_request_id' => $pr,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
case Service::class:
|
||||
// Check authorization for service deployment
|
||||
try {
|
||||
$this->authorize('deploy', $resource);
|
||||
} catch (AuthorizationException $e) {
|
||||
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
|
||||
}
|
||||
StartService::run($resource);
|
||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||
auditLog('api.service.deployed', [
|
||||
'service_uuid' => $resource->uuid,
|
||||
'service_name' => $resource->name,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
// Database resource
|
||||
// Database resource - check authorization
|
||||
try {
|
||||
$this->authorize('manage', $resource);
|
||||
} catch (AuthorizationException $e) {
|
||||
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
|
||||
}
|
||||
StartDatabase::dispatch($resource);
|
||||
|
||||
$resource->started_at ??= now();
|
||||
$resource->save();
|
||||
|
||||
$message = "Database {$resource->name} started.";
|
||||
auditLog('api.database.started', [
|
||||
'database_uuid' => $resource->uuid,
|
||||
'database_name' => $resource->name,
|
||||
'database_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
|
||||
}
|
||||
|
||||
private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview
|
||||
{
|
||||
$preview = $application->previews()->where('pull_request_id', $pullRequestId)->first();
|
||||
|
||||
if (! $preview && $dockerTag === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $preview) {
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => '',
|
||||
'docker_registry_image_tag' => $dockerTag,
|
||||
]);
|
||||
$preview->generate_preview_fqdn();
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) {
|
||||
$preview->docker_registry_image_tag = $dockerTag;
|
||||
$preview->save();
|
||||
}
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List application deployments',
|
||||
description: 'List application deployments by using the app uuid',
|
||||
@@ -347,7 +618,6 @@ class DeployController extends Controller
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
@@ -423,6 +693,10 @@ class DeployController extends Controller
|
||||
if (is_null($application)) {
|
||||
return response()->json(['message' => 'Application not found'], 404);
|
||||
}
|
||||
|
||||
// Check authorization to view application deployments
|
||||
$this->authorize('view', $application);
|
||||
|
||||
$deployments = $application->deployments($skip, $take);
|
||||
|
||||
return response()->json($deployments);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $destination->uuid,
|
||||
'name' => $destination->name,
|
||||
'network' => $destination->network,
|
||||
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
|
||||
'server_uuid' => $destination->server?->uuid,
|
||||
'created_at' => $destination->created_at,
|
||||
'updated_at' => $destination->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calling token's team id, or return an invalid-token response.
|
||||
*/
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
|
||||
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
|
||||
* controller works on Coolify versions that pre-date that scope being added
|
||||
* to the destination models (e.g. 4.0.0-beta.470).
|
||||
*/
|
||||
private function teamScopedDockers(int $teamId): array
|
||||
{
|
||||
return [
|
||||
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
];
|
||||
}
|
||||
|
||||
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
|
||||
{
|
||||
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
|
||||
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$sets = $this->teamScopedDockers($teamId);
|
||||
|
||||
return response()->json(
|
||||
$sets['standalone']->concat($sets['swarm'])
|
||||
->map(fn ($destination) => $this->transform($destination))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
|
||||
public function index_by_server(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
|
||||
->whereTeamId($teamId)
|
||||
->whereUuid($server_uuid)
|
||||
->firstOrFail();
|
||||
$list = $server->standaloneDockers->concat($server->swarmDockers);
|
||||
|
||||
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
|
||||
}
|
||||
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
return response()->json($this->transform($destination));
|
||||
}
|
||||
|
||||
public function create(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
|
||||
|
||||
$allowed = ['name', 'network', 'type'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'nullable|string|max:255',
|
||||
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
|
||||
'type' => 'nullable|in:standalone,swarm',
|
||||
]);
|
||||
$extra = array_diff(array_keys($request->all()), $allowed);
|
||||
if ($validator->fails() || ! empty($extra)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extra)) {
|
||||
foreach ($extra as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
|
||||
$type = $request->input('type', $expectedType);
|
||||
if ($type !== $expectedType) {
|
||||
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
|
||||
}
|
||||
|
||||
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
|
||||
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
|
||||
|
||||
$this->authorize('create', $class);
|
||||
|
||||
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
|
||||
if ($exists) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$destination = $class::create([
|
||||
'name' => $name,
|
||||
'network' => $request->input('network'),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isUniqueConstraintViolation($exception)) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
auditLog('api.destination.created', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destination->uuid,
|
||||
'destination_name' => $destination->name,
|
||||
'destination_type' => $type,
|
||||
'server_uuid' => $server->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
$sqlState = $exception->errorInfo[0] ?? null;
|
||||
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
|
||||
|
||||
return in_array($sqlState, ['23000', '23505'], true)
|
||||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
||||
}
|
||||
|
||||
public function delete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
$this->authorize('delete', $destination);
|
||||
|
||||
// Guard against deleting destinations with attached resources. attachedTo()
|
||||
// is recent on the destination models; fall back to a manual check for
|
||||
// older Coolify versions (e.g. 4.0.0-beta.470).
|
||||
if (method_exists($destination, 'attachedTo')) {
|
||||
if ($destination->attachedTo()) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
} else {
|
||||
$hasAttached = $destination->applications()->exists()
|
||||
|| $destination->postgresqls()->exists()
|
||||
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|
||||
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|
||||
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|
||||
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|
||||
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|
||||
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|
||||
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|
||||
|| (method_exists($destination, 'services') && $destination->services()->exists());
|
||||
if ($hasAttached) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
}
|
||||
if ($destination instanceof StandaloneDocker) {
|
||||
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
|
||||
}
|
||||
|
||||
$destinationUuid = $destination->uuid;
|
||||
$destinationName = $destination->name;
|
||||
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
|
||||
$serverUuid = $destination->server?->uuid;
|
||||
|
||||
$destination->delete();
|
||||
|
||||
auditLog('api.destination.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destinationUuid,
|
||||
'destination_name' => $destinationName,
|
||||
'destination_type' => $destinationType,
|
||||
'server_uuid' => $serverUuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class GithubController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($githubApp)
|
||||
{
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
|
||||
return serializeApiResponse($githubApp);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all GitHub apps.',
|
||||
path: '/github-apps',
|
||||
operationId: 'list-github-apps',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of GitHub apps.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'app_id' => ['type' => 'integer'],
|
||||
'installation_id' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string'],
|
||||
'private_key_id' => ['type' => 'integer'],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'is_public' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'type' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_github_apps(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$githubApps = GithubApp::where(function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId)
|
||||
->orWhere('is_system_wide', true);
|
||||
})->get();
|
||||
|
||||
$githubApps = $githubApps->map(function ($app) {
|
||||
return $this->removeSensitiveData($app);
|
||||
});
|
||||
|
||||
return response()->json($githubApps);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create GitHub App',
|
||||
description: 'Create a new GitHub app.',
|
||||
path: '/github-apps',
|
||||
operationId: 'create-github-app',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'GitHub app creation payload.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
|
||||
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
|
||||
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
|
||||
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
|
||||
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
|
||||
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
|
||||
],
|
||||
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'GitHub app created successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'app_id' => ['type' => 'integer'],
|
||||
'installation_id' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string'],
|
||||
'private_key_id' => ['type' => 'integer'],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_github_app(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [GithubApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'private_key_uuid',
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'organization' => 'nullable|string|max:255',
|
||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
'app_id' => 'required|integer',
|
||||
'installation_id' => 'required|integer',
|
||||
'client_id' => 'required|string|max:255',
|
||||
'client_secret' => 'required|string',
|
||||
'webhook_secret' => 'required|string',
|
||||
'private_key_uuid' => 'required|string',
|
||||
'is_system_wide' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the private key belongs to the team
|
||||
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
|
||||
->where('team_id', $teamId)
|
||||
->first();
|
||||
|
||||
if (! $privateKey) {
|
||||
return response()->json([
|
||||
'message' => 'Private key not found or does not belong to your team.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'uuid' => Str::uuid(),
|
||||
'name' => $request->input('name'),
|
||||
'organization' => $request->input('organization'),
|
||||
'api_url' => $request->input('api_url'),
|
||||
'html_url' => $request->input('html_url'),
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
'app_id' => $request->input('app_id'),
|
||||
'installation_id' => $request->input('installation_id'),
|
||||
'client_id' => $request->input('client_id'),
|
||||
'client_secret' => $request->input('client_secret'),
|
||||
'webhook_secret' => $request->input('webhook_secret'),
|
||||
'private_key_id' => $privateKey->id,
|
||||
'is_public' => false,
|
||||
'team_id' => $teamId,
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$payload['is_system_wide'] = $request->input('is_system_wide', false);
|
||||
}
|
||||
|
||||
$githubApp = GithubApp::create($payload);
|
||||
|
||||
auditLog('api.github_app.created', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($githubApp, 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/github-apps/{github_app_id}/repositories',
|
||||
summary: 'Load Repositories for a GitHub App',
|
||||
description: 'Fetch repositories from GitHub for a given GitHub app.',
|
||||
operationId: 'load-repositories',
|
||||
tags: ['GitHub Apps'],
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Repositories loaded successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(
|
||||
property: 'repositories',
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
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',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function load_repositories($github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$repositories = collect();
|
||||
$page = 1;
|
||||
$maxPages = 100; // Safety limit: max 10,000 repositories
|
||||
|
||||
while ($page <= $maxPages) {
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/installation/repositories', [
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
'message' => $response->json()['message'] ?? 'Failed to load repositories',
|
||||
], $response->status());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
$repos = $json['repositories'] ?? [];
|
||||
|
||||
if (empty($repos)) {
|
||||
break; // No more repositories to load
|
||||
}
|
||||
|
||||
$repositories = $repositories->concat($repos);
|
||||
$page++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'repositories' => $repositories->sortBy('name')->values(),
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json(['message' => 'GitHub app not found'], 404);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
|
||||
summary: 'Load Branches for a GitHub Repository',
|
||||
description: 'Fetch branches from GitHub for a given repository.',
|
||||
operationId: 'load-branches',
|
||||
tags: ['GitHub Apps'],
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'owner',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
description: 'Repository owner'
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'repo',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
description: 'Repository name'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Branches loaded successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(
|
||||
property: 'branches',
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
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',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function load_branches($github_app_id, $owner, $repo)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$owner}/{$repo}/branches");
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
'message' => 'Error loading branches from GitHub.',
|
||||
'error' => $response->json('message'),
|
||||
], $response->status());
|
||||
}
|
||||
|
||||
$branches = $response->json();
|
||||
|
||||
return response()->json([
|
||||
'branches' => $branches,
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json(['message' => 'GitHub app not found'], 404);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a GitHub app.
|
||||
*/
|
||||
#[OA\Patch(
|
||||
path: '/github-apps/{github_app_id}',
|
||||
operationId: 'updateGithubApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
summary: 'Update GitHub App',
|
||||
description: 'Update an existing GitHub app.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
|
||||
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
|
||||
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
|
||||
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
|
||||
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
|
||||
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitHub app updated successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
|
||||
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_github_app(Request $request, $github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('update', $githubApp);
|
||||
|
||||
// Define allowed fields for update
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'private_key_uuid',
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$allowedFields[] = 'is_system_wide';
|
||||
}
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
// Validate the request
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string';
|
||||
}
|
||||
if (isset($payload['organization'])) {
|
||||
$rules['organization'] = 'nullable|string';
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||
}
|
||||
if (isset($payload['html_url'])) {
|
||||
$rules['html_url'] = ['url', new SafeExternalUrl];
|
||||
}
|
||||
if (isset($payload['custom_user'])) {
|
||||
$rules['custom_user'] = 'string';
|
||||
}
|
||||
if (isset($payload['custom_port'])) {
|
||||
$rules['custom_port'] = 'integer|min:1|max:65535';
|
||||
}
|
||||
if (isset($payload['app_id'])) {
|
||||
$rules['app_id'] = 'integer';
|
||||
}
|
||||
if (isset($payload['installation_id'])) {
|
||||
$rules['installation_id'] = 'integer';
|
||||
}
|
||||
if (isset($payload['client_id'])) {
|
||||
$rules['client_id'] = 'string';
|
||||
}
|
||||
if (isset($payload['client_secret'])) {
|
||||
$rules['client_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['webhook_secret'])) {
|
||||
$rules['webhook_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$rules['private_key_uuid'] = 'string|uuid';
|
||||
}
|
||||
if (! isCloud() && isset($payload['is_system_wide'])) {
|
||||
$rules['is_system_wide'] = 'boolean';
|
||||
}
|
||||
|
||||
$validator = customApiValidator($payload, $rules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation error',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Handle private_key_uuid -> private_key_id conversion
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||
->where('uuid', $payload['private_key_uuid'])
|
||||
->first();
|
||||
|
||||
if (! $privateKey) {
|
||||
return response()->json([
|
||||
'message' => 'Private key not found or does not belong to your team',
|
||||
], 404);
|
||||
}
|
||||
|
||||
unset($payload['private_key_uuid']);
|
||||
$payload['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
// Update the GitHub app
|
||||
$githubApp->update($payload);
|
||||
|
||||
auditLog('api.github_app.updated', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app updated successfully',
|
||||
'data' => $githubApp,
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitHub app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a GitHub app.
|
||||
*/
|
||||
#[OA\Delete(
|
||||
path: '/github-apps/{github_app_id}',
|
||||
operationId: 'deleteGithubApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
summary: 'Delete GitHub App',
|
||||
description: 'Delete a GitHub app if it\'s not being used by any applications.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitHub app deleted successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Conflict - GitHub app is in use',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_github_app($github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('delete', $githubApp);
|
||||
|
||||
// Check if the GitHub app is being used by any applications
|
||||
if ($githubApp->applications->isNotEmpty()) {
|
||||
$count = $githubApp->applications->count();
|
||||
|
||||
return response()->json([
|
||||
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
|
||||
], 409);
|
||||
}
|
||||
|
||||
$deletedUuid = $githubApp->uuid;
|
||||
$deletedName = $githubApp->name;
|
||||
$githubApp->delete();
|
||||
|
||||
auditLog('api.github_app.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $deletedUuid,
|
||||
'github_app_name' => $deletedName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app deleted successfully',
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitHub app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class HetznerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get cloud provider token UUID from request.
|
||||
* Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id.
|
||||
*/
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Locations',
|
||||
description: 'Get all available Hetzner datacenter locations.',
|
||||
path: '/hetzner/locations',
|
||||
operationId: 'get-hetzner-locations',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner locations.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'country' => ['type' => 'string'],
|
||||
'city' => ['type' => 'string'],
|
||||
'latitude' => ['type' => 'number'],
|
||||
'longitude' => ['type' => 'number'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function locations(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$locations = $hetznerService->getLocations();
|
||||
|
||||
return response()->json($locations);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Server Types',
|
||||
description: 'Get all available Hetzner server types (instance sizes).',
|
||||
path: '/hetzner/server-types',
|
||||
operationId: 'get-hetzner-server-types',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner server types.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'cores' => ['type' => 'integer'],
|
||||
'memory' => ['type' => 'number'],
|
||||
'disk' => ['type' => 'integer'],
|
||||
'prices' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'location' => ['type' => 'string', 'description' => 'Datacenter location name'],
|
||||
'price_hourly' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'net' => ['type' => 'string'],
|
||||
'gross' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
'price_monthly' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'net' => ['type' => 'string'],
|
||||
'gross' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function serverTypes(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$serverTypes = $hetznerService->getServerTypes();
|
||||
|
||||
return response()->json($serverTypes);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Images',
|
||||
description: 'Get all available Hetzner system images (operating systems).',
|
||||
path: '/hetzner/images',
|
||||
operationId: 'get-hetzner-images',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner images.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'type' => ['type' => 'string'],
|
||||
'os_flavor' => ['type' => 'string'],
|
||||
'os_version' => ['type' => 'string'],
|
||||
'architecture' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function images(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$images = $hetznerService->getImages();
|
||||
|
||||
// Filter out deprecated images (same as UI)
|
||||
$filtered = array_filter($images, function ($image) {
|
||||
if (isset($image['type']) && $image['type'] !== 'system') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($image['deprecated']) && $image['deprecated'] === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return response()->json(array_values($filtered));
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner SSH Keys',
|
||||
description: 'Get all SSH keys stored in the Hetzner account.',
|
||||
path: '/hetzner/ssh-keys',
|
||||
operationId: 'get-hetzner-ssh-keys',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner SSH keys.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'fingerprint' => ['type' => 'string'],
|
||||
'public_key' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function sshKeys(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$sshKeys = $hetznerService->getSshKeys();
|
||||
|
||||
return response()->json($sshKeys);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Hetzner Server',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||
path: '/servers/hetzner',
|
||||
operationId: 'create-hetzner-server',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Hetzner server creation parameters',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['location', 'server_type', 'image', 'private_key_uuid'],
|
||||
properties: [
|
||||
'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'],
|
||||
'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true],
|
||||
'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
|
||||
'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
|
||||
'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
|
||||
'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'],
|
||||
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
|
||||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Hetzner server created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'],
|
||||
'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'],
|
||||
'ip' => ['type' => 'string', 'description' => 'The server IP address.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
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',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 429,
|
||||
ref: '#/components/responses/429',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request)
|
||||
{
|
||||
$allowedFields = [
|
||||
'cloud_provider_token_uuid',
|
||||
'cloud_provider_token_id',
|
||||
'location',
|
||||
'server_type',
|
||||
'image',
|
||||
'name',
|
||||
'private_key_uuid',
|
||||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'hetzner_ssh_key_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Server::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
'location' => 'required|string',
|
||||
'server_type' => 'required|string',
|
||||
'image' => 'required|integer',
|
||||
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv4' => 'nullable|boolean',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Check server limit
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if (! $request->name) {
|
||||
$request->offsetSet('name', generate_random_name());
|
||||
}
|
||||
if (is_null($request->enable_ipv4)) {
|
||||
$request->offsetSet('enable_ipv4', true);
|
||||
}
|
||||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
// Validate cloud provider token
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
// Validate private key
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
// Get public key and MD5 fingerprint
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||
|
||||
// Check if SSH key already exists on Hetzner
|
||||
$existingSshKeys = $hetznerService->getSshKeys();
|
||||
$existingKey = null;
|
||||
|
||||
foreach ($existingSshKeys as $key) {
|
||||
if ($key['fingerprint'] === $md5Fingerprint) {
|
||||
$existingKey = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload SSH key if it doesn't exist
|
||||
if ($existingKey) {
|
||||
$sshKeyId = $existingKey['id'];
|
||||
} else {
|
||||
$sshKeyName = $privateKey->name;
|
||||
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
|
||||
$sshKeyId = $uploadedKey['id'];
|
||||
}
|
||||
|
||||
// Normalize server name to lowercase for RFC 1123 compliance
|
||||
$normalizedServerName = strtolower(trim($request->name));
|
||||
|
||||
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
|
||||
$sshKeys = array_merge(
|
||||
[$sshKeyId],
|
||||
$request->hetzner_ssh_key_ids
|
||||
);
|
||||
|
||||
// Remove duplicates
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys);
|
||||
|
||||
// Prepare server creation parameters
|
||||
$params = [
|
||||
'name' => $normalizedServerName,
|
||||
'server_type' => $request->server_type,
|
||||
'image' => $request->image,
|
||||
'location' => $request->location,
|
||||
'start_after_create' => true,
|
||||
'ssh_keys' => $sshKeys,
|
||||
'public_net' => [
|
||||
'enable_ipv4' => $request->enable_ipv4,
|
||||
'enable_ipv6' => $request->enable_ipv6,
|
||||
],
|
||||
];
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
}
|
||||
|
||||
// Create server on Hetzner
|
||||
$hetznerServer = $hetznerService->createServer($params);
|
||||
|
||||
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||
$ipAddress = null;
|
||||
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||
} elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||
}
|
||||
|
||||
if (! $ipAddress) {
|
||||
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
||||
}
|
||||
|
||||
// Create server in Coolify database
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.hetzner_server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'ip' => $ipAddress,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'ip' => $ipAddress,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,43 @@ use OpenApi\Attributes as OA;
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Resource not found.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
description: 'Validation error.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Validation error.'),
|
||||
new OA\Property(
|
||||
property: 'errors',
|
||||
type: 'object',
|
||||
additionalProperties: new OA\AdditionalProperties(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'string')
|
||||
),
|
||||
example: [
|
||||
'name' => ['The name field is required.'],
|
||||
'api_url' => ['The api url field is required.', 'The api url format is invalid.'],
|
||||
]
|
||||
),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 429,
|
||||
description: 'Rate limit exceeded.',
|
||||
headers: [
|
||||
new OA\Header(
|
||||
header: 'Retry-After',
|
||||
description: 'Number of seconds to wait before retrying.',
|
||||
schema: new OA\Schema(type: 'integer', example: 60)
|
||||
),
|
||||
],
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
|
||||
]
|
||||
)),
|
||||
],
|
||||
)]
|
||||
class OpenApi
|
||||
|
||||
@@ -21,8 +21,9 @@ class OtherController extends Controller
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns the version of the application',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'text/html',
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
example: 'v4.0.0',
|
||||
)),
|
||||
new OA\Response(
|
||||
@@ -84,11 +85,15 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => true]);
|
||||
|
||||
auditLog('api.instance.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API enabled.'], 200);
|
||||
}
|
||||
|
||||
@@ -136,21 +141,141 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => false]);
|
||||
|
||||
auditLog('api.instance.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API disabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Enable MCP Server',
|
||||
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/enable',
|
||||
operationId: 'enable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server enabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to enable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function enable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => true]);
|
||||
|
||||
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Disable MCP Server',
|
||||
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/disable',
|
||||
operationId: 'disable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server disabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to disable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function disable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => false]);
|
||||
|
||||
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||
}
|
||||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$content = $request->input('content');
|
||||
$data = $request->validate([
|
||||
'content' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
|
||||
if ($webhook_url) {
|
||||
Http::post($webhook_url, [
|
||||
'content' => $content,
|
||||
Http::timeout(5)->post($webhook_url, [
|
||||
'content' => $data['content'],
|
||||
'allowed_mentions' => ['parse' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -166,8 +291,9 @@ class OtherController extends Controller
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Healthcheck endpoint.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'text/html',
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
example: 'OK',
|
||||
)),
|
||||
new OA\Response(
|
||||
|
||||
@@ -4,7 +4,10 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ProjectController extends Controller
|
||||
@@ -94,6 +97,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $project);
|
||||
|
||||
$project->load(['environments']);
|
||||
|
||||
@@ -132,6 +136,10 @@ class ProjectController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function environment_details(Request $request)
|
||||
@@ -212,6 +220,10 @@ class ProjectController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_project(Request $request)
|
||||
@@ -222,15 +234,16 @@ class ProjectController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Project::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|required',
|
||||
'description' => 'string|nullable',
|
||||
]);
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
], ValidationPatterns::combinedMessages());
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -253,6 +266,12 @@ class ProjectController extends Controller
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
auditLog('api.project.created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -275,7 +294,6 @@ class ProjectController extends Controller
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -322,6 +340,10 @@ class ProjectController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_project(Request $request)
|
||||
@@ -334,13 +356,13 @@ class ProjectController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|nullable',
|
||||
'description' => 'string|nullable',
|
||||
]);
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ValidationPatterns::nameRules(required: false),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
], ValidationPatterns::combinedMessages());
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -365,9 +387,17 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$project->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.project.updated', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
@@ -392,7 +422,6 @@ class ProjectController extends Controller
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -423,6 +452,10 @@ class ProjectController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_project(Request $request)
|
||||
@@ -439,12 +472,302 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $project);
|
||||
if (! $project->isEmpty()) {
|
||||
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$projectUuid = $project->uuid;
|
||||
$projectName = $project->name;
|
||||
$project->delete();
|
||||
|
||||
auditLog('api.project.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $projectUuid,
|
||||
'project_name' => $projectName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Project deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Environments',
|
||||
description: 'List all environments in a project.',
|
||||
path: '/projects/{uuid}/environments',
|
||||
operationId: 'get-environments',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Projects'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of environments',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Environment')
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Project not found.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function get_environments(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
|
||||
$environments = $project->environments()->select('id', 'name', 'uuid')->get();
|
||||
|
||||
return response()->json(serializeApiResponse($environments));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Environment',
|
||||
description: 'Create environment in project.',
|
||||
path: '/projects/{uuid}/environments',
|
||||
operationId: 'create-environment',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Projects'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Environment created.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the environment.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Environment created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'env123', 'description' => 'The UUID of the environment.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Project not found.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Environment with this name already exists.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_environment(Request $request)
|
||||
{
|
||||
$allowedFields = ['name'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
], ValidationPatterns::nameMessages());
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
|
||||
if ($existingEnvironment) {
|
||||
return response()->json(['message' => 'Environment with this name already exists.'], 409);
|
||||
}
|
||||
|
||||
$environment = $project->environments()->create([
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
auditLog('api.project.environment_created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'environment_name' => $environment->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $environment->uuid,
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Environment',
|
||||
description: 'Delete environment by name or UUID. Environment must be empty.',
|
||||
path: '/projects/{uuid}/environments/{environment_name_or_uuid}',
|
||||
operationId: 'delete-environment',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Projects'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Environment deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Environment deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
description: 'Environment has resources, so it cannot be deleted.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Project or environment not found.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_environment(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||
}
|
||||
if (! $request->environment_name_or_uuid) {
|
||||
return response()->json(['message' => 'Environment name or UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
|
||||
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
|
||||
if (! $environment) {
|
||||
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
|
||||
}
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $environment);
|
||||
|
||||
if (! $environment->isEmpty()) {
|
||||
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$envUuid = $environment->uuid;
|
||||
$envName = $environment->name;
|
||||
$environment->delete();
|
||||
|
||||
auditLog('api.project.environment_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $envUuid,
|
||||
'environment_name' => $envName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment deleted.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ class ResourcesController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// General authorization check for viewing resources - using Project as base resource type
|
||||
$this->authorize('viewAny', Project::class);
|
||||
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$resources = collect();
|
||||
$resources->push($projects->pluck('applications')->flatten());
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ScheduledTasksController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($task)
|
||||
{
|
||||
$task->makeHidden([
|
||||
'id',
|
||||
'team_id',
|
||||
'application_id',
|
||||
'service_id',
|
||||
]);
|
||||
|
||||
return serializeApiResponse($task);
|
||||
}
|
||||
|
||||
private function resolveApplication(Request $request, int $teamId): ?Application
|
||||
{
|
||||
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function listTasks(Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
$tasks = $resource->scheduled_tasks->map(function ($task) {
|
||||
return $this->removeSensitiveData($task);
|
||||
});
|
||||
|
||||
return response()->json($tasks);
|
||||
}
|
||||
|
||||
private function createTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'string|nullable',
|
||||
'timeout' => 'integer|min:1',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! validate_cron_expression($request->frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
$task = new ScheduledTask;
|
||||
$task->name = $request->name;
|
||||
$task->command = $request->command;
|
||||
$task->frequency = $request->frequency;
|
||||
$task->container = $request->container;
|
||||
$task->timeout = $request->has('timeout') ? $request->timeout : 300;
|
||||
$task->enabled = $request->has('enabled') ? $request->enabled : true;
|
||||
$task->team_id = $teamId;
|
||||
|
||||
if ($resource instanceof Application) {
|
||||
$task->application_id = $resource->id;
|
||||
} elseif ($resource instanceof Service) {
|
||||
$task->service_id = $resource->id;
|
||||
}
|
||||
|
||||
$task->save();
|
||||
|
||||
auditLog('api.scheduled_task.created', [
|
||||
'team_id' => $teamId,
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 201);
|
||||
}
|
||||
|
||||
private function updateTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
if ($request->all() === []) {
|
||||
return response()->json(['message' => 'At least one field must be provided.'], 422);
|
||||
}
|
||||
|
||||
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
'command' => 'string',
|
||||
'frequency' => 'string',
|
||||
'container' => 'string|nullable',
|
||||
'timeout' => 'integer|min:1',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$task->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.scheduled_task.updated', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 200);
|
||||
}
|
||||
|
||||
private function deleteTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$taskUuid = $task->uuid;
|
||||
$taskName = $task->name;
|
||||
$task->delete();
|
||||
|
||||
auditLog('api.scheduled_task.deleted', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $taskUuid,
|
||||
'task_name' => $taskName,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Scheduled task deleted.']);
|
||||
}
|
||||
|
||||
private function getExecutions(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$executions = $task->executions()->get()->map(function ($execution) {
|
||||
$execution->makeHidden(['id', 'scheduled_task_id']);
|
||||
|
||||
return serializeApiResponse($execution);
|
||||
});
|
||||
|
||||
return response()->json($executions);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tasks',
|
||||
description: 'List all scheduled tasks for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks',
|
||||
operationId: 'list-scheduled-tasks-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
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: 'Get all scheduled tasks for an application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->listTasks($application);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Task',
|
||||
description: 'Create a new scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks',
|
||||
operationId: 'create-scheduled-task-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
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(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'command', 'frequency'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Scheduled task created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
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 create_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->createTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Task',
|
||||
description: 'Update a scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'update-scheduled-task-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
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: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
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 update_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->updateTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Task',
|
||||
description: 'Delete a scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'delete-scheduled-task-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
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: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->deleteTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Executions',
|
||||
description: 'List all executions for a scheduled task on an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
|
||||
operationId: 'list-scheduled-task-executions-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
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: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all executions for a scheduled task.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->getExecutions($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tasks',
|
||||
description: 'List all scheduled tasks for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks',
|
||||
operationId: 'list-scheduled-tasks-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all scheduled tasks for a service.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->listTasks($service);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Task',
|
||||
description: 'Create a new scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks',
|
||||
operationId: 'create-scheduled-task-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'command', 'frequency'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Scheduled task created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
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 create_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->createTask($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Task',
|
||||
description: 'Update a scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'update-scheduled-task-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
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 update_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->updateTask($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Task',
|
||||
description: 'Delete a scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'delete-scheduled-task-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->deleteTask($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Executions',
|
||||
description: 'List all executions for a scheduled task on a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
|
||||
operationId: 'list-scheduled-task-executions-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all executions for a scheduled task.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->getExecutions($request, $service);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -109,6 +110,7 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('view', $key);
|
||||
|
||||
return response()->json($this->removeSensitiveData($key));
|
||||
}
|
||||
@@ -163,6 +165,10 @@ class SecurityController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_key(Request $request)
|
||||
@@ -171,8 +177,9 @@ class SecurityController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [PrivateKey::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -227,6 +234,13 @@ class SecurityController extends Controller
|
||||
'private_key' => $request->private_key,
|
||||
]);
|
||||
|
||||
auditLog('api.private_key.created', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $key->uuid,
|
||||
'private_key_name' => $key->name,
|
||||
'fingerprint' => $fingerPrint,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $key->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -282,6 +296,10 @@ class SecurityController extends Controller
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_key(Request $request)
|
||||
@@ -292,7 +310,7 @@ class SecurityController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -322,7 +340,15 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$foundKey->update($request->all());
|
||||
$this->authorize('update', $foundKey);
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.private_key.updated', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $foundKey->uuid,
|
||||
'private_key_name' => $foundKey->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $foundKey->uuid,
|
||||
@@ -398,6 +424,7 @@ class SecurityController extends Controller
|
||||
if (is_null($key)) {
|
||||
return response()->json(['message' => 'Private Key not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $key);
|
||||
|
||||
if ($key->isInUse()) {
|
||||
return response()->json([
|
||||
@@ -406,8 +433,16 @@ class SecurityController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$keyUuid = $key->uuid;
|
||||
$keyName = $key->name;
|
||||
$key->forceDelete();
|
||||
|
||||
auditLog('api.private_key.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $keyUuid,
|
||||
'private_key_name' => $keyName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Private Key deleted.',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SentinelController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle a Sentinel agent metrics push.
|
||||
*
|
||||
* Sentinel pushes its full container list on a fixed interval (default 60s),
|
||||
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
|
||||
* server per minute, the job is only dispatched when the container state hash
|
||||
* changes, or when the force window has elapsed.
|
||||
*/
|
||||
public function push(Request $request)
|
||||
{
|
||||
$token = $request->header('Authorization');
|
||||
if (! $token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_missing');
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$naked_token = str_replace('Bearer ', '', $token);
|
||||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
auditLogWebhookFailure('sentinel', 'decrypt_failed');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||
if (! $server_uuid) {
|
||||
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server = Server::where('uuid', $server_uuid)->first();
|
||||
if (! $server) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_found', [
|
||||
'server_uuid' => $server_uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_functional', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server is not functional'], 401);
|
||||
}
|
||||
|
||||
if ($server->settings->sentinel_token !== $naked_token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_mismatch', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'containers' => ['present', 'array'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(serializeApiResponse([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||
PushServerUpdateJob::dispatch($server, $data);
|
||||
|
||||
auditLog('sentinel.metrics_pushed', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'ok'], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether PushServerUpdateJob should be dispatched for this push.
|
||||
*
|
||||
* Dispatches when: first push (no cached hash), the container state changed,
|
||||
* or the force window elapsed.
|
||||
*/
|
||||
private function shouldDispatchUpdate(Server $server, array $data): bool
|
||||
{
|
||||
$hash = $this->containerStateHash($data);
|
||||
$hashKey = "sentinel:push-hash:{$server->id}";
|
||||
$forceKey = "sentinel:push-force:{$server->id}";
|
||||
$lockKey = "sentinel:push-lock:{$server->id}";
|
||||
|
||||
try {
|
||||
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
|
||||
$cachedHash = Cache::get($hashKey);
|
||||
$forceActive = Cache::has($forceKey);
|
||||
|
||||
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
|
||||
|
||||
if ($shouldDispatch) {
|
||||
// Day-long TTL bounds memory if a server stops pushing entirely.
|
||||
Cache::put($hashKey, $hash, now()->addDay());
|
||||
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
|
||||
}
|
||||
|
||||
return $shouldDispatch;
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable hash of container state.
|
||||
*
|
||||
* Covers [name, state] only — metrics, filesystem_usage_root, and
|
||||
* health_status are excluded on purpose. Disk % churns constantly, and
|
||||
* health checks can flap between starting/healthy/unhealthy while the
|
||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||
* The force window still refreshes full state periodically. Sorted by name
|
||||
* so container ordering from Sentinel does not affect the hash.
|
||||
*/
|
||||
private function containerStateHash(array $data): string
|
||||
{
|
||||
$containers = collect(data_get($data, 'containers', []))
|
||||
->map(fn ($c) => [
|
||||
'name' => data_get($c, 'name'),
|
||||
'state' => data_get($c, 'state'),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return hash('xxh128', json_encode($containers));
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Stringable;
|
||||
@@ -144,6 +148,7 @@ class ServersController extends Controller
|
||||
if (is_null($server)) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $server);
|
||||
if ($with_resources) {
|
||||
$server['resources'] = $server->definedResources()->map(function ($resource) {
|
||||
$payload = [
|
||||
@@ -288,15 +293,24 @@ class ServersController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->get('uuid');
|
||||
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (is_null($server)) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$uuid = $request->query('uuid');
|
||||
if ($uuid) {
|
||||
$domains = Application::getDomainsByUuid($uuid);
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(serializeApiResponse($domains));
|
||||
return response()->json(serializeApiResponse($application->fqdns));
|
||||
}
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$domains = collect();
|
||||
$applications = $projects->pluck('applications')->flatten();
|
||||
$applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) {
|
||||
return $application->destination?->server?->id === $server->id;
|
||||
});
|
||||
$settings = instanceSettings();
|
||||
if ($applications->count() > 0) {
|
||||
foreach ($applications as $application) {
|
||||
@@ -336,7 +350,9 @@ class ServersController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
$services = $projects->pluck('services')->flatten();
|
||||
$services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) {
|
||||
return $service->server_id === $server->id;
|
||||
});
|
||||
if ($services->count() > 0) {
|
||||
foreach ($services as $service) {
|
||||
$service_applications = $service->applications;
|
||||
@@ -349,7 +365,8 @@ class ServersController extends Controller
|
||||
})->filter(function (Stringable $fqdn) {
|
||||
return $fqdn->isNotEmpty();
|
||||
});
|
||||
if ($ip === 'host.docker.internal') {
|
||||
$serviceIp = $server->ip;
|
||||
if ($serviceIp === 'host.docker.internal') {
|
||||
if ($settings->public_ipv4) {
|
||||
$domains->push([
|
||||
'domain' => $fqdn,
|
||||
@@ -365,13 +382,13 @@ class ServersController extends Controller
|
||||
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
|
||||
$domains->push([
|
||||
'domain' => $fqdn,
|
||||
'ip' => $ip,
|
||||
'ip' => $serviceIp,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$domains->push([
|
||||
'domain' => $fqdn,
|
||||
'ip' => $ip,
|
||||
'ip' => $serviceIp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -447,6 +464,10 @@ class ServersController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_server(Request $request)
|
||||
@@ -457,21 +478,24 @@ class ServersController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [ModelsServer::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
'description' => 'string|nullable',
|
||||
'ip' => 'string|required',
|
||||
'port' => 'integer|nullable',
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'user' => 'string|nullable',
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -515,9 +539,13 @@ class ServersController extends Controller
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$allServers = ModelsServer::whereIp($request->ip)->get();
|
||||
if ($allServers->count() > 0) {
|
||||
return response()->json(['message' => 'Server with this IP already exists.'], 400);
|
||||
$foundServer = ModelsServer::whereIp($request->ip)->first();
|
||||
if ($foundServer) {
|
||||
if ($foundServer->team_id === $teamId) {
|
||||
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
|
||||
}
|
||||
|
||||
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
|
||||
@@ -542,6 +570,14 @@ class ServersController extends Controller
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'ip' => $server->ip,
|
||||
'is_build_server' => (bool) $request->is_build_server,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -576,6 +612,12 @@ class ServersController extends Controller
|
||||
'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'],
|
||||
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
|
||||
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
|
||||
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
|
||||
'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
|
||||
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
|
||||
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
|
||||
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
|
||||
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -604,11 +646,15 @@ class ServersController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_server(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type'];
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -616,19 +662,27 @@ class ServersController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255|nullable',
|
||||
'description' => 'string|nullable',
|
||||
'ip' => 'string|nullable',
|
||||
'port' => 'integer|nullable',
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|nullable',
|
||||
'user' => 'string|nullable',
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
'concurrent_builds' => 'integer|min:1',
|
||||
'dynamic_timeout' => 'integer|min:1',
|
||||
'deployment_queue_limit' => 'integer|min:1',
|
||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||
'server_disk_usage_check_frequency' => 'string',
|
||||
'connection_timeout' => 'integer|min:1|max:300',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -649,26 +703,58 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
if ($request->proxy_type) {
|
||||
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
||||
return str($proxyType->value)->lower();
|
||||
});
|
||||
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
} else {
|
||||
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
||||
}
|
||||
}
|
||||
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
|
||||
if ($request->is_build_server) {
|
||||
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
|
||||
if ($request->filled('private_key_uuid')) {
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$updateFields['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server->update($updateFields);
|
||||
if ($request->has('is_build_server')) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->is_build_server,
|
||||
'is_build_server' => $request->boolean('is_build_server'),
|
||||
]);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
||||
if (! empty($advancedSettings)) {
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->proxy_type) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -691,7 +777,6 @@ class ServersController extends Controller
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -722,6 +807,10 @@ class ServersController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_server(Request $request)
|
||||
@@ -739,14 +828,42 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
if ($server->definedResources()->count() > 0) {
|
||||
return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
|
||||
$this->authorize('delete', $server);
|
||||
|
||||
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if ($server->definedResources()->count() > 0 && ! $force) {
|
||||
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
|
||||
}
|
||||
if ($server->isLocalhost()) {
|
||||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
foreach ($server->definedResources() as $resource) {
|
||||
DeleteResourceJob::dispatch($resource);
|
||||
}
|
||||
}
|
||||
|
||||
$deletedUuid = $server->uuid;
|
||||
$deletedName = $server->name;
|
||||
$deletedIp = $server->ip;
|
||||
$server->delete();
|
||||
DeleteServer::dispatch($server);
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
false, // Don't delete from Hetzner via API
|
||||
$server->hetzner_server_id,
|
||||
$server->cloud_provider_token_id,
|
||||
$server->team_id
|
||||
);
|
||||
|
||||
auditLog('api.server.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $deletedUuid,
|
||||
'server_name' => $deletedName,
|
||||
'ip' => $deletedIp,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
@@ -790,6 +907,10 @@ class ServersController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function validate_server(Request $request)
|
||||
@@ -807,8 +928,15 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
ValidateServer::dispatch($server);
|
||||
|
||||
auditLog('api.server.validated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Validation started.'], 201);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,14 +14,6 @@ class TeamController extends Controller
|
||||
'custom_server_limit',
|
||||
'pivot',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === false) {
|
||||
$team->makeHidden([
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'resend_api_key',
|
||||
'telegram_token',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($team);
|
||||
}
|
||||
@@ -118,6 +110,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$team = $this->removeSensitiveData($team);
|
||||
|
||||
return response()->json(
|
||||
@@ -176,9 +169,12 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$members = $team->members;
|
||||
$members->makeHidden([
|
||||
'pivot',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
@@ -216,7 +212,10 @@ class TeamController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$team = auth()->user()->currentTeam();
|
||||
$team = auth()->user()->teams->where('id', $teamId)->first();
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$this->removeSensitiveData($team),
|
||||
@@ -261,9 +260,14 @@ class TeamController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$team = auth()->user()->currentTeam();
|
||||
$team = auth()->user()->teams->where('id', $teamId)->first();
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$team->members->makeHidden([
|
||||
'pivot',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
|
||||
Reference in New Issue
Block a user