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
commit 31f904ef9f
35 changed files with 7215 additions and 113 deletions

View file

@ -0,0 +1,65 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
class DeployServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
{
$service = $serviceApplication->service;
$composeServiceName = $serviceApplication->name;
$service->parse();
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$workdir = $service->workdir();
$composeFile = "{$workdir}/docker-compose.yml";
$safeWorkdir = escapeshellarg($workdir);
$safeComposeFile = escapeshellarg($composeFile);
$safeProjectName = escapeshellarg($service->uuid);
$safeComposeServiceName = escapeshellarg($composeServiceName);
$commands = collect([
'echo '.escapeshellarg("Saved configuration files to {$workdir}."),
'touch '.escapeshellarg("{$workdir}/.env"),
]);
if ($pullLatestImages) {
$commands->push('echo Pulling image for service.');
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}");
}
if ($service->networks()->count() > 0) {
$commands->push('echo Creating Docker network.');
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
}
$upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps";
if ($forceRebuild) {
$upCommand .= ' --build';
}
$upCommand .= " {$safeComposeServiceName}";
$commands->push('echo Starting service container.');
$commands->push($upCommand);
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
if (data_get($service, 'connect_to_docker_network')) {
$network = escapeshellarg($service->destination->network);
$containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}");
$networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}");
$commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true");
}
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
class RestartServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
instant_remote_process([
"docker restart {$containerName}",
], $server);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
class StopServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
instant_remote_process([
"docker stop {$containerName}",
], $server);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use App\Support\ServiceComposeUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UpdateServiceApplicationFromApi
{
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse
{
$forceDomainOverride = $request->boolean('force_domain_override');
if (array_key_exists('url', $payload)) {
$urlRaw = $payload['url'];
if ($urlRaw !== null && ! is_string($urlRaw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['url' => 'The url must be a string.'],
], 422);
}
$parsed = ServiceComposeUrl::validateUrlString(
is_string($urlRaw) ? $urlRaw : null,
$forceDomainOverride
);
if (count($parsed['errors']) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $parsed['errors'],
], 422);
}
if ($parsed['normalized'] !== null) {
$containerUrls = str($parsed['normalized'])
->explode(',')
->map(fn ($url) => str(trim((string) $url))->lower());
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [$result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $forceDomainOverride) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$serviceApplication->fqdn = $parsed['normalized'];
}
if (array_key_exists('human_name', $payload)) {
$serviceApplication->human_name = $payload['human_name'];
}
if (array_key_exists('description', $payload)) {
$serviceApplication->description = $payload['description'];
}
if (array_key_exists('image', $payload)) {
$serviceApplication->image = $payload['image'];
}
if (array_key_exists('exclude_from_status', $payload)) {
$serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_gzip_enabled', $payload)) {
$serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_stripprefix_enabled', $payload)) {
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server;
if ($enabled && ! $server->isLogDrainEnabled()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'],
],
], 422);
}
$serviceApplication->is_log_drain_enabled = $enabled;
}
$serviceApplication->save();
$serviceApplication->refresh();
updateCompose($serviceApplication);
return null;
}
}

View file

@ -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);
@ -3908,6 +3950,99 @@ public function action_restart(Request $request)
);
}
#[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 @@ 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);
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Http\Controllers\Api\TagsController;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
trait HandlesTagsApi
{
/**
* Find the taggable resource by UUID within the team.
*/
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
/**
* Get the 404 message for the taggable resource.
*/
abstract protected function tagResourceNotFoundMessage(): string;
public function listTags(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('view', $resource);
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
}
public function createTag(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('update', $resource);
if ($request->has('tag_name') && $request->has('tag_names')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
], 422);
}
$validator = Validator::make($request->all(), [
'tag_name' => 'required_without:tag_names|string',
'tag_names' => 'required_without:tag_name|array|min:1',
'tag_names.*' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$this->attachTagsToResource($resource, $tagNames, $teamId);
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
}
public function deleteTag(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('update', $resource);
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
if (! $tag) {
return response()->json(['message' => 'Tag not found.'], 404);
}
if (! $resource->tags()->whereKey($tag->id)->exists()) {
return response()->json(['message' => 'Tag not found on resource.'], 404);
}
$resource->tags()->detach($tag->id);
$tag->deleteIfOrphaned();
return response()->json(['message' => 'Tag removed.']);
}
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
{
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
if (mb_strlen($tagName) < 2) {
continue;
}
$tag = Tag::query()->createOrFirst([
'team_id' => $teamId,
'name' => $tagName,
]);
$resource->tags()->syncWithoutDetaching([$tag->id]);
}
}
protected function validateTagsParameter(Request $request): ?JsonResponse
{
if (! $request->has('tags')) {
return null;
}
$tagNames = $this->normalizeTagNames($request->input('tags', []));
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$request->merge(['tags' => $tagNames]);
return null;
}
protected function normalizeTagNames(array $tagNames): array
{
return collect($tagNames)
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
->unique()
->values()
->all();
}
}

View file

