feat(api): Service Application Management API (#9249)
This commit is contained in:
commit
0cca267953
13 changed files with 2279 additions and 4 deletions
65
app/Actions/Service/DeployServiceApplication.php
Normal file
65
app/Actions/Service/DeployServiceApplication.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class DeployServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$composeServiceName = $serviceApplication->name;
|
||||
|
||||
$service->parse();
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
|
||||
$workdir = $service->workdir();
|
||||
$composeFile = "{$workdir}/docker-compose.yml";
|
||||
$safeWorkdir = escapeshellarg($workdir);
|
||||
$safeComposeFile = escapeshellarg($composeFile);
|
||||
$safeProjectName = escapeshellarg($service->uuid);
|
||||
$safeComposeServiceName = escapeshellarg($composeServiceName);
|
||||
|
||||
$commands = collect([
|
||||
'echo '.escapeshellarg("Saved configuration files to {$workdir}."),
|
||||
'touch '.escapeshellarg("{$workdir}/.env"),
|
||||
]);
|
||||
|
||||
if ($pullLatestImages) {
|
||||
$commands->push('echo Pulling image for service.');
|
||||
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}");
|
||||
}
|
||||
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands->push('echo Creating Docker network.');
|
||||
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
|
||||
}
|
||||
|
||||
$upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps";
|
||||
if ($forceRebuild) {
|
||||
$upCommand .= ' --build';
|
||||
}
|
||||
$upCommand .= " {$safeComposeServiceName}";
|
||||
$commands->push('echo Starting service container.');
|
||||
$commands->push($upCommand);
|
||||
|
||||
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
|
||||
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$network = escapeshellarg($service->destination->network);
|
||||
$containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}");
|
||||
$networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}");
|
||||
$commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true");
|
||||
}
|
||||
|
||||
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||
}
|
||||
}
|
||||
24
app/Actions/Service/RestartServiceApplication.php
Normal file
24
app/Actions/Service/RestartServiceApplication.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RestartServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker restart {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
24
app/Actions/Service/StopServiceApplication.php
Normal file
24
app/Actions/Service/StopServiceApplication.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopServiceApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication $serviceApplication): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker stop {$containerName}",
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
107
app/Actions/Service/UpdateServiceApplicationFromApi.php
Normal file
107
app/Actions/Service/UpdateServiceApplicationFromApi.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\ServiceComposeUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UpdateServiceApplicationFromApi
|
||||
{
|
||||
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse
|
||||
{
|
||||
$forceDomainOverride = $request->boolean('force_domain_override');
|
||||
|
||||
if (array_key_exists('url', $payload)) {
|
||||
$urlRaw = $payload['url'];
|
||||
if ($urlRaw !== null && ! is_string($urlRaw)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['url' => 'The url must be a string.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$parsed = ServiceComposeUrl::validateUrlString(
|
||||
is_string($urlRaw) ? $urlRaw : null,
|
||||
$forceDomainOverride
|
||||
);
|
||||
|
||||
if (count($parsed['errors']) > 0) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $parsed['errors'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($parsed['normalized'] !== null) {
|
||||
$containerUrls = str($parsed['normalized'])
|
||||
->explode(',')
|
||||
->map(fn ($url) => str(trim((string) $url))->lower());
|
||||
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid);
|
||||
if (isset($result['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$result['error']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($result['hasConflicts'] && ! $forceDomainOverride) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => $result['conflicts'],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
}
|
||||
|
||||
$serviceApplication->fqdn = $parsed['normalized'];
|
||||
}
|
||||
|
||||
if (array_key_exists('human_name', $payload)) {
|
||||
$serviceApplication->human_name = $payload['human_name'];
|
||||
}
|
||||
|
||||
if (array_key_exists('description', $payload)) {
|
||||
$serviceApplication->description = $payload['description'];
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$serviceApplication->image = $payload['image'];
|
||||
}
|
||||
|
||||
if (array_key_exists('exclude_from_status', $payload)) {
|
||||
$serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_gzip_enabled', $payload)) {
|
||||
$serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_stripprefix_enabled', $payload)) {
|
||||
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_log_drain_enabled', $payload)) {
|
||||
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($enabled && ! $server->isLogDrainEnabled()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$serviceApplication->is_log_drain_enabled = $enabled;
|
||||
}
|
||||
|
||||
$serviceApplication->save();
|
||||
$serviceApplication->refresh();
|
||||
|
||||
updateCompose($serviceApplication);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
766
app/Http/Controllers/Api/ServiceApplicationsController.php
Normal file
766
app/Http/Controllers/Api/ServiceApplicationsController.php
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Actions\Service\UpdateServiceApplicationFromApi;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceApplicationsController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(ServiceApplication $serviceApplication): array
|
||||
{
|
||||
$serviceApplication->makeHidden([
|
||||
'id',
|
||||
'resourceable',
|
||||
'resourceable_id',
|
||||
'resourceable_type',
|
||||
]);
|
||||
|
||||
$serialized = serializeApiResponse($serviceApplication);
|
||||
|
||||
if ($serialized instanceof Collection) {
|
||||
return $serialized->all();
|
||||
}
|
||||
|
||||
return (array) $serialized;
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)
|
||||
->whereUuid($uuid)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
|
||||
{
|
||||
$appUuid = $request->route('app_uuid');
|
||||
if (! $appUuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $service->applications()
|
||||
->where('uuid', $appUuid)
|
||||
->with(['service.destination.server'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function swarmNotSupportedResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'This operation is not supported for Swarm servers yet.',
|
||||
], 501);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List service applications',
|
||||
description: 'List compose service applications (containers) for a single service.',
|
||||
path: '/services/{uuid}/applications',
|
||||
operationId: 'list-service-applications-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service applications for this service.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'object')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$items = $service->applications()
|
||||
->get()
|
||||
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service application',
|
||||
description: 'Get a single compose service application by service UUID and application UUID.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}',
|
||||
operationId: 'get-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Service application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceApplication);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceApplication));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update service application',
|
||||
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}',
|
||||
operationId: 'patch-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'force_domain_override',
|
||||
in: 'query',
|
||||
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'url' => new OA\Property(
|
||||
property: 'url',
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
|
||||
),
|
||||
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
|
||||
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
|
||||
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
|
||||
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
|
||||
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
|
||||
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
|
||||
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Updated service application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Domain conflicts (unless force_domain_override).',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $serviceApplication);
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (empty($payload)) {
|
||||
$payload = $request->request->all();
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'url',
|
||||
'human_name',
|
||||
'description',
|
||||
'image',
|
||||
'exclude_from_status',
|
||||
'is_log_drain_enabled',
|
||||
'is_gzip_enabled',
|
||||
'is_stripprefix_enabled',
|
||||
];
|
||||
|
||||
$validationRules = [
|
||||
'url' => 'nullable|string',
|
||||
'human_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'image' => 'nullable|string',
|
||||
'exclude_from_status' => 'sometimes|boolean',
|
||||
'is_log_drain_enabled' => 'sometimes|boolean',
|
||||
'is_gzip_enabled' => 'sometimes|boolean',
|
||||
'is_stripprefix_enabled' => 'sometimes|boolean',
|
||||
];
|
||||
|
||||
$validator = Validator::make($payload, $validationRules);
|
||||
|
||||
$extraFields = array_diff(array_keys($payload), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload);
|
||||
if ($response instanceof JsonResponse) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$serviceApplication->refresh();
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceApplication));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service application logs',
|
||||
description: 'Get Docker logs for a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/logs',
|
||||
operationId: 'get-service-application-logs-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Logs.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'logs' => new OA\Property(property: 'logs', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function logs_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
|
||||
|
||||
$status = getContainerStatus($server, $containerName);
|
||||
if ($status !== 'running') {
|
||||
return response()->json([
|
||||
'message' => 'Service application container is not running.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$lines = (int) ($request->query('lines', 100) ?: 100);
|
||||
$logs = getContainerLogs($server, $containerName, $lines);
|
||||
|
||||
return response()->json([
|
||||
'logs' => $logs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start or redeploy service application container',
|
||||
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/start',
|
||||
operationId: 'start-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'force',
|
||||
in: 'query',
|
||||
description: 'When true, passes --build to docker compose up.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'latest',
|
||||
in: 'query',
|
||||
description: 'When true, pulls the image for this compose service before up.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Deploy request queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function action_start(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$pullLatest = $request->boolean('latest', false);
|
||||
$forceRebuild = $request->boolean('force', false);
|
||||
|
||||
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application deploy request queued.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Restart service application container',
|
||||
description: 'Restarts a single compose service container (docker restart).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/restart',
|
||||
operationId: 'restart-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Restart queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function action_restart(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
RestartServiceApplication::dispatch($serviceApplication);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application restart request queued.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Stop service application container',
|
||||
description: 'Stops a single compose service container (docker stop).',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/stop',
|
||||
operationId: 'stop-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Stop queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function action_stop(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
|
||||
if (! $serviceApplication) {
|
||||
return response()->json(['message' => 'Service application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('deploy', $serviceApplication);
|
||||
|
||||
$server = $serviceApplication->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json([
|
||||
'message' => 'Server is not functional.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
StopServiceApplication::dispatch($serviceApplication);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service application stop request queued.',
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
55
app/Support/ServiceComposeUrl.php
Normal file
55
app/Support/ServiceComposeUrl.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class ServiceComposeUrl
|
||||
{
|
||||
/**
|
||||
* Validate a comma-separated URL string like `urls[].url` on PATCH /services/{uuid}.
|
||||
*
|
||||
* @return array{errors: array<int, string>, normalized: ?string}
|
||||
*/
|
||||
public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if ($urlValue === null || $urlValue === '') {
|
||||
return ['errors' => [], 'normalized' => null];
|
||||
}
|
||||
|
||||
$urls = str($urlValue)
|
||||
->replaceStart(',', '')
|
||||
->replaceEnd(',', '')
|
||||
->trim()
|
||||
->explode(',')
|
||||
->map(fn ($url) => trim((string) $url))
|
||||
->filter();
|
||||
|
||||
foreach ($urls as $url) {
|
||||
if (! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "Invalid URL: {$url}";
|
||||
}
|
||||
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
|
||||
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
|
||||
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
|
||||
}
|
||||
}
|
||||
|
||||
$duplicates = $urls->duplicates()->unique()->values();
|
||||
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
|
||||
$errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.';
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
return ['errors' => $errors, 'normalized' => null];
|
||||
}
|
||||
|
||||
$normalized = $urls
|
||||
->map(fn ($u) => str($u)->lower()->value())
|
||||
->unique()
|
||||
->filter(fn ($u) => filled($u))
|
||||
->implode(',');
|
||||
|
||||
return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null];
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
509
openapi.json
509
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"
|
||||
|
|
|
|||
327
openapi.yaml
327
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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
339
tests/Feature/ServiceApplicationsApiTest.php
Normal file
339
tests/Feature/ServiceApplicationsApiTest.php
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createBearerTokenForServiceApplicationsApiTest(User $user, Team $team, array $abilities = ['*']): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createServiceWithApplicationForApiTest(object $ctx): object
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $ctx->environment->id,
|
||||
'server_id' => $ctx->server->id,
|
||||
'destination_id' => $ctx->destination->id,
|
||||
'destination_type' => $ctx->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
|
||||
$sa = ServiceApplication::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'web',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
return (object) ['service' => $service, 'serviceApplication' => $sa];
|
||||
}
|
||||
|
||||
function createServiceWithoutApplicationsForApiTest(object $ctx): Service
|
||||
{
|
||||
return Service::factory()->create([
|
||||
'environment_id' => $ctx->environment->id,
|
||||
'server_id' => $ctx->server->id,
|
||||
'destination_id' => $ctx->destination->id,
|
||||
'destination_type' => $ctx->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications', function () {
|
||||
test('returns empty array when service has no applications', function () {
|
||||
$service = createServiceWithoutApplicationsForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([]);
|
||||
});
|
||||
|
||||
test('lists service applications for the service', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]);
|
||||
});
|
||||
|
||||
test('returns 404 when service does not exist', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
|
||||
test('does not list applications from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherServer->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
|
||||
$otherCtx = (object) [
|
||||
'server' => $otherServer,
|
||||
'destination' => StandaloneDocker::where('server_id', $otherServer->id)->first(),
|
||||
'environment' => Environment::factory()->create([
|
||||
'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id,
|
||||
]),
|
||||
];
|
||||
$ctx = createServiceWithApplicationForApiTest($otherCtx);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
test('returns 404 for unknown service', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid is not under service', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service application not found.']);
|
||||
});
|
||||
|
||||
test('returns service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
test('returns 400 without valid token', function () {
|
||||
$response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [
|
||||
'human_name' => 'x',
|
||||
], ['Accept' => 'application/json', 'Content-Type' => 'application/json']);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('updates human_name', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'human_name' => 'Web UI',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['human_name' => 'Web UI']);
|
||||
$ctx->serviceApplication->refresh();
|
||||
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
|
||||
});
|
||||
|
||||
test('returns 422 for invalid url scheme', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'url' => 'ftp://example.com',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 when enabling log drain but server has no log drain', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'is_log_drain_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain');
|
||||
});
|
||||
|
||||
test('read-only token cannot update a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$readOnlyToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readOnlyToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'human_name' => 'Blocked',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('returns 409 for domain conflicts unless forced', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$ctx->serviceApplication->update(['fqdn' => 'http://used.example.com']);
|
||||
$otherApplication = ServiceApplication::create([
|
||||
'name' => 'admin',
|
||||
'service_id' => $ctx->service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}", [
|
||||
'url' => 'http://used.example.com',
|
||||
])->assertStatus(409);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}?force_domain_override=true", [
|
||||
'url' => 'http://used.example.com',
|
||||
])->assertStatus(200);
|
||||
|
||||
$otherApplication->refresh();
|
||||
expect($otherApplication->fqdn)->toBe('http://used.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () {
|
||||
test('returns 400 without valid token', function () {
|
||||
$response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('queues restart for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application restart request queued.']);
|
||||
RestartServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () {
|
||||
test('write token cannot start a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$writeToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['write']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('queues deploy for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application deploy request queued.']);
|
||||
DeployServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () {
|
||||
test('queues stop for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application stop request queued.']);
|
||||
StopServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () {
|
||||
test('returns 400 when server is not functional', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs");
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonFragment(['message' => 'Server is not functional.']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue