diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php new file mode 100644 index 0000000000..363166bccf --- /dev/null +++ b/app/Actions/Service/DeployServiceApplication.php @@ -0,0 +1,65 @@ +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'); + } +} diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php new file mode 100644 index 0000000000..c83cbe660d --- /dev/null +++ b/app/Actions/Service/RestartServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); + + instant_remote_process([ + "docker restart {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php new file mode 100644 index 0000000000..cc1afbb96d --- /dev/null +++ b/app/Actions/Service/StopServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); + + instant_remote_process([ + "docker stop {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php new file mode 100644 index 0000000000..7bc04dfdbb --- /dev/null +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -0,0 +1,107 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php new file mode 100644 index 0000000000..a144b3ea6d --- /dev/null +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -0,0 +1,766 @@ +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); + } +} diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index c730ab0c67..491b9e4246 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -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. */ diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php new file mode 100644 index 0000000000..cdeb75e58d --- /dev/null +++ b/app/Support/ServiceComposeUrl.php @@ -0,0 +1,55 @@ +, 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]; + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index e6643a3375..0440ae352b 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -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'; } diff --git a/openapi.json b/openapi.json index cccf5a1399..d45d0fb49a 100644 --- a/openapi.json +++ b/openapi.json @@ -11434,6 +11434,511 @@ ] } }, + "\/services\/{uuid}\/applications": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "List service applications", + "description": "List compose service applications (containers) for a single service.", + "operationId": "list-service-applications-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service applications for this service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Get service application", + "description": "Get a single compose service application by service UUID and application UUID.", + "operationId": "get-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Service application.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Service applications" + ], + "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}).", + "operationId": "patch-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "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": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "content": { + "application\/json": { + "schema": { + "properties": { + "url": { + "description": "Comma-separated list of URLs (e.g. \"http:\/\/app.example.com:8080,https:\/\/app2.example.com\"). Stored as fqdn.", + "type": [ + "string", + "null" + ] + }, + "human_name": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "exclude_from_status": { + "type": [ + "boolean", + "null" + ] + }, + "is_log_drain_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "is_gzip_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "is_stripprefix_enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated service application.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts (unless force_domain_override)." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/logs": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Get service application logs", + "description": "Get Docker logs for a single compose service container.", + "operationId": "get-service-application-logs-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Logs.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/start": { + "get": { + "tags": [ + "Service applications" + ], + "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.", + "operationId": "start-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "When true, passes --build to docker compose up.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "latest", + "in": "query", + "description": "When true, pulls the image for this compose service before up.", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Deploy request queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/restart": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Restart service application container", + "description": "Restarts a single compose service container (docker restart).", + "operationId": "restart-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/applications\/{app_uuid}\/stop": { + "get": { + "tags": [ + "Service applications" + ], + "summary": "Stop service application container", + "description": "Stops a single compose service container (docker stop).", + "operationId": "stop-service-application-by-service-and-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app_uuid", + "in": "path", + "description": "Service application UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Stop queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "501": { + "description": "Swarm not supported." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services": { "get": { "tags": [ @@ -14683,6 +15188,10 @@ "name": "Servers", "description": "Servers" }, + { + "name": "Service applications", + "description": "Service applications" + }, { "name": "Services", "description": "Services" diff --git a/openapi.yaml b/openapi.yaml index 619eed53c6..9b7288eba3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7297,6 +7297,330 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/applications': + get: + tags: + - 'Service applications' + summary: 'List service applications' + description: 'List compose service applications (containers) for a single service.' + operationId: list-service-applications-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service applications for this service.' + content: + application/json: + schema: + type: array + items: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}': + get: + tags: + - 'Service applications' + summary: 'Get service application' + description: 'Get a single compose service application by service UUID and application UUID.' + operationId: get-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Service application.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Service applications' + 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}).' + operationId: patch-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + 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: + type: boolean + default: false + requestBody: + content: + application/json: + schema: + properties: + url: + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + type: [string, 'null'] + human_name: + type: [string, 'null'] + description: + type: [string, 'null'] + image: + type: [string, 'null'] + exclude_from_status: + type: [boolean, 'null'] + is_log_drain_enabled: + type: [boolean, 'null'] + is_gzip_enabled: + type: [boolean, 'null'] + is_stripprefix_enabled: + type: [boolean, 'null'] + type: object + responses: + '200': + description: 'Updated service application.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Domain conflicts (unless force_domain_override).' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/logs': + get: + tags: + - 'Service applications' + summary: 'Get service application logs' + description: 'Get Docker logs for a single compose service container.' + operationId: get-service-application-logs-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: lines + in: query + description: 'Number of lines to show from the end of the logs.' + required: false + schema: + type: integer + format: int32 + default: 100 + responses: + '200': + description: Logs. + content: + application/json: + schema: + properties: + logs: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/start': + get: + tags: + - 'Service applications' + 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.' + operationId: start-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + - + name: force + in: query + description: 'When true, passes --build to docker compose up.' + required: false + schema: + type: boolean + default: false + - + name: latest + in: query + description: 'When true, pulls the image for this compose service before up.' + required: false + schema: + type: boolean + default: false + responses: + '200': + description: 'Deploy request queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/restart': + get: + tags: + - 'Service applications' + summary: 'Restart service application container' + description: 'Restarts a single compose service container (docker restart).' + operationId: restart-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Restart queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] + '/services/{uuid}/applications/{app_uuid}/stop': + get: + tags: + - 'Service applications' + summary: 'Stop service application container' + description: 'Stops a single compose service container (docker stop).' + operationId: stop-service-application-by-service-and-app-uuid + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: app_uuid + in: path + description: 'Service application UUID.' + required: true + schema: + type: string + responses: + '200': + description: 'Stop queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '501': + description: 'Swarm not supported.' + security: + - + bearerAuth: [] /services: get: tags: @@ -9436,6 +9760,9 @@ tags: - name: Servers description: Servers + - + name: 'Service applications' + description: 'Service applications' - name: Services description: Services diff --git a/routes/api.php b/routes/api.php index 2564b9389f..7f14c8c2fc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ 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; @@ -219,6 +220,14 @@ 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']); diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index 45101635b7..42c08c29d4 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -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(); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php new file mode 100644 index 0000000000..d65967bd80 --- /dev/null +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -0,0 +1,339 @@ + 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.']); + }); +});