@ -28,6 +28,18 @@
class DatabasesController extends Controller
{
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 = [
@ -2890,6 +2943,99 @@ public function list_backup_executions(Request $request)
]);
}
#[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 @@ 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);
}
}

View file

@ -460,6 +460,195 @@ public function sshKeys(Request $request)
}
}
#[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 @@ public function sshKeys(Request $request)
'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 @@ public function createServer(Request $request)
'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 @@ public function createServer(Request $request)
'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 @@ public function createServer(Request $request)
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 @@ public function createServer(Request $request)
],
];
$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 @@ public function createServer(Request $request)
$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);

View file

@ -97,11 +97,6 @@ public function push(Request $request)
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);

View file

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

View file

@ -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);
}
@ -1839,6 +1869,99 @@ public function delete_env_by_uuid(Request $request)
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 @@ 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);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class TagsController extends Controller
{
public static function serializeTag(Tag $tag): array
{
return [
'uuid' => $tag->uuid,
'name' => $tag->name,
'created_at' => $tag->created_at,
'updated_at' => $tag->updated_at,
];
}
#[OA\Get(
summary: 'List',
description: 'List all tags for the current team.',
path: '/tags',
operationId: 'list-tags',
security: [
['bearerAuth' => []],
],
tags: ['Tags'],
responses: [
new OA\Response(
response: 200,
description: 'All tags for the current team.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
]
)]
public function tags(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
return response()->json($tags->map(self::serializeTag(...)));
}
}

View file

@ -91,9 +91,7 @@ public function deleteTag(string $id)
$this->authorize('update', $this->resource);
$this->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) {

View file

@ -46,6 +46,10 @@ class ByHetzner extends Component
public array $hetznerSshKeys = [];
public array $hetznerFirewalls = [];
public array $hetznerNetworks = [];
public ?string $selected_location = null;
public ?int $selected_image = null;
@ -54,6 +58,10 @@ class ByHetzner extends Component
public array $selectedHetznerSshKeyIds = [];
public array $selectedHetznerFirewallIds = [];
public array $selectedHetznerNetworkIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
@ -64,6 +72,10 @@ class ByHetzner extends Component
public bool $enable_ipv6 = true;
public bool $enable_backups = false;
public bool $show_cloud_init_script = false;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
@ -112,10 +124,15 @@ public function resetSelection()
{
$this->selected_token_id = null;
$this->current_step = 1;
$this->enable_backups = false;
$this->cloud_init_script = null;
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
$this->show_cloud_init_script = false;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
}
public function loadTokens()
@ -164,8 +181,14 @@ protected function rules(): array
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedHetznerSshKeyIds' => 'nullable|array',
'selectedHetznerSshKeyIds.*' => 'integer',
'selectedHetznerFirewallIds' => 'nullable|array',
'selectedHetznerFirewallIds.*' => 'integer',
'selectedHetznerNetworkIds' => 'nullable|array',
'selectedHetznerNetworkIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
'enable_backups' => 'required|boolean',
'show_cloud_init_script' => 'boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
@ -245,6 +268,9 @@ public function previousStep()
private function loadHetznerData(string $token)
{
$this->loading_data = true;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
try {
$hetznerService = new HetznerService($token);
@ -274,6 +300,14 @@ private function loadHetznerData(string $token)
->toArray();
// Load SSH keys from Hetzner
$this->hetznerSshKeys = $hetznerService->getSshKeys();
$this->hetznerFirewalls = collect($hetznerService->getFirewalls())
->sortBy('name')
->values()
->toArray();
$this->hetznerNetworks = collect($hetznerService->getNetworks())
->sortBy('name')
->values()
->toArray();
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
@ -349,6 +383,37 @@ public function getAvailableImagesProperty()
return $filtered;
}
public function getAvailableNetworksProperty(): array
{
$attachableNetworks = collect($this->hetznerNetworks)
->filter(function (array $network) {
return collect($network['subnets'] ?? [])->contains(function (array $subnet) {
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true);
});
});
if (! $this->selected_location) {
return $attachableNetworks->values()->toArray();
}
$location = collect($this->locations)->firstWhere('name', $this->selected_location);
$networkZone = $location['network_zone'] ?? null;
if (! $networkZone) {
return $attachableNetworks->values()->toArray();
}
return $attachableNetworks
->filter(function (array $network) use ($networkZone) {
return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) {
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true)
&& ($subnet['network_zone'] ?? null) === $networkZone;
});
})
->values()
->toArray();
}
public function getSelectedServerPriceProperty(): ?string
{
if (! $this->selected_server_type) {
@ -366,11 +431,74 @@ public function getSelectedServerPriceProperty(): ?string
return '€'.number_format($price, 2);
}
public function getSelectedServerBackupSurchargeProperty(): ?string
{
if (! $this->selected_server_type) {
return null;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
return null;
}
$price = (float) $serverType['prices'][0]['price_monthly']['gross'];
return '€'.number_format($price * 0.2, 2);
}
public function getAdvancedHetznerOptionsSummaryProperty(): array
{
$summary = [];
if (count($this->selectedHetznerSshKeyIds) > 0) {
$summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds));
}
if (count($this->selectedHetznerFirewallIds) > 0) {
$summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds));
}
if (count($this->selectedHetznerNetworkIds) > 0) {
$summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds));
}
if ($this->enable_backups) {
$summary[] = 'Backups on';
}
if (! $this->enable_ipv4 || ! $this->enable_ipv6) {
$summary[] = collect([
$this->enable_ipv4 ? 'IPv4' : null,
$this->enable_ipv6 ? 'IPv6' : null,
])->filter()->join(' + ') ?: 'No public IP';
}
if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) {
$summary[] = 'Cloud-init';
}
return $summary;
}
public function showCloudInitScript(): void
{
$this->show_cloud_init_script = true;
}
public function updatedSelectedLocation($value)
{
// Reset server type and image when location changes
$this->selected_server_type = null;
$this->selected_image = null;
$this->selectedHetznerNetworkIds = array_values(array_filter(
$this->selectedHetznerNetworkIds,
function (int $selectedNetworkId): bool {
return collect($this->availableNetworks)->contains('id', $selectedNetworkId);
}
));
}
public function updatedSelectedServerType($value)
@ -390,6 +518,14 @@ public function updatedSelectedCloudInitScriptId($value)
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
$this->show_cloud_init_script = true;
}
}
public function updatedSaveCloudInitScript(bool $value): void
{
if (! $value) {
$this->cloud_init_script_name = null;
}
}
@ -399,12 +535,11 @@ public function clearCloudInitScript()
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
$this->show_cloud_init_script = false;
}
private function createHetznerServer(string $token): array
private function createHetznerServer(HetznerService $hetznerService): array
{
$hetznerService = new HetznerService($token);
// Get the private key and extract public key
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
@ -458,6 +593,18 @@ private function createHetznerServer(string $token): array
],
];
$firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds));
if ($firewallIds !== []) {
$params['firewalls'] = array_map(function (int $firewallId): array {
return ['firewall' => $firewallId];
}, $firewallIds);
}
$networkIds = array_values(array_unique($this->selectedHetznerNetworkIds));
if ($networkIds !== []) {
$params['networks'] = $networkIds;
}
// Add cloud-init script if provided
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
@ -473,6 +620,13 @@ public function submit()
{
$this->validate();
if (! $this->enable_ipv4 && ! $this->enable_ipv6) {
$this->addError('enable_ipv4', 'Enable at least one public IP protocol.');
$this->addError('enable_ipv6', 'Enable at least one public IP protocol.');
return null;
}
try {
$this->authorize('create', Server::class);
@ -492,9 +646,10 @@ public function submit()
}
$hetznerToken = $this->getHetznerToken();
$hetznerService = new HetznerService($hetznerToken);
// Create server on Hetzner
$hetznerServer = $this->createHetznerServer($hetznerToken);
$hetznerServer = $this->createHetznerServer($hetznerService);
// Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null;
@ -524,6 +679,14 @@ public function submit()
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->enable_backups) {
try {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
} catch (\Throwable $e) {
report($e);
}
}
if ($this->from_onboarding) {
// Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([

View file

@ -47,6 +47,11 @@ public static function ownedByCurrentTeam()
return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name');
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name');
}
public function isEmpty()
{
return $this->applications()->count() == 0 &&

View file

@ -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');

View file

@ -32,6 +32,14 @@ public function update(User $user, ServiceApplication $serviceApplication): bool
return Gate::allows('update', $serviceApplication->service);
}
/**
* Determine whether the user can deploy or run lifecycle actions on the parent service stack.
*/
public function deploy(User $user, ServiceApplication $serviceApplication): bool
{
return Gate::allows('deploy', $serviceApplication->service);
}
/**
* Determine whether the user can delete the model.
*/

View file

@ -118,6 +118,16 @@ public function getSshKeys(): array
return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
}
public function getFirewalls(): array
{
return $this->requestPaginated('get', '/firewalls', 'firewalls');
}
public function getNetworks(): array
{
return $this->requestPaginated('get', '/networks', 'networks');
}
public function uploadSshKey(string $name, string $publicKey): array
{
$response = $this->request('post', '/ssh_keys', [
@ -136,6 +146,13 @@ public function createServer(array $params): array
return $response['server'] ?? [];
}
public function enableServerBackup(int $serverId): array
{
$response = $this->request('post', "/servers/{$serverId}/actions/enable_backup");
return $response['action'] ?? [];
}
public function getServer(int $serverId): array
{
$response = $this->request('get', "/servers/{$serverId}");

View file

@ -0,0 +1,55 @@
<?php
namespace App\Support;
class ServiceComposeUrl
{
/**
* Validate a comma-separated URL string like `urls[].url` on PATCH /services/{uuid}.
*
* @return array{errors: array<int, string>, normalized: ?string}
*/
public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array
{
$errors = [];
if ($urlValue === null || $urlValue === '') {
return ['errors' => [], 'normalized' => null];
}
$urls = str($urlValue)
->replaceStart(',', '')
->replaceEnd(',', '')
->trim()
->explode(',')
->map(fn ($url) => trim((string) $url))
->filter();
foreach ($urls as $url) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
}
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
$errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.';
}
if (count($errors) > 0) {
return ['errors' => $errors, 'normalized' => null];
}
$normalized = $urls
->map(fn ($u) => str($u)->lower()->value())
->unique()
->filter(fn ($u) => filled($u))
->implode(',');
return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null];
}
}

