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

This commit is contained in:
Andras Bacsai committed 2026-07-07 21:23:07 +02:00
commit 31f904ef9f
35 files changed
+7215 -113

No files matched your search

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