Merge remote-tracking branch 'origin/next' into feature/vultr-cloud-provider
This commit is contained in:
commit
31f904ef9f
35 changed files with 7215 additions and 113 deletions
65
app/Actions/Service/DeployServiceApplication.php
Normal file
65
app/Actions/Service/DeployServiceApplication.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class DeployServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$composeServiceName = $serviceApplication->name;
|
||||
|
||||
$service->parse();
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
|
||||
$workdir = $service->workdir();
|
||||
$composeFile = "{$workdir}/docker-compose.yml";
|
||||
$safeWorkdir = escapeshellarg($workdir);
|
||||
$safeComposeFile = escapeshellarg($composeFile);
|
||||
$safeProjectName = escapeshellarg($service->uuid);
|
||||
$safeComposeServiceName = escapeshellarg($composeServiceName);
|
||||
|
||||
$commands = collect([
|
||||
'echo '.escapeshellarg("Saved configuration files to {$workdir}."),
|
||||
'touch '.escapeshellarg("{$workdir}/.env"),
|
||||
]);
|
||||
|
||||
if ($pullLatestImages) {
|
||||
$commands->push('echo Pulling image for service.');
|
||||
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}");
|
||||
}
|
||||
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands->push('echo Creating Docker network.');
|
||||
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
|
||||
}
|
||||
|
||||
$upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps";
|
||||
if ($forceRebuild) {
|
||||
$upCommand .= ' --build';
|
||||
}
|
||||
$upCommand .= " {$safeComposeServiceName}";
|
||||
$commands->push('echo Starting service container.');
|
||||
$commands->push($upCommand);
|
||||
|
||||
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
|
||||
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$network = escapeshellarg($service->destination->network);
|
||||
$containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}");
|
||||
$networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}");
|
||||
$commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true");
|
||||
}
|
||||
|
||||
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||
}
|
||||
}
|
||||
24
app/Actions/Service/RestartServiceApplication.php
Normal file
24
app/Actions/Service/RestartServiceApplication.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RestartServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker restart {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
24
app/Actions/Service/StopServiceApplication.php
Normal file
24
app/Actions/Service/StopServiceApplication.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker stop {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
107
app/Actions/Service/UpdateServiceApplicationFromApi.php
Normal file
107
app/Actions/Service/UpdateServiceApplicationFromApi.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\ServiceComposeUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UpdateServiceApplicationFromApi
|
||||
{
|
||||
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse
|
||||
{
|
||||
$forceDomainOverride = $request->boolean('force_domain_override');
|
||||
|
||||
if (array_key_exists('url', $payload)) {
|
||||
$urlRaw = $payload['url'];
|
||||
if ($urlRaw !== null && ! is_string($urlRaw)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['url' => 'The url must be a string.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$parsed = ServiceComposeUrl::validateUrlString(
|
||||
is_string($urlRaw) ? $urlRaw : null,
|
||||
$forceDomainOverride
|
||||
);
|
||||
|
||||
if (count($parsed['errors']) > 0) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $parsed['errors'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($parsed['normalized'] !== null) {
|
||||
$containerUrls = str($parsed['normalized'])
|
||||
->explode(',')
|
||||
->map(fn ($url) => str(trim((string) $url))->lower());
|
||||
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid);
|
||||
if (isset($result['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$result['error']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($result['hasConflicts'] && ! $forceDomainOverride) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => $result['conflicts'],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
}
|
||||
|
||||
$serviceApplication->fqdn = $parsed['normalized'];
|
||||
}
|
||||
|
||||
if (array_key_exists('human_name', $payload)) {
|
||||
$serviceApplication->human_name = $payload['human_name'];
|
||||
}
|
||||
|
||||
if (array_key_exists('description', $payload)) {
|
||||
$serviceApplication->description = $payload['description'];
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$serviceApplication->image = $payload['image'];
|
||||
}
|
||||
|
||||
if (array_key_exists('exclude_from_status', $payload)) {
|
||||
$serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_gzip_enabled', $payload)) {
|
||||
$serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_stripprefix_enabled', $payload)) {
|
||||
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_log_drain_enabled', $payload)) {
|
||||
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($enabled && ! $server->isLogDrainEnabled()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$serviceApplication->is_log_drain_enabled = $enabled;
|
||||
}
|
||||
|
||||
$serviceApplication->save();
|
||||
$serviceApplication->refresh();
|
||||
|
||||
updateCompose($serviceApplication);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,18 @@
|
|||
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Application not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
|
|
@ -280,6 +292,7 @@ public function applications(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
|
||||
],
|
||||
)
|
||||
|
|
@ -447,6 +460,7 @@ public function create_public_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
|
||||
],
|
||||
)
|
||||
|
|
@ -614,6 +628,7 @@ public function create_private_gh_app_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
|
||||
],
|
||||
)
|
||||
|
|
@ -753,6 +768,7 @@ public function create_private_deploy_key_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -888,6 +904,7 @@ public function create_dockerfile_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -964,7 +981,7 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
|
|
@ -978,6 +995,8 @@ private function create_application(Request $request, $type)
|
|||
'http_basic_auth_username' => 'string|nullable',
|
||||
'http_basic_auth_password' => 'string|nullable',
|
||||
'autogenerate_domain' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -995,6 +1014,13 @@ private function create_application(Request $request, $type)
|
|||
], 422);
|
||||
}
|
||||
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$tagNames = $request->input('tags') ?? [];
|
||||
|
||||
$environmentUuid = $request->environment_uuid;
|
||||
$environmentName = $request->environment_name;
|
||||
if (blank($environmentUuid) && blank($environmentName)) {
|
||||
|
|
@ -1252,6 +1278,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1495,6 +1524,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1708,6 +1740,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1832,6 +1867,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1955,6 +1993,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($application, $tagNames, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1986,6 +2027,7 @@ private function create_application(Request $request, $type)
|
|||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Invalid type.'], 400);
|
||||
|
|
@ -3908,6 +3950,99 @@ public function action_restart(Request $request)
|
|||
);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Move',
|
||||
description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.',
|
||||
path: '/applications/{uuid}/move',
|
||||
operationId: 'move-application-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Target environment to move the application to.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
|
||||
],
|
||||
required: ['environment_uuid'],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Application moved successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Application moved successfully.'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'project_uuid' => ['type' => 'string'],
|
||||
'environment_uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
return moveResourceToEnvironment($request, $application, 'Application', $teamId);
|
||||
}
|
||||
|
||||
private function validateDataApplications(Request $request, Server $server)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
|
@ -4654,4 +4789,148 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
|
|||
|
||||
return response()->json(['message' => 'Preview deletion request queued.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for an application by UUID.',
|
||||
path: '/applications/{uuid}/tags',
|
||||
operationId: 'list-tags-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to an application by UUID.',
|
||||
path: '/applications/{uuid}/tags',
|
||||
operationId: 'create-tag-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from an application by UUID.',
|
||||
path: '/applications/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
Normal file
174
app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
trait HandlesTagsApi
|
||||
{
|
||||
/**
|
||||
* Find the taggable resource by UUID within the team.
|
||||
*/
|
||||
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
|
||||
|
||||
/**
|
||||
* Get the 404 message for the taggable resource.
|
||||
*/
|
||||
abstract protected function tagResourceNotFoundMessage(): string;
|
||||
|
||||
public function listTags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
|
||||
}
|
||||
|
||||
public function createTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($request->has('tag_name') && $request->has('tag_names')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'tag_name' => 'required_without:tag_names|string',
|
||||
'tag_names' => 'required_without:tag_name|array|min:1',
|
||||
'tag_names.*' => 'string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->attachTagsToResource($resource, $tagNames, $teamId);
|
||||
|
||||
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function deleteTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $resource->tags()->whereKey($tag->id)->exists()) {
|
||||
return response()->json(['message' => 'Tag not found on resource.'], 404);
|
||||
}
|
||||
|
||||
$resource->tags()->detach($tag->id);
|
||||
$tag->deleteIfOrphaned();
|
||||
|
||||
return response()->json(['message' => 'Tag removed.']);
|
||||
}
|
||||
|
||||
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
|
||||
{
|
||||
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
|
||||
if (mb_strlen($tagName) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = Tag::query()->createOrFirst([
|
||||
'team_id' => $teamId,
|
||||
'name' => $tagName,
|
||||
]);
|
||||
|
||||
$resource->tags()->syncWithoutDetaching([$tag->id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateTagsParameter(Request $request): ?JsonResponse
|
||||
{
|
||||
if (! $request->has('tags')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->input('tags', []));
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request->merge(['tags' => $tagNames]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalizeTagNames(array $tagNames): array
|
||||
{
|
||||
return collect($tagNames)
|
||||
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,18 @@
|
|||
|
||||
class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Database not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
|
|
@ -1220,6 +1232,7 @@ public function update_backup(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1288,6 +1301,7 @@ public function create_database_postgresql(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1355,6 +1369,7 @@ public function create_database_clickhouse(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1423,6 +1438,7 @@ public function create_database_dragonfly(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1491,6 +1507,7 @@ public function create_database_redis(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1562,6 +1579,7 @@ public function create_database_keydb(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1633,6 +1651,7 @@ public function create_database_mariadb(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1701,6 +1720,7 @@ public function create_database_mysql(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1731,7 +1751,7 @@ public function create_database_mongodb(Request $request)
|
|||
|
||||
public function create_database(Request $request, NewDatabaseTypes $type)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -1830,6 +1850,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
'limits_cpuset' => 'string|nullable',
|
||||
'limits_cpu_shares' => 'numeric',
|
||||
'instant_deploy' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
if ($validator->failed()) {
|
||||
return response()->json([
|
||||
|
|
@ -1837,6 +1859,13 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$tagNames = $request->input('tags') ?? [];
|
||||
|
||||
if ($request->public_port) {
|
||||
if ($request->public_port < 1024 || $request->public_port > 65535) {
|
||||
return response()->json([
|
||||
|
|
@ -1848,7 +1877,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
}
|
||||
if ($type === NewDatabaseTypes::POSTGRESQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
|
|
@ -1896,6 +1925,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
'uuid' => $database->uuid,
|
||||
|
|
@ -1917,7 +1949,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MARIADB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mariadb_conf' => 'string',
|
||||
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
|
|
@ -1964,6 +1996,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
|
|
@ -1986,7 +2021,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MYSQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
|
|
@ -2033,6 +2068,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
|
|
@ -2055,7 +2093,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::REDIS) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'redis_conf' => 'string',
|
||||
|
|
@ -2099,6 +2137,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
|
|
@ -2121,7 +2162,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
]);
|
||||
|
|
@ -2146,12 +2187,15 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $database->uuid,
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::KEYDB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'keydb_conf' => 'string',
|
||||
|
|
@ -2195,6 +2239,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
|
|
@ -2217,7 +2264,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
|
|
@ -2241,6 +2288,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
|
|
@ -2263,7 +2313,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MONGODB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mongo_conf' => 'string',
|
||||
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
|
|
@ -2309,6 +2359,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$payload = [
|
||||
|
|
@ -2890,6 +2943,99 @@ public function list_backup_executions(Request $request)
|
|||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Move',
|
||||
description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.',
|
||||
path: '/databases/{uuid}/move',
|
||||
operationId: 'move-database-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Target environment to move the database to.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
|
||||
],
|
||||
required: ['environment_uuid'],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Database moved successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Database moved successfully.'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'project_uuid' => ['type' => 'string'],
|
||||
'environment_uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start',
|
||||
description: 'Start database. `Post` request is also accepted.',
|
||||
|
|
@ -4342,4 +4488,148 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a database by UUID.',
|
||||
path: '/databases/{uuid}/tags',
|
||||
operationId: 'list-tags-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a database by UUID.',
|
||||
path: '/databases/{uuid}/tags',
|
||||
operationId: 'create-tag-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a database by UUID.',
|
||||
path: '/databases/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,6 +460,195 @@ public function sshKeys(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Firewalls',
|
||||
description: 'Get all existing Hetzner firewalls for the current project.',
|
||||
path: '/hetzner/firewalls',
|
||||
operationId: 'get-hetzner-firewalls',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner firewalls.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function firewalls(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getFirewalls());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Networks',
|
||||
description: 'Get all existing Hetzner private networks for the current project.',
|
||||
path: '/hetzner/networks',
|
||||
operationId: 'get-hetzner-networks',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner networks.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'ip_range' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function networks(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getNetworks());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Hetzner Server',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||
|
|
@ -487,7 +676,10 @@ public function sshKeys(Request $request)
|
|||
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
|
||||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
|
||||
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
|
|
@ -545,7 +737,10 @@ public function createServer(Request $request)
|
|||
'private_key_uuid',
|
||||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'enable_backups',
|
||||
'hetzner_ssh_key_ids',
|
||||
'hetzner_firewall_ids',
|
||||
'hetzner_network_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
|
@ -571,8 +766,13 @@ public function createServer(Request $request)
|
|||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv4' => 'nullable|boolean',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'enable_backups' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'hetzner_firewall_ids' => 'nullable|array',
|
||||
'hetzner_firewall_ids.*' => 'integer',
|
||||
'hetzner_network_ids' => 'nullable|array',
|
||||
'hetzner_network_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
|
@ -608,13 +808,32 @@ public function createServer(Request $request)
|
|||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->enable_backups)) {
|
||||
$request->offsetSet('enable_backups', false);
|
||||
}
|
||||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_firewall_ids)) {
|
||||
$request->offsetSet('hetzner_firewall_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_network_ids)) {
|
||||
$request->offsetSet('hetzner_network_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'enable_ipv4' => ['Enable at least one public IP protocol.'],
|
||||
'enable_ipv6' => ['Enable at least one public IP protocol.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate cloud provider token
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
|
|
@ -687,6 +906,18 @@ public function createServer(Request $request)
|
|||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($request->hetzner_network_ids));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
|
|
@ -723,6 +954,14 @@ public function createServer(Request $request)
|
|||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($request->enable_backups) {
|
||||
try {
|
||||
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
|
|
|
|||
|
|
@ -97,11 +97,6 @@ public function push(Request $request)
|
|||
|
||||
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||
PushServerUpdateJob::dispatch($server, $data);
|
||||
|
||||
auditLog('sentinel.metrics_pushed', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'ok'], 200);
|
||||
|
|
|
|||
766
app/Http/Controllers/Api/ServiceApplicationsController.php
Normal file
766
app/Http/Controllers/Api/ServiceApplicationsController.php
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Actions\Service\UpdateServiceApplicationFromApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceApplicationsController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(ServiceApplication $serviceApplication): array
|
||||
{
|
||||
$serviceApplication->makeHidden([
|
||||
'id',
|
||||
'resourceable',
|
||||
'resourceable_id',
|
||||
'resourceable_type',
|
||||
]);
|
||||
|
||||
$serialized = serializeApiResponse($serviceApplication);
|
||||
|
||||
if ($serialized instanceof Collection) {
|
||||
return $serialized->all();
|
||||
}
|
||||
|
||||
return (array) $serialized;
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)
|
||||
->whereUuid($uuid)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
|
||||
{
|
||||
$appUuid = $request->route('app_uuid');
|
||||
if (! $appUuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $service->applications()
|
||||
->where('uuid', $appUuid)
|
||||
->with(['service.destination.server'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function swarmNotSupportedResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'This operation is not supported for Swarm servers yet.',
|
||||
], 501);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List service applications',
|
||||
description: 'List compose service applications (containers) for a single service.',
|
||||
path: '/services/{uuid}/applications',
|
||||
operationId: 'list-service-applications-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service applications for this service.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$items = $service->applications()
|
||||
->get()
|
||||
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service application',
|
||||
description: 'Get a single compose service application by service UUID and application UUID.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}',
|
||||
operationId: 'get-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceApplication);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceApplication));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update service application',
|
||||
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}',
|
||||
operationId: 'patch-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'force_domain_override',
|
||||
in: 'query',
|
||||
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'url' => new OA\Property(
|
||||
property: 'url',
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
|
||||
),
|
||||
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
|
||||
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
|
||||
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
|
||||
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
|
||||
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
|
||||
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
|
||||
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Updated service application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts (unless force_domain_override).',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $serviceApplication);
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (empty($payload)) {
|
||||
$payload = $request->request->all();
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'url',
|
||||
'human_name',
|
||||
'description',
|
||||
'image',
|
||||
'exclude_from_status',
|
||||
'is_log_drain_enabled',
|
||||
'is_gzip_enabled',
|
||||
'is_stripprefix_enabled',
|
||||
];
|
||||
|
||||
$validationRules = [
|
||||
'url' => 'nullable|string',
|
||||
'human_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'image' => 'nullable|string',
|
||||
'exclude_from_status' => 'sometimes|boolean',
|
||||
'is_log_drain_enabled' => 'sometimes|boolean',
|
||||
'is_gzip_enabled' => 'sometimes|boolean',
|
||||
'is_stripprefix_enabled' => 'sometimes|boolean',
|
||||
];
|
||||
|
||||
$validator = Validator::make($payload, $validationRules);
|
||||
|
||||
$extraFields = array_diff(array_keys($payload), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload);
|
||||
if ($response instanceof JsonResponse) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$serviceApplication->refresh();
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceApplication));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service application logs',
|
||||
description: 'Get Docker logs for a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/logs',
|
||||
operationId: 'get-service-application-logs-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Logs.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'logs' => new OA\Property(property: 'logs', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function logs_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
|
||||
|
||||
$status = getContainerStatus($server, $containerName);
|
||||
if ($status !== 'running') {
|
||||
return response()->json([
|
||||
'message' => 'Service application container is not running.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$lines = (int) ($request->query('lines', 100) ?: 100);
|
||||
$logs = getContainerLogs($server, $containerName, $lines);
|
||||
|
||||
return response()->json([
|
||||
'logs' => $logs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start or redeploy service application container',
|
||||
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/start',
|
||||
operationId: 'start-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'force',
|
||||
in: 'query',
|
||||
description: 'When true, passes --build to docker compose up.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'latest',
|
||||
in: 'query',
|
||||
description: 'When true, pulls the image for this compose service before up.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Deploy request queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function action_start(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$pullLatest = $request->boolean('latest', false);
|
||||
$forceRebuild = $request->boolean('force', false);
|
||||
|
||||
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application deploy request queued.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Restart service application container',
|
||||
description: 'Restarts a single compose service container (docker restart).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/restart',
|
||||
operationId: 'restart-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Restart queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function action_restart(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
RestartServiceApplication::dispatch($serviceApplication);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application restart request queued.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Stop service application container',
|
||||
description: 'Stops a single compose service container (docker stop).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/stop',
|
||||
operationId: 'stop-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Stop queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function action_stop(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
StopServiceApplication::dispatch($serviceApplication);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application stop request queued.',
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,18 @@
|
|||
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Service not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
|
|
@ -276,6 +288,7 @@ public function services(Request $request)
|
|||
],
|
||||
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -342,7 +355,7 @@ public function services(Request $request)
|
|||
)]
|
||||
public function create_service(Request $request)
|
||||
{
|
||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -372,6 +385,8 @@ public function create_service(Request $request)
|
|||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
|
|
@ -393,6 +408,11 @@ public function create_service(Request $request)
|
|||
], 422);
|
||||
}
|
||||
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
if (filled($request->type) && filled($request->docker_compose_raw)) {
|
||||
return response()->json([
|
||||
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
|
||||
|
|
@ -531,6 +551,10 @@ public function create_service(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
@ -551,7 +575,7 @@ public function create_service(Request $request)
|
|||
|
||||
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
||||
} elseif (filled($request->docker_compose_raw)) {
|
||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$validationRules = [
|
||||
'project_uuid' => 'string|required',
|
||||
|
|
@ -570,6 +594,8 @@ public function create_service(Request $request)
|
|||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
|
|
@ -703,6 +729,10 @@ public function create_service(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
@ -1839,6 +1869,99 @@ public function delete_env_by_uuid(Request $request)
|
|||
return response()->json(['message' => 'Environment variable deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Move',
|
||||
description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.',
|
||||
path: '/services/{uuid}/move',
|
||||
operationId: 'move-service-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Target environment to move the service to.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
|
||||
],
|
||||
required: ['environment_uuid'],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service moved successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Service moved successfully.'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'project_uuid' => ['type' => 'string'],
|
||||
'environment_uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $service);
|
||||
|
||||
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start',
|
||||
description: 'Start service. `Post` request is also accepted.',
|
||||
|
|
@ -2798,4 +2921,148 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'list-tags-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'create-tag-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a service by UUID.',
|
||||
path: '/services/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
app/Http/Controllers/Api/TagsController.php
Normal file
61
app/Http/Controllers/Api/TagsController.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TagsController extends Controller
|
||||
{
|
||||
public static function serializeTag(Tag $tag): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $tag->uuid,
|
||||
'name' => $tag->name,
|
||||
'created_at' => $tag->created_at,
|
||||
'updated_at' => $tag->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all tags for the current team.',
|
||||
path: '/tags',
|
||||
operationId: 'list-tags',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All tags for the current team.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
|
||||
|
||||
return response()->json($tags->map(self::serializeTag(...)));
|
||||
}
|
||||
}
|
||||
|
|
@ -91,9 +91,7 @@ public function deleteTag(string $id)
|
|||
$this->authorize('update', $this->resource);
|
||||
$this->resource->tags()->detach($id);
|
||||
$found_more_tags = Tag::ownedByCurrentTeam()->find($id);
|
||||
if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) {
|
||||
$found_more_tags->delete();
|
||||
}
|
||||
$found_more_tags?->deleteIfOrphaned();
|
||||
$this->refresh();
|
||||
$this->dispatch('success', 'Tag deleted.');
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ class ByHetzner extends Component
|
|||
|
||||
public array $hetznerSshKeys = [];
|
||||
|
||||
public array $hetznerFirewalls = [];
|
||||
|
||||
public array $hetznerNetworks = [];
|
||||
|
||||
public ?string $selected_location = null;
|
||||
|
||||
public ?int $selected_image = null;
|
||||
|
|
@ -54,6 +58,10 @@ class ByHetzner extends Component
|
|||
|
||||
public array $selectedHetznerSshKeyIds = [];
|
||||
|
||||
public array $selectedHetznerFirewallIds = [];
|
||||
|
||||
public array $selectedHetznerNetworkIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
|
@ -64,6 +72,10 @@ class ByHetzner extends Component
|
|||
|
||||
public bool $enable_ipv6 = true;
|
||||
|
||||
public bool $enable_backups = false;
|
||||
|
||||
public bool $show_cloud_init_script = false;
|
||||
|
||||
public ?string $cloud_init_script = null;
|
||||
|
||||
public bool $save_cloud_init_script = false;
|
||||
|
|
@ -112,10 +124,15 @@ public function resetSelection()
|
|||
{
|
||||
$this->selected_token_id = null;
|
||||
$this->current_step = 1;
|
||||
$this->enable_backups = false;
|
||||
$this->cloud_init_script = null;
|
||||
$this->save_cloud_init_script = false;
|
||||
$this->cloud_init_script_name = null;
|
||||
$this->selected_cloud_init_script_id = null;
|
||||
$this->show_cloud_init_script = false;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
|
|
@ -164,8 +181,14 @@ protected function rules(): array
|
|||
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
|
||||
'selectedHetznerSshKeyIds' => 'nullable|array',
|
||||
'selectedHetznerSshKeyIds.*' => 'integer',
|
||||
'selectedHetznerFirewallIds' => 'nullable|array',
|
||||
'selectedHetznerFirewallIds.*' => 'integer',
|
||||
'selectedHetznerNetworkIds' => 'nullable|array',
|
||||
'selectedHetznerNetworkIds.*' => 'integer',
|
||||
'enable_ipv4' => 'required|boolean',
|
||||
'enable_ipv6' => 'required|boolean',
|
||||
'enable_backups' => 'required|boolean',
|
||||
'show_cloud_init_script' => 'boolean',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'save_cloud_init_script' => 'boolean',
|
||||
'cloud_init_script_name' => 'nullable|string|max:255',
|
||||
|
|
@ -245,6 +268,9 @@ public function previousStep()
|
|||
private function loadHetznerData(string $token)
|
||||
{
|
||||
$this->loading_data = true;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
|
@ -274,6 +300,14 @@ private function loadHetznerData(string $token)
|
|||
->toArray();
|
||||
// Load SSH keys from Hetzner
|
||||
$this->hetznerSshKeys = $hetznerService->getSshKeys();
|
||||
$this->hetznerFirewalls = collect($hetznerService->getFirewalls())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->hetznerNetworks = collect($hetznerService->getNetworks())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->loading_data = false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->loading_data = false;
|
||||
|
|
@ -349,6 +383,37 @@ public function getAvailableImagesProperty()
|
|||
return $filtered;
|
||||
}
|
||||
|
||||
public function getAvailableNetworksProperty(): array
|
||||
{
|
||||
$attachableNetworks = collect($this->hetznerNetworks)
|
||||
->filter(function (array $network) {
|
||||
return collect($network['subnets'] ?? [])->contains(function (array $subnet) {
|
||||
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true);
|
||||
});
|
||||
});
|
||||
|
||||
if (! $this->selected_location) {
|
||||
return $attachableNetworks->values()->toArray();
|
||||
}
|
||||
|
||||
$location = collect($this->locations)->firstWhere('name', $this->selected_location);
|
||||
$networkZone = $location['network_zone'] ?? null;
|
||||
|
||||
if (! $networkZone) {
|
||||
return $attachableNetworks->values()->toArray();
|
||||
}
|
||||
|
||||
return $attachableNetworks
|
||||
->filter(function (array $network) use ($networkZone) {
|
||||
return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) {
|
||||
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true)
|
||||
&& ($subnet['network_zone'] ?? null) === $networkZone;
|
||||
});
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getSelectedServerPriceProperty(): ?string
|
||||
{
|
||||
if (! $this->selected_server_type) {
|
||||
|
|
@ -366,11 +431,74 @@ public function getSelectedServerPriceProperty(): ?string
|
|||
return '€'.number_format($price, 2);
|
||||
}
|
||||
|
||||
public function getSelectedServerBackupSurchargeProperty(): ?string
|
||||
{
|
||||
if (! $this->selected_server_type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
|
||||
|
||||
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$price = (float) $serverType['prices'][0]['price_monthly']['gross'];
|
||||
|
||||
return '€'.number_format($price * 0.2, 2);
|
||||
}
|
||||
|
||||
public function getAdvancedHetznerOptionsSummaryProperty(): array
|
||||
{
|
||||
$summary = [];
|
||||
|
||||
if (count($this->selectedHetznerSshKeyIds) > 0) {
|
||||
$summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds));
|
||||
}
|
||||
|
||||
if (count($this->selectedHetznerFirewallIds) > 0) {
|
||||
$summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds));
|
||||
}
|
||||
|
||||
if (count($this->selectedHetznerNetworkIds) > 0) {
|
||||
$summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds));
|
||||
}
|
||||
|
||||
if ($this->enable_backups) {
|
||||
$summary[] = 'Backups on';
|
||||
}
|
||||
|
||||
if (! $this->enable_ipv4 || ! $this->enable_ipv6) {
|
||||
$summary[] = collect([
|
||||
$this->enable_ipv4 ? 'IPv4' : null,
|
||||
$this->enable_ipv6 ? 'IPv6' : null,
|
||||
])->filter()->join(' + ') ?: 'No public IP';
|
||||
}
|
||||
|
||||
if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) {
|
||||
$summary[] = 'Cloud-init';
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function showCloudInitScript(): void
|
||||
{
|
||||
$this->show_cloud_init_script = true;
|
||||
}
|
||||
|
||||
public function updatedSelectedLocation($value)
|
||||
{
|
||||
// Reset server type and image when location changes
|
||||
$this->selected_server_type = null;
|
||||
$this->selected_image = null;
|
||||
|
||||
$this->selectedHetznerNetworkIds = array_values(array_filter(
|
||||
$this->selectedHetznerNetworkIds,
|
||||
function (int $selectedNetworkId): bool {
|
||||
return collect($this->availableNetworks)->contains('id', $selectedNetworkId);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
public function updatedSelectedServerType($value)
|
||||
|
|
@ -390,6 +518,14 @@ public function updatedSelectedCloudInitScriptId($value)
|
|||
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
|
||||
$this->cloud_init_script = $script->script;
|
||||
$this->cloud_init_script_name = $script->name;
|
||||
$this->show_cloud_init_script = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSaveCloudInitScript(bool $value): void
|
||||
{
|
||||
if (! $value) {
|
||||
$this->cloud_init_script_name = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,12 +535,11 @@ public function clearCloudInitScript()
|
|||
$this->cloud_init_script = '';
|
||||
$this->cloud_init_script_name = '';
|
||||
$this->save_cloud_init_script = false;
|
||||
$this->show_cloud_init_script = false;
|
||||
}
|
||||
|
||||
private function createHetznerServer(string $token): array
|
||||
private function createHetznerServer(HetznerService $hetznerService): array
|
||||
{
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
||||
// Get the private key and extract public key
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||
|
||||
|
|
@ -458,6 +593,18 @@ private function createHetznerServer(string $token): array
|
|||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($this->selectedHetznerNetworkIds));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($this->cloud_init_script)) {
|
||||
$params['user_data'] = $this->cloud_init_script;
|
||||
|
|
@ -473,6 +620,13 @@ public function submit()
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
if (! $this->enable_ipv4 && ! $this->enable_ipv6) {
|
||||
$this->addError('enable_ipv4', 'Enable at least one public IP protocol.');
|
||||
$this->addError('enable_ipv6', 'Enable at least one public IP protocol.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
|
|
@ -492,9 +646,10 @@ public function submit()
|
|||
}
|
||||
|
||||
$hetznerToken = $this->getHetznerToken();
|
||||
$hetznerService = new HetznerService($hetznerToken);
|
||||
|
||||
// Create server on Hetzner
|
||||
$hetznerServer = $this->createHetznerServer($hetznerToken);
|
||||
$hetznerServer = $this->createHetznerServer($hetznerService);
|
||||
|
||||
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||
$ipAddress = null;
|
||||
|
|
@ -524,6 +679,14 @@ public function submit()
|
|||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($this->enable_backups) {
|
||||
try {
|
||||
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
// Complete the boarding when server is successfully created via Hetzner
|
||||
currentTeam()->update([
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ public static function ownedByCurrentTeam()
|
|||
return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name');
|
||||
}
|
||||
|
||||
public function isEmpty()
|
||||
{
|
||||
return $this->applications()->count() == 0 &&
|
||||
|
|
|
|||
|
|
@ -3,7 +3,19 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Tag model',
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'created_at', type: 'string'),
|
||||
new OA\Property(property: 'updated_at', type: 'string'),
|
||||
]
|
||||
)]
|
||||
class Tag extends BaseModel
|
||||
{
|
||||
use HasSafeStringAttribute;
|
||||
|
|
@ -23,6 +35,13 @@ public static function ownedByCurrentTeam()
|
|||
return Tag::whereTeamId(currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
public function deleteIfOrphaned(): void
|
||||
{
|
||||
if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) {
|
||||
$this->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function applications()
|
||||
{
|
||||
return $this->morphedByMany(Application::class, 'taggable');
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ public function update(User $user, ServiceApplication $serviceApplication): bool
|
|||
return Gate::allows('update', $serviceApplication->service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can deploy or run lifecycle actions on the parent service stack.
|
||||
*/
|
||||
public function deploy(User $user, ServiceApplication $serviceApplication): bool
|
||||
{
|
||||
return Gate::allows('deploy', $serviceApplication->service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -118,6 +118,16 @@ public function getSshKeys(): array
|
|||
return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
|
||||
}
|
||||
|
||||
public function getFirewalls(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/firewalls', 'firewalls');
|
||||
}
|
||||
|
||||
public function getNetworks(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/networks', 'networks');
|
||||
}
|
||||
|
||||
public function uploadSshKey(string $name, string $publicKey): array
|
||||
{
|
||||
$response = $this->request('post', '/ssh_keys', [
|
||||
|
|
@ -136,6 +146,13 @@ public function createServer(array $params): array
|
|||
return $response['server'] ?? [];
|
||||
}
|
||||
|
||||
public function enableServerBackup(int $serverId): array
|
||||
{
|
||||
$response = $this->request('post', "/servers/{$serverId}/actions/enable_backup");
|
||||
|
||||
return $response['action'] ?? [];
|
||||
}
|
||||
|
||||
public function getServer(int $serverId): array
|
||||
{
|
||||
$response = $this->request('get', "/servers/{$serverId}");
|
||||
|
|
|
|||
55
app/Support/ServiceComposeUrl.php
Normal file
55
app/Support/ServiceComposeUrl.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,15 @@
|
|||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\RedirectTypes;
|
||||
use App\Enums\StaticImageTypes;
|
||||
use App\Models\Environment;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
function getTeamIdFromToken()
|
||||
|
|
@ -172,6 +176,64 @@ function sharedDataApplications()
|
|||
];
|
||||
}
|
||||
|
||||
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
|
||||
{
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'environment_uuid' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), ['environment_uuid']);
|
||||
if (! empty($extraFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$newEnvironment = Environment::ownedByCurrentTeamAPI($teamId)
|
||||
->whereUuid($request->environment_uuid)
|
||||
->first();
|
||||
|
||||
if (! $newEnvironment) {
|
||||
return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404);
|
||||
}
|
||||
|
||||
Gate::authorize('update', $newEnvironment);
|
||||
|
||||
if ($resource->environment_id === $newEnvironment->id) {
|
||||
return response()->json(['message' => "$resourceType is already in this environment."], 400);
|
||||
}
|
||||
|
||||
$oldEnvironment = $resource->environment()->with('project')->first();
|
||||
|
||||
$resource->update(['environment_id' => $newEnvironment->id]);
|
||||
|
||||
auditLog('api.'.str($resourceType)->lower()->value().'.moved', [
|
||||
'team_id' => $teamId,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'resource_type' => str($resourceType)->lower()->value(),
|
||||
'from_project_uuid' => $oldEnvironment?->project?->uuid,
|
||||
'from_environment_uuid' => $oldEnvironment?->uuid,
|
||||
'to_project_uuid' => $newEnvironment->project->uuid,
|
||||
'to_environment_uuid' => $newEnvironment->uuid,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => "$resourceType moved successfully.",
|
||||
'uuid' => $resource->uuid,
|
||||
'project_uuid' => $newEnvironment->project->uuid,
|
||||
'environment_uuid' => $newEnvironment->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
function validateIncomingRequest(Request $request)
|
||||
{
|
||||
// check if request is json
|
||||
|
|
@ -222,4 +284,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
|||
$request->offsetUnset('is_preserve_repository_enabled');
|
||||
$request->offsetUnset('include_source_commit_in_build');
|
||||
$request->offsetUnset('docker_compose_raw');
|
||||
$request->offsetUnset('tags');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,13 +178,18 @@ function executeInDocker(string $containerId, string $command)
|
|||
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
|
||||
}
|
||||
|
||||
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
||||
function buildContainerStatusCommand(Server $server, string $container_id): string
|
||||
{
|
||||
if ($server->isSwarm()) {
|
||||
$container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError);
|
||||
} else {
|
||||
$container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError);
|
||||
return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' ";
|
||||
}
|
||||
|
||||
return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id);
|
||||
}
|
||||
|
||||
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
||||
{
|
||||
$container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError);
|
||||
if (! $container) {
|
||||
return 'exited';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->indexExists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('tags')
|
||||
->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count'))
|
||||
->whereNotNull('team_id')
|
||||
->groupBy('team_id', 'name')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderBy('keep_id')
|
||||
->cursor()
|
||||
->each(function ($duplicate): void {
|
||||
DB::table('tags')
|
||||
->select('id')
|
||||
->where('team_id', $duplicate->team_id)
|
||||
->where('name', $duplicate->name)
|
||||
->where('id', '!=', $duplicate->keep_id)
|
||||
->orderBy('id')
|
||||
->cursor()
|
||||
->each(function ($duplicateTag) use ($duplicate): void {
|
||||
DB::table('taggables')
|
||||
->where('tag_id', $duplicateTag->id)
|
||||
->orderBy('taggable_id')
|
||||
->cursor()
|
||||
->each(function ($taggable) use ($duplicate): void {
|
||||
DB::table('taggables')->updateOrInsert([
|
||||
'tag_id' => $duplicate->keep_id,
|
||||
'taggable_id' => $taggable->taggable_id,
|
||||
'taggable_type' => $taggable->taggable_type,
|
||||
]);
|
||||
});
|
||||
|
||||
DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete();
|
||||
DB::table('tags')->where('id', $duplicateTag->id)->delete();
|
||||
});
|
||||
});
|
||||
|
||||
Schema::table('tags', function (Blueprint $table) {
|
||||
$table->unique(['team_id', 'name'], 'tags_team_id_name_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! $this->indexExists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('tags', function (Blueprint $table) {
|
||||
$table->dropUnique('tags_team_id_name_unique');
|
||||
});
|
||||
}
|
||||
|
||||
private function indexExists(): bool
|
||||
{
|
||||
return collect(Schema::getIndexes('tags'))
|
||||
->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique');
|
||||
}
|
||||
};
|
||||
1591
openapi.json
1591
openapi.json
File diff suppressed because it is too large
Load diff
1011
openapi.yaml
1011
openapi.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -289,7 +289,7 @@ @utility info-helper {
|
|||
}
|
||||
|
||||
@utility info-helper-popup {
|
||||
@apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words;
|
||||
@apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words;
|
||||
}
|
||||
|
||||
@utility buyme {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
@props([
|
||||
'inline' => false,
|
||||
'triggerClass' => '',
|
||||
'panelClass' => '',
|
||||
])
|
||||
|
||||
<div x-data="{
|
||||
dropdownOpen: false,
|
||||
panelStyles: '',
|
||||
|
|
@ -9,7 +15,7 @@
|
|||
this.dropdownOpen = false;
|
||||
},
|
||||
updatePanelPosition() {
|
||||
if (window.innerWidth >= 768) {
|
||||
if ({{ $inline ? 'true' : 'false' }} || window.innerWidth >= 768) {
|
||||
this.panelStyles = '';
|
||||
|
||||
return;
|
||||
|
|
@ -37,9 +43,13 @@
|
|||
this.panelStyles = `position: fixed; left: ${left}px; top: ${top}px;`;
|
||||
});
|
||||
}
|
||||
}" class="relative" @click.outside="close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
|
||||
<button x-ref="trigger" @click="dropdownOpen ? close() : open()"
|
||||
class="inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none">
|
||||
}" @class(['relative', 'w-full' => $inline]) @click.outside="if (! {{ $inline ? 'true' : 'false' }}) close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
|
||||
<button type="button" x-ref="trigger" @click="dropdownOpen ? close() : open()"
|
||||
@class([
|
||||
'inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none',
|
||||
'w-full border border-neutral-300 bg-white px-3 py-2 text-left dark:border-coolgray-300 dark:bg-coolgray-100' => $inline,
|
||||
$triggerClass,
|
||||
])>
|
||||
<span class="flex flex-col items-start h-full leading-none">
|
||||
{{ $title }}
|
||||
</span>
|
||||
|
|
@ -50,11 +60,18 @@ class="inline-flex items-center justify-start pr-8 transition-colors focus:outli
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-ref="panel" x-show="dropdownOpen" @click.away="close()" x-transition:enter="ease-out duration-200"
|
||||
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()" x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
:style="panelStyles" class="absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6" x-cloak>
|
||||
<div
|
||||
class="border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200">
|
||||
:style="panelStyles" @class([
|
||||
'mt-1 w-full' => $inline,
|
||||
'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline,
|
||||
]) x-cloak>
|
||||
<div @class([
|
||||
'border border-neutral-300 bg-white p-1 dark:border-coolgray-300',
|
||||
'shadow-sm dark:bg-coolgray-200' => ! $inline,
|
||||
'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline,
|
||||
$panelClass,
|
||||
])>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
|
||||
{{ $attributes->merge(['class' => 'group']) }}>
|
||||
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
|
||||
<div class="info-helper">
|
||||
@isset($icon)
|
||||
{{ $icon }}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
@if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
|
||||
({{ $serverType['cpu_vendor_info'] }})
|
||||
@endif
|
||||
, {{ $serverType['memory'] }}GB RAM,
|
||||
, {{ $serverType['memory'] }}GB RAM,
|
||||
{{ $serverType['disk'] }}GB
|
||||
@if (isset($serverType['architecture']))
|
||||
[{{ $serverType['architecture'] }}]
|
||||
|
|
@ -135,58 +135,118 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<x-forms.datalist label="Additional SSH Keys (from Hetzner)" id="selectedHetznerSshKeyIds"
|
||||
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
|
||||
? 'Search and select SSH keys...'
|
||||
: 'No SSH keys found in Hetzner account'">
|
||||
@foreach ($hetznerSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium">Network Configuration</label>
|
||||
<div class="flex gap-4">
|
||||
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
|
||||
helper="Enable public IPv4 address for this server" />
|
||||
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
|
||||
helper="Enable public IPv6 address for this server" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
@if ($saved_cloud_init_scripts->count() > 0)
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
|
||||
<option value="">Load saved script...</option>
|
||||
@foreach ($saved_cloud_init_scripts as $script)
|
||||
<option value="{{ $script->id }}">{{ $script->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Clear
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
|
||||
<x-slot:title>
|
||||
<span class="text-sm font-medium">Advanced Hetzner options</span>
|
||||
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, firewalls, private networks, backups, and cloud-init.</span>
|
||||
@if (count($this->advancedHetznerOptionsSummary) > 0)
|
||||
<span class="mt-1 flex flex-wrap gap-1.5">
|
||||
@foreach ($this->advancedHetznerOptionsSummary as $summaryItem)
|
||||
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
|
||||
{{ $summaryItem }}
|
||||
</span>
|
||||
@endforeach
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.textarea id="cloud_init_script" label=""
|
||||
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
|
||||
rows="8" />
|
||||
</x-slot>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
|
||||
<div class="flex w-full flex-col gap-4 p-3">
|
||||
<div>
|
||||
<x-forms.datalist label="Extra SSH Keys" id="selectedHetznerSshKeyIds"
|
||||
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
|
||||
? 'Search and select SSH keys...'
|
||||
: 'No SSH keys found in Hetzner account'">
|
||||
@foreach ($hetznerSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<x-forms.datalist label="Firewalls" id="selectedHetznerFirewallIds"
|
||||
helper="Optionally apply existing Hetzner firewalls when the server is created."
|
||||
:multiple="true" :disabled="count($hetznerFirewalls) === 0" :placeholder="count($hetznerFirewalls) > 0
|
||||
? 'Search and select firewalls...'
|
||||
: 'No firewalls found in Hetzner account'">
|
||||
@foreach ($hetznerFirewalls as $firewall)
|
||||
<option value="{{ $firewall['id'] }}">
|
||||
{{ $firewall['name'] }}
|
||||
@if (isset($firewall['rules']))
|
||||
- {{ count($firewall['rules']) }} rules
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
|
||||
<x-forms.datalist label="Private Networks" id="selectedHetznerNetworkIds"
|
||||
helper="Optionally attach one or more private networks. Networks are filtered to the selected location's network zone when possible."
|
||||
:multiple="true" :disabled="count($this->availableNetworks) === 0" :placeholder="count($this->availableNetworks) > 0
|
||||
? 'Search and select networks...'
|
||||
: 'No compatible networks found'">
|
||||
@foreach ($this->availableNetworks as $network)
|
||||
<option value="{{ $network['id'] }}">
|
||||
{{ $network['name'] }} - {{ $network['ip_range'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
|
||||
helper="Enable public IPv4 address for this server" fullWidth />
|
||||
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
|
||||
helper="Enable public IPv6 address for this server" fullWidth />
|
||||
<x-forms.checkbox id="enable_backups" label="Enable Hetzner Backups" fullWidth
|
||||
helper="Hetzner bills backups at an additional 20% of the server monthly fee{{ $this->selectedServerBackupSurcharge ? ' (about ' . $this->selectedServerBackupSurcharge . '/mo for the selected server type)' : '' }}." />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id))
|
||||
<div>
|
||||
<x-forms.button type="button" wire:click="showCloudInitScript">
|
||||
Add cloud-init script
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
@if ($saved_cloud_init_scripts->count() > 0)
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
|
||||
<option value="">Load saved script...</option>
|
||||
@foreach ($saved_cloud_init_scripts as $script)
|
||||
<option value="{{ $script->id }}">{{ $script->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Clear
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Remove
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.textarea id="cloud_init_script" label=""
|
||||
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
|
||||
rows="8" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
|
||||
@if ($save_cloud_init_script)
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dropdown>
|
||||
|
||||
<div class="flex gap-2 justify-between">
|
||||
<x-forms.button type="button" wire:click="previousStep">
|
||||
|
|
@ -201,4 +261,4 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
use App\Http\Controllers\Api\SecurityController;
|
||||
use App\Http\Controllers\Api\SentinelController;
|
||||
use App\Http\Controllers\Api\ServersController;
|
||||
use App\Http\Controllers\Api\ServiceApplicationsController;
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Http\Controllers\Api\TeamController;
|
||||
use App\Http\Controllers\Api\VultrController;
|
||||
use App\Http\Middleware\ApiAllowed;
|
||||
|
|
@ -105,6 +107,8 @@
|
|||
Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/firewalls', [HetznerController::class, 'firewalls'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/networks', [HetznerController::class, 'networks'])->middleware(['api.ability:read']);
|
||||
Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/vultr/regions', [VultrController::class, 'regions'])->middleware(['api.ability:read']);
|
||||
|
|
@ -115,6 +119,8 @@
|
|||
|
||||
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']);
|
||||
Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']);
|
||||
|
|
@ -137,6 +143,11 @@
|
|||
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
|
@ -182,6 +193,11 @@
|
|||
Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
|
@ -205,10 +221,23 @@
|
|||
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/services/{uuid}/applications', [ServiceApplicationsController::class, 'index'])->middleware(['api.ability:read']);
|
||||
Route::get('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::patch('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/start', [ServiceApplicationsController::class, 'action_start'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
|
|
|
|||
|
|
@ -6,14 +6,22 @@
|
|||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('cache.default', 'array');
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
|
||||
InstanceSettings::query()->whereKey(0)->delete();
|
||||
$settings = new InstanceSettings(['is_api_enabled' => true]);
|
||||
$settings = new InstanceSettings([
|
||||
'is_api_enabled' => true,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
|
|
@ -29,15 +37,25 @@
|
|||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Create a Hetzner cloud provider token
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
$this->hetznerToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Test Hetzner Token',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
// Create a private key
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -239,17 +257,105 @@
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/firewalls', function () {
|
||||
test('gets Hetzner firewalls', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
['id' => 39, 'name' => 'ssh-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'web-firewall']);
|
||||
});
|
||||
|
||||
test('member read token cannot use a stored cloud provider token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [['id' => 38, 'name' => 'web-firewall']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$memberToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/networks', function () {
|
||||
test('gets Hetzner networks', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []],
|
||||
['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'private-eu']);
|
||||
});
|
||||
|
||||
test('member read token cannot use a stored cloud provider token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [['id' => 456, 'name' => 'private-eu']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$memberToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/hetzner', function () {
|
||||
test('creates a Hetzner server', function () {
|
||||
// Mock Hetzner API calls
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
|
|
@ -289,15 +395,134 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('enables backups after creating a Hetzner server when requested', function () {
|
||||
Http::fake(function (HttpRequest $request) {
|
||||
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
|
||||
return Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
|
||||
return Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
|
||||
return Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'name' => 'test-server',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
|
||||
return Http::response([
|
||||
'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => true,
|
||||
'enable_ipv6' => true,
|
||||
'enable_backups' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup');
|
||||
});
|
||||
|
||||
test('registers server when backup enablement fails after Hetzner creation', function () {
|
||||
Http::fake(function (HttpRequest $request) {
|
||||
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
|
||||
return Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
|
||||
return Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
|
||||
return Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'name' => 'test-server',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
|
||||
return Http::response([
|
||||
'error' => ['message' => 'backup unavailable'],
|
||||
], 500);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => true,
|
||||
'enable_ipv6' => true,
|
||||
'enable_backups' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-server',
|
||||
'team_id' => $this->team->id,
|
||||
'hetzner_server_id' => 456,
|
||||
]);
|
||||
});
|
||||
|
||||
test('generates server name if not provided', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
|
|
@ -331,13 +556,10 @@
|
|||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'cloud_provider_token_id',
|
||||
'location',
|
||||
'server_type',
|
||||
'image',
|
||||
'private_key_uuid',
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid request.',
|
||||
'error' => 'Invalid JSON.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -375,13 +597,13 @@
|
|||
|
||||
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
|
|
@ -412,13 +634,13 @@
|
|||
|
||||
test('uses IPv6 when only IPv6 is enabled', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
|
|
@ -447,6 +669,75 @@
|
|||
$response->assertJsonFragment(['ip' => '2001:db8::1']);
|
||||
});
|
||||
|
||||
test('rejects server creation when both public IP protocols are disabled', function () {
|
||||
Http::fake();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => false,
|
||||
'enable_ipv6' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['enable_ipv4', 'enable_ipv6']);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('passes selected firewalls and networks to Hetzner server creation', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'hetzner_firewall_ids' => [38, 39],
|
||||
'hetzner_network_ids' => [456, 457, 456],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
Http::assertSent(function (HttpRequest $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers'
|
||||
&& $request['networks'] === [456, 457]
|
||||
&& $request['firewalls'] === [
|
||||
['firewall' => 38],
|
||||
['firewall' => 39],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
|
|
@ -545,4 +836,38 @@
|
|||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('firewalls endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('networks endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
314
tests/Feature/MoveResourceApiTest.php
Normal file
314
tests/Feature/MoveResourceApiTest.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->targetProject = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->targetEnvironment = $this->targetProject->environments()->first();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/move', function () {
|
||||
test('moves application to another environment', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Application moved successfully.']);
|
||||
$response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']);
|
||||
|
||||
$application->refresh();
|
||||
expect($application->environment_id)->toBe($this->targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('writes audit log when application is moved', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('api.application.moved', Mockery::on(function (array $context) use ($application) {
|
||||
return $context['event'] === 'api.application.moved'
|
||||
&& $context['resource_uuid'] === $application->uuid
|
||||
&& $context['resource_type'] === 'application'
|
||||
&& $context['from_environment_uuid'] === $this->environment->uuid
|
||||
&& $context['to_environment_uuid'] === $this->targetEnvironment->uuid
|
||||
&& $context['from_project_uuid'] === $this->project->uuid
|
||||
&& $context['to_project_uuid'] === $this->targetProject->uuid;
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$request = Request::create('/', 'POST', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response = moveResourceToEnvironment($request, $application, 'Application', $this->team->id);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
test('returns 404 when application not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/applications/non-existent-uuid/move', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 422 when environment_uuid is missing', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 when extra fields are provided', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
'bogus_field' => 'value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 404 when target environment belongs to another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $otherEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('authorizes the target environment before moving', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Gate::before(function (User $user, string $ability, array $arguments) {
|
||||
$target = $arguments[0] ?? null;
|
||||
|
||||
if ($ability === 'update' && $target instanceof Environment && $target->is($this->targetEnvironment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$request = Request::create('/', 'POST', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
expect(fn () => moveResourceToEnvironment($request, $application, 'Application', $this->team->id))
|
||||
->toThrow(AuthorizationException::class);
|
||||
|
||||
$application->refresh();
|
||||
expect($application->environment_id)->toBe($this->environment->id);
|
||||
});
|
||||
|
||||
test('returns 400 when application is already in the target environment', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
|
||||
test('preserves resource-level environment variables after move', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test-value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$application->refresh();
|
||||
$envVar = $application->environment_variables->where('key', 'TEST_VAR')->first();
|
||||
expect($envVar)->not->toBeNull();
|
||||
expect($envVar->value)->toBe('test-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/move', function () {
|
||||
test('moves database to another environment', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'secret',
|
||||
'postgres_db' => 'testdb',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$database->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Database moved successfully.']);
|
||||
|
||||
$database->refresh();
|
||||
expect($database->environment_id)->toBe($this->targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('returns 404 when database not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/non-existent-uuid/move', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/move', function () {
|
||||
test('moves service to another environment', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/services/{$service->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service moved successfully.']);
|
||||
|
||||
$service->refresh();
|
||||
expect($service->environment_id)->toBe($this->targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('returns 404 when service not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/services/non-existent-uuid/move', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -996,6 +996,43 @@
|
|||
});
|
||||
});
|
||||
|
||||
describe('service application lifecycle command escaping', function () {
|
||||
test('service application deploy action escapes docker command arguments', function () {
|
||||
$source = file_get_contents(app_path('Actions/Service/DeployServiceApplication.php'));
|
||||
|
||||
expect($source)->toContain('escapeshellarg($workdir)')
|
||||
->and($source)->toContain('escapeshellarg($composeFile)')
|
||||
->and($source)->toContain('escapeshellarg($service->uuid)')
|
||||
->and($source)->toContain('escapeshellarg($composeServiceName)')
|
||||
->and($source)->toContain('escapeshellarg($service->destination->network)');
|
||||
});
|
||||
|
||||
test('service application restart and stop actions escape container names', function (string $path) {
|
||||
$source = file_get_contents(app_path($path));
|
||||
|
||||
expect($source)->toContain('escapeshellarg($serviceApplication->name');
|
||||
})->with([
|
||||
'restart action' => 'Actions/Service/RestartServiceApplication.php',
|
||||
'stop action' => 'Actions/Service/StopServiceApplication.php',
|
||||
]);
|
||||
|
||||
test('docker status helper escapes container names', function () {
|
||||
$source = file_get_contents(base_path('bootstrap/helpers/docker.php'));
|
||||
|
||||
expect($source)->toContain('function buildContainerStatusCommand')
|
||||
->and($source)->toContain('escapeshellarg("name={$container_id}")')
|
||||
->and($source)->toContain('escapeshellarg($container_id)');
|
||||
});
|
||||
|
||||
test('service application logs endpoint passes raw container name to docker helpers', function () {
|
||||
$source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php'));
|
||||
|
||||
expect($source)->toContain('getContainerStatus($server, $containerName)')
|
||||
->and($source)->toContain('getContainerLogs($server, $containerName, $lines)')
|
||||
->and($source)->not->toContain('$safeContainerName = escapeshellarg($containerName)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/build/start command validation', function () {
|
||||
test('rejects semicolon injection in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Note: Full Livewire integration tests require database setup
|
||||
|
|
@ -159,7 +162,7 @@
|
|||
|
||||
test('completes boarding when server is created from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component with from_onboarding flag
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
|
|
@ -176,13 +179,142 @@
|
|||
|
||||
test('boarding flag remains unchanged when not from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component without from_onboarding flag (default false)
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('from_onboarding', false);
|
||||
|
||||
// Boarding should still be enabled since it wasn't created from onboarding
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
});
|
||||
|
||||
test('uses the shared dropdown UI for advanced Hetzner options', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Advanced Hetzner options')
|
||||
->assertSeeHtml('dropdownOpen')
|
||||
->assertSeeHtml('x-ref="panel"')
|
||||
->assertSeeHtml('dark:bg-coolgray-100')
|
||||
->assertSeeHtml('dark:bg-transparent')
|
||||
->assertSeeHtml('@click.outside="if (! true) close()"');
|
||||
});
|
||||
|
||||
test('renders advanced Hetzner option controls inside the dropdown menu', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Extra SSH Keys')
|
||||
->assertSee('Firewalls')
|
||||
->assertSee('Private Networks')
|
||||
->assertSee('Enable Hetzner Backups')
|
||||
->assertSee('Add cloud-init script')
|
||||
->assertSee('additional 20% of the server monthly fee');
|
||||
});
|
||||
|
||||
test('shows the cloud init script name only when saving the script', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->set('show_cloud_init_script', true)
|
||||
->assertSee('Cloud-Init Script')
|
||||
->assertSee('Save this script for later use')
|
||||
->assertDontSee('Script name...')
|
||||
->set('save_cloud_init_script', true)
|
||||
->assertSee('Script name...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hetzner data loading', function () {
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('loads firewalls and networks for the selected Hetzner token', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'locations' => [
|
||||
['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'server_types' => [
|
||||
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'images' => [
|
||||
['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
[
|
||||
'id' => 456,
|
||||
'name' => 'private-eu',
|
||||
'ip_range' => '10.0.0.0/16',
|
||||
'subnets' => [
|
||||
['type' => 'cloud', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->call('nextStep')
|
||||
->assertSet('current_step', 2);
|
||||
|
||||
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('rejects submitting without a public IP protocol before calling Hetzner', function () {
|
||||
Http::fake();
|
||||
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->set('server_name', 'test-server')
|
||||
->set('selected_location', 'nbg1')
|
||||
->set('selected_server_type', 'cx11')
|
||||
->set('selected_image', 15512617)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->set('enable_ipv4', false)
|
||||
->set('enable_ipv6', false)
|
||||
->call('submit')
|
||||
->assertHasErrors(['enable_ipv4', 'enable_ipv6']);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
339
tests/Feature/ServiceApplicationsApiTest.php
Normal file
339
tests/Feature/ServiceApplicationsApiTest.php
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createBearerTokenForServiceApplicationsApiTest(User $user, Team $team, array $abilities = ['*']): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createServiceWithApplicationForApiTest(object $ctx): object
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $ctx->environment->id,
|
||||
'server_id' => $ctx->server->id,
|
||||
'destination_id' => $ctx->destination->id,
|
||||
'destination_type' => $ctx->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
|
||||
$sa = ServiceApplication::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'web',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
return (object) ['service' => $service, 'serviceApplication' => $sa];
|
||||
}
|
||||
|
||||
function createServiceWithoutApplicationsForApiTest(object $ctx): Service
|
||||
{
|
||||
return Service::factory()->create([
|
||||
'environment_id' => $ctx->environment->id,
|
||||
'server_id' => $ctx->server->id,
|
||||
'destination_id' => $ctx->destination->id,
|
||||
'destination_type' => $ctx->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications', function () {
|
||||
test('returns empty array when service has no applications', function () {
|
||||
$service = createServiceWithoutApplicationsForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([]);
|
||||
});
|
||||
|
||||
test('lists service applications for the service', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]);
|
||||
});
|
||||
|
||||
test('returns 404 when service does not exist', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
|
||||
test('does not list applications from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherServer->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
|
||||
$otherCtx = (object) [
|
||||
'server' => $otherServer,
|
||||
'destination' => StandaloneDocker::where('server_id', $otherServer->id)->first(),
|
||||
'environment' => Environment::factory()->create([
|
||||
'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id,
|
||||
]),
|
||||
];
|
||||
$ctx = createServiceWithApplicationForApiTest($otherCtx);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
test('returns 404 for unknown service', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid is not under service', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service application not found.']);
|
||||
});
|
||||
|
||||
test('returns service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
test('returns 400 without valid token', function () {
|
||||
$response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [
|
||||
'human_name' => 'x',
|
||||
], ['Accept' => 'application/json', 'Content-Type' => 'application/json']);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('updates human_name', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'human_name' => 'Web UI',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['human_name' => 'Web UI']);
|
||||
$ctx->serviceApplication->refresh();
|
||||
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
|
||||
});
|
||||
|
||||
test('returns 422 for invalid url scheme', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'url' => 'ftp://example.com',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 when enabling log drain but server has no log drain', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'is_log_drain_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain');
|
||||
});
|
||||
|
||||
test('read-only token cannot update a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$readOnlyToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readOnlyToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'human_name' => 'Blocked',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('returns 409 for domain conflicts unless forced', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$ctx->serviceApplication->update(['fqdn' => 'http://used.example.com']);
|
||||
$otherApplication = ServiceApplication::create([
|
||||
'name' => 'admin',
|
||||
'service_id' => $ctx->service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}", [
|
||||
'url' => 'http://used.example.com',
|
||||
])->assertStatus(409);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}?force_domain_override=true", [
|
||||
'url' => 'http://used.example.com',
|
||||
])->assertStatus(200);
|
||||
|
||||
$otherApplication->refresh();
|
||||
expect($otherApplication->fqdn)->toBe('http://used.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () {
|
||||
test('returns 400 without valid token', function () {
|
||||
$response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('queues restart for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application restart request queued.']);
|
||||
RestartServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () {
|
||||
test('write token cannot start a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$writeToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['write']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('queues deploy for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application deploy request queued.']);
|
||||
DeployServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () {
|
||||
test('queues stop for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application stop request queued.']);
|
||||
StopServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () {
|
||||
test('returns 400 when server is not functional', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs");
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonFragment(['message' => 'Server is not functional.']);
|
||||
});
|
||||
});
|
||||
547
tests/Feature/TagApiTest.php
Normal file
547
tests/Feature/TagApiTest.php
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\Tags as TagsComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function tagApiAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/tags', function () {
|
||||
test('returns all tags for current team', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
Tag::create(['name' => 'staging', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
$response->assertJsonFragment(['name' => 'staging']);
|
||||
});
|
||||
|
||||
test('returns empty array when no tags exist', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
|
||||
test('does not return tags from other teams', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
|
||||
Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'my-tag']);
|
||||
$response->assertJsonMissing(['name' => 'other-team-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
test('returns tags for an application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/non-existent-uuid/tags');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('returns empty array when application has no tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
test('adds a single tag via tag_name', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('adds multiple tags via tag_names array', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_names' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(2);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('reuses existing team tag instead of creating duplicate', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('rejects tag_name shorter than 2 characters', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'x',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('rejects both tag_name and tag_names provided simultaneously', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
'tag_names' => ['staging'],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
|
||||
});
|
||||
|
||||
test('skips duplicate tag already on resource', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/non-existent-uuid/tags', [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('garbage-collects orphaned tag', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
expect(Tag::find($tag->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns 404 when deleting a tag that is not attached to the application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('keeps tag if still used by other resources', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent tag', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/databases/{uuid}/tags', function () {
|
||||
test('returns tags for a database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/databases/{$database->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'database-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/tags', function () {
|
||||
test('adds tag to database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/databases/{$database->uuid}/tags", [
|
||||
'tag_name' => 'database-tag',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($database->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/tags', function () {
|
||||
test('returns tags for a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
|
||||
$service->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'service-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/tags', function () {
|
||||
test('adds tag to service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/services/{$service->uuid}/tags", [
|
||||
'tag_name' => 'service-tag',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect($service->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
|
||||
$service->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($service->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource creation tags', function () {
|
||||
test('adds tags while creating a public application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'autogenerate_domain' => false,
|
||||
'tags' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$application = Application::whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->tags()->pluck('name')->sort()->values()->all())
|
||||
->toBe(['frontend', 'production']);
|
||||
});
|
||||
|
||||
test('adds tags while creating a postgresql database', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/databases/postgresql', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'instant_deploy' => false,
|
||||
'tags' => ['database', 'production'],
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$database = StandalonePostgresql::whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($database->tags()->pluck('name')->sort()->values()->all())
|
||||
->toBe(['database', 'production']);
|
||||
});
|
||||
|
||||
test('adds tags while creating a docker compose service', function () {
|
||||
$compose = base64_encode(<<<'YAML'
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
YAML);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/services', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'docker_compose_raw' => $compose,
|
||||
'instant_deploy' => false,
|
||||
'tags' => ['service', 'production'],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$service = Service::whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($service->tags()->pluck('name')->sort()->values()->all())
|
||||
->toBe(['production', 'service']);
|
||||
});
|
||||
|
||||
test('rejects creation tags that are too short after sanitization', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/databases/postgresql', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'instant_deploy' => false,
|
||||
'tags' => ['<b>x</b>'],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag name sanitization', function () {
|
||||
test('strips HTML tags from tag names', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => '<script>alert("xss")</script>production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['name' => 'alert("xss")production']);
|
||||
$response->assertJsonMissing(['name' => '<script>']);
|
||||
});
|
||||
|
||||
test('lowercases tag names', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'Production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
|
||||
test('rejects tag names that are too short after sanitization', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => '<b>x</b>',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
expect($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag uniqueness', function () {
|
||||
test('prevents duplicate tag names per team at the database level', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
expect(fn () => Tag::create(['name' => 'production', 'team_id' => $this->team->id]))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag cleanup across resource types', function () {
|
||||
test('does not delete a tag still attached to a database when removed from an application in the UI', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
Livewire::test(TagsComponent::class, ['resource' => $this->application])
|
||||
->call('deleteTag', (string) $tag->id);
|
||||
|
||||
expect(Tag::find($tag->id))->not->toBeNull()
|
||||
->and($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-resource tag isolation', function () {
|
||||
test('cannot delete tag via wrong resource', function () {
|
||||
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
// Tag is not attached to $this->application, but we try to delete via it
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
// Tag should still exist on the other app (detach on non-attached is a no-op)
|
||||
expect($otherApp->tags()->count())->toBe(1);
|
||||
// Tag should NOT be garbage-collected since otherApp still uses it
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue