From 4459c9f73dcba1e0ff65d18d6fb25aeb089dd1c2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai Date: Tue, 2 Jul 2024 16:12:04 +0200 Subject: [PATCH] feat: api api api api api api --- app/Actions/Application/LoadComposeFile.php | 16 + app/Actions/Service/RestartService.php | 18 + app/Enums/BuildPackTypes.php | 2 +- app/Enums/NewResourceTypes.php | 2 +- app/Events/DatabaseStatusChanged.php | 16 +- app/Events/ServiceStatusChanged.php | 16 +- .../Api/ApplicationsController.php | 191 ++++++++--- .../Controllers/Api/DatabasesController.php | 85 ++++- .../Controllers/Api/SecurityController.php | 2 +- .../Controllers/Api/ServicesController.php | 314 ++++++++++++++++++ ...piToken.php => IgnoreReadOnlyApiToken.php} | 2 +- app/Jobs/ApplicationDeploymentJob.php | 2 +- routes/api.php | 55 ++- 13 files changed, 636 insertions(+), 85 deletions(-) create mode 100644 app/Actions/Application/LoadComposeFile.php create mode 100644 app/Actions/Service/RestartService.php create mode 100644 app/Http/Controllers/Api/ServicesController.php rename app/Http/Middleware/{ReadOnlyApiToken.php => IgnoreReadOnlyApiToken.php} (96%) diff --git a/app/Actions/Application/LoadComposeFile.php b/app/Actions/Application/LoadComposeFile.php new file mode 100644 index 000000000..838b541e2 --- /dev/null +++ b/app/Actions/Application/LoadComposeFile.php @@ -0,0 +1,16 @@ +loadComposeFile(); + } +} diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php new file mode 100644 index 000000000..1b6a5c32c --- /dev/null +++ b/app/Actions/Service/RestartService.php @@ -0,0 +1,18 @@ +user()->id ?? null; } if (is_null($userId)) { - throw new \RuntimeException('User id is null'); + return false; } $this->userId = $userId; } - public function broadcastOn(): array + public function broadcastOn(): ?array { - return [ - new PrivateChannel("user.{$this->userId}"), - ]; + if ($this->userId) { + return [ + new PrivateChannel("user.{$this->userId}"), + ]; + } + + return null; } } diff --git a/app/Events/ServiceStatusChanged.php b/app/Events/ServiceStatusChanged.php index e3e24a248..dc965d0a2 100644 --- a/app/Events/ServiceStatusChanged.php +++ b/app/Events/ServiceStatusChanged.php @@ -12,7 +12,7 @@ class ServiceStatusChanged implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; - public $userId; + public ?string $userId = null; public function __construct($userId = null) { @@ -20,15 +20,19 @@ public function __construct($userId = null) $userId = auth()->user()->id ?? null; } if (is_null($userId)) { - throw new \Exception('User id is null'); + return false; } $this->userId = $userId; } - public function broadcastOn(): array + public function broadcastOn(): ?array { - return [ - new PrivateChannel("user.{$this->userId}"), - ]; + if ($this->userId) { + return [ + new PrivateChannel("user.{$this->userId}"), + ]; + } + + return null; } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 9626b0488..916183d05 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers\Api; +use App\Actions\Application\LoadComposeFile; use App\Actions\Application\StopApplication; +use App\Actions\Service\StartService; use App\Enums\BuildPackTypes; use App\Enums\NewResourceTypes; use App\Http\Controllers\Controller; @@ -64,7 +66,7 @@ public function applications(Request $request) public function create_application(Request $request) { - $allowedFields = ['project_uuid', 'environment_name', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', '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']; + $allowedFields = ['project_uuid', 'environment_name', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'docker_compose_location', 'docker_compose', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); @@ -136,6 +138,12 @@ public function create_application(Request $request) 'git_branch' => 'string|required', 'build_pack' => [Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'docker_compose_location' => 'string', + 'docker_compose' => 'string|nullable', + 'docker_compose_raw' => 'string|nullable', + 'docker_compose_domains' => 'array|nullable', + 'docker_compose_custom_start_command' => 'string|nullable', + 'docker_compose_custom_build_command' => 'string|nullable', ]); if ($validator->fails()) { return response()->json([ @@ -152,6 +160,19 @@ public function create_application(Request $request) removeUnnecessaryFieldsFromRequest($request); $application->fill($request->all()); + $dockerComposeDomainsJson = collect(); + if ($request->has('docker_compose_domains')) { + $dockerComposeDomains = collect($request->docker_compose_domains); + if ($dockerComposeDomains->count() > 0) { + $dockerComposeDomains->each(function ($domain, $key) use ($dockerComposeDomainsJson) { + $dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]); + }); + } + $request->offsetUnset('docker_compose_domains'); + } + if ($dockerComposeDomainsJson->count() > 0) { + $application->docker_compose_domains = $dockerComposeDomainsJson; + } $application->fqdn = $fqdn; $application->destination_id = $destination->id; @@ -168,6 +189,10 @@ public function create_application(Request $request) no_questions_asked: true, is_api: true, ); + } else { + if ($application->build_pack === 'dockercompose') { + LoadComposeFile::dispatch($application); + } } return response()->json([ @@ -185,6 +210,13 @@ public function create_application(Request $request) 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', + 'watch_paths' => 'string|nullable', + 'docker_compose_location' => 'string', + 'docker_compose' => 'string|nullable', + 'docker_compose_raw' => 'string|nullable', + 'docker_compose_domains' => 'array|nullable', + 'docker_compose_custom_start_command' => 'string|nullable', + 'docker_compose_custom_build_command' => 'string|nullable', ]); if ($validator->fails()) { return response()->json([ @@ -210,6 +242,24 @@ public function create_application(Request $request) $application->fill($request->all()); + $dockerComposeDomainsJson = collect(); + if ($request->has('docker_compose_domains')) { + $yaml = Yaml::parse($application->docker_compose_raw); + $services = data_get($yaml, 'services'); + $dockerComposeDomains = collect($request->docker_compose_domains); + if ($dockerComposeDomains->count() > 0) { + $dockerComposeDomains->each(function ($domain, $key) use ($services, $dockerComposeDomainsJson) { + $name = data_get($domain, 'name'); + if (data_get($services, $name)) { + $dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]); + } + }); + } + $request->offsetUnset('docker_compose_domains'); + } + if ($dockerComposeDomainsJson->count() > 0) { + $application->docker_compose_domains = $dockerComposeDomainsJson; + } $application->fqdn = $fqdn; $application->git_repository = $gitRepository; $application->destination_id = $destination->id; @@ -228,6 +278,10 @@ public function create_application(Request $request) no_questions_asked: true, is_api: true, ); + } else { + if ($application->build_pack === 'dockercompose') { + LoadComposeFile::dispatch($application); + } } return response()->json([ @@ -245,7 +299,15 @@ public function create_application(Request $request) 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', + 'watch_paths' => 'string|nullable', + 'docker_compose_location' => 'string', + 'docker_compose' => 'string|nullable', + 'docker_compose_raw' => 'string|nullable', + 'docker_compose_domains' => 'array|nullable', + 'docker_compose_custom_start_command' => 'string|nullable', + 'docker_compose_custom_build_command' => 'string|nullable', ]); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -265,6 +327,25 @@ public function create_application(Request $request) removeUnnecessaryFieldsFromRequest($request); $application->fill($request->all()); + + $dockerComposeDomainsJson = collect(); + if ($request->has('docker_compose_domains')) { + $yaml = Yaml::parse($application->docker_compose_raw); + $services = data_get($yaml, 'services'); + $dockerComposeDomains = collect($request->docker_compose_domains); + if ($dockerComposeDomains->count() > 0) { + $dockerComposeDomains->each(function ($domain, $key) use ($services, $dockerComposeDomainsJson) { + $name = data_get($domain, 'name'); + if (data_get($services, $name)) { + $dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]); + } + }); + } + $request->offsetUnset('docker_compose_domains'); + } + if ($dockerComposeDomainsJson->count() > 0) { + $application->docker_compose_domains = $dockerComposeDomainsJson; + } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; $application->destination_id = $destination->id; @@ -281,6 +362,10 @@ public function create_application(Request $request) no_questions_asked: true, is_api: true, ); + } else { + if ($application->build_pack === 'dockercompose') { + LoadComposeFile::dispatch($application); + } } return response()->json([ @@ -415,14 +500,30 @@ public function create_application(Request $request) 'success' => true, 'data' => serializeApiResponse($application), ]); - } elseif ($type === 'docker-compose-empty') { + } elseif ($type === 'dockercompose') { + $allowedFields = ['project_uuid', 'environment_name', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw']; + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + 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([ + 'success' => false, + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } if (! $request->has('name')) { $request->offsetSet('name', 'service'.new Cuid2(7)); } $validator = customApiValidator($request->all(), [ sharedDataApplications(), - 'docker_compose' => 'string|required', - 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', + 'docker_compose_raw' => 'string|required', ]); if ($validator->fails()) { return response()->json([ @@ -435,25 +536,25 @@ public function create_application(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - if (! isBase64Encoded($request->docker_compose)) { + if (! isBase64Encoded($request->docker_compose_raw)) { return response()->json([ 'success' => false, 'message' => 'Validation failed.', 'errors' => [ - 'docker_compose' => 'The docker_compose should be base64 encoded.', + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', ], ], 422); } - $dockerCompose = base64_decode($request->docker_compose); - if (mb_detect_encoding($dockerCompose, 'ASCII', true) === false) { + $dockerComposeRaw = base64_decode($request->docker_compose_raw); + if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ - 'docker_compose' => 'The docker_compose should be base64 encoded.', + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', ], ], 422); } - $dockerCompose = base64_decode($request->docker_compose); + $dockerCompose = base64_decode($request->docker_compose_raw); $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); // $isValid = validateComposeFile($dockerComposeRaw, $server_id); @@ -463,8 +564,8 @@ public function create_application(Request $request) $service = new Service(); removeUnnecessaryFieldsFromRequest($request); - $service->name = $request->name; - $service->description = $request->description; + $service->fill($request->all()); + $service->docker_compose_raw = $dockerComposeRaw; $service->environment_id = $environment->id; $service->server_id = $server->id; @@ -474,16 +575,7 @@ public function create_application(Request $request) $service->name = "service-$service->uuid"; $service->parse(isNew: true); - // if ($instantDeploy) { - // $deployment_uuid = new Cuid2(7); - - // queue_application_deployment( - // application: $application, - // deployment_uuid: $deployment_uuid, - // no_questions_asked: true, - // is_api: true, - // ); - // } + StartService::dispatch($service); return response()->json([ 'success' => true, @@ -572,7 +664,7 @@ public function update_by_uuid(Request $request) ], 404); } $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'redirect']; + $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', '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', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect']; $validator = customApiValidator($request->all(), [ sharedDataApplications(), @@ -583,7 +675,7 @@ public function update_by_uuid(Request $request) 'docker_compose_location' => 'string', 'docker_compose' => 'string|nullable', 'docker_compose_raw' => 'string|nullable', - // 'docker_compose_domains' => 'string|nullable', // must be like: "{\"api\":{\"domain\":\"http:\\/\\/b8sos8k.127.0.0.1.sslip.io\"}}" + 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', 'docker_compose_custom_build_command' => 'string|nullable', ]); @@ -635,8 +727,26 @@ public function update_by_uuid(Request $request) $request->offsetUnset('domains'); } + $dockerComposeDomainsJson = collect(); + if ($request->has('docker_compose_domains')) { + $yaml = Yaml::parse($application->docker_compose_raw); + $services = data_get($yaml, 'services'); + $dockerComposeDomains = collect($request->docker_compose_domains); + if ($dockerComposeDomains->count() > 0) { + $dockerComposeDomains->each(function ($domain, $key) use ($services, $dockerComposeDomainsJson) { + $name = data_get($domain, 'name'); + if (data_get($services, $name)) { + $dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]); + } + }); + } + $request->offsetUnset('docker_compose_domains'); + } $data = $request->all(); data_set($data, 'fqdn', $domains); + if ($dockerComposeDomainsJson->count() > 0) { + data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); + } $application->fill($data); $application->save(); @@ -983,7 +1093,7 @@ public function delete_env_by_uuid(Request $request) 'message' => 'Environment variable not found.', ], 404); } - $found_env->delete(); + $found_env->forceDelete(); return response()->json([ 'success' => true, @@ -1038,7 +1148,6 @@ public function action_stop(Request $request) return invalidTokenResponse(); } $uuid = $request->route('uuid'); - $sync = $request->query->get('sync') ?? false; if (! $uuid) { return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); } @@ -1046,25 +1155,14 @@ public function action_stop(Request $request) if (! $application) { return response()->json(['success' => false, 'message' => 'Application not found.'], 404); } - if ($sync) { - StopApplication::run($application); + StopApplication::dispatch($application); - return response()->json( - [ - 'success' => true, - 'message' => 'Stopped the application.', - ], - ); - } else { - StopApplication::dispatch($application); - - return response()->json( - [ - 'success' => true, - 'message' => 'Stopping request queued.', - ], - ); - } + return response()->json( + [ + 'success' => true, + 'message' => 'Application stopping request queued.', + ], + ); } public function action_restart(Request $request) @@ -1108,11 +1206,6 @@ private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); - // Default build pack is nixpacks - if (! $request->has('build_pack')) { - $request->offsetSet('build_pack', 'nixpacks'); - } - // Validate ports_mappings if ($request->has('ports_mappings')) { $ports = []; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 1a5106d7c..5623cd3ed 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabase; @@ -557,11 +558,93 @@ public function delete_by_uuid(Request $request) return response()->json(['success' => false, 'message' => 'Database not found.'], 404); } StopDatabase::dispatch($database); - $database->delete(); + $database->forceDelete(); return response()->json([ 'success' => true, 'message' => 'Database deletion request queued.', ]); } + + public function action_deploy(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['success' => false, 'message' => 'Database not found.'], 404); + } + if (str($database->status)->contains('running')) { + return response()->json(['success' => false, 'message' => 'Database is already running.'], 400); + } + StartDatabase::dispatch($database); + + return response()->json( + [ + 'success' => true, + 'message' => 'Database starting request queued.', + ], + 200 + ); + } + + public function action_stop(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['success' => false, 'message' => 'Database not found.'], 404); + } + if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { + return response()->json(['success' => false, 'message' => 'Database is already stopped.'], 400); + } + StopDatabase::dispatch($database); + + return response()->json( + [ + 'success' => true, + 'message' => 'Database stopping request queued.', + ], + 200 + ); + } + + public function action_restart(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['success' => false, 'message' => 'Database not found.'], 404); + } + RestartDatabase::dispatch($database); + + return response()->json( + [ + 'success' => true, + 'message' => 'Database restarting request queued.', + ], + 200 + ); + + } } diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index 51c6fee26..5e07c5a73 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -150,7 +150,7 @@ public function delete_key(Request $request) if (is_null($key)) { return response()->json(['success' => false, 'message' => 'Key not found.'], 404); } - $key->delete(); + $key->forceDelete(); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php new file mode 100644 index 000000000..dde5cabde --- /dev/null +++ b/app/Http/Controllers/Api/ServicesController.php @@ -0,0 +1,314 @@ +user()->currentAccessToken(); + if ($token->can('view:sensitive')) { + return serializeApiResponse($service); + } + + $service->makeHidden([ + 'docker_compose_raw', + 'docker_compose', + ]); + + return serializeApiResponse($service); + } + + public function services(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $projects = Project::where('team_id', $teamId)->get(); + $services = collect(); + foreach ($projects as $project) { + $services->push($project->services()->get()); + } + foreach ($services as $service) { + $service = $this->removeSensitiveData($service); + } + + return response()->json([ + 'success' => true, + 'data' => $services, + ]); + } + + public function create_service(Request $request) + { + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'server_uuid', 'destination_uuid', 'instant_deploy']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = customApiValidator($request->all(), [ + 'type' => 'string|required', + 'project_uuid' => 'string|required', + 'environment_name' => 'string|required', + 'server_uuid' => 'string|required', + 'destination_uuid' => 'string', + 'name' => 'string|max:255', + 'description' => 'string|nullable', + 'instant_deploy' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + 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([ + 'success' => false, + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + $serverUuid = $request->server_uuid; + $instantDeploy = $request->instant_deploy ?? false; + if ($request->is_public && ! $request->public_port) { + $request->offsetSet('is_public', false); + } + $project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first(); + if (! $project) { + return response()->json(['succes' => false, 'message' => 'Project not found.'], 404); + } + $environment = $project->environments()->where('name', $request->environment_name)->first(); + if (! $environment) { + return response()->json(['success' => false, 'message' => 'Environment not found.'], 404); + } + $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); + if (! $server) { + return response()->json(['success' => false, 'message' => 'Server not found.'], 404); + } + $destinations = $server->destinations(); + if ($destinations->count() == 0) { + return response()->json(['success' => false, 'message' => 'Server has no destinations.'], 400); + } + if ($destinations->count() > 1 && ! $request->has('destination_uuid')) { + return response()->json(['success' => false, 'message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); + } + $destination = $destinations->first(); + $services = get_service_templates(); + $serviceKeys = $services->keys(); + if ($serviceKeys->contains($request->type)) { + $oneClickServiceName = $request->type; + $oneClickService = data_get($services, "$oneClickServiceName.compose"); + $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null); + if ($oneClickDotEnvs) { + $oneClickDotEnvs = str(base64_decode($oneClickDotEnvs))->split('/\r\n|\r|\n/')->filter(function ($value) { + return ! empty($value); + }); + } + if ($oneClickService) { + $service_payload = [ + 'name' => "$oneClickServiceName-".str()->random(10), + 'docker_compose_raw' => base64_decode($oneClickService), + 'environment_id' => $environment->id, + 'service_type' => $oneClickServiceName, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + if ($oneClickServiceName === 'cloudflared') { + data_set($service_payload, 'connect_to_docker_network', true); + } + $service = Service::create($service_payload); + $service->name = "$oneClickServiceName-".$service->uuid; + $service->save(); + if ($oneClickDotEnvs?->count() > 0) { + $oneClickDotEnvs->each(function ($value) use ($service) { + $key = str()->before($value, '='); + $value = str(str()->after($value, '=')); + $generatedValue = $value; + if ($value->contains('SERVICE_')) { + $command = $value->after('SERVICE_')->beforeLast('_'); + $generatedValue = generateEnvValue($command->value(), $service); + } + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $generatedValue, + 'service_id' => $service->id, + 'is_build_time' => false, + 'is_preview' => false, + ]); + }); + } + $service->parse(isNew: true); + if ($instantDeploy) { + StartService::dispatch($service); + } + $domains = $service->applications()->get()->pluck('fqdn')->sort(); + $domains = $domains->map(function ($domain) { + return str($domain)->beforeLast(':')->value(); + }); + + return response()->json([ + 'success' => true, + 'message' => 'Service created.', + 'data' => $this->removeSensitiveData([ + 'id' => $service->id, + 'uuid' => $service->uuid, + 'name' => $service->name, + 'domains' => $domains, + ]), + ]); + } + + return response()->json(['success' => false, 'message' => 'Service not found.'], 404); + } else { + return response()->json(['success' => false, 'message' => 'Invalid service type.', 'valid_service_types' => $serviceKeys], 400); + } + + return response()->json(['success' => false, 'message' => 'Invalid service type.'], 400); + } + + public function service_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if (! $request->uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 404); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['success' => false, 'message' => 'Service not found.'], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $this->removeSensitiveData($service), + ]); + } + + public function delete_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if (! $request->uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 404); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['success' => false, 'message' => 'Service not found.'], 404); + } + DeleteResourceJob::dispatch($service); + + return response()->json([ + 'success' => true, + 'message' => 'Service deletion request queued.', + ]); + } + + public function action_deploy(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['success' => false, 'message' => 'Service not found.'], 404); + } + if (str($service->status())->contains('running')) { + return response()->json(['success' => false, 'message' => 'Service is already running.'], 400); + } + StartService::dispatch($service); + + return response()->json( + [ + 'success' => true, + 'message' => 'Service starting request queued.', + ], + 200 + ); + } + + public function action_stop(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['success' => false, 'message' => 'Service not found.'], 404); + } + if (str($service->status())->contains('stopped') || str($service->status())->contains('exited')) { + return response()->json(['success' => false, 'message' => 'Service is already stopped.'], 400); + } + StopService::dispatch($service); + + return response()->json( + [ + 'success' => true, + 'message' => 'Service stopping request queued.', + ], + 200 + ); + } + + public function action_restart(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['success' => false, 'message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['success' => false, 'message' => 'Service not found.'], 404); + } + RestartService::dispatch($service); + + return response()->json( + [ + 'success' => true, + 'message' => 'Service restarting request queued.', + ], + 200 + ); + + } +} diff --git a/app/Http/Middleware/ReadOnlyApiToken.php b/app/Http/Middleware/IgnoreReadOnlyApiToken.php similarity index 96% rename from app/Http/Middleware/ReadOnlyApiToken.php rename to app/Http/Middleware/IgnoreReadOnlyApiToken.php index 447a406a6..c5c77dfba 100644 --- a/app/Http/Middleware/ReadOnlyApiToken.php +++ b/app/Http/Middleware/IgnoreReadOnlyApiToken.php @@ -6,7 +6,7 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -class ReadOnlyApiToken +class IgnoreReadOnlyApiToken { /** * Handle an incoming request. diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 229cb2532..e3bf281d8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -127,7 +127,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private string $dockerfile_location = '/Dockerfile'; - private string $docker_compose_location = '/docker-compose.yml'; + private string $docker_compose_location = '/docker-compose.yaml'; private ?string $docker_compose_custom_start_command = null; diff --git a/routes/api.php b/routes/api.php index b119ac636..6cc1c93d5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,10 +8,11 @@ use App\Http\Controllers\Api\ResourcesController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; +use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; +use App\Http\Middleware\IgnoreReadOnlyApiToken; use App\Http\Middleware\OnlyRootApiToken; -use App\Http\Middleware\ReadOnlyApiToken; use App\Models\InstanceSettings; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -83,13 +84,13 @@ Route::get('/projects/{uuid}/{environment_name}', [ProjectController::class, 'environment_details']); Route::get('/security/keys', [SecurityController::class, 'keys']); - Route::post('/security/keys', [SecurityController::class, 'create_key'])->middleware([ReadOnlyApiToken::class]); + Route::post('/security/keys', [SecurityController::class, 'create_key'])->middleware([IgnoreReadOnlyApiToken::class]); Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid']); - Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware([ReadOnlyApiToken::class]); - Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware([ReadOnlyApiToken::class]); + Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware([IgnoreReadOnlyApiToken::class]); - Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware([ReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware([IgnoreReadOnlyApiToken::class]); Route::get('/deployments', [DeployController::class, 'deployments']); Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid']); @@ -101,30 +102,48 @@ Route::get('/resources', [ResourcesController::class, 'resources']); Route::get('/applications', [ApplicationsController::class, 'applications']); - Route::post('/applications', [ApplicationsController::class, 'create_application'])->middleware([ReadOnlyApiToken::class]); + Route::post('/applications', [ApplicationsController::class, 'create_application'])->middleware([IgnoreReadOnlyApiToken::class]); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid']); - Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware([ReadOnlyApiToken::class]); - Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware([ReadOnlyApiToken::class]); + Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs_by_uuid']); - Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware([ReadOnlyApiToken::class]); - Route::post('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware([ReadOnlyApiToken::class]); + Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::post('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware([IgnoreReadOnlyApiToken::class]); Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid']); - Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware([ReadOnlyApiToken::class]); + Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); - Route::match(['get', 'post'], '/applications/{uuid}/action/deploy', [ApplicationsController::class, 'action_deploy'])->middleware([ReadOnlyApiToken::class]); - Route::match(['get', 'post'], '/applications/{uuid}/action/restart', [ApplicationsController::class, 'action_restart'])->middleware([ReadOnlyApiToken::class]); - Route::match(['get', 'post'], '/applications/{uuid}/action/stop', [ApplicationsController::class, 'action_stop'])->middleware([ReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/applications/{uuid}/deploy', [ApplicationsController::class, 'action_deploy'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware([IgnoreReadOnlyApiToken::class]); Route::get('/databases', [DatabasesController::class, 'databases']); - Route::post('/databases', [DatabasesController::class, 'create_database'])->middleware([ReadOnlyApiToken::class]); + Route::post('/databases', [DatabasesController::class, 'create_database'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid']); + Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); - Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware([ReadOnlyApiToken::class]); - Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware([ReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/databases/{uuid}/deploy', [DatabasesController::class, 'action_deploy'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware([IgnoreReadOnlyApiToken::class]); - Route::delete('/envs/{env_uuid}', [EnvironmentVariablesController::class, 'delete_env_by_uuid'])->middleware([ReadOnlyApiToken::class]); + Route::get('/services', [ServicesController::class, 'services']); + Route::post('/services', [ServicesController::class, 'create_service'])->middleware([IgnoreReadOnlyApiToken::class]); + + Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid']); + // Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); + + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/services/{uuid}/deploy', [ServicesController::class, 'action_deploy'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware([IgnoreReadOnlyApiToken::class]); + Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware([IgnoreReadOnlyApiToken::class]); + + Route::delete('/envs/{env_uuid}', [EnvironmentVariablesController::class, 'delete_env_by_uuid'])->middleware([IgnoreReadOnlyApiToken::class]); });