Merge remote-tracking branch 'origin/next' into feature/vultr-cloud-provider

This commit is contained in:
Andras Bacsai
2026-07-07 21:23:07 +02:00
35 changed files with 7215 additions and 113 deletions
@@ -33,6 +33,18 @@ use Symfony\Component\Yaml\Yaml;
class ApplicationsController extends Controller
{
use Concerns\HandlesTagsApi;
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
}
protected function tagResourceNotFoundMessage(): string
{
return 'Application not found.';
}
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
@@ -280,6 +292,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -447,6 +460,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -614,6 +628,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -753,6 +768,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
],
)
),
@@ -888,6 +904,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
],
)
),
@@ -964,7 +981,7 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -978,6 +995,8 @@ class ApplicationsController extends Controller
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
'autogenerate_domain' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -995,6 +1014,13 @@ class ApplicationsController extends Controller
], 422);
}
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
$environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
@@ -1252,6 +1278,9 @@ class ApplicationsController extends Controller
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1495,6 +1524,9 @@ class ApplicationsController extends Controller
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1708,6 +1740,9 @@ class ApplicationsController extends Controller
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1832,6 +1867,9 @@ class ApplicationsController extends Controller
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1955,6 +1993,9 @@ class ApplicationsController extends Controller
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
if ($tagNames !== []) {
$this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1986,6 +2027,7 @@ class ApplicationsController extends Controller
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
}
return response()->json(['message' => 'Invalid type.'], 400);
@@ -3908,6 +3950,99 @@ class ApplicationsController extends Controller
);
}
#[OA\Post(
summary: 'Move',
description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.',
path: '/applications/{uuid}/move',
operationId: 'move-application-by-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(
description: 'Target environment to move the application to.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
],
required: ['environment_uuid'],
)
),
]
),
responses: [
new OA\Response(
response: 200,
description: 'Application moved successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Application moved successfully.'],
'uuid' => ['type' => 'string'],
'project_uuid' => ['type' => 'string'],
'environment_uuid' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
return moveResourceToEnvironment($request, $application, 'Application', $teamId);
}
private function validateDataApplications(Request $request, Server $server)
{
$teamId = getTeamIdFromToken();
@@ -4654,4 +4789,148 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Preview deletion request queued.']);
}
#[OA\Get(
summary: 'List Tags',
description: 'List tags for an application by UUID.',
path: '/applications/{uuid}/tags',
operationId: 'list-tags-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of tags.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function tags(Request $request): JsonResponse
{
return $this->listTags($request);
}
#[OA\Post(
summary: 'Create Tag',
description: 'Add tag(s) to an application by UUID.',
path: '/applications/{uuid}/tags',
operationId: 'create-tag-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
'tag_names' => [
'type' => 'array',
'items' => new OA\Items(type: 'string'),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Tags added successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_tag(Request $request): JsonResponse
{
return $this->createTag($request);
}
#[OA\Delete(
summary: 'Delete Tag',
description: 'Remove a tag from an application by UUID.',
path: '/applications/{uuid}/tags/{tag_uuid}',
operationId: 'delete-tag-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'tag_uuid',
in: 'path',
description: 'UUID of the tag.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag removed.',
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function delete_tag(Request $request): JsonResponse
{
return $this->deleteTag($request);
}
}
@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Http\Controllers\Api\TagsController;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
trait HandlesTagsApi
{
/**
* Find the taggable resource by UUID within the team.
*/
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
/**
* Get the 404 message for the taggable resource.
*/
abstract protected function tagResourceNotFoundMessage(): string;
public function listTags(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('view', $resource);
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
}
public function createTag(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('update', $resource);
if ($request->has('tag_name') && $request->has('tag_names')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
], 422);
}
$validator = Validator::make($request->all(), [
'tag_name' => 'required_without:tag_names|string',
'tag_names' => 'required_without:tag_name|array|min:1',
'tag_names.*' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$this->attachTagsToResource($resource, $tagNames, $teamId);
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
}
public function deleteTag(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('update', $resource);
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
if (! $tag) {
return response()->json(['message' => 'Tag not found.'], 404);
}
if (! $resource->tags()->whereKey($tag->id)->exists()) {
return response()->json(['message' => 'Tag not found on resource.'], 404);
}
$resource->tags()->detach($tag->id);
$tag->deleteIfOrphaned();
return response()->json(['message' => 'Tag removed.']);
}
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
{
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
if (mb_strlen($tagName) < 2) {
continue;
}
$tag = Tag::query()->createOrFirst([
'team_id' => $teamId,
'name' => $tagName,
]);
$resource->tags()->syncWithoutDetaching([$tag->id]);
}
}
protected function validateTagsParameter(Request $request): ?JsonResponse
{
if (! $request->has('tags')) {
return null;
}
$tagNames = $this->normalizeTagNames($request->input('tags', []));
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$request->merge(['tags' => $tagNames]);
return null;
}
protected function normalizeTagNames(array $tagNames): array
{
return collect($tagNames)
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
->unique()
->values()
->all();
}
}
@@ -28,6 +28,18 @@ use OpenApi\Attributes as OA;
class DatabasesController extends Controller
{
use Concerns\HandlesTagsApi;
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
}
protected function tagResourceNotFoundMessage(): string
{
return 'Database not found.';
}
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
@@ -1220,6 +1232,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1288,6 +1301,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1355,6 +1369,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1423,6 +1438,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1491,6 +1507,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1562,6 +1579,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1633,6 +1651,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1701,6 +1720,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1731,7 +1751,7 @@ class DatabasesController extends Controller
public function create_database(Request $request, NewDatabaseTypes $type)
{
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -1830,6 +1850,8 @@ class DatabasesController extends Controller
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'instant_deploy' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]);
if ($validator->failed()) {
return response()->json([
@@ -1837,6 +1859,13 @@ class DatabasesController extends Controller
'errors' => $validator->errors(),
], 422);
}
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
if ($request->public_port) {
if ($request->public_port < 1024 || $request->public_port > 65535) {
return response()->json([
@@ -1848,7 +1877,7 @@ class DatabasesController extends Controller
}
}
if ($type === NewDatabaseTypes::POSTGRESQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -1896,6 +1925,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
'uuid' => $database->uuid,
@@ -1917,7 +1949,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
$validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string',
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -1964,6 +1996,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -1986,7 +2021,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -2033,6 +2068,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -2055,7 +2093,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
@@ -2099,6 +2137,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -2121,7 +2162,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
@@ -2146,12 +2187,15 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
return response()->json(serializeApiResponse([
'uuid' => $database->uuid,
]))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::KEYDB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
@@ -2195,6 +2239,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -2217,7 +2264,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
@@ -2241,6 +2288,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -2263,7 +2313,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
@@ -2309,6 +2359,9 @@ class DatabasesController extends Controller
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -2890,6 +2943,99 @@ class DatabasesController extends Controller
]);
}
#[OA\Post(
summary: 'Move',
description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.',
path: '/databases/{uuid}/move',
operationId: 'move-database-by-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(
description: 'Target environment to move the database to.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
],
required: ['environment_uuid'],
)
),
]
),
responses: [
new OA\Response(
response: 200,
description: 'Database moved successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Database moved successfully.'],
'uuid' => ['type' => 'string'],
'project_uuid' => ['type' => 'string'],
'environment_uuid' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
}
#[OA\Get(
summary: 'Start',
description: 'Start database. `Post` request is also accepted.',
@@ -4342,4 +4488,148 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Storage deleted.']);
}
#[OA\Get(
summary: 'List Tags',
description: 'List tags for a database by UUID.',
path: '/databases/{uuid}/tags',
operationId: 'list-tags-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of tags.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function tags(Request $request): JsonResponse
{
return $this->listTags($request);
}
#[OA\Post(
summary: 'Create Tag',
description: 'Add tag(s) to a database by UUID.',
path: '/databases/{uuid}/tags',
operationId: 'create-tag-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
'tag_names' => [
'type' => 'array',
'items' => new OA\Items(type: 'string'),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Tags added successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_tag(Request $request): JsonResponse
{
return $this->createTag($request);
}
#[OA\Delete(
summary: 'Delete Tag',
description: 'Remove a tag from a database by UUID.',
path: '/databases/{uuid}/tags/{tag_uuid}',
operationId: 'delete-tag-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'tag_uuid',
in: 'path',
description: 'UUID of the tag.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag removed.',
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function delete_tag(Request $request): JsonResponse
{
return $this->deleteTag($request);
}
}
@@ -460,6 +460,195 @@ class HetznerController extends Controller
}
}
#[OA\Get(
summary: 'Get Hetzner Firewalls',
description: 'Get all existing Hetzner firewalls for the current project.',
path: '/hetzner/firewalls',
operationId: 'get-hetzner-firewalls',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner firewalls.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function firewalls(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getFirewalls());
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
}
}
#[OA\Get(
summary: 'Get Hetzner Networks',
description: 'Get all existing Hetzner private networks for the current project.',
path: '/hetzner/networks',
operationId: 'get-hetzner-networks',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner networks.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'ip_range' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function networks(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getNetworks());
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
}
}
#[OA\Post(
summary: 'Create Hetzner Server',
description: 'Create a new server on Hetzner and register it in Coolify.',
@@ -487,7 +676,10 @@ class HetznerController extends Controller
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
],
@@ -545,7 +737,10 @@ class HetznerController extends Controller
'private_key_uuid',
'enable_ipv4',
'enable_ipv6',
'enable_backups',
'hetzner_ssh_key_ids',
'hetzner_firewall_ids',
'hetzner_network_ids',
'cloud_init_script',
'instant_validate',
];
@@ -571,8 +766,13 @@ class HetznerController extends Controller
'private_key_uuid' => 'required|string',
'enable_ipv4' => 'nullable|boolean',
'enable_ipv6' => 'nullable|boolean',
'enable_backups' => 'nullable|boolean',
'hetzner_ssh_key_ids' => 'nullable|array',
'hetzner_ssh_key_ids.*' => 'integer',
'hetzner_firewall_ids' => 'nullable|array',
'hetzner_firewall_ids.*' => 'integer',
'hetzner_network_ids' => 'nullable|array',
'hetzner_network_ids.*' => 'integer',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'instant_validate' => 'nullable|boolean',
]);
@@ -608,13 +808,32 @@ class HetznerController extends Controller
if (is_null($request->enable_ipv6)) {
$request->offsetSet('enable_ipv6', true);
}
if (is_null($request->enable_backups)) {
$request->offsetSet('enable_backups', false);
}
if (is_null($request->hetzner_ssh_key_ids)) {
$request->offsetSet('hetzner_ssh_key_ids', []);
}
if (is_null($request->hetzner_firewall_ids)) {
$request->offsetSet('hetzner_firewall_ids', []);
}
if (is_null($request->hetzner_network_ids)) {
$request->offsetSet('hetzner_network_ids', []);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
}
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'enable_ipv4' => ['Enable at least one public IP protocol.'],
'enable_ipv6' => ['Enable at least one public IP protocol.'],
],
], 422);
}
// Validate cloud provider token
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
@@ -687,6 +906,18 @@ class HetznerController extends Controller
],
];
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
if ($firewallIds !== []) {
$params['firewalls'] = array_map(function (int $firewallId): array {
return ['firewall' => $firewallId];
}, $firewallIds);
}
$networkIds = array_values(array_unique($request->hetzner_network_ids));
if ($networkIds !== []) {
$params['networks'] = $networkIds;
}
// Add cloud-init script if provided
if (! empty($request->cloud_init_script)) {
$params['user_data'] = $request->cloud_init_script;
@@ -723,6 +954,14 @@ class HetznerController extends Controller
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($request->enable_backups) {
try {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
} catch (\Throwable $e) {
report($e);
}
}
// Validate server if requested
if ($request->instant_validate) {
ValidateServer::dispatch($server);
@@ -97,11 +97,6 @@ class SentinelController extends Controller
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
}
return response()->json(['message' => 'ok'], 200);
@@ -0,0 +1,766 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Service\UpdateServiceApplicationFromApi;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class ServiceApplicationsController extends Controller
{
private function removeSensitiveData(ServiceApplication $serviceApplication): array
{
$serviceApplication->makeHidden([
'id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
$serialized = serializeApiResponse($serviceApplication);
if ($serialized instanceof Collection) {
return $serialized->all();
}
return (array) $serialized;
}
private function resolveService(Request $request, int $teamId): ?Service
{
$uuid = $request->route('uuid');
if (! $uuid) {
return null;
}
return Service::whereRelation('environment.project.team', 'id', $teamId)
->whereUuid($uuid)
->first();
}
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
{
$appUuid = $request->route('app_uuid');
if (! $appUuid) {
return null;
}
return $service->applications()
->where('uuid', $appUuid)
->with(['service.destination.server'])
->first();
}
private function swarmNotSupportedResponse(): JsonResponse
{
return response()->json([
'message' => 'This operation is not supported for Swarm servers yet.',
], 501);
}
#[OA\Get(
summary: 'List service applications',
description: 'List compose service applications (containers) for a single service.',
path: '/services/{uuid}/applications',
operationId: 'list-service-applications-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service applications for this service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(type: 'object')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('view', $service);
$items = $service->applications()
->get()
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
return response()->json($items);
}
#[OA\Get(
summary: 'Get service application',
description: 'Get a single compose service application by service UUID and application UUID.',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'get-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Patch(
summary: 'Update service application',
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'patch-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force_domain_override',
in: 'query',
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
requestBody: new OA\RequestBody(
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'url' => new OA\Property(
property: 'url',
type: 'string',
nullable: true,
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
),
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'Updated service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 409,
description: 'Domain conflicts (unless force_domain_override).',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('update', $serviceApplication);
$payload = $request->json()->all();
if (empty($payload)) {
$payload = $request->request->all();
}
$allowedFields = [
'url',
'human_name',
'description',
'image',
'exclude_from_status',
'is_log_drain_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
];
$validationRules = [
'url' => 'nullable|string',
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'nullable|string',
'exclude_from_status' => 'sometimes|boolean',
'is_log_drain_enabled' => 'sometimes|boolean',
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
];
$validator = Validator::make($payload, $validationRules);
$extraFields = array_diff(array_keys($payload), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload);
if ($response instanceof JsonResponse) {
return $response;
}
$serviceApplication->refresh();
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Get(
summary: 'Get service application logs',
description: 'Get Docker logs for a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/logs',
operationId: 'get-service-application-logs-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Logs.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => new OA\Property(property: 'logs', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function logs_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
$status = getContainerStatus($server, $containerName);
if ($status !== 'running') {
return response()->json([
'message' => 'Service application container is not running.',
], 400);
}
$lines = (int) ($request->query('lines', 100) ?: 100);
$logs = getContainerLogs($server, $containerName, $lines);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Get(
summary: 'Start or redeploy service application container',
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
path: '/services/{uuid}/applications/{app_uuid}/start',
operationId: 'start-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force',
in: 'query',
description: 'When true, passes --build to docker compose up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
new OA\Parameter(
name: 'latest',
in: 'query',
description: 'When true, pulls the image for this compose service before up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Deploy request queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_start(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$pullLatest = $request->boolean('latest', false);
$forceRebuild = $request->boolean('force', false);
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
return response()->json([
'message' => 'Service application deploy request queued.',
], 200);
}
#[OA\Get(
summary: 'Restart service application container',
description: 'Restarts a single compose service container (docker restart).',
path: '/services/{uuid}/applications/{app_uuid}/restart',
operationId: 'restart-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Restart queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_restart(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
RestartServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application restart request queued.',
], 200);
}
#[OA\Get(
summary: 'Stop service application container',
description: 'Stops a single compose service container (docker stop).',
path: '/services/{uuid}/applications/{app_uuid}/stop',
operationId: 'stop-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Stop queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_stop(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
StopServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application stop request queued.',
], 200);
}
}
+269 -2
View File
@@ -24,6 +24,18 @@ use Symfony\Component\Yaml\Yaml;
class ServicesController extends Controller
{
use Concerns\HandlesTagsApi;
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
}
protected function tagResourceNotFoundMessage(): string
{
return 'Service not found.';
}
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
@@ -276,6 +288,7 @@ class ServicesController extends Controller
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
],
),
),
@@ -342,7 +355,7 @@ class ServicesController extends Controller
)]
public function create_service(Request $request)
{
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -372,6 +385,8 @@ class ServicesController extends Controller
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -393,6 +408,11 @@ class ServicesController extends Controller
], 422);
}
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
@@ -531,6 +551,10 @@ class ServicesController extends Controller
}
}
if ($request->has('tags')) {
$this->attachTagsToResource($service, $request->tags, $teamId);
}
if ($instantDeploy) {
StartService::dispatch($service);
}
@@ -551,7 +575,7 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -570,6 +594,8 @@ class ServicesController extends Controller
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -703,6 +729,10 @@ class ServicesController extends Controller
}
}
if ($request->has('tags')) {
$this->attachTagsToResource($service, $request->tags, $teamId);
}
if ($instantDeploy) {
StartService::dispatch($service);
}
@@ -1839,6 +1869,99 @@ class ServicesController extends Controller
return response()->json(['message' => 'Environment variable deleted.']);
}
#[OA\Post(
summary: 'Move',
description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.',
path: '/services/{uuid}/move',
operationId: 'move-service-by-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(
description: 'Target environment to move the service to.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
],
required: ['environment_uuid'],
)
),
]
),
responses: [
new OA\Response(
response: 200,
description: 'Service moved successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Service moved successfully.'],
'uuid' => ['type' => 'string'],
'project_uuid' => ['type' => 'string'],
'environment_uuid' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
}
#[OA\Get(
summary: 'Start',
description: 'Start service. `Post` request is also accepted.',
@@ -2798,4 +2921,148 @@ class ServicesController extends Controller
return response()->json(['message' => 'Storage deleted.']);
}
#[OA\Get(
summary: 'List Tags',
description: 'List tags for a service by UUID.',
path: '/services/{uuid}/tags',
operationId: 'list-tags-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of tags.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function tags(Request $request): JsonResponse
{
return $this->listTags($request);
}
#[OA\Post(
summary: 'Create Tag',
description: 'Add tag(s) to a service by UUID.',
path: '/services/{uuid}/tags',
operationId: 'create-tag-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
'tag_names' => [
'type' => 'array',
'items' => new OA\Items(type: 'string'),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Tags added successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_tag(Request $request): JsonResponse
{
return $this->createTag($request);
}
#[OA\Delete(
summary: 'Delete Tag',
description: 'Remove a tag from a service by UUID.',
path: '/services/{uuid}/tags/{tag_uuid}',
operationId: 'delete-tag-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'tag_uuid',
in: 'path',
description: 'UUID of the tag.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag removed.',
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function delete_tag(Request $request): JsonResponse
{
return $this->deleteTag($request);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class TagsController extends Controller
{
public static function serializeTag(Tag $tag): array
{
return [
'uuid' => $tag->uuid,
'name' => $tag->name,
'created_at' => $tag->created_at,
'updated_at' => $tag->updated_at,
];
}
#[OA\Get(
summary: 'List',
description: 'List all tags for the current team.',
path: '/tags',
operationId: 'list-tags',
security: [
['bearerAuth' => []],
],
tags: ['Tags'],
responses: [
new OA\Response(
response: 200,
description: 'All tags for the current team.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
]
)]
public function tags(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
return response()->json($tags->map(self::serializeTag(...)));
}
}