diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index ab063ca8d..e10bc0658 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -33,6 +33,18 @@
class ApplicationsController extends Controller
{
+ use Concerns\HandlesTagsApi;
+
+ protected function findTaggableResource(string $uuid, int|string $teamId): mixed
+ {
+ return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
+ }
+
+ protected function tagResourceNotFoundMessage(): string
+ {
+ return 'Application not found.';
+ }
+
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
@@ -280,6 +292,7 @@ public function applications(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -447,6 +460,7 @@ public function create_public_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -614,6 +628,7 @@ 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.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -753,6 +768,7 @@ 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.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
],
)
),
@@ -888,6 +904,7 @@ public function create_dockerfile_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
],
)
),
@@ -964,7 +981,7 @@ private function create_application(Request $request, $type)
if ($return instanceof JsonResponse) {
return $return;
}
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -978,6 +995,8 @@ private function create_application(Request $request, $type)
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
'autogenerate_domain' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -995,6 +1014,13 @@ private function create_application(Request $request, $type)
], 422);
}
+ $return = $this->validateTagsParameter($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $tagNames = $request->input('tags') ?? [];
+
$environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
@@ -1252,6 +1278,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1495,6 +1524,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1708,6 +1740,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1832,6 +1867,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1955,6 +1993,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1986,6 +2027,7 @@ private function create_application(Request $request, $type)
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
+
}
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.']);
}
+
+ #[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);
+ }
}
diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
new file mode 100644
index 000000000..4c15d0726
--- /dev/null
+++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
@@ -0,0 +1,174 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index d6622f29e..3cd2f0a73 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -28,6 +28,18 @@
class DatabasesController extends Controller
{
+ use Concerns\HandlesTagsApi;
+
+ protected function findTaggableResource(string $uuid, int|string $teamId): mixed
+ {
+ return queryDatabaseByUuidWithinTeam($uuid, $teamId);
+ }
+
+ protected function tagResourceNotFoundMessage(): string
+ {
+ return 'Database not found.';
+ }
+
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
@@ -1220,6 +1232,7 @@ public function update_backup(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1288,6 +1301,7 @@ public function create_database_postgresql(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1355,6 +1369,7 @@ public function create_database_clickhouse(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1423,6 +1438,7 @@ public function create_database_dragonfly(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1491,6 +1507,7 @@ public function create_database_redis(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1562,6 +1579,7 @@ public function create_database_keydb(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1633,6 +1651,7 @@ public function create_database_mariadb(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1701,6 +1720,7 @@ public function create_database_mysql(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1731,7 +1751,7 @@ public function create_database_mongodb(Request $request)
public function create_database(Request $request, NewDatabaseTypes $type)
{
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -1830,6 +1850,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'instant_deploy' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
]);
if ($validator->failed()) {
return response()->json([
@@ -1837,6 +1859,13 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'errors' => $validator->errors(),
], 422);
}
+ $return = $this->validateTagsParameter($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $tagNames = $request->input('tags') ?? [];
+
if ($request->public_port) {
if ($request->public_port < 1024 || $request->public_port > 65535) {
return response()->json([
@@ -1848,7 +1877,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
}
if ($type === NewDatabaseTypes::POSTGRESQL) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -1896,6 +1925,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
'uuid' => $database->uuid,
@@ -1917,7 +1949,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
$validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string',
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -1964,6 +1996,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
@@ -1986,7 +2021,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -2033,6 +2068,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
@@ -2055,7 +2093,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
@@ -2099,6 +2137,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
@@ -2121,7 +2162,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
@@ -2146,12 +2187,15 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
return response()->json(serializeApiResponse([
'uuid' => $database->uuid,
]))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::KEYDB) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
@@ -2195,6 +2239,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
@@ -2217,7 +2264,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -2241,6 +2288,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
@@ -2263,7 +2313,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
@@ -2309,6 +2359,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
+ }
$database->refresh();
$payload = [
@@ -4342,4 +4395,148 @@ public function delete_storage(Request $request): JsonResponse
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);
+ }
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 24aca896b..e32415e79 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -24,6 +24,18 @@
class ServicesController extends Controller
{
+ use Concerns\HandlesTagsApi;
+
+ protected function findTaggableResource(string $uuid, int|string $teamId): mixed
+ {
+ return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
+ }
+
+ protected function tagResourceNotFoundMessage(): string
+ {
+ return 'Service not found.';
+ }
+
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
@@ -276,6 +288,7 @@ public function services(Request $request)
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
],
),
),
@@ -342,7 +355,7 @@ public function services(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();
if (is_null($teamId)) {
@@ -372,6 +385,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -393,6 +408,11 @@ public function create_service(Request $request)
], 422);
}
+ $return = $this->validateTagsParameter($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
@@ -531,6 +551,10 @@ public function create_service(Request $request)
}
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($service, $request->tags, $teamId);
+ }
+
if ($instantDeploy) {
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);
} elseif (filled($request->docker_compose_raw)) {
- $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -570,6 +594,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -703,6 +729,10 @@ public function create_service(Request $request)
}
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($service, $request->tags, $teamId);
+ }
+
if ($instantDeploy) {
StartService::dispatch($service);
}
@@ -2798,4 +2828,148 @@ public function delete_storage(Request $request): JsonResponse
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);
+ }
}
diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php
new file mode 100644
index 000000000..173a8ab7b
--- /dev/null
+++ b/app/Http/Controllers/Api/TagsController.php
@@ -0,0 +1,61 @@
+ $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(...)));
+ }
+}
diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php
index 37b8b277a..61d04e20b 100644
--- a/app/Livewire/Project/Shared/Tags.php
+++ b/app/Livewire/Project/Shared/Tags.php
@@ -91,9 +91,7 @@ public function deleteTag(string $id)
$this->authorize('update', $this->resource);
$this->resource->tags()->detach($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->delete();
- }
+ $found_more_tags?->deleteIfOrphaned();
$this->refresh();
$this->dispatch('success', 'Tag deleted.');
} catch (\Exception $e) {
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index e6fbd3a06..d5cccabd8 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -3,7 +3,19 @@
namespace App\Models;
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
{
use HasSafeStringAttribute;
@@ -23,6 +35,13 @@ public static function ownedByCurrentTeam()
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()
{
return $this->morphedByMany(Application::class, 'taggable');
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index c75fbc705..f8a2b0d66 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -222,4 +222,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('docker_compose_raw');
+ $request->offsetUnset('tags');
}
diff --git a/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php
new file mode 100644
index 000000000..a25fdc18b
--- /dev/null
+++ b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php
@@ -0,0 +1,76 @@
+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');
+ }
+};
diff --git a/openapi.json b/openapi.json
index d2774dc9e..cccf5a139 100644
--- a/openapi.json
+++ b/openapi.json
@@ -412,6 +412,13 @@
"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": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
+ },
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
@@ -866,6 +873,13 @@
"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": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
+ },
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
@@ -1320,6 +1334,13 @@
"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": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
+ },
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
@@ -1678,6 +1699,13 @@
"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": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
}
},
"type": "object"
@@ -2017,6 +2045,13 @@
"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": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
}
},
"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": {
"get": {
"tags": [
@@ -5018,6 +5226,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5150,6 +5365,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5278,6 +5500,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5410,6 +5639,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5542,6 +5778,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5686,6 +5929,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5830,6 +6080,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5962,6 +6219,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"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": {
"get": {
"tags": [
@@ -11120,6 +11557,13 @@
"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": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the service."
}
},
"type": "object"
@@ -12542,6 +12986,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": {
"get": {
"tags": [
@@ -13762,6 +14415,24 @@
},
"type": "object"
},
+ "Tag": {
+ "description": "Tag model",
+ "properties": {
+ "uuid": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"Team": {
"description": "Team model",
"properties": {
@@ -14016,6 +14687,10 @@
"name": "Services",
"description": "Services"
},
+ {
+ "name": "Tags",
+ "description": "Tags"
+ },
{
"name": "Teams",
"description": "Teams"
diff --git a/openapi.yaml b/openapi.yaml
index b3f014bfd..619eed53c 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -293,6 +293,10 @@ paths:
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: { type: string }
+ description: 'Tags to assign to the application.'
is_preserve_repository_enabled:
type: boolean
default: false
@@ -583,6 +587,10 @@ paths:
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: { type: string }
+ description: 'Tags to assign to the application.'
is_preserve_repository_enabled:
type: boolean
default: false
@@ -873,6 +881,10 @@ paths:
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: { type: string }
+ description: 'Tags to assign to the application.'
is_preserve_repository_enabled:
type: boolean
default: false
@@ -1104,6 +1116,10 @@ paths:
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: { type: string }
+ description: 'Tags to assign to the application.'
type: object
responses:
'201':
@@ -1321,6 +1337,10 @@ paths:
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: { type: string }
+ description: 'Tags to assign to the application.'
type: object
responses:
'201':
@@ -2376,6 +2396,121 @@ paths:
security:
-
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:
get:
tags:
@@ -3244,6 +3379,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3339,6 +3478,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3431,6 +3574,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3526,6 +3673,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3621,6 +3772,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3725,6 +3880,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3829,6 +3988,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3924,6 +4087,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -4636,6 +4803,121 @@ paths:
security:
-
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:
get:
tags:
@@ -7100,6 +7382,10 @@ paths:
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: { type: string }
+ description: 'Tags to assign to the service.'
type: object
responses:
'201':
@@ -7952,6 +8238,144 @@ paths:
security:
-
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:
get:
tags:
@@ -8832,6 +9256,18 @@ components:
type: string
description: 'The date and time when the service was deleted.'
type: object
+ Tag:
+ description: 'Tag model'
+ properties:
+ uuid:
+ type: string
+ name:
+ type: string
+ created_at:
+ type: string
+ updated_at:
+ type: string
+ type: object
Team:
description: 'Team model'
properties:
@@ -9003,6 +9439,9 @@ tags:
-
name: Services
description: Services
+ -
+ name: Tags
+ description: Tags
-
name: Teams
description: Teams
diff --git a/routes/api.php b/routes/api.php
index 7dc426dd3..2564b9389 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -15,6 +15,7 @@
use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServicesController;
+use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController;
use App\Http\Middleware\ApiAllowed;
use Illuminate\Support\Facades\Route;
@@ -110,6 +111,8 @@
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::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']);
@@ -132,6 +135,10 @@
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::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}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@@ -177,6 +184,10 @@
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::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}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@@ -200,6 +211,10 @@
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}/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}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php
new file mode 100644
index 000000000..448d7917f
--- /dev/null
+++ b/tests/Feature/TagApiTest.php
@@ -0,0 +1,547 @@
+ 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' => ['x'],
+ ]);
+
+ $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' => 'production',
+ ]);
+
+ $response->assertCreated();
+ $response->assertJsonFragment(['name' => 'alert("xss")production']);
+ $response->assertJsonMissing(['name' => '