feat(api): add tag management endpoints for applications, databases, and services (#9275)

This commit is contained in:
Andras Bacsai 2026-07-07 14:12:13 +02:00 committed by GitHub
commit 5dc697c428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2577 additions and 15 deletions

View file

@ -33,6 +33,18 @@
class ApplicationsController extends Controller 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 private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{ {
if (request()->attributes->get('can_read_sensitive', false) === true) { if (request()->attributes->get('can_read_sensitive', false) === true) {
@ -280,6 +292,7 @@ public function applications(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], '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.'], '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.'], '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.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
], ],
) )
@ -447,6 +460,7 @@ public function create_public_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], '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.'], '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.'], '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.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
], ],
) )
@ -614,6 +628,7 @@ public function create_private_gh_app_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], '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.'], '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.'], '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.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
], ],
) )
@ -753,6 +768,7 @@ public function create_private_deploy_key_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], '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.'], '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.'], '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 @@ public function create_dockerfile_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], '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.'], '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.'], '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 @@ private function create_application(Request $request, $type)
if ($return instanceof JsonResponse) { if ($return instanceof JsonResponse) {
return $return; 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(), [ $validator = customApiValidator($request->all(), [
'name' => 'string|max:255', 'name' => 'string|max:255',
@ -978,6 +995,8 @@ private function create_application(Request $request, $type)
'http_basic_auth_username' => 'string|nullable', 'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable', 'http_basic_auth_password' => 'string|nullable',
'autogenerate_domain' => 'boolean', 'autogenerate_domain' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]); ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -995,6 +1014,13 @@ private function create_application(Request $request, $type)
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
$environmentUuid = $request->environment_uuid; $environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name; $environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) { if (blank($environmentUuid) && blank($environmentName)) {
@ -1252,6 +1278,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true); $application->isConfigurationChanged(true);
if ($instantDeploy) { if ($instantDeploy) {
@ -1495,6 +1524,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true); $application->isConfigurationChanged(true);
if ($instantDeploy) { if ($instantDeploy) {
@ -1708,6 +1740,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true); $application->isConfigurationChanged(true);
if ($instantDeploy) { if ($instantDeploy) {
@ -1832,6 +1867,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true); $application->isConfigurationChanged(true);
if ($instantDeploy) { if ($instantDeploy) {
@ -1955,6 +1993,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true); $application->isConfigurationChanged(true);
if ($instantDeploy) { if ($instantDeploy) {
@ -1986,6 +2027,7 @@ private function create_application(Request $request, $type)
'uuid' => data_get($application, 'uuid'), 'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'), 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201); ]))->setStatusCode(201);
} }
return response()->json(['message' => 'Invalid type.'], 400); return response()->json(['message' => 'Invalid type.'], 400);
@ -4654,4 +4696,148 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
return response()->json(['message' => 'Preview deletion request queued.']); 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);
}
} }

View file

@ -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();
}
}

View file

