feat(api): require POST for state-changing endpoints

Make start/stop/restart, deploy, enable/disable, and server validate
POST-only, with GET returning 405. Server validate accepts optional
install and uses ValidateAndInstallServerJob. Update OpenAPI and tests.
This commit is contained in:
Andras Bacsai 2026-07-19 11:42:04 +02:00
parent e2c2180f4f
commit 0633b543ee
16 changed files with 297 additions and 605 deletions

View file

@ -3849,9 +3849,9 @@ public function delete_env_by_uuid(Request $request)
]);
}
#[OA\Get(
#[OA\Post(
summary: 'Start',
description: 'Start application. `Post` request is also accepted.',
description: 'Start application.',
path: '/applications/{uuid}/start',
operationId: 'start-application-by-uuid',
security: [
@ -3973,9 +3973,9 @@ public function action_deploy(Request $request)
);
}
#[OA\Get(
#[OA\Post(
summary: 'Stop',
description: 'Stop application. `Post` request is also accepted.',
description: 'Stop application.',
path: '/applications/{uuid}/stop',
operationId: 'stop-application-by-uuid',
security: [
@ -4066,9 +4066,9 @@ public function action_stop(Request $request)
);
}
#[OA\Get(
#[OA\Post(
summary: 'Restart',
description: 'Restart application. `Post` request is also accepted.',
description: 'Restart application.',
path: '/applications/{uuid}/restart',
operationId: 'restart-application-by-uuid',
security: [

View file

@ -3050,9 +3050,9 @@ public function move_by_uuid(Request $request): JsonResponse
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
}
#[OA\Get(
#[OA\Post(
summary: 'Start',
description: 'Start database. `Post` request is also accepted.',
description: 'Start database.',
path: '/databases/{uuid}/start',
operationId: 'start-database-by-uuid',
security: [
@ -3137,9 +3137,9 @@ public function action_deploy(Request $request)
);
}
#[OA\Get(
#[OA\Post(
summary: 'Stop',
description: 'Stop database. `Post` request is also accepted.',
description: 'Stop database.',
path: '/databases/{uuid}/stop',
operationId: 'stop-database-by-uuid',
security: [
@ -3236,9 +3236,9 @@ public function action_stop(Request $request)
);
}
#[OA\Get(
#[OA\Post(
summary: 'Restart',
description: 'Restart database. `Post` request is also accepted.',
description: 'Restart database.',
path: '/databases/{uuid}/restart',
operationId: 'restart-database-by-uuid',
security: [

View file

@ -304,9 +304,9 @@ public function cancel_deployment(Request $request)
}
}
#[OA\Get(
#[OA\Post(
summary: 'Deploy',
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',
description: 'Deploy by tag or UUID using query parameters or a JSON body.',
path: '/deploy',
operationId: 'deploy-by-tag-or-uuid',
security: [

View file

@ -3,12 +3,20 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use OpenApi\Attributes as OA;
class OtherController extends Controller
{
public function post_required(): JsonResponse
{
return response()
->json(['message' => 'This endpoint has changed to a POST request.'], 405)
->header('Allow', 'POST');
}
#[OA\Get(
summary: 'Version',
description: 'Get Coolify version.',
@ -41,7 +49,7 @@ public function version(Request $request)
return response(config('constants.coolify.version'));
}
#[OA\Get(
#[OA\Post(
summary: 'Enable API',
description: 'Enable API (only with root permissions).',
path: '/enable',
@ -97,7 +105,7 @@ public function enable_api(Request $request)
return response()->json(['message' => 'API enabled.'], 200);
}
#[OA\Get(
#[OA\Post(
summary: 'Disable API',
description: 'Disable API (only with root permissions).',
path: '/disable',

View file

