feat(api): Service Application Management API (#9249)

This commit is contained in:
Andras Bacsai 2026-07-07 18:05:57 +02:00 committed by GitHub
commit 0cca267953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2279 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,766 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Service\UpdateServiceApplicationFromApi;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class ServiceApplicationsController extends Controller
{
private function removeSensitiveData(ServiceApplication $serviceApplication): array
{
$serviceApplication->makeHidden([
'id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
$serialized = serializeApiResponse($serviceApplication);
if ($serialized instanceof Collection) {
return $serialized->all();
}
return (array) $serialized;
}
private function resolveService(Request $request, int $teamId): ?Service
{
$uuid = $request->route('uuid');
if (! $uuid) {
return null;
}
return Service::whereRelation('environment.project.team', 'id', $teamId)
->whereUuid($uuid)
->first();
}
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
{
$appUuid = $request->route('app_uuid');
if (! $appUuid) {
return null;
}
return $service->applications()
->where('uuid', $appUuid)
->with(['service.destination.server'])
->first();
}
private function swarmNotSupportedResponse(): JsonResponse
{
return response()->json([
'message' => 'This operation is not supported for Swarm servers yet.',
], 501);
}
#[OA\Get(
summary: 'List service applications',
description: 'List compose service applications (containers) for a single service.',
path: '/services/{uuid}/applications',
operationId: 'list-service-applications-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service applications for this service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(type: 'object')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('view', $service);
$items = $service->applications()
->get()
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
return response()->json($items);
}
#[OA\Get(
summary: 'Get service application',
description: 'Get a single compose service application by service UUID and application UUID.',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'get-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Patch(
summary: 'Update service application',
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'patch-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force_domain_override',
in: 'query',
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
requestBody: new OA\RequestBody(
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'url' => new OA\Property(
property: 'url',
type: 'string',
nullable: true,
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
),
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'Updated service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 409,
description: 'Domain conflicts (unless force_domain_override).',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('update', $serviceApplication);
$payload = $request->json()->all();
if (empty($payload)) {
$payload = $request->request->all();
}
$allowedFields = [
'url',
'human_name',
'description',
'image',
'exclude_from_status',
'is_log_drain_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
];
$validationRules = [
'url' => 'nullable|string',
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'nullable|string',
'exclude_from_status' => 'sometimes|boolean',
'is_log_drain_enabled' => 'sometimes|boolean',
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
];
$validator = Validator::make($payload, $validationRules);
$extraFields = array_diff(array_keys($payload), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload);
if ($response instanceof JsonResponse) {
return $response;
}
$serviceApplication->refresh();
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Get(
summary: 'Get service application logs',
description: 'Get Docker logs for a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/logs',
operationId: 'get-service-application-logs-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Logs.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => new OA\Property(property: 'logs', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function logs_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
$status = getContainerStatus($server, $containerName);
if ($status !== 'running') {
return response()->json([
'message' => 'Service application container is not running.',
], 400);
}
$lines = (int) ($request->query('lines', 100) ?: 100);
$logs = getContainerLogs($server, $containerName, $lines);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Get(
summary: 'Start or redeploy service application container',
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
path: '/services/{uuid}/applications/{app_uuid}/start',
operationId: 'start-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force',
in: 'query',
description: 'When true, passes --build to docker compose up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
new OA\Parameter(
name: 'latest',
in: 'query',
description: 'When true, pulls the image for this compose service before up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Deploy request queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_start(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$pullLatest = $request->boolean('latest', false);
$forceRebuild = $request->boolean('force', false);
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
return response()->json([
'message' => 'Service application deploy request queued.',
], 200);
}
#[OA\Get(
summary: 'Restart service application container',
description: 'Restarts a single compose service container (docker restart).',
path: '/services/{uuid}/applications/{app_uuid}/restart',
operationId: 'restart-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Restart queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_restart(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
RestartServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application restart request queued.',
], 200);
}
#[OA\Get(
summary: 'Stop service application container',
description: 'Stops a single compose service container (docker stop).',
path: '/services/{uuid}/applications/{app_uuid}/stop',
operationId: 'stop-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Stop queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_stop(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
StopServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application stop request queued.',
], 200);
}
}

View file

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

View file

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

View file

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

View file

@ -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"

View file

@ -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

View file

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

View file

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

View file

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