View file

@ -3,11 +3,15 @@
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Environment;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
function getTeamIdFromToken()
@ -172,6 +176,64 @@ function sharedDataApplications()
];
}
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
$validator = Validator::make($request->all(), [
'environment_uuid' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$extraFields = array_diff(array_keys($request->all()), ['environment_uuid']);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
$newEnvironment = Environment::ownedByCurrentTeamAPI($teamId)
->whereUuid($request->environment_uuid)
->first();
if (! $newEnvironment) {
return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404);
}
Gate::authorize('update', $newEnvironment);
if ($resource->environment_id === $newEnvironment->id) {
return response()->json(['message' => "$resourceType is already in this environment."], 400);
}
$oldEnvironment = $resource->environment()->with('project')->first();
$resource->update(['environment_id' => $newEnvironment->id]);
auditLog('api.'.str($resourceType)->lower()->value().'.moved', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_project_uuid' => $oldEnvironment?->project?->uuid,
'from_environment_uuid' => $oldEnvironment?->uuid,
'to_project_uuid' => $newEnvironment->project->uuid,
'to_environment_uuid' => $newEnvironment->uuid,
]);
return response()->json([
'message' => "$resourceType moved successfully.",
'uuid' => $resource->uuid,
'project_uuid' => $newEnvironment->project->uuid,
'environment_uuid' => $newEnvironment->uuid,
]);
}
function validateIncomingRequest(Request $request)
{
// check if request is json
@ -222,4 +284,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');
}

View file

@ -178,13 +178,18 @@ function executeInDocker(string $containerId, string $command)
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
}
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
function buildContainerStatusCommand(Server $server, string $container_id): string
{
if ($server->isSwarm()) {
$container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError);
} else {
$container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError);
return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' ";
}
return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id);
}
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
{
$container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError);
if (! $container) {
return 'exited';
}

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -289,7 +289,7 @@ @utility info-helper {
}
@utility info-helper-popup {
@apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words;
@apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words;
}
@utility buyme {

View file

@ -1,3 +1,9 @@
@props([
'inline' => false,
'triggerClass' => '',
'panelClass' => '',
])
<div x-data="{
dropdownOpen: false,
panelStyles: '',
@ -9,7 +15,7 @@
this.dropdownOpen = false;
},
updatePanelPosition() {
if (window.innerWidth >= 768) {
if ({{ $inline ? 'true' : 'false' }} || window.innerWidth >= 768) {
this.panelStyles = '';
return;
@ -37,9 +43,13 @@
this.panelStyles = `position: fixed; left: ${left}px; top: ${top}px;`;
});
}
}" class="relative" @click.outside="close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
<button x-ref="trigger" @click="dropdownOpen ? close() : open()"
class="inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none">
}" @class(['relative', 'w-full' => $inline]) @click.outside="if (! {{ $inline ? 'true' : 'false' }}) close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
<button type="button" x-ref="trigger" @click="dropdownOpen ? close() : open()"
@class([
'inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none',
'w-full border border-neutral-300 bg-white px-3 py-2 text-left dark:border-coolgray-300 dark:bg-coolgray-100' => $inline,
$triggerClass,
])>
<span class="flex flex-col items-start h-full leading-none">
{{ $title }}
</span>
@ -50,11 +60,18 @@ class="inline-flex items-center justify-start pr-8 transition-colors focus:outli
</svg>
</button>
<div x-ref="panel" x-show="dropdownOpen" @click.away="close()" x-transition:enter="ease-out duration-200"
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
:style="panelStyles" class="absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6" x-cloak>
<div
class="border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200">
:style="panelStyles" @class([
'mt-1 w-full' => $inline,
'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline,
]) x-cloak>
<div @class([
'border border-neutral-300 bg-white p-1 dark:border-coolgray-300',
'shadow-sm dark:bg-coolgray-200' => ! $inline,
'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline,
$panelClass,
])>
{{ $slot }}
</div>
</div>

View file

@ -1,5 +1,5 @@
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
{{ $attributes->merge(['class' => 'group']) }}>
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
<div class="info-helper">
@isset($icon)
{{ $icon }}

View file

