From e2c2180f4fe6387b7745f60d543fa98249e8307e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:56:01 +0200 Subject: [PATCH 1/3] fix(dev): support root bind mounts and LAN Vite access Keep the dev container as root for s6 init so composer can create vendor/ on root-owned mounts, then chown writable paths to www-data. Move init-setup into a shell script and expose VITE_HOST/PORT for remote HMR (LAN/Tailscale) with Vite listening on 0.0.0.0. --- .env.development.example | 8 ++++ bootstrap/cache/.gitignore | 0 docker-compose-maxio.dev.yml | 1 + docker-compose.dev.yml | 1 + docker/development/Dockerfile | 7 +++- .../etc/s6-overlay/s6-rc.d/init-setup/up | 22 ++-------- .../etc/s6-overlay/scripts/init-setup.sh | 42 +++++++++++++++++++ jean.json | 4 +- package.json | 2 +- storage/app/.gitignore | 0 storage/app/public/.gitignore | 0 storage/debugbar/.gitignore | 0 storage/framework/.gitignore | 0 storage/framework/cache/.gitignore | 0 storage/framework/sessions/.gitignore | 0 storage/framework/testing/.gitignore | 0 storage/framework/views/.gitignore | 0 storage/logs/.gitignore | 0 storage/pail/.gitignore | 0 vite.config.js | 37 ++++++++-------- 20 files changed, 80 insertions(+), 44 deletions(-) mode change 100644 => 100755 bootstrap/cache/.gitignore mode change 100644 => 100755 docker/development/etc/s6-overlay/s6-rc.d/init-setup/up create mode 100755 docker/development/etc/s6-overlay/scripts/init-setup.sh mode change 100644 => 100755 storage/app/.gitignore mode change 100644 => 100755 storage/app/public/.gitignore mode change 100644 => 100755 storage/debugbar/.gitignore mode change 100644 => 100755 storage/framework/.gitignore mode change 100644 => 100755 storage/framework/cache/.gitignore mode change 100644 => 100755 storage/framework/sessions/.gitignore mode change 100644 => 100755 storage/framework/testing/.gitignore mode change 100644 => 100755 storage/framework/views/.gitignore mode change 100644 => 100755 storage/logs/.gitignore mode change 100644 => 100755 storage/pail/.gitignore diff --git a/.env.development.example b/.env.development.example index 9f594765d..6aa8f6419 100644 --- a/.env.development.example +++ b/.env.development.example @@ -30,6 +30,14 @@ DB_PORT=5432 # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false +# Enable Laravel Debugbar (disabled by default; set true when needed) +DEBUGBAR_ENABLED=false + +# Vite dev server. Defaults to localhost. For phone/LAN/Tailscale access, set to +# the host machine's reachable IP (e.g. VITE_HOST=100.75.155.70), then recreate vite. +VITE_HOST=localhost +VITE_PORT=5173 + # Enable Laravel Nightwatch monitoring NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore old mode 100644 new mode 100755 diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index 61037391b..610394964 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -96,6 +96,7 @@ services: container_name: coolify-vite working_dir: /var/www/html environment: + # Set VITE_HOST in .env to a browser-reachable IP/hostname for LAN/Tailscale access VITE_HOST: "${VITE_HOST:-localhost}" VITE_PORT: "${VITE_PORT:-5173}" ports: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8f84b5d60..7b753a037 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -96,6 +96,7 @@ services: container_name: coolify-vite working_dir: /var/www/html environment: + # Set VITE_HOST in .env to a browser-reachable IP/hostname for LAN/Tailscale access VITE_HOST: "${VITE_HOST:-localhost}" VITE_PORT: "${VITE_PORT:-5173}" ports: diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index a5e5a7a3c..28837fa41 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -98,5 +98,8 @@ RUN mkdir -p /etc/nginx/conf.d && \ COPY --from=minio-client /usr/bin/mc /usr/bin/mc RUN chmod +x /usr/bin/mc -# Switch to non-root user -USER www-data +# Stay as root for s6 init so bind-mounted workspaces work even when the host +# tree is root-owned (CI, Jean, rootful Docker). PHP-FPM/nginx still run as +# www-data via their service configs. Do NOT set USER www-data here — that +# makes init-setup (composer install) fail with "vendor could not be created". +USER root diff --git a/docker/development/etc/s6-overlay/s6-rc.d/init-setup/up b/docker/development/etc/s6-overlay/s6-rc.d/init-setup/up old mode 100644 new mode 100755 index 67e0f5c1a..c2bdb2294 --- a/docker/development/etc/s6-overlay/s6-rc.d/init-setup/up +++ b/docker/development/etc/s6-overlay/s6-rc.d/init-setup/up @@ -1,22 +1,6 @@ #!/command/execlineb -P -# Use with-contenv to ensure environment variables are available +# s6 oneshots are execline pipelines (shebang shell scripts are not run as sh). +# Delegate to a real shell script for readable setup logic. with-contenv -cd /var/www/html -foreground { - composer - install -} -foreground { - php - artisan - migrate - --step -} -foreground { - php - artisan - dev - --init -} - +/etc/s6-overlay/scripts/init-setup.sh diff --git a/docker/development/etc/s6-overlay/scripts/init-setup.sh b/docker/development/etc/s6-overlay/scripts/init-setup.sh new file mode 100755 index 000000000..e800ed6cd --- /dev/null +++ b/docker/development/etc/s6-overlay/scripts/init-setup.sh @@ -0,0 +1,42 @@ +#!/command/with-contenv sh +set -eu + +cd /var/www/html + +# When the project is bind-mounted, host ownership may be root:root while the +# app runs as www-data (UID 1000). Fix the minimum needed so composer/laravel +# can write, without a recursive chown of the whole tree. +prepare_bind_mount() { + mkdir -p \ + storage/framework/cache \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache + + if [ "$(id -u)" = "0" ]; then + # Top-level only: allows creating vendor/ when the mount is root-owned + chown www-data:www-data /var/www/html 2>/dev/null || true + chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true + git config --global --add safe.directory /var/www/html 2>/dev/null || true + fi +} + +prepare_bind_mount + +echo "👉 init-setup: composer install..." +# Run as root when needed so a root-owned bind mount cannot block vendor/ +# creation, then hand writable paths to www-data for PHP-FPM. +composer install --no-interaction + +echo "👉 init-setup: migrate..." +php artisan migrate --step --force + +echo "👉 init-setup: artisan dev --init..." +php artisan dev --init + +if [ "$(id -u)" = "0" ]; then + chown -R www-data:www-data vendor storage bootstrap/cache 2>/dev/null || true +fi + +echo "👉 init-setup: done" diff --git a/jean.json b/jean.json index 5cd8362d9..7e4bcc00b 100644 --- a/jean.json +++ b/jean.json @@ -1,8 +1,8 @@ { "scripts": { - "setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json", + "setup": "cp $JEAN_ROOT_PATH/.env .", "teardown": null, - "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down" + "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; docker compose -f docker-compose.yml -f docker-compose.dev.yml up; docker compose -f docker-compose.yml -f docker-compose.dev.yml down" }, "ports": [ { diff --git a/package.json b/package.json index 3cb3406c7..7b8d70d07 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build" }, "devDependencies": { diff --git a/storage/app/.gitignore b/storage/app/.gitignore old mode 100644 new mode 100755 diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore old mode 100644 new mode 100755 diff --git a/storage/debugbar/.gitignore b/storage/debugbar/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore old mode 100644 new mode 100755 diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore old mode 100644 new mode 100755 diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore old mode 100644 new mode 100755 diff --git a/storage/pail/.gitignore b/storage/pail/.gitignore old mode 100644 new mode 100755 diff --git a/vite.config.js b/vite.config.js index 36069fb28..2e0ac170b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,33 +2,30 @@ import { defineConfig, loadEnv } from "vite"; import laravel from "laravel-vite-plugin"; export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), '') - const viteHost = env.VITE_HOST || null; - const vitePort = Number(env.VITE_PORT || 5173); + const env = loadEnv(mode, process.cwd(), ""); + // Prefer process.env so Docker Compose can override without writing .env. + // Set VITE_HOST to a browser-reachable hostname/IP when accessing the app + // from another device (LAN / Tailscale), e.g. VITE_HOST=100.75.155.70 + const viteHost = (process.env.VITE_HOST || env.VITE_HOST || "localhost").trim(); + const vitePort = Number(process.env.VITE_PORT || env.VITE_PORT || 5173); return { server: { watch: { - ignored: [ - "**/dev_*_data/**", - "**/storage/**", - ], + ignored: ["**/dev_*_data/**", "**/storage/**"], }, + // Listen on all interfaces so Docker / remote clients can reach the dev server host: "0.0.0.0", + port: vitePort, + strictPort: true, allowedHosts: true, - cors: { - origin: [ - /^https?:\/\/localhost(:\d+)?$/, - /^https?:\/\/127\.0\.0\.1(:\d+)?$/, - /^https?:\/\/\[::1\](:\d+)?$/, - ...(env.APP_URL ? [env.APP_URL] : []), - ...(viteHost ? [`http://${viteHost}:${vitePort}`, `https://${viteHost}:${vitePort}`] : []), - ], + // App (:8000) and Vite (:5173) are different origins; allow any host in dev + cors: true, + origin: `http://${viteHost}:${vitePort}`, + hmr: { + host: viteHost, + clientPort: vitePort, }, - origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined, - hmr: viteHost - ? { host: viteHost, clientPort: vitePort } - : true, }, plugins: [ laravel({ @@ -36,5 +33,5 @@ export default defineConfig(({ mode }) => { refresh: true, }), ], - } + }; }); From 0633b543ee124a63bdc0f7751cefc3f6fe227559 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:42:04 +0200 Subject: [PATCH 2/3] 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. --- .../Api/ApplicationsController.php | 12 +- .../Controllers/Api/DatabasesController.php | 12 +- app/Http/Controllers/Api/DeployController.php | 4 +- app/Http/Controllers/Api/OtherController.php | 12 +- .../Controllers/Api/ServersController.php | 39 ++- .../Api/ServiceApplicationsController.php | 179 ------------ .../Controllers/Api/ServicesController.php | 12 +- openapi.json | 260 +++--------------- openapi.yaml | 190 +++---------- routes/api.php | 48 ++-- tests/Feature/Api/ServerValidationApiTest.php | 56 ++++ .../Api/StateChangingRoutesUsePostTest.php | 43 +++ .../Authorization/ApiTokenPermissionTest.php | 4 +- tests/Feature/Security/AuditLogTest.php | 2 +- tests/Unit/ServerValidationOpenApiTest.php | 19 ++ tests/Unit/ServiceApplicationsOpenApiTest.php | 10 +- 16 files changed, 297 insertions(+), 605 deletions(-) create mode 100644 tests/Feature/Api/ServerValidationApiTest.php create mode 100644 tests/Feature/Api/StateChangingRoutesUsePostTest.php create mode 100644 tests/Unit/ServerValidationOpenApiTest.php 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'); }); From a92ca6e3b8e0791c3703fb3211a92f89cfb6216d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:18:55 +0200 Subject: [PATCH 3/3] version ++ --- other/nightly/versions.json | 2 +- versions.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 9c9a405aa..c7f3e8896 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.2" + "version": "4.2.0" }, "nightly": { "version": "4.2.0" diff --git a/versions.json b/versions.json index 9c9a405aa..c7f3e8896 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.2" + "version": "4.2.0" }, "nightly": { "version": "4.2.0"