@ -8,6 +8,7 @@
use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\PrivateKey;
use App\Models\Project;
@ -887,7 +888,7 @@ public function delete_server(Request $request)
return response()->json(['message' => 'Server deleted.']);
}
#[OA\Get(
#[OA\Post(
summary: 'Validate',
description: 'Validate server by UUID.',
path: '/servers/{uuid}/validate',
@ -899,6 +900,19 @@ public function delete_server(Request $request)
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(
property: 'install',
description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.',
type: 'boolean',
default: false,
),
],
),
),
responses: [
new OA\Response(
response: 201,
@ -948,14 +962,33 @@ public function validate_server(Request $request)
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
ValidateServer::dispatch($server);
$validator = customApiValidator($request->all(), [
'install' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$install = $request->boolean('install', false);
if ($install) {
ValidateAndInstallServerJob::dispatch($server);
} else {
ValidateServer::dispatch($server);
}
auditLog('api.server.validated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'install' => $install,
]);
return response()->json(['message' => 'Validation started.'], 201);
$message = $install ? 'Validation and installation started.' : 'Validation started.';
return response()->json(['message' => $message], 201);
}
}

View file

@ -498,75 +498,6 @@ public function logs_by_uuid(Request $request): JsonResponse
]);
}
#[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.',
),
]
)]
#[OA\Post(
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.',
@ -635,61 +566,6 @@ public function action_start(Request $request): JsonResponse
], 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.',
),
]
)]
#[OA\Post(
summary: 'Restart service application container',
description: 'Restarts a single compose service container.',
@ -753,61 +629,6 @@ public function action_restart(Request $request): JsonResponse
], 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.',
),
]
)]
#[OA\Post(
summary: 'Stop service application container',
description: 'Stops a single compose service container.',

View file