@ -73,7 +73,7 @@
@if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
({{ $serverType['cpu_vendor_info'] }})
@endif
, {{ $serverType['memory'] }}GB RAM,
, {{ $serverType['memory'] }}GB RAM,
{{ $serverType['disk'] }}GB
@if (isset($serverType['architecture']))
[{{ $serverType['architecture'] }}]
@ -135,58 +135,118 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
</p>
@endif
</div>
<div>
<x-forms.datalist label="Additional SSH Keys (from Hetzner)" id="selectedHetznerSshKeyIds"
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in Hetzner account'">
@foreach ($hetznerSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="flex gap-4">
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
helper="Enable public IPv4 address for this server" />
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
<x-slot:title>
<span class="text-sm font-medium">Advanced Hetzner options</span>
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, firewalls, private networks, backups, and cloud-init.</span>
@if (count($this->advancedHetznerOptionsSummary) > 0)
<span class="mt-1 flex flex-wrap gap-1.5">
@foreach ($this->advancedHetznerOptionsSummary as $summaryItem)
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
{{ $summaryItem }}
</span>
@endforeach
</span>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
rows="8" />
</x-slot>
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
<div class="flex w-full flex-col gap-4 p-3">
<div>
<x-forms.datalist label="Extra SSH Keys" id="selectedHetznerSshKeyIds"
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in Hetzner account'">
@foreach ($hetznerSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.datalist label="Firewalls" id="selectedHetznerFirewallIds"
helper="Optionally apply existing Hetzner firewalls when the server is created."
:multiple="true" :disabled="count($hetznerFirewalls) === 0" :placeholder="count($hetznerFirewalls) > 0
? 'Search and select firewalls...'
: 'No firewalls found in Hetzner account'">
@foreach ($hetznerFirewalls as $firewall)
<option value="{{ $firewall['id'] }}">
{{ $firewall['name'] }}
@if (isset($firewall['rules']))
- {{ count($firewall['rules']) }} rules
@endif
</option>
@endforeach
</x-forms.datalist>
<x-forms.datalist label="Private Networks" id="selectedHetznerNetworkIds"
helper="Optionally attach one or more private networks. Networks are filtered to the selected location's network zone when possible."
:multiple="true" :disabled="count($this->availableNetworks) === 0" :placeholder="count($this->availableNetworks) > 0
? 'Search and select networks...'
: 'No compatible networks found'">
@foreach ($this->availableNetworks as $network)
<option value="{{ $network['id'] }}">
{{ $network['name'] }} - {{ $network['ip_range'] }}
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
helper="Enable public IPv4 address for this server" fullWidth />
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" fullWidth />
<x-forms.checkbox id="enable_backups" label="Enable Hetzner Backups" fullWidth
helper="Hetzner bills backups at an additional 20% of the server monthly fee{{ $this->selectedServerBackupSurcharge ? ' (about ' . $this->selectedServerBackupSurcharge . '/mo for the selected server type)' : '' }}." />
</div>
<div class="flex flex-col gap-2">
@if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id))
<div>
<x-forms.button type="button" wire:click="showCloudInitScript">
Add cloud-init script
</x-forms.button>
</div>
@else
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@else
<x-forms.button type="button" wire:click="clearCloudInitScript">
Remove
</x-forms.button>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
@if ($save_cloud_init_script)
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
@endif
</div>
@endif
</div>
</div>
</div>
</x-dropdown>
<div class="flex gap-2 justify-between">
<x-forms.button type="button" wire:click="previousStep">
@ -201,4 +261,4 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
@endif
@endif
@endif
</div>
</div>

View file

