mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 06:23:23 +00:00
Merge remote-tracking branch 'origin/next' into feat/hetzner-firewalls-and-internal-networks
This commit is contained in:
@@ -33,6 +33,18 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Application not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
@@ -280,6 +292,7 @@ class ApplicationsController extends Controller
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
|
||||
],
|
||||
)
|
||||
@@ -447,6 +460,7 @@ class ApplicationsController extends Controller
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
|
||||
],
|
||||
)
|
||||
@@ -614,6 +628,7 @@ class ApplicationsController extends Controller
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
|
||||
],
|
||||
)
|
||||
@@ -753,6 +768,7 @@ class ApplicationsController extends Controller
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -888,6 +904,7 @@ class ApplicationsController extends Controller
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -964,7 +981,7 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
@@ -978,6 +995,8 @@ class ApplicationsController extends Controller
|
||||
'http_basic_auth_username' => 'string|nullable',
|
||||
'http_basic_auth_password' => 'string|nullable',
|
||||
'autogenerate_domain' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -995,6 +1014,13 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$tagNames = $request->input('tags') ?? [];
|
||||
|
||||
$environmentUuid = $request->environment_uuid;
|
||||
$environmentName = $request->environment_name;
|
||||
if (blank($environmentUuid) && blank($environmentName)) {
|
||||
@@ -1252,6 +1278,9 @@ class ApplicationsController extends Controller
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -1495,6 +1524,9 @@ class ApplicationsController extends Controller
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -1708,6 +1740,9 @@ class ApplicationsController extends Controller
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -1832,6 +1867,9 @@ class ApplicationsController extends Controller
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -1955,6 +1993,9 @@ class ApplicationsController extends Controller
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
@@ -1986,6 +2027,7 @@ class ApplicationsController extends Controller
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Invalid type.'], 400);
|
||||
@@ -4654,4 +4696,148 @@ class ApplicationsController extends Controller
|
||||
|
||||
return response()->json(['message' => 'Preview deletion request queued.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for an application by UUID.',
|
||||
path: '/applications/{uuid}/tags',
|
||||
operationId: 'list-tags-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
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 tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to an application by UUID.',
|
||||
path: '/applications/{uuid}/tags',
|
||||
operationId: 'create-tag-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from an application by UUID.',
|
||||
path: '/applications/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
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 delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
trait HandlesTagsApi
|
||||
{
|
||||
/**
|
||||
* Find the taggable resource by UUID within the team.
|
||||
*/
|
||||
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
|
||||
|
||||
/**
|
||||
* Get the 404 message for the taggable resource.
|
||||
*/
|
||||
abstract protected function tagResourceNotFoundMessage(): string;
|
||||
|
||||
public function listTags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
|
||||
}
|
||||
|
||||
public function createTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($request->has('tag_name') && $request->has('tag_names')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'tag_name' => 'required_without:tag_names|string',
|
||||
'tag_names' => 'required_without:tag_name|array|min:1',
|
||||
'tag_names.*' => 'string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
|
||||
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);
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->attachTagsToResource($resource, $tagNames, $teamId);
|
||||
|
||||
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function deleteTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $resource->tags()->whereKey($tag->id)->exists()) {
|
||||
return response()->json(['message' => 'Tag not found on resource.'], 404);
|
||||
}
|
||||
|
||||
$resource->tags()->detach($tag->id);
|
||||
$tag->deleteIfOrphaned();
|
||||
|
||||
return response()->json(['message' => 'Tag removed.']);
|
||||
}
|
||||
|
||||
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
|
||||
{
|
||||
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
|
||||
if (mb_strlen($tagName) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = Tag::query()->createOrFirst([
|
||||
'team_id' => $teamId,
|
||||
'name' => $tagName,
|
||||
]);
|
||||
|
||||
$resource->tags()->syncWithoutDetaching([$tag->id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateTagsParameter(Request $request): ?JsonResponse
|
||||
{
|
||||
if (! $request->has('tags')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->input('tags', []));
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request->merge(['tags' => $tagNames]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalizeTagNames(array $tagNames): array
|
||||
{
|
||||
return collect($tagNames)
|
||||
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,18 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Database not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
@@ -1220,6 +1232,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1288,6 +1301,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1355,6 +1369,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1423,6 +1438,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1491,6 +1507,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1562,6 +1579,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1633,6 +1651,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1701,6 +1720,7 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1731,7 +1751,7 @@ class DatabasesController extends Controller
|
||||
|
||||
public function create_database(Request $request, NewDatabaseTypes $type)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -1830,6 +1850,8 @@ class DatabasesController extends Controller
|
||||
'limits_cpuset' => 'string|nullable',
|
||||
'limits_cpu_shares' => 'numeric',
|
||||
'instant_deploy' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
if ($validator->failed()) {
|
||||
return response()->json([
|
||||
@@ -1837,6 +1859,13 @@ class DatabasesController extends Controller
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$tagNames = $request->input('tags') ?? [];
|
||||
|
||||
if ($request->public_port) {
|
||||
if ($request->public_port < 1024 || $request->public_port > 65535) {
|
||||
return response()->json([
|
||||
@@ -1848,7 +1877,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
if ($type === NewDatabaseTypes::POSTGRESQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
@@ -1896,6 +1925,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
'uuid' => $database->uuid,
|
||||
@@ -1917,7 +1949,7 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MARIADB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mariadb_conf' => 'string',
|
||||
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
@@ -1964,6 +1996,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -1986,7 +2021,7 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MYSQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
@@ -2033,6 +2068,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2055,7 +2093,7 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::REDIS) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'redis_conf' => 'string',
|
||||
@@ -2099,6 +2137,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2121,7 +2162,7 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
]);
|
||||
@@ -2146,12 +2187,15 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $database->uuid,
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::KEYDB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'keydb_conf' => 'string',
|
||||
@@ -2195,6 +2239,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2217,7 +2264,7 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
@@ -2241,6 +2288,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -2263,7 +2313,7 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MONGODB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mongo_conf' => 'string',
|
||||
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
@@ -2309,6 +2359,9 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
@@ -4342,4 +4395,148 @@ class DatabasesController extends Controller
|
||||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a database by UUID.',
|
||||
path: '/databases/{uuid}/tags',
|
||||
operationId: 'list-tags-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
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 tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a database by UUID.',
|
||||
path: '/databases/{uuid}/tags',
|
||||
operationId: 'create-tag-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a database by UUID.',
|
||||
path: '/databases/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
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 delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,18 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Service not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
@@ -276,6 +288,7 @@ class ServicesController extends Controller
|
||||
],
|
||||
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -342,7 +355,7 @@ class ServicesController extends Controller
|
||||
)]
|
||||
public function create_service(Request $request)
|
||||
{
|
||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -372,6 +385,8 @@ class ServicesController extends Controller
|
||||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
@@ -393,6 +408,11 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
if (filled($request->type) && filled($request->docker_compose_raw)) {
|
||||
return response()->json([
|
||||
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
|
||||
@@ -531,6 +551,10 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
@@ -551,7 +575,7 @@ class ServicesController extends Controller
|
||||
|
||||
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
||||
} elseif (filled($request->docker_compose_raw)) {
|
||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$validationRules = [
|
||||
'project_uuid' => 'string|required',
|
||||
@@ -570,6 +594,8 @@ class ServicesController extends Controller
|
||||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
@@ -703,6 +729,10 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
@@ -2798,4 +2828,148 @@ class ServicesController extends Controller
|
||||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'list-tags-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
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: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
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 tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'create-tag-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
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(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a service by UUID.',
|
||||
path: '/services/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
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: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
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 delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TagsController extends Controller
|
||||
{
|
||||
public static function serializeTag(Tag $tag): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $tag->uuid,
|
||||
'name' => $tag->name,
|
||||
'created_at' => $tag->created_at,
|
||||
'updated_at' => $tag->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all tags for the current team.',
|
||||
path: '/tags',
|
||||
operationId: 'list-tags',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All tags for the current team.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
|
||||
|
||||
return response()->json($tags->map(self::serializeTag(...)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user