@ -1970,9 +1970,9 @@ public function move_by_uuid(Request $request): JsonResponse
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
}
#[OA\Get(
#[OA\Post(
summary: 'Start',
description: 'Start service. `Post` request is also accepted.',
description: 'Start service.',
path: '/services/{uuid}/start',
operationId: 'start-service-by-uuid',
security: [
@ -2056,9 +2056,9 @@ public function action_deploy(Request $request)
);
}
#[OA\Get(
#[OA\Post(
summary: 'Stop',
description: 'Stop service. `Post` request is also accepted.',
description: 'Stop service.',
path: '/services/{uuid}/stop',
operationId: 'stop-service-by-uuid',
security: [
@ -2154,9 +2154,9 @@ public function action_stop(Request $request)
);
}
#[OA\Get(
#[OA\Post(
summary: 'Restart',
description: 'Restart service. `Post` request is also accepted.',
description: 'Restart service.',
path: '/services/{uuid}/restart',
operationId: 'restart-service-by-uuid',
security: [

View file

@ -3535,12 +3535,12 @@
}
},
"\/applications\/{uuid}\/start": {
"get": {
"post": {
"tags": [
"Applications"
],
"summary": "Start",
"description": "Start application. `Post` request is also accepted.",
"description": "Start application.",
"operationId": "start-application-by-uuid",
"parameters": [
{
@ -3612,12 +3612,12 @@
}
},
"\/applications\/{uuid}\/stop": {
"get": {
"post": {
"tags": [
"Applications"
],
"summary": "Stop",
"description": "Stop application. `Post` request is also accepted.",
"description": "Stop application.",
"operationId": "stop-application-by-uuid",
"parameters": [
{
@ -3674,12 +3674,12 @@
}
},
"\/applications\/{uuid}\/restart": {
"get": {
"post": {
"tags": [
"Applications"
],
"summary": "Restart",
"description": "Restart application. `Post` request is also accepted.",
"description": "Restart application.",
"operationId": "restart-application-by-uuid",
"parameters": [
{
@ -7034,12 +7034,12 @@
}
},
"\/databases\/{uuid}\/start": {
"get": {
"post": {
"tags": [
"Databases"
],
"summary": "Start",
"description": "Start database. `Post` request is also accepted.",
"description": "Start database.",
"operationId": "start-database-by-uuid",
"parameters": [
{
@ -7087,12 +7087,12 @@
}
},
"\/databases\/{uuid}\/stop": {
"get": {
"post": {
"tags": [
"Databases"
],
"summary": "Stop",
"description": "Stop database. `Post` request is also accepted.",
"description": "Stop database.",
"operationId": "stop-database-by-uuid",
"parameters": [
{
@ -7149,12 +7149,12 @@
}
},
"\/databases\/{uuid}\/restart": {
"get": {
"post": {
"tags": [
"Databases"
],
"summary": "Restart",
"description": "Restart database. `Post` request is also accepted.",
"description": "Restart database.",
"operationId": "restart-database-by-uuid",
"parameters": [
{
@ -8256,12 +8256,12 @@
}
},
"\/deploy": {
"get": {
"post": {
"tags": [
"Deployments"
],
"summary": "Deploy",
"description": "Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.",
"description": "Deploy by tag or UUID using query parameters or a JSON body.",
"operationId": "deploy-by-tag-or-uuid",
"parameters": [
{
@ -10103,7 +10103,7 @@
}
},
"\/enable": {
"get": {
"post": {
"summary": "Enable API",
"description": "Enable API (only with root permissions).",
"operationId": "enable-api",
@ -10155,7 +10155,7 @@
}
},
"\/disable": {
"get": {
"post": {
"summary": "Disable API",
"description": "Disable API (only with root permissions).",
"operationId": "disable-api",
@ -12376,7 +12376,7 @@
}
},
"\/servers\/{uuid}\/validate": {
"get": {
"post": {
"tags": [
"Servers"
],
@ -12394,6 +12394,23 @@
}
}
],
"requestBody": {
"required": false,
"content": {
"application\/json": {
"schema": {
"properties": {
"install": {
"description": "Install missing prerequisites and Docker. This can restart the Docker daemon.",
"type": "boolean",
"default": false
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Server validation started.",
@ -12804,85 +12821,6 @@
}
},
"\/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": []
}
]
},
"post": {
"tags": [
"Service applications"
@ -12963,65 +12901,6 @@
}
},
"\/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": []
}
]
},
"post": {
"tags": [
"Service applications"
@ -13084,65 +12963,6 @@
}
},
"\/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": []
}
]
},
"post": {
"tags": [
"Service applications"
@ -14797,12 +14617,12 @@
}
},
"\/services\/{uuid}\/start": {
"get": {
"post": {
"tags": [
"Services"
],
"summary": "Start",
"description": "Start service. `Post` request is also accepted.",
"description": "Start service.",
"operationId": "start-service-by-uuid",
"parameters": [
{
@ -14850,12 +14670,12 @@
}
},
"\/services\/{uuid}\/stop": {
"get": {
"post": {
"tags": [
"Services"
],
"summary": "Stop",
"description": "Stop service. `Post` request is also accepted.",
"description": "Stop service.",
"operationId": "stop-service-by-uuid",
"parameters": [
{
@ -14912,12 +14732,12 @@
}
},
"\/services\/{uuid}\/restart": {
"get": {
"post": {
"tags": [
"Services"
],
"summary": "Restart",
"description": "Restart service. `Post` request is also accepted.",
"description": "Restart service.",
"operationId": "restart-service-by-uuid",
"parameters": [
{

View file

@ -2304,11 +2304,11 @@ paths:
-
bearerAuth: []
'/applications/{uuid}/start':
get:
post:
tags:
- Applications
summary: Start
description: 'Start application. `Post` request is also accepted.'
description: 'Start application.'
operationId: start-application-by-uuid
parameters:
-
@ -2352,11 +2352,11 @@ paths:
-
bearerAuth: []
'/applications/{uuid}/stop':
get:
post:
tags:
- Applications
summary: Stop
description: 'Stop application. `Post` request is also accepted.'
description: 'Stop application.'
operationId: stop-application-by-uuid
parameters:
-
@ -2392,11 +2392,11 @@ paths:
-
bearerAuth: []
'/applications/{uuid}/restart':
get:
post:
tags:
- Applications
summary: Restart
description: 'Restart application. `Post` request is also accepted.'
description: 'Restart application.'
operationId: restart-application-by-uuid
parameters:
-
@ -4635,11 +4635,11 @@ paths:
-
bearerAuth: []
'/databases/{uuid}/start':
get:
post:
tags:
- Databases
summary: Start
description: 'Start database. `Post` request is also accepted.'
description: 'Start database.'
operationId: start-database-by-uuid
parameters:
-
@ -4668,11 +4668,11 @@ paths:
-
bearerAuth: []
'/databases/{uuid}/stop':
get:
post:
tags:
- Databases
summary: Stop
description: 'Stop database. `Post` request is also accepted.'
description: 'Stop database.'
operationId: stop-database-by-uuid
parameters:
-
@ -4708,11 +4708,11 @@ paths:
-
bearerAuth: []
'/databases/{uuid}/restart':
get:
post:
tags:
- Databases
summary: Restart
description: 'Restart database. `Post` request is also accepted.'
description: 'Restart database.'
operationId: restart-database-by-uuid
parameters:
-
@ -5408,11 +5408,11 @@ paths:
-
bearerAuth: []
/deploy:
get:
post:
tags:
- Deployments
summary: Deploy
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.'
description: 'Deploy by tag or UUID using query parameters or a JSON body.'
operationId: deploy-by-tag-or-uuid
parameters:
-
@ -6499,7 +6499,7 @@ paths:
-
bearerAuth: []
/enable:
get:
post:
summary: 'Enable API'
description: 'Enable API (only with root permissions).'
operationId: enable-api
@ -6528,7 +6528,7 @@ paths:
-
bearerAuth: []
/disable:
get:
post:
summary: 'Disable API'
description: 'Disable API (only with root permissions).'
operationId: disable-api
@ -7951,7 +7951,7 @@ paths:
-
bearerAuth: []
'/servers/{uuid}/validate':
get:
post:
tags:
- Servers
summary: Validate
@ -7965,6 +7965,17 @@ paths:
required: true
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
properties:
install:
description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.'
type: boolean
default: false
type: object
responses:
'201':
description: 'Server validation started.'
@ -8221,61 +8232,6 @@ paths:
-
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: []
post:
tags:
- 'Service applications'
@ -8330,45 +8286,6 @@ paths:
-
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: []
post:
tags:
- 'Service applications'
@ -8409,45 +8326,6 @@ paths:
-
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: []
post:
tags:
- 'Service applications'
@ -9449,11 +9327,11 @@ paths:
-
bearerAuth: []
'/services/{uuid}/start':
get:
post:
tags:
- Services
summary: Start
description: 'Start service. `Post` request is also accepted.'
description: 'Start service.'
operationId: start-service-by-uuid
parameters:
-
@ -9482,11 +9360,11 @@ paths:
-
bearerAuth: []
'/services/{uuid}/stop':
get:
post:
tags:
- Services
summary: Stop
description: 'Stop service. `Post` request is also accepted.'
description: 'Stop service.'
operationId: stop-service-by-uuid
parameters:
-
@ -9522,11 +9400,11 @@ paths:
-
bearerAuth: []
'/services/{uuid}/restart':
get:
post:
tags:
- Services
summary: Restart
description: 'Restart service. `Post` request is also accepted.'
description: 'Restart service.'
operationId: restart-service-by-uuid
parameters:
-

View file

@ -38,8 +38,10 @@
'middleware' => ['auth:sanctum', 'api.token.team', 'api.ability:write'],
'prefix' => 'v1',
], function () {
Route::get('/enable', [OtherController::class, 'enable_api']);
Route::get('/disable', [OtherController::class, 'disable_api']);
Route::get('/enable', [OtherController::class, 'post_required']);
Route::get('/disable', [OtherController::class, 'post_required']);
Route::post('/enable', [OtherController::class, 'enable_api']);
Route::post('/disable', [OtherController::class, 'disable_api']);
Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']);
Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']);
});
@ -81,7 +83,8 @@
Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
Route::get('/deploy', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::post('/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->middleware(['api.ability:read']);
Route::post('/deployments/{uuid}/cancel', [DeployController::class, 'cancel_deployment'])->middleware(['api.ability:deploy']);
@ -99,7 +102,8 @@
Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']);
Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/validate', [OtherController::class, 'post_required'])->middleware(['api.ability:write']);
Route::post('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
@ -156,9 +160,12 @@
Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::post('/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::post('/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::post('/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']);
@ -206,9 +213,12 @@
Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/databases/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/databases/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/databases/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::post('/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::post('/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::post('/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
@ -234,17 +244,23 @@
Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::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('/services/{uuid}/applications/{app_uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/applications/{app_uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/applications/{app_uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/applications/{app_uuid}/start', [ServiceApplicationsController::class, 'action_start'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::post('/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']);

View file

@ -0,0 +1,56 @@
<?php
use App\Actions\Server\ValidateServer;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->token = $this->user->createToken('server-validation', ['write'])->plainTextToken;
Queue::fake();
});
function serverValidationHeaders(): array
{
return ['Authorization' => 'Bearer '.test()->token];
}
it('validates without installing by default', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate");
$response->assertCreated()->assertJson(['message' => 'Validation started.']);
Queue::assertNotPushed(ValidateAndInstallServerJob::class);
});
it('validates and installs when explicitly requested', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]);
$response->assertCreated()->assertJson(['message' => 'Validation and installation started.']);
Queue::assertPushed(
ValidateAndInstallServerJob::class,
fn (ValidateAndInstallServerJob $job): bool => $job->server->is($this->server)
);
Queue::assertNotPushed(ValidateServer::class);
});
it('rejects an invalid install option', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => 'yes']);
$response->assertUnprocessable()->assertJsonValidationErrors('install');
});

View file

@ -0,0 +1,43 @@
<?php
use App\Http\Controllers\Api\OtherController;
use Illuminate\Routing\Route;
it('uses POST for state changes and keeps GET as a non-mutating compatibility response', function (string $uri) {
$routes = collect(app('router')->getRoutes()->getRoutes())
->filter(fn (Route $route): bool => $route->uri() === $uri);
$postRoute = $routes->first(fn (Route $route): bool => $route->methods() === ['POST']);
$getRoute = $routes->first(fn (Route $route): bool => $route->methods() === ['GET', 'HEAD']);
expect($postRoute)->not->toBeNull()
->and($getRoute)->not->toBeNull()
->and($getRoute->getActionName())->toBe(OtherController::class.'@post_required');
})->with([
'enable API' => 'api/v1/enable',
'disable API' => 'api/v1/disable',
'deploy' => 'api/v1/deploy',
'validate server' => 'api/v1/servers/{uuid}/validate',
'start application' => 'api/v1/applications/{uuid}/start',
'restart application' => 'api/v1/applications/{uuid}/restart',
'stop application' => 'api/v1/applications/{uuid}/stop',
'start database' => 'api/v1/databases/{uuid}/start',
'restart database' => 'api/v1/databases/{uuid}/restart',
'stop database' => 'api/v1/databases/{uuid}/stop',
'start service' => 'api/v1/services/{uuid}/start',
'restart service' => 'api/v1/services/{uuid}/restart',
'stop service' => 'api/v1/services/{uuid}/stop',
'start service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/start',
'restart service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/restart',
'stop service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/stop',
]);
it('tells GET callers to use POST without changing state', function () {
$response = app(OtherController::class)->post_required();
expect($response->getStatusCode())->toBe(405)
->and($response->headers->get('Allow'))->toBe('POST')
->and($response->getData(true))->toBe([
'message' => 'This endpoint has changed to a POST request.',
]);
});

View file

@ -77,13 +77,13 @@
});
});
describe('GET /api/v1/servers/{uuid}/validate', function () {
describe('POST /api/v1/servers/{uuid}/validate', function () {
test('read-only token cannot trigger server validation', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/servers/fake-uuid/validate');
])->postJson('/api/v1/servers/fake-uuid/validate');
$response->assertStatus(403);
});

View file

@ -397,7 +397,7 @@ function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/enable');
])->postJson('/api/v1/enable');
$response->assertStatus(403);
});

View file

@ -0,0 +1,19 @@
<?php
it('documents optional installation during server validation', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$operation = $openApi['paths']['/servers/{uuid}/validate']['post'];
$install = $operation['requestBody']['content']['application/json']['schema']['properties']['install'];
expect($install)
->toMatchArray([
'type' => 'boolean',
'default' => false,
])
->and($install['description'])->toContain('restart the Docker daemon');
});

View file

@ -1,6 +1,6 @@
<?php
it('documents both supported methods for service application actions', function () {
it('documents POST for service application actions', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
@ -8,7 +8,6 @@
);
$actionPaths = [
'/services/{uuid}/applications/{app_uuid}/logs',
'/services/{uuid}/applications/{app_uuid}/start',
'/services/{uuid}/applications/{app_uuid}/restart',
'/services/{uuid}/applications/{app_uuid}/stop',
@ -16,17 +15,16 @@
foreach ($actionPaths as $path) {
expect($openApi['paths'][$path])
->toHaveKeys(['get', 'post'])
->toHaveKey('post')
->not->toHaveKey('get')
->and($openApi['paths'][$path]['post']['responses'])
->toHaveKeys(['200', '400', '401', '404', '501']);
}
expect($openApi['paths'][$actionPaths[0]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('logs')
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[1]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[3]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message');
});