@ -14,7 +14,9 @@
use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServiceApplicationsController;
use App\Http\Controllers\Api\ServicesController;
use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController;
use App\Http\Controllers\Api\VultrController;
use App\Http\Middleware\ApiAllowed;
@ -105,6 +107,8 @@
Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']);
Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']);
Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']);
Route::get('/hetzner/firewalls', [HetznerController::class, 'firewalls'])->middleware(['api.ability:read']);
Route::get('/hetzner/networks', [HetznerController::class, 'networks'])->middleware(['api.ability:read']);
Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']);
Route::get('/vultr/regions', [VultrController::class, 'regions'])->middleware(['api.ability:read']);
@ -115,6 +119,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']);
@ -137,6 +143,11 @@
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::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->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']);
@ -182,6 +193,11 @@
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::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->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']);
@ -205,10 +221,23 @@
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::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->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']);
Route::get('/services/{uuid}/applications', [ServiceApplicationsController::class, 'index'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'update'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/start', [ServiceApplicationsController::class, 'action_start'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);

View file

@ -6,14 +6,22 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('cache.default', 'array');
config()->set('app.maintenance.driver', 'file');
config()->set('app.maintenance.store', 'array');
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings = new InstanceSettings([
'is_api_enabled' => true,
'is_registration_enabled' => true,
]);
$settings->id = 0;
$settings->save();
Once::flush();
@ -29,15 +37,25 @@
$this->bearerToken = $this->token->plainTextToken;
// Create a Hetzner cloud provider token
$this->hetznerToken = CloudProviderToken::factory()->create([
$this->hetznerToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Test Hetzner Token',
'token' => 'test-hetzner-api-token',
]);
// Create a private key
$this->privateKey = PrivateKey::factory()->create([
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Key',
'description' => 'Test SSH key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
]);
});
@ -239,17 +257,105 @@
});
});
describe('GET /api/v1/hetzner/firewalls', function () {
test('gets Hetzner firewalls', function () {
Http::fake([
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'firewalls' => [
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
['id' => 39, 'name' => 'ssh-firewall', 'rules' => []],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertSuccessful();
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'web-firewall']);
});
test('member read token cannot use a stored cloud provider token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake([
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'firewalls' => [['id' => 38, 'name' => 'web-firewall']],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
});
});
describe('GET /api/v1/hetzner/networks', function () {
test('gets Hetzner networks', function () {
Http::fake([
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'networks' => [
['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []],
['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertSuccessful();
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'private-eu']);
});
test('member read token cannot use a stored cloud provider token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake([
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'networks' => [['id' => 456, 'name' => 'private-eu']],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
});
});
describe('POST /api/v1/servers/hetzner', function () {
test('creates a Hetzner server', function () {
// Mock Hetzner API calls
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@ -289,15 +395,134 @@
]);
});
test('enables backups after creating a Hetzner server when requested', function () {
Http::fake(function (HttpRequest $request) {
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
return Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
return Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
return Http::response([
'server' => [
'id' => 456,
'name' => 'test-server',
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
return Http::response([
'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'],
], 201);
}
return Http::response([], 404);
});
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => true,
'enable_ipv6' => true,
'enable_backups' => true,
]);
$response->assertStatus(201);
Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST'
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup');
});
test('registers server when backup enablement fails after Hetzner creation', function () {
Http::fake(function (HttpRequest $request) {
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
return Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
return Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
return Http::response([
'server' => [
'id' => 456,
'name' => 'test-server',
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
return Http::response([
'error' => ['message' => 'backup unavailable'],
], 500);
}
return Http::response([], 404);
});
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => true,
'enable_ipv6' => true,
'enable_backups' => true,
]);
$response->assertCreated();
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'team_id' => $this->team->id,
'hetzner_server_id' => 456,
]);
});
test('generates server name if not provided', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@ -331,13 +556,10 @@
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', []);
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'cloud_provider_token_id',
'location',
'server_type',
'image',
'private_key_uuid',
$response->assertStatus(400);
$response->assertJson([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
]);
});
@ -375,13 +597,13 @@
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@ -412,13 +634,13 @@
test('uses IPv6 when only IPv6 is enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@ -447,6 +669,75 @@
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('rejects server creation when both public IP protocols are disabled', function () {
Http::fake();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => false,
'enable_ipv6' => false,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['enable_ipv4', 'enable_ipv6']);
Http::assertNothingSent();
});
test('passes selected firewalls and networks to Hetzner server creation', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'hetzner_firewall_ids' => [38, 39],
'hetzner_network_ids' => [456, 457, 456],
]);
$response->assertCreated();
Http::assertSent(function (HttpRequest $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://api.hetzner.cloud/v1/servers'
&& $request['networks'] === [456, 457]
&& $request['firewalls'] === [
['firewall' => 38],
['firewall' => 39],
];
});
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
@ -545,4 +836,38 @@
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('firewalls endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('networks endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
});

View file

