From a4ab69df56fb24be1bd4ec1960a43e9e56d7aa30 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:55:47 +0200 Subject: [PATCH] feat(v5): authorize creates, deep-link selection, list apps in v4 Restrict V5 application and resource-connection creation to team admins. Resolve project, environment, and application from query params and keep session selection in sync. Surface V5 apps on the v4 resource index and count them for project/environment emptiness. Create the flux data dir on install and upgrade. --- .../Controllers/V5/ApplicationController.php | 1 + .../V5/Concerns/ResolvesProjectSelection.php | 11 +++- .../Controllers/V5/DashboardController.php | 8 ++- .../V5/ResourceConnectionController.php | 1 + app/Livewire/Project/Resource/Index.php | 28 ++++++++- app/Models/Environment.php | 11 +++- app/Models/Project.php | 6 +- app/Policies/V5/ApplicationPolicy.php | 7 +++ app/Policies/V5/ResourceConnectionPolicy.php | 7 +++ other/nightly/install.sh | 3 +- other/nightly/upgrade.sh | 3 + package-lock.json | 58 ++++++++++++------- resources/js/v5/Pages/Dashboard.tsx | 16 ++++- resources/js/v5/types.ts | 1 + .../livewire/project/resource/index.blade.php | 20 +++++-- scripts/install.sh | 3 +- scripts/upgrade.sh | 3 + tests/Feature/V5/DashboardControllerTest.php | 31 ++++++++++ .../Feature/V5/V5CreateAuthorizationTest.php | 33 +++++++++++ tests/Feature/V5/V5ParentLifecycleTest.php | 47 +++++++++++++++ tests/Unit/UpgradePostgresScriptTest.php | 13 +++++ tests/Unit/V5/Policies/V5PolicyTest.php | 12 ++++ .../V5/V4ResourceIndexV5ApplicationTest.php | 14 +++++ 23 files changed, 299 insertions(+), 38 deletions(-) create mode 100644 tests/Feature/V5/V5CreateAuthorizationTest.php create mode 100644 tests/Feature/V5/V5ParentLifecycleTest.php create mode 100644 tests/Unit/V5/V4ResourceIndexV5ApplicationTest.php diff --git a/app/Http/Controllers/V5/ApplicationController.php b/app/Http/Controllers/V5/ApplicationController.php index 8d11cd6b6..ca1a4c636 100644 --- a/app/Http/Controllers/V5/ApplicationController.php +++ b/app/Http/Controllers/V5/ApplicationController.php @@ -51,6 +51,7 @@ class ApplicationController extends Controller public function store(Request $request): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('create', [V5Application::class, $currentTeam]); $projects = $this->projects($currentTeam); [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); diff --git a/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php b/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php index 1d48eff33..a741d7f8f 100644 --- a/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php +++ b/app/Http/Controllers/V5/Concerns/ResolvesProjectSelection.php @@ -34,8 +34,8 @@ trait ResolvesProjectSelection */ protected function selectedProjectAndEnvironment(Request $request, array $projects): array { - $selectedProjectUuid = $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY); - $selectedEnvironmentUuid = $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY); + $selectedProjectUuid = $request->query('project', $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY)); + $selectedEnvironmentUuid = $request->query('environment', $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY)); $selectedProject = null; foreach ($projects as $project) { @@ -59,6 +59,13 @@ trait ResolvesProjectSelection $selectedEnvironment ??= $selectedProject['environments'][0] ?? null; + if ($request->query->has('project') || $request->query->has('environment')) { + $request->session()->put([ + self::SELECTED_PROJECT_SESSION_KEY => $selectedProject['uuid'] ?? null, + self::SELECTED_ENVIRONMENT_SESSION_KEY => $selectedEnvironment['uuid'] ?? null, + ]); + } + return [$selectedProject, $selectedEnvironment]; } diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index 9f5e26736..de7176875 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -33,17 +33,23 @@ class DashboardController extends Controller $currentTeam = $request->attributes->get('v5.currentTeam'); $projects = $this->projects($currentTeam); [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); + $applications = $this->applications($currentTeam, $selectedProject, $selectedEnvironment); + $requestedApplicationUuid = $request->query('application'); + $selectedApplicationUuid = collect($applications)->contains( + fn (array $application): bool => $application['id'] === $requestedApplicationUuid + ) ? $requestedApplicationUuid : null; return Inertia::render('Dashboard', [ 'currentTeam' => $this->serializeCurrentTeam($currentTeam), 'flux' => $fluxHealth->check(), - 'applications' => $this->applications($currentTeam, $selectedProject, $selectedEnvironment), + 'applications' => $applications, 'caddyIngresses' => $this->caddyIngresses($currentTeam), 'resourceConnections' => $this->resourceConnections($currentTeam, $selectedProject, $selectedEnvironment), 'nginxServers' => $this->nginxServers($currentTeam), 'projects' => $projects, 'selectedProjectUuid' => $selectedProject['uuid'] ?? null, 'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null, + 'selectedApplicationUuid' => $selectedApplicationUuid, ]); } diff --git a/app/Http/Controllers/V5/ResourceConnectionController.php b/app/Http/Controllers/V5/ResourceConnectionController.php index d901940d6..299432e83 100644 --- a/app/Http/Controllers/V5/ResourceConnectionController.php +++ b/app/Http/Controllers/V5/ResourceConnectionController.php @@ -35,6 +35,7 @@ class ResourceConnectionController extends Controller public function store(Request $request): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); + $this->authorize('create', [ResourceConnection::class, $currentTeam]); $projects = $this->projects($currentTeam); [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 094b61b28..acfa1c03f 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Resource; use App\Models\Environment; use App\Models\Project; +use App\Models\V5\Application as V5Application; use Illuminate\Support\Collection; use Livewire\Component; @@ -61,6 +62,7 @@ class Index extends Component ->select('id', 'uuid', 'name', 'project_id') ->with([ 'applications:id,uuid,name,environment_id', + 'v5Applications:id,uuid,name,environment_id,status', 'services:id,uuid,name,environment_id', 'postgresqls:id,uuid,name,environment_id', 'redis:id,uuid,name,environment_id', @@ -103,6 +105,23 @@ class Index extends Component return $application; }); + $this->applications = $this->applications + ->merge(V5Application::query() + ->where('team_id', currentTeam()->id) + ->where('project_id', $this->project->id) + ->where('environment_id', $this->environment->id) + ->with('server:id,name') + ->get() + ->map(function (V5Application $application) use ($projectUuid, $environmentUuid) { + $application->hrefLink = route('v5.dashboard', [ + 'project' => $projectUuid, + 'environment' => $environmentUuid, + 'application' => $application->uuid, + ]); + + return $application; + })) + ->sortBy('name'); // Load all database resources in a single query per type $databaseTypes = [ @@ -180,16 +199,19 @@ class Index extends Component 'uuid' => $item->uuid, 'name' => $item->name, 'fqdn' => $item->fqdn ?? null, - 'description' => $item->description ?? null, + 'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null), 'status' => $item->status ?? '', + 'version' => $item instanceof V5Application ? 'v5' : 'v4', 'server_status' => $item->server_status ?? null, 'hrefLink' => $item->hrefLink ?? '', 'destination' => [ 'server' => [ - 'name' => $item->destination?->server?->name ?? 'Unknown', + 'name' => $item instanceof V5Application + ? ($item->server?->name ?? 'Unknown') + : ($item->destination?->server?->name ?? 'Unknown'), ], ], - 'tags' => $item->tags->map(fn ($tag) => [ + 'tags' => ($item instanceof V5Application ? collect() : $item->tags)->map(fn ($tag) => [ 'id' => $tag->id, 'name' => $tag->name, ])->values()->toArray(), diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a..0e5e3c673 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Models\V5\Application as V5Application; +use App\Models\V5\ResourceConnection as V5ResourceConnection; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -54,7 +56,9 @@ class Environment extends BaseModel public function isEmpty() { - return $this->applications()->count() == 0 && + return ! V5Application::query()->where('environment_id', $this->id)->exists() && + ! V5ResourceConnection::query()->where('environment_id', $this->id)->exists() && + $this->applications()->count() == 0 && $this->redis()->count() == 0 && $this->postgresqls()->count() == 0 && $this->mysqls()->count() == 0 && @@ -76,6 +80,11 @@ class Environment extends BaseModel return $this->hasMany(Application::class); } + public function v5Applications() + { + return $this->hasMany(V5Application::class); + } + public function postgresqls() { return $this->hasMany(StandalonePostgresql::class); diff --git a/app/Models/Project.php b/app/Models/Project.php index 5c821b017..fe64e320d 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Models\V5\Application as V5Application; +use App\Models\V5\ResourceConnection as V5ResourceConnection; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -144,7 +146,9 @@ class Project extends BaseModel public function isEmpty() { - return $this->applications()->count() == 0 && + return ! V5Application::query()->where('project_id', $this->id)->exists() && + ! V5ResourceConnection::query()->where('project_id', $this->id)->exists() && + $this->applications()->count() == 0 && $this->redis()->count() == 0 && $this->postgresqls()->count() == 0 && $this->mysqls()->count() == 0 && diff --git a/app/Policies/V5/ApplicationPolicy.php b/app/Policies/V5/ApplicationPolicy.php index 5b1877a00..aca24aa95 100644 --- a/app/Policies/V5/ApplicationPolicy.php +++ b/app/Policies/V5/ApplicationPolicy.php @@ -9,6 +9,13 @@ use Illuminate\Auth\Access\Response; class ApplicationPolicy { + public function create(User $user, Team $team): Response + { + return $user->isAdminOfTeam($team->id) + ? Response::allow() + : Response::deny('You do not have permission to manage applications in this team.'); + } + /** * Determine whether the user can view the application within the current team. * diff --git a/app/Policies/V5/ResourceConnectionPolicy.php b/app/Policies/V5/ResourceConnectionPolicy.php index 6b3dcde79..04e472e8e 100644 --- a/app/Policies/V5/ResourceConnectionPolicy.php +++ b/app/Policies/V5/ResourceConnectionPolicy.php @@ -9,6 +9,13 @@ use Illuminate\Auth\Access\Response; class ResourceConnectionPolicy { + public function create(User $user, Team $team): Response + { + return $user->isAdminOfTeam($team->id) + ? Response::allow() + : Response::deny('You do not have permission to manage resource connections in this team.'); + } + /** * Determine whether the user can update the connection within the current team. */ diff --git a/other/nightly/install.sh b/other/nightly/install.sh index f4458d440..33437aad5 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -228,11 +228,12 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel,flux} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic chown -R 9999:root /data/coolify +chown -R 9999:root /data/coolify/flux chmod -R 700 /data/coolify INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 635ddd77c..a15ec39a8 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -171,6 +171,9 @@ else log "Network 'coolify' already exists" fi +mkdir -p /data/coolify/flux +chown -R 9999:root /data/coolify/flux + # Check if Docker config file exists DOCKER_CONFIG_MOUNT="" if [ -f /root/.docker/config.json ]; then diff --git a/package-lock.json b/package-lock.json index 6422dd492..9b050a8a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,7 +135,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -740,7 +739,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -789,7 +787,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -997,12 +994,36 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1244,7 +1265,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2082,7 +2102,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2167,7 +2188,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2178,7 +2198,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2337,8 +2356,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/accepts": { "version": "2.0.0", @@ -2425,6 +2443,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2445,6 +2464,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2600,7 +2620,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3172,6 +3191,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -3200,7 +3220,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-prop": { "version": "6.0.1", @@ -3523,7 +3544,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3973,7 +3993,6 @@ "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4871,6 +4890,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5622,6 +5642,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5777,7 +5798,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5787,7 +5807,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5800,7 +5819,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -6501,8 +6521,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -6718,7 +6737,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6841,7 +6859,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7465,7 +7482,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index 3ee7522ed..c1f7db12c 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -90,6 +90,7 @@ export default function Dashboard({ projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null, + selectedApplicationUuid = null, }: V5DashboardProps) { const [applications, setApplications] = useState(initialApplications); const [ingresses, setIngresses] = useState(caddyIngresses); @@ -175,11 +176,20 @@ export default function Dashboard({ setIngresses(settledResources.ingresses); resetConnections(initialResourceConnections); setSelectedNginxServerId((currentServerId) => currentServerId || nginxServers[0]?.id || ''); - setSelectedApplicationId(null); - setSelectedInspectorApplicationId(null); + const linkedApplicationExists = settledResources.applications.some((application) => application.id === selectedApplicationUuid); + setSelectedApplicationId(linkedApplicationExists ? selectedApplicationUuid : null); + setSelectedInspectorApplicationId(linkedApplicationExists ? selectedApplicationUuid : null); centerOnCanvasNodes(settledResources.applications, settledResources.ingresses); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialApplications, caddyIngresses, initialResourceConnections, nginxServers[0]?.id, selectedProjectUuid, selectedEnvironmentUuid]); + }, [ + initialApplications, + caddyIngresses, + initialResourceConnections, + nginxServers[0]?.id, + selectedProjectUuid, + selectedEnvironmentUuid, + selectedApplicationUuid, + ]); useCanvasResourceMerge({ teamId: currentTeam?.id ?? null, diff --git a/resources/js/v5/types.ts b/resources/js/v5/types.ts index 24f48894b..10f16ad3f 100644 --- a/resources/js/v5/types.ts +++ b/resources/js/v5/types.ts @@ -145,6 +145,7 @@ export type V5DashboardProps = { projects?: V5Project[]; selectedProjectUuid?: string | null; selectedEnvironmentUuid?: string | null; + selectedApplicationUuid?: string | null; }; export type SelectItemOption = { diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index a38a04043..4caafb287 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -91,6 +91,7 @@ ->merge($env->clickhouses ?? collect()); $envResources = collect() ->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app])) ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) ->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])) ->sortBy(fn($item) => strtolower($item['resource']->name)); @@ -140,6 +141,7 @@ ->merge($env->clickhouses ?? collect()); $envResources = collect() ->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app])) + ->merge($env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app])) ->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db])) ->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc])); @endphp @@ -157,6 +159,11 @@ $resType = $envResource['type']; $res = $envResource['resource']; $resRoute = match ($resType) { + 'v5-application' => route('v5.dashboard', [ + 'project' => $project->uuid, + 'environment' => $env->uuid, + 'application' => $res->uuid, + ]), 'application' => route('project.application.configuration', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $env->uuid, @@ -233,10 +240,15 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3"> diff --git a/scripts/install.sh b/scripts/install.sh index 96449bb79..25315c834 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -228,11 +228,12 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel,flux} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic chown -R 9999:root /data/coolify +chown -R 9999:root /data/coolify/flux chmod -R 700 /data/coolify INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 206327f65..144dcb21c 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -171,6 +171,9 @@ else log "Network 'coolify' already exists" fi +mkdir -p /data/coolify/flux +chown -R 9999:root /data/coolify/flux + # Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621) # Only changes owner — preserves existing group to respect custom setups SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown") diff --git a/tests/Feature/V5/DashboardControllerTest.php b/tests/Feature/V5/DashboardControllerTest.php index 2569d6405..3508e14fb 100644 --- a/tests/Feature/V5/DashboardControllerTest.php +++ b/tests/Feature/V5/DashboardControllerTest.php @@ -3,6 +3,7 @@ use App\Events\V5RealtimeTestEvent; use App\Models\Team; use App\Models\User; +use App\Models\V5\Application; use App\Models\V5\Application as V5Application; use App\Models\V5\Server as V5Server; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; @@ -356,6 +357,36 @@ it('shares existing projects and environments with the v5 dashboard page', funct ->assertDontSee($otherEnvironment->uuid); }); +it('opens a linked v5 application in its project and environment', function () { + $this->withoutVite(); + fakeFluxHealth(); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'linked-project', 'production'); + $application = Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'created_by_user_id' => $user->id, + 'name' => 'linked-nginx', + 'image' => 'nginx:alpine', + 'container_name' => 'linked-v5-nginx', + ]); + + $this->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->get(route('v5.dashboard', [ + 'project' => $project->uuid, + 'environment' => $environment->uuid, + 'application' => $application->uuid, + ])) + ->assertSuccessful() + ->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false) + ->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false) + ->assertSee('"selectedApplicationUuid":"'.$application->uuid.'"', false); +}); + it('persists the selected v5 project and environment in the session', function () { $this->withoutVite(); fakeFluxHealth(); diff --git a/tests/Feature/V5/V5CreateAuthorizationTest.php b/tests/Feature/V5/V5CreateAuthorizationTest.php new file mode 100644 index 000000000..1e42a26df --- /dev/null +++ b/tests/Feature/V5/V5CreateAuthorizationTest.php @@ -0,0 +1,33 @@ +teams()->updateExistingPivot($team->id, ['role' => 'member']); + + $this->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson('/v5/applications/nginx') + ->assertForbidden(); + + expect(V5Application::query()->count())->toBe(0); +}); + +it('prevents team members from creating v5 resource connections', function () { + [$user, $team] = createV5UserWithTeam(); + $user->teams()->updateExistingPivot($team->id, ['role' => 'member']); + + $this->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson('/v5/resource-connections') + ->assertForbidden(); + + expect(ResourceConnection::query()->count())->toBe(0); +}); diff --git a/tests/Feature/V5/V5ParentLifecycleTest.php b/tests/Feature/V5/V5ParentLifecycleTest.php new file mode 100644 index 000000000..c2d592e46 --- /dev/null +++ b/tests/Feature/V5/V5ParentLifecycleTest.php @@ -0,0 +1,47 @@ +create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx', + 'image' => 'nginx:alpine', + 'container_name' => 'v5-nginx', + ]); + + expect($project->isEmpty())->toBeFalse() + ->and($environment->isEmpty())->toBeFalse(); +}); + +it('does not consider projects or environments with v5 resource connections empty', function () { + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); + + ResourceConnection::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'resource_one_type' => V5Application::class, + 'resource_one_id' => 1, + 'resource_two_type' => V5Application::class, + 'resource_two_id' => 2, + 'resource_pair_key' => 'application:1|application:2', + 'created_by_user_id' => $user->id, + ]); + + expect($project->isEmpty())->toBeFalse() + ->and($environment->isEmpty())->toBeFalse(); +}); diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php index 6fe028ddb..42ef53113 100644 --- a/tests/Unit/UpgradePostgresScriptTest.php +++ b/tests/Unit/UpgradePostgresScriptTest.php @@ -56,6 +56,19 @@ it('generates a dedicated flux laravel api token during install and upgrade', fu 'nightly upgrade' => 'other/nightly/upgrade.sh', ]); +it('creates a writable flux storage directory during install and upgrade', function (string $path) { + $script = file_get_contents(getcwd().'/'.$path); + + expect($script) + ->toContain('/data/coolify/flux') + ->toContain('chown -R 9999:root /data/coolify/flux'); +})->with([ + 'stable install' => 'scripts/install.sh', + 'nightly install' => 'other/nightly/install.sh', + 'stable upgrade' => 'scripts/upgrade.sh', + 'nightly upgrade' => 'other/nightly/upgrade.sh', +]); + it('uses the selected registry url when extracting upgrade images', function (string $path) { $script = file_get_contents(getcwd().'/'.$path); diff --git a/tests/Unit/V5/Policies/V5PolicyTest.php b/tests/Unit/V5/Policies/V5PolicyTest.php index a3cc4b684..745f97ae3 100644 --- a/tests/Unit/V5/Policies/V5PolicyTest.php +++ b/tests/Unit/V5/Policies/V5PolicyTest.php @@ -230,6 +230,8 @@ it('denies application mutations for a member of the current team as forbidden', $member = v5PolicyMemberUser(); $policy = new ApplicationPolicy; + expect($policy->create($member, $team)->denied())->toBeTrue(); + foreach (['update', 'updateIngress', 'delete'] as $ability) { $response = $policy->{$ability}($member, $application, $team); @@ -244,6 +246,8 @@ it('denies resource connection mutations for a member of the current team as for $member = v5PolicyMemberUser(); $policy = new ResourceConnectionPolicy; + expect($policy->create($member, $team)->denied())->toBeTrue(); + foreach (['update', 'delete'] as $ability) { $response = $policy->{$ability}($member, $connection, $team); @@ -251,3 +255,11 @@ it('denies resource connection mutations for a member of the current team as for ->and($response->status())->toBeNull(); } }); + +it('allows admins to create applications and resource connections', function () { + $team = v5PolicyTeam(10); + $admin = v5PolicyUser('admin'); + + expect((new ApplicationPolicy)->create($admin, $team)->allowed())->toBeTrue() + ->and((new ResourceConnectionPolicy)->create($admin, $team)->allowed())->toBeTrue(); +}); diff --git a/tests/Unit/V5/V4ResourceIndexV5ApplicationTest.php b/tests/Unit/V5/V4ResourceIndexV5ApplicationTest.php new file mode 100644 index 000000000..592f5ae48 --- /dev/null +++ b/tests/Unit/V5/V4ResourceIndexV5ApplicationTest.php @@ -0,0 +1,14 @@ +toContain('V5Application') + ->toContain("route('v5.dashboard'") + ->toContain("? 'v5' : 'v4'") + ->and($view) + ->toContain("item.version === 'v5'") + ->toContain('V5'); +});