diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 468589a1d..80c3d4ac2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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: [ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f2c7f2226..4c3b0ed43 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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: [ diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 34f47d289..396844cb0 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -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: [ diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index f17a4e46b..9fa18e3dc 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -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', diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 4343971ed..278c7714d 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -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); } } diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index e1df34903..7df2f7ce5 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -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.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9c074c9a1..c87f02156 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -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: [ diff --git a/openapi.json b/openapi.json index 093b61010..1d78ff8ba 100644 --- a/openapi.json +++ b/openapi.json @@ -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": [ { diff --git a/openapi.yaml b/openapi.yaml index 061940991..2c0f4e9ea 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: - diff --git a/routes/api.php b/routes/api.php index a33af37c8..7407f2924 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']); diff --git a/tests/Feature/Api/ServerValidationApiTest.php b/tests/Feature/Api/ServerValidationApiTest.php new file mode 100644 index 000000000..4493d8531 --- /dev/null +++ b/tests/Feature/Api/ServerValidationApiTest.php @@ -0,0 +1,56 @@ + 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'); +}); diff --git a/tests/Feature/Api/StateChangingRoutesUsePostTest.php b/tests/Feature/Api/StateChangingRoutesUsePostTest.php new file mode 100644 index 000000000..f48722d8f --- /dev/null +++ b/tests/Feature/Api/StateChangingRoutesUsePostTest.php @@ -0,0 +1,43 @@ +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.', + ]); +}); diff --git a/tests/Feature/Authorization/ApiTokenPermissionTest.php b/tests/Feature/Authorization/ApiTokenPermissionTest.php index 7cbd77791..fe5952e1c 100644 --- a/tests/Feature/Authorization/ApiTokenPermissionTest.php +++ b/tests/Feature/Authorization/ApiTokenPermissionTest.php @@ -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); }); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php index b923508e7..b70f7ef2a 100644 --- a/tests/Feature/Security/AuditLogTest.php +++ b/tests/Feature/Security/AuditLogTest.php @@ -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); }); diff --git a/tests/Unit/ServerValidationOpenApiTest.php b/tests/Unit/ServerValidationOpenApiTest.php new file mode 100644 index 000000000..82f907ced --- /dev/null +++ b/tests/Unit/ServerValidationOpenApiTest.php @@ -0,0 +1,19 @@ +toMatchArray([ + 'type' => 'boolean', + 'default' => false, + ]) + ->and($install['description'])->toContain('restart the Docker daemon'); +}); diff --git a/tests/Unit/ServiceApplicationsOpenApiTest.php b/tests/Unit/ServiceApplicationsOpenApiTest.php index dec4d10c4..9d47bddd8 100644 --- a/tests/Unit/ServiceApplicationsOpenApiTest.php +++ b/tests/Unit/ServiceApplicationsOpenApiTest.php @@ -1,6 +1,6 @@ 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'); });