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'); +});