@ -0,0 +1,314 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['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 = $this->project->environments()->first();
$this->targetProject = Project::factory()->create(['team_id' => $this->team->id]);
$this->targetEnvironment = $this->targetProject->environments()->first();
});
describe('POST /api/v1/applications/{uuid}/move', function () {
test('moves application to another environment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Application moved successfully.']);
$response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']);
$application->refresh();
expect($application->environment_id)->toBe($this->targetEnvironment->id);
});
test('writes audit log when application is moved', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('api.application.moved', Mockery::on(function (array $context) use ($application) {
return $context['event'] === 'api.application.moved'
&& $context['resource_uuid'] === $application->uuid
&& $context['resource_type'] === 'application'
&& $context['from_environment_uuid'] === $this->environment->uuid
&& $context['to_environment_uuid'] === $this->targetEnvironment->uuid
&& $context['from_project_uuid'] === $this->project->uuid
&& $context['to_project_uuid'] === $this->targetProject->uuid;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
$this->actingAs($this->user);
$request = Request::create('/', 'POST', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response = moveResourceToEnvironment($request, $application, 'Application', $this->team->id);
expect($response->getStatusCode())->toBe(200);
});
test('returns 404 when application not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/applications/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
test('returns 422 when environment_uuid is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", []);
$response->assertStatus(422);
});
test('returns 422 when extra fields are provided', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
'bogus_field' => 'value',
]);
$response->assertStatus(422);
});
test('returns 404 when target environment belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $otherEnvironment->uuid,
]);
$response->assertStatus(404);
});
test('authorizes the target environment before moving', function () {
$this->actingAs($this->user);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Gate::before(function (User $user, string $ability, array $arguments) {
$target = $arguments[0] ?? null;
if ($ability === 'update' && $target instanceof Environment && $target->is($this->targetEnvironment)) {
return false;
}
return null;
});
$request = Request::create('/', 'POST', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
expect(fn () => moveResourceToEnvironment($request, $application, 'Application', $this->team->id))
->toThrow(AuthorizationException::class);
$application->refresh();
expect($application->environment_id)->toBe($this->environment->id);
});
test('returns 400 when application is already in the target environment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->environment->uuid,
]);
$response->assertStatus(400);
});
test('preserves resource-level environment variables after move', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => 'test-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$application->refresh();
$envVar = $application->environment_variables->where('key', 'TEST_VAR')->first();
expect($envVar)->not->toBeNull();
expect($envVar->value)->toBe('test-value');
});
});
describe('POST /api/v1/databases/{uuid}/move', function () {
test('moves database to another environment', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_user' => 'postgres',
'postgres_password' => 'secret',
'postgres_db' => 'testdb',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Database moved successfully.']);
$database->refresh();
expect($database->environment_id)->toBe($this->targetEnvironment->id);
});
test('returns 404 when database not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
});
describe('POST /api/v1/services/{uuid}/move', function () {
test('moves service to another environment', 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([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service moved successfully.']);
$service->refresh();
expect($service->environment_id)->toBe($this->targetEnvironment->id);
});
test('returns 404 when service not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/services/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
});

View file

@ -996,6 +996,43 @@
});
});
describe('service application lifecycle command escaping', function () {
test('service application deploy action escapes docker command arguments', function () {
$source = file_get_contents(app_path('Actions/Service/DeployServiceApplication.php'));
expect($source)->toContain('escapeshellarg($workdir)')
->and($source)->toContain('escapeshellarg($composeFile)')
->and($source)->toContain('escapeshellarg($service->uuid)')
->and($source)->toContain('escapeshellarg($composeServiceName)')
->and($source)->toContain('escapeshellarg($service->destination->network)');
});
test('service application restart and stop actions escape container names', function (string $path) {
$source = file_get_contents(app_path($path));
expect($source)->toContain('escapeshellarg($serviceApplication->name');
})->with([
'restart action' => 'Actions/Service/RestartServiceApplication.php',
'stop action' => 'Actions/Service/StopServiceApplication.php',
]);
test('docker status helper escapes container names', function () {
$source = file_get_contents(base_path('bootstrap/helpers/docker.php'));
expect($source)->toContain('function buildContainerStatusCommand')
->and($source)->toContain('escapeshellarg("name={$container_id}")')
->and($source)->toContain('escapeshellarg($container_id)');
});
test('service application logs endpoint passes raw container name to docker helpers', function () {
$source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php'));
expect($source)->toContain('getContainerStatus($server, $containerName)')
->and($source)->toContain('getContainerLogs($server, $containerName, $lines)')
->and($source)->not->toContain('$safeContainerName = escapeshellarg($containerName)');
});
});
describe('install/build/start command validation', function () {
test('rejects semicolon injection in install_command', function () {
$rules = sharedDataApplications();

View file

@ -1,9 +1,12 @@
<?php
use App\Livewire\Server\New\ByHetzner;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
// Note: Full Livewire integration tests require database setup
@ -159,7 +162,7 @@
test('completes boarding when server is created from onboarding', function () {
// Verify boarding is initially enabled
expect($this->team->fresh()->show_boarding)->toBeTrue();
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
// Mount the component with from_onboarding flag
$component = Livewire::test(ByHetzner::class)
@ -176,13 +179,142 @@
test('boarding flag remains unchanged when not from onboarding', function () {
// Verify boarding is initially enabled
expect($this->team->fresh()->show_boarding)->toBeTrue();
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
// Mount the component without from_onboarding flag (default false)
Livewire::test(ByHetzner::class)
->set('from_onboarding', false);
// Boarding should still be enabled since it wasn't created from onboarding
expect($this->team->fresh()->show_boarding)->toBeTrue();
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
});
test('uses the shared dropdown UI for advanced Hetzner options', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->assertSee('Advanced Hetzner options')
->assertSeeHtml('dropdownOpen')
->assertSeeHtml('x-ref="panel"')
->assertSeeHtml('dark:bg-coolgray-100')
->assertSeeHtml('dark:bg-transparent')
->assertSeeHtml('@click.outside="if (! true) close()"');
});
test('renders advanced Hetzner option controls inside the dropdown menu', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->assertSee('Extra SSH Keys')
->assertSee('Firewalls')
->assertSee('Private Networks')
->assertSee('Enable Hetzner Backups')
->assertSee('Add cloud-init script')
->assertSee('additional 20% of the server monthly fee');
});
test('shows the cloud init script name only when saving the script', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('show_cloud_init_script', true)
->assertSee('Cloud-Init Script')
->assertSee('Save this script for later use')
->assertDontSee('Script name...')
->set('save_cloud_init_script', true)
->assertSee('Script name...');
});
});
describe('Hetzner data loading', function () {
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->hetznerToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'token' => 'test-hetzner-api-token',
]);
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
});
test('loads firewalls and networks for the selected Hetzner token', function () {
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'locations' => [
['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'firewalls' => [
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'networks' => [
[
'id' => 456,
'name' => 'private-eu',
'ip_range' => '10.0.0.0/16',
'subnets' => [
['type' => 'cloud', 'network_zone' => 'eu-central'],
],
],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$component = Livewire::test(ByHetzner::class)
->set('selected_token_id', $this->hetznerToken->id)
->call('nextStep')
->assertSet('current_step', 2);
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
->and($component->get('hetznerNetworks'))->toHaveCount(1);
});
test('rejects submitting without a public IP protocol before calling Hetzner', function () {
Http::fake();
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('selected_token_id', $this->hetznerToken->id)
->set('server_name', 'test-server')
->set('selected_location', 'nbg1')
->set('selected_server_type', 'cx11')
->set('selected_image', 15512617)
->set('private_key_id', $this->privateKey->id)
->set('enable_ipv4', false)
->set('enable_ipv6', false)
->call('submit')
->assertHasErrors(['enable_ipv4', 'enable_ipv6']);
Http::assertNothingSent();
});
});

View file

@ -0,0 +1,339 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::forceCreate(['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']);
$this->bearerToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$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]);
});
function createBearerTokenForServiceApplicationsApiTest(User $user, Team $team, array $abilities = ['*']): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function createServiceWithApplicationForApiTest(object $ctx): object
{
$service = Service::factory()->create([
'environment_id' => $ctx->environment->id,
'server_id' => $ctx->server->id,
'destination_id' => $ctx->destination->id,
'destination_type' => $ctx->destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$sa = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'web',
'service_id' => $service->id,
'image' => 'nginx:alpine',
]);
return (object) ['service' => $service, 'serviceApplication' => $sa];
}
function createServiceWithoutApplicationsForApiTest(object $ctx): Service
{
return Service::factory()->create([
'environment_id' => $ctx->environment->id,
'server_id' => $ctx->server->id,
'destination_id' => $ctx->destination->id,
'destination_type' => $ctx->destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
}
describe('GET /api/v1/services/{uuid}/applications', function () {
test('returns empty array when service has no applications', function () {
$service = createServiceWithoutApplicationsForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$service->uuid}/applications");
$response->assertStatus(200);
$response->assertJson([]);
});
test('lists service applications for the service', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
$response->assertStatus(200);
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]);
});
test('returns 404 when service does not exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications');
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service not found.']);
});
test('does not list applications from another team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherServer->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$otherCtx = (object) [
'server' => $otherServer,
'destination' => StandaloneDocker::where('server_id', $otherServer->id)->first(),
'environment' => Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id,
]),
];
$ctx = createServiceWithApplicationForApiTest($otherCtx);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service not found.']);
});
});
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () {
test('returns 404 for unknown service', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345');
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service not found.']);
});
test('returns 404 when application uuid is not under service', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003");
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service application not found.']);
});
test('returns service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}");
$response->assertStatus(200);
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
});
});
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
test('returns 400 without valid token', function () {
$response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [
'human_name' => 'x',
], ['Accept' => 'application/json', 'Content-Type' => 'application/json']);
$response->assertStatus(401);
});
test('updates human_name', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'human_name' => 'Web UI',
]);
$response->assertStatus(200);
$response->assertJsonFragment(['human_name' => 'Web UI']);
$ctx->serviceApplication->refresh();
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
});
test('returns 422 for invalid url scheme', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'url' => 'ftp://example.com',
]);
$response->assertStatus(422);
});
test('returns 422 when enabling log drain but server has no log drain', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'is_log_drain_enabled' => true,
]);
$response->assertStatus(422);
expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain');
});
test('read-only token cannot update a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$readOnlyToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readOnlyToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'human_name' => 'Blocked',
]);
$response->assertStatus(403);
});
test('returns 409 for domain conflicts unless forced', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$ctx->serviceApplication->update(['fqdn' => 'http://used.example.com']);
$otherApplication = ServiceApplication::create([
'name' => 'admin',
'service_id' => $ctx->service->id,
'image' => 'nginx:alpine',
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}", [
'url' => 'http://used.example.com',
])->assertStatus(409);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}?force_domain_override=true", [
'url' => 'http://used.example.com',
])->assertStatus(200);
$otherApplication->refresh();
expect($otherApplication->fqdn)->toBe('http://used.example.com');
});
});
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () {
test('returns 400 without valid token', function () {
$response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart');
$response->assertStatus(401);
});
test('queues restart for a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart");
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service application restart request queued.']);
RestartServiceApplication::assertPushed();
});
});
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () {
test('write token cannot start a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$writeToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['write']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start");
$response->assertStatus(403);
});
test('queues deploy for a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1");
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service application deploy request queued.']);
DeployServiceApplication::assertPushed();
});
});
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () {
test('queues stop for a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop");
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service application stop request queued.']);
StopServiceApplication::assertPushed();
});
});
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () {
test('returns 400 when server is not functional', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$this->server->settings->update([
'is_reachable' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs");
$response->assertStatus(400);
$response->assertJsonFragment(['message' => 'Server is not functional.']);
});
});

View file

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