@ -28,6 +28,18 @@
class DatabasesController extends Controller 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 private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{ {
if (request()->attributes->get('can_read_sensitive', false) === true) { if (request()->attributes->get('can_read_sensitive', false) === true) {
@ -1220,6 +1232,7 @@ public function update_backup(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_postgresql(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_clickhouse(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_dragonfly(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_redis(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_keydb(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_mariadb(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_mysql(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy 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 @@ public function create_database_mongodb(Request $request)
public function create_database(Request $request, NewDatabaseTypes $type) 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(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -1830,6 +1850,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'limits_cpuset' => 'string|nullable', 'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric', 'limits_cpu_shares' => 'numeric',
'instant_deploy' => 'boolean', 'instant_deploy' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]); ]);
if ($validator->failed()) { if ($validator->failed()) {
return response()->json([ return response()->json([
@ -1837,6 +1859,13 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'errors' => $validator->errors(), 'errors' => $validator->errors(),
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
if ($request->public_port) { if ($request->public_port) {
if ($request->public_port < 1024 || $request->public_port > 65535) { if ($request->public_port < 1024 || $request->public_port > 65535) {
return response()->json([ return response()->json([
@ -1848,7 +1877,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
} }
if ($type === NewDatabaseTypes::POSTGRESQL) { 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(), [ $validator = customApiValidator($request->all(), [
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -1896,6 +1925,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
'uuid' => $database->uuid, 'uuid' => $database->uuid,
@ -1917,7 +1949,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) { } 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(), [ $validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string', 'mariadb_conf' => 'string',
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -1964,6 +1996,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -1986,7 +2021,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) { } 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(), [ $validator = customApiValidator($request->all(), [
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -2033,6 +2068,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2055,7 +2093,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) { } 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(), [ $validator = customApiValidator($request->all(), [
'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string', 'redis_conf' => 'string',
@ -2099,6 +2137,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2121,7 +2162,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) { } 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(), [ $validator = customApiValidator($request->all(), [
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]); ]);
@ -2146,12 +2187,15 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => $database->uuid, 'uuid' => $database->uuid,
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::KEYDB) { } 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(), [ $validator = customApiValidator($request->all(), [
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string', 'keydb_conf' => 'string',
@ -2195,6 +2239,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2217,7 +2264,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) { } 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(), [ $validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -2241,6 +2288,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2263,7 +2313,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) { } 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(), [ $validator = customApiValidator($request->all(), [
'mongo_conf' => 'string', 'mongo_conf' => 'string',
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
@ -2309,6 +2359,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -4342,4 +4395,148 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']); 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);
}
} }

View file

@ -24,6 +24,18 @@
class ServicesController extends Controller 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 private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{ {
if (request()->attributes->get('can_read_sensitive', false) === true) { if (request()->attributes->get('can_read_sensitive', false) === true) {
@ -276,6 +288,7 @@ public function services(Request $request)
], ],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], '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.'], '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 @@ public function services(Request $request)
)] )]
public function create_service(Request $request) 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(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -372,6 +385,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -393,6 +408,11 @@ public function create_service(Request $request)
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
if (filled($request->type) && filled($request->docker_compose_raw)) { if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([ return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', 'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
@ -531,6 +551,10 @@ public function create_service(Request $request)
} }
} }
if ($request->has('tags')) {
$this->attachTagsToResource($service, $request->tags, $teamId);
}
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }
@ -551,7 +575,7 @@ public function create_service(Request $request)
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) { } 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 = [ $validationRules = [
'project_uuid' => 'string|required', 'project_uuid' => 'string|required',
@ -570,6 +594,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -703,6 +729,10 @@ public function create_service(Request $request)
} }
} }
if ($request->has('tags')) {
$this->attachTagsToResource($service, $request->tags, $teamId);
}
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }
@ -2798,4 +2828,148 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']); 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);
}
} }

View file

@ -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(...)));
}
}

View file

@ -91,9 +91,7 @@ public function deleteTag(string $id)
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->resource->tags()->detach($id); $this->resource->tags()->detach($id);
$found_more_tags = Tag::ownedByCurrentTeam()->find($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id);
if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { $found_more_tags?->deleteIfOrphaned();
$found_more_tags->delete();
}
$this->refresh(); $this->refresh();
$this->dispatch('success', 'Tag deleted.'); $this->dispatch('success', 'Tag deleted.');
} catch (\Exception $e) { } catch (\Exception $e) {

View file

@ -3,7 +3,19 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Tag model',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'created_at', type: 'string'),
new OA\Property(property: 'updated_at', type: 'string'),
]
)]
class Tag extends BaseModel class Tag extends BaseModel
{ {
use HasSafeStringAttribute; use HasSafeStringAttribute;
@ -23,6 +35,13 @@ public static function ownedByCurrentTeam()
return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); return Tag::whereTeamId(currentTeam()->id)->orderBy('name');
} }
public function deleteIfOrphaned(): void
{
if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) {
$this->delete();
}
}
public function applications() public function applications()
{ {
return $this->morphedByMany(Application::class, 'taggable'); return $this->morphedByMany(Application::class, 'taggable');

View file

@ -222,4 +222,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_preserve_repository_enabled'); $request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build'); $request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('docker_compose_raw'); $request->offsetUnset('docker_compose_raw');
$request->offsetUnset('tags');
} }

View file

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if ($this->indexExists()) {
return;
}
DB::table('tags')
->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count'))
->whereNotNull('team_id')
->groupBy('team_id', 'name')
->havingRaw('COUNT(*) > 1')
->orderBy('keep_id')
->cursor()
->each(function ($duplicate): void {
DB::table('tags')
->select('id')
->where('team_id', $duplicate->team_id)
->where('name', $duplicate->name)
->where('id', '!=', $duplicate->keep_id)
->orderBy('id')
->cursor()
->each(function ($duplicateTag) use ($duplicate): void {
DB::table('taggables')
->where('tag_id', $duplicateTag->id)
->orderBy('taggable_id')
->cursor()
->each(function ($taggable) use ($duplicate): void {
DB::table('taggables')->updateOrInsert([
'tag_id' => $duplicate->keep_id,
'taggable_id' => $taggable->taggable_id,
'taggable_type' => $taggable->taggable_type,
]);
});
DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete();
DB::table('tags')->where('id', $duplicateTag->id)->delete();
});
});
Schema::table('tags', function (Blueprint $table) {
$table->unique(['team_id', 'name'], 'tags_team_id_name_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! $this->indexExists()) {
return;
}
Schema::table('tags', function (Blueprint $table) {
$table->dropUnique('tags_team_id_name_unique');
});
}
private function indexExists(): bool
{
return collect(Schema::getIndexes('tags'))
->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique');
}
};

View file

@ -412,6 +412,13 @@
"default": true, "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." "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": {
"type": "string"
},
"description": "Tags to assign to the application."
},
"is_preserve_repository_enabled": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
@ -866,6 +873,13 @@
"default": true, "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." "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": {
"type": "string"
},
"description": "Tags to assign to the application."
},
"is_preserve_repository_enabled": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
@ -1320,6 +1334,13 @@
"default": true, "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." "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": {
"type": "string"
},
"description": "Tags to assign to the application."
},
"is_preserve_repository_enabled": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
@ -1678,6 +1699,13 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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": {
"type": "string"
},
"description": "Tags to assign to the application."
} }
}, },
"type": "object" "type": "object"
@ -2017,6 +2045,13 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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": {
"type": "string"
},
"description": "Tags to assign to the application."
} }
}, },
"type": "object" "type": "object"
@ -3720,6 +3755,179 @@
] ]
} }
}, },
"\/applications\/{uuid}\/tags": {
"get": {
"tags": [
"Applications"
],
"summary": "List Tags",
"description": "List tags for an application by UUID.",
"operationId": "list-tags-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of tags.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Applications"
],
"summary": "Create Tag",
"description": "Add tag(s) to an application by UUID.",
"operationId": "create-tag-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"tag_name": {
"type": "string",
"description": "The tag name (min 2 characters). Required if tag_names is not provided."
},
"tag_names": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of tag names (each min 2 characters). Required if tag_name is not provided."
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Tags added successfully.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/applications\/{uuid}\/tags\/{tag_uuid}": {
"delete": {
"tags": [
"Applications"
],
"summary": "Delete Tag",
"description": "Remove a tag from an application by UUID.",
"operationId": "delete-tag-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "tag_uuid",
"in": "path",
"description": "UUID of the tag.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Tag removed."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/cloud-tokens": { "\/cloud-tokens": {
"get": { "get": {
"tags": [ "tags": [
@ -5018,6 +5226,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5150,6 +5365,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5278,6 +5500,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5410,6 +5639,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5542,6 +5778,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5686,6 +5929,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5830,6 +6080,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5962,6 +6219,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -7106,6 +7370,179 @@
] ]
} }
}, },
"\/databases\/{uuid}\/tags": {
"get": {
"tags": [
"Databases"
],
"summary": "List Tags",
"description": "List tags for a database by UUID.",
"operationId": "list-tags-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of tags.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Databases"
],
"summary": "Create Tag",
"description": "Add tag(s) to a database by UUID.",
"operationId": "create-tag-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"tag_name": {
"type": "string",
"description": "The tag name (min 2 characters). Required if tag_names is not provided."
},
"tag_names": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of tag names (each min 2 characters). Required if tag_name is not provided."
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Tags added successfully.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/tags\/{tag_uuid}": {
"delete": {
"tags": [
"Databases"
],
"summary": "Delete Tag",
"description": "Remove a tag from a database by UUID.",
"operationId": "delete-tag-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "tag_uuid",
"in": "path",
"description": "UUID of the tag.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Tag removed."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/deployments": { "\/deployments": {
"get": { "get": {
"tags": [ "tags": [
@ -10968,6 +11405,13 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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": {
"type": "string"
},
"description": "Tags to assign to the service."
} }
}, },
"type": "object" "type": "object"
@ -12390,6 +12834,215 @@
] ]
} }
}, },
"\/services\/{uuid}\/tags": {
"get": {
"tags": [
"Services"
],
"summary": "List Tags",
"description": "List tags for a service by UUID.",
"operationId": "list-tags-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of tags.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Services"
],
"summary": "Create Tag",
"description": "Add tag(s) to a service by UUID.",
"operationId": "create-tag-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"tag_name": {
"type": "string",
"description": "The tag name (min 2 characters). Required if tag_names is not provided."
},
"tag_names": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of tag names (each min 2 characters). Required if tag_name is not provided."
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Tags added successfully.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/tags\/{tag_uuid}": {
"delete": {
"tags": [
"Services"
],
"summary": "Delete Tag",
"description": "Remove a tag from a service by UUID.",
"operationId": "delete-tag-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "tag_uuid",
"in": "path",
"description": "UUID of the tag.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Tag removed."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/tags": {
"get": {
"tags": [
"Tags"
],
"summary": "List",
"description": "List all tags for the current team.",
"operationId": "list-tags",
"responses": {
"200": {
"description": "All tags for the current team.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#\/components\/schemas\/Tag"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/teams": { "\/teams": {
"get": { "get": {
"tags": [ "tags": [
@ -13610,6 +14263,24 @@
}, },
"type": "object" "type": "object"
}, },
"Tag": {
"description": "Tag model",
"properties": {
"uuid": {
"type": "string"
},
"name": {
"type": "string"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
},
"Team": { "Team": {
"description": "Team model", "description": "Team model",
"properties": { "properties": {
@ -13864,6 +14535,10 @@
"name": "Services", "name": "Services",
"description": "Services" "description": "Services"
}, },
{
"name": "Tags",
"description": "Tags"
},
{ {
"name": "Teams", "name": "Teams",
"description": "Teams" "description": "Teams"

View file

@ -293,6 +293,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: { type: string }
description: 'Tags to assign to the application.'
is_preserve_repository_enabled: is_preserve_repository_enabled:
type: boolean type: boolean
default: false default: false
@ -583,6 +587,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: { type: string }
description: 'Tags to assign to the application.'
is_preserve_repository_enabled: is_preserve_repository_enabled:
type: boolean type: boolean
default: false default: false
@ -873,6 +881,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: { type: string }
description: 'Tags to assign to the application.'
is_preserve_repository_enabled: is_preserve_repository_enabled:
type: boolean type: boolean
default: false default: false
@ -1104,6 +1116,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: { type: string }
description: 'Tags to assign to the application.'
type: object type: object
responses: responses:
'201': '201':
@ -1321,6 +1337,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: { type: string }
description: 'Tags to assign to the application.'
type: object type: object
responses: responses:
'201': '201':
@ -2376,6 +2396,121 @@ paths:
security: security:
- -
bearerAuth: [] bearerAuth: []
'/applications/{uuid}/tags':
get:
tags:
- Applications
summary: 'List Tags'
description: 'List tags for an application by UUID.'
operationId: list-tags-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
responses:
'200':
description: 'List of tags.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Applications
summary: 'Create Tag'
description: 'Add tag(s) to an application by UUID.'
operationId: create-tag-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
properties:
tag_name:
type: string
description: 'The tag name (min 2 characters). Required if tag_names is not provided.'
tag_names:
type: array
items: { type: string }
description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.'
type: object
responses:
'201':
description: 'Tags added successfully.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/applications/{uuid}/tags/{tag_uuid}':
delete:
tags:
- Applications
summary: 'Delete Tag'
description: 'Remove a tag from an application by UUID.'
operationId: delete-tag-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: tag_uuid
in: path
description: 'UUID of the tag.'
required: true
schema:
type: string
responses:
'200':
description: 'Tag removed.'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/cloud-tokens: /cloud-tokens:
get: get:
tags: tags:
@ -3244,6 +3379,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3339,6 +3478,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3431,6 +3574,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3526,6 +3673,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3621,6 +3772,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3725,6 +3880,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3829,6 +3988,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3924,6 +4087,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -4636,6 +4803,121 @@ paths:
security: security:
- -
bearerAuth: [] bearerAuth: []
'/databases/{uuid}/tags':
get:
tags:
- Databases
summary: 'List Tags'
description: 'List tags for a database by UUID.'
operationId: list-tags-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
responses:
'200':
description: 'List of tags.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Databases
summary: 'Create Tag'
description: 'Add tag(s) to a database by UUID.'
operationId: create-tag-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
properties:
tag_name:
type: string
description: 'The tag name (min 2 characters). Required if tag_names is not provided.'
tag_names:
type: array
items: { type: string }
description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.'
type: object
responses:
'201':
description: 'Tags added successfully.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/tags/{tag_uuid}':
delete:
tags:
- Databases
summary: 'Delete Tag'
description: 'Remove a tag from a database by UUID.'
operationId: delete-tag-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
-
name: tag_uuid
in: path
description: 'UUID of the tag.'
required: true
schema:
type: string
responses:
'200':
description: 'Tag removed.'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/deployments: /deployments:
get: get:
tags: tags:
@ -7008,6 +7290,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: { type: string }
description: 'Tags to assign to the service.'
type: object type: object
responses: responses:
'201': '201':
@ -7860,6 +8146,144 @@ paths:
security: security:
- -
bearerAuth: [] bearerAuth: []
'/services/{uuid}/tags':
get:
tags:
- Services
summary: 'List Tags'
description: 'List tags for a service by UUID.'
operationId: list-tags-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
responses:
'200':
description: 'List of tags.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Services
summary: 'Create Tag'
description: 'Add tag(s) to a service by UUID.'
operationId: create-tag-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
properties:
tag_name:
type: string
description: 'The tag name (min 2 characters). Required if tag_names is not provided.'
tag_names:
type: array
items: { type: string }
description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.'
type: object
responses:
'201':
description: 'Tags added successfully.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/tags/{tag_uuid}':
delete:
tags:
- Services
summary: 'Delete Tag'
description: 'Remove a tag from a service by UUID.'
operationId: delete-tag-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: tag_uuid
in: path
description: 'UUID of the tag.'
required: true
schema:
type: string
responses:
'200':
description: 'Tag removed.'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/tags:
get:
tags:
- Tags
summary: List
description: 'List all tags for the current team.'
operationId: list-tags
responses:
'200':
description: 'All tags for the current team.'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
/teams: /teams:
get: get:
tags: tags:
@ -8740,6 +9164,18 @@ components:
type: string type: string
description: 'The date and time when the service was deleted.' description: 'The date and time when the service was deleted.'
type: object type: object
Tag:
description: 'Tag model'
properties:
uuid:
type: string
name:
type: string
created_at:
type: string
updated_at:
type: string
type: object
Team: Team:
description: 'Team model' description: 'Team model'
properties: properties:
@ -8911,6 +9347,9 @@ tags:
- -
name: Services name: Services
description: Services description: Services
-
name: Tags
description: Tags
- -
name: Teams name: Teams
description: Teams description: Teams

View file

@ -15,6 +15,7 @@
use App\Http\Controllers\Api\SentinelController; use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\ServicesController;
use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController; use App\Http\Controllers\Api\TeamController;
use App\Http\Middleware\ApiAllowed; use App\Http\Middleware\ApiAllowed;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -108,6 +109,8 @@
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']);
Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']);
Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']);
Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']);
@ -130,6 +133,10 @@
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@ -175,6 +182,10 @@
Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']);
Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@ -198,6 +209,10 @@
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);

View file

@ -0,0 +1,547 @@
<?php
use App\Livewire\Project\Shared\Tags as TagsComponent;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function tagApiAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('GET /api/v1/tags', function () {
test('returns all tags for current team', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
Tag::create(['name' => 'staging', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
$response->assertOk();
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'production']);
$response->assertJsonFragment(['name' => 'staging']);
});
test('returns empty array when no tags exist', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
$response->assertOk();
$response->assertJsonCount(0);
});
test('does not return tags from other teams', function () {
$otherTeam = Team::factory()->create();
Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'my-tag']);
$response->assertJsonMissing(['name' => 'other-team-tag']);
});
});
describe('GET /api/v1/applications/{uuid}/tags', function () {
test('returns tags for an application', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']);
});
test('returns 404 for non-existent application', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/non-existent-uuid/tags');
$response->assertNotFound();
});
test('returns empty array when application has no tags', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(0);
});
});
describe('POST /api/v1/applications/{uuid}/tags', function () {
test('adds a single tag via tag_name', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
]);
$response->assertCreated();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']);
expect($this->application->tags()->count())->toBe(1);
});
test('adds multiple tags via tag_names array', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_names' => ['production', 'frontend'],
]);
$response->assertCreated();
$response->assertJsonCount(2);
expect($this->application->tags()->count())->toBe(2);
});
test('reuses existing team tag instead of creating duplicate', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
]);
$response->assertCreated();
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
});
test('rejects tag_name shorter than 2 characters', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'x',
]);
$response->assertUnprocessable();
});
test('rejects both tag_name and tag_names provided simultaneously', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
'tag_names' => ['staging'],
]);
$response->assertUnprocessable();
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
});
test('skips duplicate tag already on resource', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
]);
$response->assertCreated();
expect($this->application->tags()->count())->toBe(1);
});
test('returns 404 for non-existent application', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/applications/non-existent-uuid/tags', [
'tag_name' => 'production',
]);
$response->assertNotFound();
});
});
describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
test('removes tag from application', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertOk();
expect($this->application->tags()->count())->toBe(0);
});
test('garbage-collects orphaned tag', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
expect(Tag::find($tag->id))->toBeNull();
});
test('returns 404 when deleting a tag that is not attached to the application', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertNotFound();
expect(Tag::find($tag->id))->not->toBeNull();
});
test('keeps tag if still used by other resources', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$otherApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherApp->tags()->attach($tag->id);
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
expect(Tag::find($tag->id))->not->toBeNull();
});
test('returns 404 for non-existent tag', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
$response->assertNotFound();
});
});
describe('GET /api/v1/databases/{uuid}/tags', function () {
test('returns tags for a database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
$database->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/databases/{$database->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'database-tag']);
});
});
describe('POST /api/v1/databases/{uuid}/tags', function () {
test('adds tag to database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/databases/{$database->uuid}/tags", [
'tag_name' => 'database-tag',
]);
$response->assertCreated();
expect($database->tags()->count())->toBe(1);
});
});
describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
test('removes tag from database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
$database->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
$response->assertOk();
expect($database->tags()->count())->toBe(0);
});
});
describe('GET /api/v1/services/{uuid}/tags', function () {
test('returns tags for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
$service->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'service-tag']);
});
});
describe('POST /api/v1/services/{uuid}/tags', function () {
test('adds tag to service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/tags", [
'tag_name' => 'service-tag',
]);
$response->assertCreated();
expect($service->tags()->count())->toBe(1);
});
});
describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
test('removes tag from service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
$service->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
$response->assertOk();
expect($service->tags()->count())->toBe(0);
});
});
describe('Resource creation tags', function () {
test('adds tags while creating a public application', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
'git_branch' => 'main',
'build_pack' => 'static',
'ports_exposes' => '80',
'autogenerate_domain' => false,
'tags' => ['production', 'frontend'],
]);
$response->assertSuccessful();
$application = Application::whereUuid($response->json('uuid'))->firstOrFail();
expect($application->tags()->pluck('name')->sort()->values()->all())
->toBe(['frontend', 'production']);
});
test('adds tags while creating a postgresql database', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/databases/postgresql', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'instant_deploy' => false,
'tags' => ['database', 'production'],
]);
$response->assertSuccessful();
$database = StandalonePostgresql::whereUuid($response->json('uuid'))->firstOrFail();
expect($database->tags()->pluck('name')->sort()->values()->all())
->toBe(['database', 'production']);
});
test('adds tags while creating a docker compose service', function () {
$compose = base64_encode(<<<'YAML'
services:
nginx:
image: nginx:alpine
YAML);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/services', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'docker_compose_raw' => $compose,
'instant_deploy' => false,
'tags' => ['service', 'production'],
]);
$response->assertCreated();
$service = Service::whereUuid($response->json('uuid'))->firstOrFail();
expect($service->tags()->pluck('name')->sort()->values()->all())
->toBe(['production', 'service']);
});
test('rejects creation tags that are too short after sanitization', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/databases/postgresql', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'instant_deploy' => false,
'tags' => ['<b>x</b>'],
]);
$response->assertUnprocessable();
});
});
describe('Tag name sanitization', function () {
test('strips HTML tags from tag names', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => '<script>alert("xss")</script>production',
]);
$response->assertCreated();
$response->assertJsonFragment(['name' => 'alert("xss")production']);
$response->assertJsonMissing(['name' => '<script>']);
});
test('lowercases tag names', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'Production',
]);
$response->assertCreated();
$response->assertJsonFragment(['name' => 'production']);
});
test('rejects tag names that are too short after sanitization', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => '<b>x</b>',
]);
$response->assertUnprocessable();
expect($this->application->tags()->count())->toBe(0);
});
});
describe('Tag uniqueness', function () {
test('prevents duplicate tag names per team at the database level', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
expect(fn () => Tag::create(['name' => 'production', 'team_id' => $this->team->id]))
->toThrow(QueryException::class);
});
});
describe('Tag cleanup across resource types', function () {
test('does not delete a tag still attached to a database when removed from an application in the UI', function () {
$this->actingAs($this->user);
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$database->tags()->attach($tag->id);
Livewire::test(TagsComponent::class, ['resource' => $this->application])
->call('deleteTag', (string) $tag->id);
expect(Tag::find($tag->id))->not->toBeNull()
->and($database->tags()->count())->toBe(1);
});
});
describe('Cross-resource tag isolation', function () {
test('cannot delete tag via wrong resource', function () {
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
$otherApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherApp->tags()->attach($tag->id);
// Tag is not attached to $this->application, but we try to delete via it
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertNotFound();
// Tag should still exist on the other app (detach on non-attached is a no-op)
expect($otherApp->tags()->count())->toBe(1);
// Tag should NOT be garbage-collected since otherApp still uses it
expect(Tag::find($tag->id))->not->toBeNull();
});
});