diff --git a/.changes/a-list-view-on-the-new.md b/.changes/a-list-view-on-the-new.md new file mode 100644 index 0000000000..6583416058 --- /dev/null +++ b/.changes/a-list-view-on-the-new.md @@ -0,0 +1,5 @@ +--- +type: added +commit: 91d233be59 +--- +A list view on the new resource page diff --git a/.changes/a-not-used-label-on-the.md b/.changes/a-not-used-label-on-the.md new file mode 100644 index 0000000000..73e50b121f --- /dev/null +++ b/.changes/a-not-used-label-on-the.md @@ -0,0 +1,8 @@ +--- +type: added +commit: 777362cd97 +--- +A `Not used` label on the TLS certificates of the Traefik proxy page, and a `Delete unused` button that deletes them in one step ([#12447](https://github.com/coollabsio/coolify/issues/12447)) + +- A certificate is unused when no route and no resource on the server uses its domain +- Traefik renews every certificate in `acme.json` until you delete it and restart the proxy diff --git a/.changes/a-run-in-a-shell-option.md b/.changes/a-run-in-a-shell-option.md new file mode 100644 index 0000000000..cf25b02fd3 --- /dev/null +++ b/.changes/a-run-in-a-shell-option.md @@ -0,0 +1,8 @@ +--- +type: added +commit: 31c3fcf50d +--- +A `Run in a shell` option on scheduled tasks, so containers without `sh`, such as distroless or scratch images, can run them ([#8244](https://github.com/coollabsio/coolify/issues/8244)) + +- Turn it off to split the command into arguments and run it directly +- The API and the MCP tools set it too diff --git a/.changes/a-separate-notification-sender-in-the.md b/.changes/a-separate-notification-sender-in-the.md new file mode 100644 index 0000000000..1be671cc47 --- /dev/null +++ b/.changes/a-separate-notification-sender-in-the.md @@ -0,0 +1,9 @@ +--- +type: added +commit: a5b4e1d32e +--- +A separate notification sender in the instance email settings + +- Team notifications through the instance email settings, the S3 connection error alert and the channel disabled alert use it +- Account emails keep the default sender +- The `emails` artisan command no longer exists diff --git a/.changes/a-skip-tls-certificate-verification-setting.md b/.changes/a-skip-tls-certificate-verification-setting.md new file mode 100644 index 0000000000..49017e3a33 --- /dev/null +++ b/.changes/a-skip-tls-certificate-verification-setting.md @@ -0,0 +1,9 @@ +--- +type: added +commit: 7f521e496d +--- +A `Skip TLS certificate verification` setting on S3 storages, for endpoints with a self-signed or private CA certificate ([#9762](https://github.com/coollabsio/coolify/issues/9762), [#4402](https://github.com/coollabsio/coolify/issues/4402)) + +- It applies to the connection test, backups, restores and the deletion of old backups +- The S3 storages API sets it with `skip_tls_verification` +- Browsers that download files from the storage must still trust the certificate diff --git a/.changes/a-star-button-on-servers-and.md b/.changes/a-star-button-on-servers-and.md new file mode 100644 index 0000000000..48571dac7a --- /dev/null +++ b/.changes/a-star-button-on-servers-and.md @@ -0,0 +1,7 @@ +--- +type: added +commit: c648f4ec6a +--- +A star button on servers and resources for quick access on the dashboard + +- The button is after the status in the top bar diff --git a/.changes/a-ui-scale-option-in-the.md b/.changes/a-ui-scale-option-in-the.md new file mode 100644 index 0000000000..6936de298b --- /dev/null +++ b/.changes/a-ui-scale-option-in-the.md @@ -0,0 +1,5 @@ +--- +type: added +commit: bd499de1fb +--- +A `UI scale` option in the appearance settings diff --git a/.changes/a-warning-when-the-docker-compose.md b/.changes/a-warning-when-the-docker-compose.md new file mode 100644 index 0000000000..03a364112e --- /dev/null +++ b/.changes/a-warning-when-the-docker-compose.md @@ -0,0 +1,7 @@ +--- +type: added +commit: fc027be248 +--- +A warning when the Docker Compose file of an application is in a subdirectory of the base directory, with a button that moves the directory of the file into the base directory ([#9525](https://github.com/coollabsio/coolify/issues/9525)) + +- Coolify runs Docker Compose in the base directory, so relative paths such as `build.context: ..` resolve from there diff --git a/.changes/api-endpoints-and-mcp-tools-that.md b/.changes/api-endpoints-and-mcp-tools-that.md new file mode 100644 index 0000000000..ae9824bf12 --- /dev/null +++ b/.changes/api-endpoints-and-mcp-tools-that.md @@ -0,0 +1,8 @@ +--- +type: added +commit: 1703265353 +--- +API endpoints and MCP tools that manage image scanning and read the scan results + +- `GET` and `PATCH /api/v1/servers/{uuid}/image-scanning`, `GET /api/v1/vulnerabilities` and `GET /api/v1/{applications|databases|services}/{uuid}/vulnerabilities` +- The MCP tools are `get_vulnerabilities` and `manage_image_scanning` diff --git a/.changes/api-token-search-out-of-sync.md b/.changes/api-token-search-out-of-sync.md new file mode 100644 index 0000000000..f96f247c84 --- /dev/null +++ b/.changes/api-token-search-out-of-sync.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 59481c7bb6 +--- +API token search out of sync after you create, edit or revoke a token ([#12465](https://github.com/coollabsio/coolify/issues/12465)) diff --git a/.changes/applications-and-databases-removed-from-the.md b/.changes/applications-and-databases-removed-from-the.md new file mode 100644 index 0000000000..5ca616c8d9 --- /dev/null +++ b/.changes/applications-and-databases-removed-from-the.md @@ -0,0 +1,10 @@ +--- +type: fixed +commit: e29dcf70d9 +--- +Applications and databases removed from the Docker Compose file of a service showing as `Exited` and keeping the service degraded ([#9591](https://github.com/coollabsio/coolify/issues/9591)) + +- They show `Not in Compose file` and do not count in the service status +- Coolify keeps their settings and uses them again when you add the service back under the same name +- To remove one, delete it in its settings +- Existing ones are marked at the next deployment or save of the service diff --git a/.changes/applications-deleted-on-all-their-servers.md b/.changes/applications-deleted-on-all-their-servers.md new file mode 100644 index 0000000000..e349671fc4 --- /dev/null +++ b/.changes/applications-deleted-on-all-their-servers.md @@ -0,0 +1,10 @@ +--- +type: fixed +commit: 83a5667387 +--- +Applications deleted on all their servers when you delete a server that they use only as an additional destination + +- This applies to `Delete all resources` and `DELETE /api/v1/servers/{uuid}?force=true` +- Coolify removes the application only from the deleted server and stops its containers there +- A server that has only such applications can be deleted without `Delete all resources` +- The delete confirmation lists the resources it deletes and the applications that keep running on another server diff --git a/.changes/applied-the-limits-of-api-tokens.md b/.changes/applied-the-limits-of-api-tokens.md new file mode 100644 index 0000000000..0430930612 --- /dev/null +++ b/.changes/applied-the-limits-of-api-tokens.md @@ -0,0 +1,7 @@ +--- +type: security +commit: 3c588d482d +--- +Applied the limits of API tokens to more permission checks + +- A message explains when a token limit blocks a change diff --git a/.changes/automatic-update-notifications-the-root-team.md b/.changes/automatic-update-notifications-the-root-team.md new file mode 100644 index 0000000000..491a148357 --- /dev/null +++ b/.changes/automatic-update-notifications-the-root-team.md @@ -0,0 +1,7 @@ +--- +type: added +commit: a5c3ca0b73 +--- +Automatic update notifications, the root team gets one when an automatic update starts, fails or finishes + +- A manual upgrade that fails before the upgrade script starts shows the error in the upgrade modal diff --git a/.changes/bugsink-service-template-filling-the-disk.md b/.changes/bugsink-service-template-filling-the-disk.md new file mode 100644 index 0000000000..a6eb08f0bd --- /dev/null +++ b/.changes/bugsink-service-template-filling-the-disk.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: ccaa04ce33 +--- +Bugsink service template filling the disk with MySQL binary logs until Bugsink stopped ([#10626](https://github.com/coollabsio/coolify/issues/10626)) + +- New services use PostgreSQL 17 and Bugsink 2 +- To stop the growth in an existing service, run `PURGE BINARY LOGS BEFORE NOW();` in MySQL, then add `command: ['--disable-log-bin']` to the `mysql` service and restart it diff --git a/.changes/builds-that-failed-with-x509-errors.md b/.changes/builds-that-failed-with-x509-errors.md new file mode 100644 index 0000000000..44dd206847 --- /dev/null +++ b/.changes/builds-that-failed-with-x509-errors.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 4b9fe035dc +--- +Builds that failed with x509 errors behind a TLS-inspecting proxy, although the server trusts the proxy CA ([#7568](https://github.com/coollabsio/coolify/issues/7568)) + +- The helper container mounts the CA bundle of the server read-only diff --git a/.changes/cancelled-the-older-queued-webhook-deployments.md b/.changes/cancelled-the-older-queued-webhook-deployments.md new file mode 100644 index 0000000000..e04496c0cb --- /dev/null +++ b/.changes/cancelled-the-older-queued-webhook-deployments.md @@ -0,0 +1,12 @@ +--- +type: changed +breaking: true +commit: cf1e0e70c5 +--- +Cancelled the older queued webhook deployments of an application by default when a newer push arrives, so only the latest commit is built ([#10921](https://github.com/coollabsio/coolify/discussions/10921), [#7678](https://github.com/coollabsio/coolify/discussions/7678)) + +- It works per application, pull request and server +- `Advanced` > `Deployment` and `is_auto_cancel_queued_deployments_enabled` in the applications API turn it off +- Manual, API, restart and rollback deployments are never cancelled +- An opt-in setting, `is_auto_cancel_building_deployments_enabled` in the API, also stops an older webhook deployment that is still building, a deployment that started to replace containers always finishes +- `cancel_previous` (`none`, `queued` or `building`) on `/api/v1/deploy` and the MCP `deploy` tool cancels the older webhook, API and MCP deployments, the default is `none` diff --git a/.changes/checked-that-members-with-access-grants.md b/.changes/checked-that-members-with-access-grants.md new file mode 100644 index 0000000000..a335b5e2d8 --- /dev/null +++ b/.changes/checked-that-members-with-access-grants.md @@ -0,0 +1,10 @@ +--- +type: security +commit: 8011d0d6ab +--- +Checked that members with access grants can use the selected server and the team credentials + +- A new, cloned or migrated resource needs a server that the member can use, the localhost server needs an instance admin +- Server lists show only the servers that the member can use +- Team private keys, GitHub Apps, Gitea Apps and new S3 storages of backup schedules need write access on the whole team, also through the API +- A message explains when write access on the whole team is needed diff --git a/.changes/checked-the-deploy-permission-before-a.md b/.changes/checked-the-deploy-permission-before-a.md new file mode 100644 index 0000000000..859b974e7b --- /dev/null +++ b/.changes/checked-the-deploy-permission-before-a.md @@ -0,0 +1,5 @@ +--- +type: security +commit: 25b4236e35 +--- +Checked the deploy permission before a deploy through the API creates a preview or changes its image tag diff --git a/.changes/compose-services-that-could-not-reach.md b/.changes/compose-services-that-could-not-reach.md new file mode 100644 index 0000000000..3f49941fb6 --- /dev/null +++ b/.changes/compose-services-that-could-not-reach.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 3c87879e6b +--- +Compose services that could not reach each other by name in preview deployments and failed to start ([#8030](https://github.com/coollabsio/coolify/issues/8030)) + +- The original name is an alias on the preview network diff --git a/.changes/container-image-vulnerability-scanning-opt-in.md b/.changes/container-image-vulnerability-scanning-opt-in.md new file mode 100644 index 0000000000..73fdbcee6c --- /dev/null +++ b/.changes/container-image-vulnerability-scanning-opt-in.md @@ -0,0 +1,9 @@ +--- +type: added +commit: 3c33f61d7b +--- +Container image vulnerability scanning, opt-in for each server and each application + +- Syft and Grype run on your server, and no data goes to a third party +- Each resource gets one security status and version recommendations, with an optional deploy gate for each application +- The team `Security` page and the notifications show the results diff --git a/.changes/copy-from-instance-settings-from-the.md b/.changes/copy-from-instance-settings-from-the.md new file mode 100644 index 0000000000..e598afeff3 --- /dev/null +++ b/.changes/copy-from-instance-settings-from-the.md @@ -0,0 +1,5 @@ +--- +type: removed +commit: f7867923b7 +--- +`Copy from instance settings` from the email notification page, select `Use system-wide settings` instead diff --git a/.changes/copy-logs-not-changing-the-clipboard.md b/.changes/copy-logs-not-changing-the-clipboard.md new file mode 100644 index 0000000000..23450d6c69 --- /dev/null +++ b/.changes/copy-logs-not-changing-the-clipboard.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 1ff99b0dc8 +--- +`Copy logs` not changing the clipboard on iPhone and iPad, in the runtime and deployment logs ([#9408](https://github.com/coollabsio/coolify/issues/9408)) + +- Coolify starts the clipboard write during the tap and adds the logs when they load diff --git a/.changes/cut-off-descriptions-of-postgresql-mongodb.md b/.changes/cut-off-descriptions-of-postgresql-mongodb.md new file mode 100644 index 0000000000..d8b926f296 --- /dev/null +++ b/.changes/cut-off-descriptions-of-postgresql-mongodb.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 813bc72af4 +--- +Cut-off descriptions of PostgreSQL, MongoDB and ClickHouse on the new resource page diff --git a/.changes/data-that-looked-lost-after-you.md b/.changes/data-that-looked-lost-after-you.md new file mode 100644 index 0000000000..7a868f20ee --- /dev/null +++ b/.changes/data-that-looked-lost-after-you.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: a05fe524f8 +--- +Data that looked lost after you changed the name of a persistent volume, because a new name points to a new, empty Docker volume ([#8816](https://github.com/coollabsio/coolify/issues/8816)) + +- A dialog asks for confirmation and tells you to copy the data into the new volume diff --git a/.changes/database-backup-executions-table-that-did.md b/.changes/database-backup-executions-table-that-did.md new file mode 100644 index 0000000000..9be99fba1a --- /dev/null +++ b/.changes/database-backup-executions-table-that-did.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: 0b89b857ed +--- +Database backup executions table that did not fit the page and was hard to read ([#7188](https://github.com/coollabsio/coolify/issues/7188)) + +- It shows the file name of the archive, with a button that copies its path +- It shows `All databases` for a backup of all databases diff --git a/.changes/database-backups-saved-to-s3-when.md b/.changes/database-backups-saved-to-s3-when.md new file mode 100644 index 0000000000..becdbfcbcc --- /dev/null +++ b/.changes/database-backups-saved-to-s3-when.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 3b64cbc379 +--- +Database backups saved to S3 when an API request does not send `save_s3` + +- The backup page resets the S3 selection when a change is not allowed diff --git a/.changes/database-backups-that-passed-the-worker.md b/.changes/database-backups-that-passed-the-worker.md new file mode 100644 index 0000000000..6ed3367e4c --- /dev/null +++ b/.changes/database-backups-that-passed-the-worker.md @@ -0,0 +1,10 @@ +--- +type: fixed +commit: 5c71067481 +--- +Database backups that passed the worker timeout, stayed running, and left the upload container and the dump file on the server + +- Each step gets only the time that is left of the backup timeout, so a run that takes too long fails and cleans up +- An SSH retry does not start a second dump +- The backup log explains exit code 124, and the timeout help says that it covers the complete run +- The daily helper container cleanup removes the upload containers of executions that are not running diff --git a/.changes/deleted-coolify-yaml-and-an-unreachable.md b/.changes/deleted-coolify-yaml-and-an-unreachable.md new file mode 100644 index 0000000000..a4925682d3 --- /dev/null +++ b/.changes/deleted-coolify-yaml-and-an-unreachable.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: ed03a31b3a +--- +Deleted `coolify.yaml` and an unreachable dashboard on a server with the IP `host.docker.internal` that is not the default localhost server ([#12444](https://github.com/coollabsio/coolify/issues/12444)) + +- Coolify keeps treating this server as localhost after a save, for example after a proxy restart diff --git a/.changes/deleted-volume-coming-back-after-a.md b/.changes/deleted-volume-coming-back-after-a.md new file mode 100644 index 0000000000..19dff39023 --- /dev/null +++ b/.changes/deleted-volume-coming-back-after-a.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: 780d67ccbb +--- +Deleted volume coming back after a redeploy when the image declares `VOLUME` at its path ([#6875](https://github.com/coollabsio/coolify/issues/6875)) + +- Coolify removes containers that still mount a deleted volume before it starts the new ones +- The stale volume delete dialog warns when a container still uses the volume diff --git a/.changes/deploy-canvas-cards-with-another-border.md b/.changes/deploy-canvas-cards-with-another-border.md new file mode 100644 index 0000000000..aae8ad11b1 --- /dev/null +++ b/.changes/deploy-canvas-cards-with-another-border.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 5960814df5 +--- +Deploy canvas cards with another border than the resource tiles diff --git a/.changes/deployments-started-through-the-mcp-server.md b/.changes/deployments-started-through-the-mcp-server.md new file mode 100644 index 0000000000..48a2abfebc --- /dev/null +++ b/.changes/deployments-started-through-the-mcp-server.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: b063a41624 +--- +Deployments started through the MCP server saved as API deployments + +- The deployment list shows them with the `MCP` source and filter +- The API and MCP deployment data have a new `is_mcp` field diff --git a/.changes/deployments-that-failed-on-one-502.md b/.changes/deployments-that-failed-on-one-502.md new file mode 100644 index 0000000000..6628cec726 --- /dev/null +++ b/.changes/deployments-that-failed-on-one-502.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: d70801aaa1 +--- +Deployments that failed on one `502` or one timeout from the GitHub API ([#9785](https://github.com/coollabsio/coolify/issues/9785)) + +- GitHub API reads retry up to three times on a 5xx error or a failed connection +- Writes, such as pull request comments, are not retried diff --git a/.changes/deployments-that-failed-with-curl-error.md b/.changes/deployments-that-failed-with-curl-error.md new file mode 100644 index 0000000000..0d08bc88af --- /dev/null +++ b/.changes/deployments-that-failed-with-curl-error.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: b7a2c75739 +--- +Deployments that failed with `cURL error 28` on `https://api.github.com/zen`, because Coolify compared its clock with GitHub before each token request ([#9785](https://github.com/coollabsio/coolify/issues/9785)) + +- Coolify compares the clocks only when GitHub rejects a request, from the `Date` header of that response diff --git a/.changes/deployments-that-failed-with-the-container.md b/.changes/deployments-that-failed-with-the-container.md new file mode 100644 index 0000000000..b39f253b44 --- /dev/null +++ b/.changes/deployments-that-failed-with-the-container.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: d6405d5545 +--- +Deployments that failed with "The container name ... is already in use" when `Use build secrets` is on ([#9363](https://github.com/coollabsio/coolify/issues/9363)) + +- Coolify removes the old helper container and waits until Docker frees the name diff --git a/.changes/deployments-that-stopped-on-a-broken.md b/.changes/deployments-that-stopped-on-a-broken.md new file mode 100644 index 0000000000..548e89c346 --- /dev/null +++ b/.changes/deployments-that-stopped-on-a-broken.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 0b45d8dd6c +--- +Deployments that stopped on a broken `.env` file, because multi-line JSON values saved before exact escaping had no quotes ([#8279](https://github.com/coollabsio/coolify/issues/8279)) + +- These values use exact escaping, which gives the container the same value as before beta.463 diff --git a/.changes/deployments-to-additional-servers-losing-the.md b/.changes/deployments-to-additional-servers-losing-the.md new file mode 100644 index 0000000000..17c1be2a33 --- /dev/null +++ b/.changes/deployments-to-additional-servers-losing-the.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 37b4a8b4e7 +--- +Deployments to additional servers losing the source of the main deployment + +- The audit log records actions that MCP tools do through the API as MCP events diff --git a/.changes/different-coolify-branch-values-in-the.md b/.changes/different-coolify-branch-values-in-the.md new file mode 100644 index 0000000000..3daa5e9f70 --- /dev/null +++ b/.changes/different-coolify-branch-values-in-the.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 71814b4541 +--- +Different `COOLIFY_BRANCH` values in the parts of a preview deployment ([#9572](https://github.com/coollabsio/coolify/issues/9572)) + +- All parts use `pull//head` diff --git a/.changes/disabled-self-hosted-servers-that-stayed.md b/.changes/disabled-self-hosted-servers-that-stayed.md new file mode 100644 index 0000000000..fff2a0eea2 --- /dev/null +++ b/.changes/disabled-self-hosted-servers-that-stayed.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: f7ea962145 +--- +Disabled self-hosted servers that stayed disabled after a validation that connects ([#12475](https://github.com/coollabsio/coolify/issues/12475)) + +- The unreachable cleanup skips the localhost server +- The server page explains why a server is disabled +- Coolify Cloud servers and transferred servers stay disabled diff --git a/.changes/dns-matches-on-a-domain-in.md b/.changes/dns-matches-on-a-domain-in.md new file mode 100644 index 0000000000..5cc469b4e6 --- /dev/null +++ b/.changes/dns-matches-on-a-domain-in.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: b7defddc32 +--- +`DNS matches` on a domain in a connected Cloudflare zone that goes through Cloudflare to a different server, which then failed with Cloudflare error 526 and a Let's Encrypt error ([#12436](https://github.com/coollabsio/coolify/issues/12436)) + +- The domain shows `DNS mismatch` +- CNAME records, for example of a Cloudflare Tunnel, are still accepted +- A clone of an application or a service, or a new one through the API, gets a Cloudflare DNS record for each domain in a zone with automatic DNS, a clone only for the domains that the source does not use diff --git a/.changes/docker-builds-that-failed-with-buildkit.md b/.changes/docker-builds-that-failed-with-buildkit.md new file mode 100644 index 0000000000..d600062721 --- /dev/null +++ b/.changes/docker-builds-that-failed-with-buildkit.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: 8c6774e17f +--- +Docker builds that failed with "BuildKit is enabled but the buildx component is missing or broken" when an application has a build-time variable `HOME` or `DOCKER_CONFIG` ([#9726](https://github.com/coollabsio/coolify/issues/9726)) + +- The Docker CLI in the helper container always uses `/root/.docker` +- A `HOME` build argument keeps its value diff --git a/.changes/docker-compose-previews-sharing-one-project.md b/.changes/docker-compose-previews-sharing-one-project.md new file mode 100644 index 0000000000..57f9bb52da --- /dev/null +++ b/.changes/docker-compose-previews-sharing-one-project.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: d09480973e +--- +Docker Compose previews sharing one project with the application and the other previews, so Docker Compose treated their containers as orphans ([#9014](https://github.com/coollabsio/coolify/issues/9014)) + +- Each preview has its own Compose project, and the networks that the Compose file declares are not shared diff --git a/.changes/dockerfile-healthcheck-options-not-imported-as.md b/.changes/dockerfile-healthcheck-options-not-imported-as.md new file mode 100644 index 0000000000..59a3e5e743 --- /dev/null +++ b/.changes/dockerfile-healthcheck-options-not-imported-as.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 51a7e97619 +--- +Dockerfile `HEALTHCHECK` options not imported as Docker reads them, zero and sub-second values became 0 seconds ([#12455](https://github.com/coollabsio/coolify/issues/12455)) + +- Coolify reads compound and fractional durations, quoted values, only the flags before `CMD`, the `escape` directive and `NONE` with flags diff --git a/.changes/dockerfile-healthcheck-read-from-a-stage.md b/.changes/dockerfile-healthcheck-read-from-a-stage.md new file mode 100644 index 0000000000..250574c346 --- /dev/null +++ b/.changes/dockerfile-healthcheck-read-from-a-stage.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: fcf92b751f +--- +Dockerfile `HEALTHCHECK` read from a stage that is not built ([#9475](https://github.com/coollabsio/coolify/issues/9475)) diff --git a/.changes/domains-showing-not-checked-when-an.md b/.changes/domains-showing-not-checked-when-an.md new file mode 100644 index 0000000000..086038c392 --- /dev/null +++ b/.changes/domains-showing-not-checked-when-an.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: c3a21eceeb +--- +Domains showing `Not checked` when an existing Cloudflare DNS record of a new domain has a different IP + +- Coolify replaces the record when it created the record and no other resource uses it +- Otherwise it opens the `Configure DNS` dialog, where you can replace the record, and the domain shows the conflict diff --git a/.changes/duplicate-and-empty-rows-in-the.md b/.changes/duplicate-and-empty-rows-in-the.md new file mode 100644 index 0000000000..f0ff496d7d --- /dev/null +++ b/.changes/duplicate-and-empty-rows-in-the.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: b7d0e6be2a +--- +Duplicate and empty rows in the audit log diff --git a/.changes/duplicate-environment-variables-and-extra-copies.md b/.changes/duplicate-environment-variables-and-extra-copies.md new file mode 100644 index 0000000000..1e10d48260 --- /dev/null +++ b/.changes/duplicate-environment-variables-and-extra-copies.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: f9bde1cd7a +--- +Duplicate environment variables, and extra copies that could not be removed ([#8041](https://github.com/coollabsio/coolify/issues/8041)) diff --git a/.changes/empty-database-backups-of-odoo-services.md b/.changes/empty-database-backups-of-odoo-services.md new file mode 100644 index 0000000000..4980384998 --- /dev/null +++ b/.changes/empty-database-backups-of-odoo-services.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 232e8b8616 +--- +Empty database backups of Odoo services, which backed up only the empty `postgres` database ([#7555](https://github.com/coollabsio/coolify/issues/7555)) + +- New backup schedules of Odoo services back up all databases by default diff --git a/.changes/empty-disk-usage-on-the-metrics.md b/.changes/empty-disk-usage-on-the-metrics.md new file mode 100644 index 0000000000..a6148f0788 --- /dev/null +++ b/.changes/empty-disk-usage-on-the-metrics.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 6254f44f16 +--- +Empty disk usage on the `Metrics` tab, the `Fleet` page and in the MCP `get_server_metrics` tool when Sentinel reports the Coolify data disk under another mount point than `/`, `/data` or `/data/coolify` ([#12448](https://github.com/coollabsio/coolify/issues/12448)) + +- This occurs for example on a NAS where `/data` is a symlink into a data volume diff --git a/.changes/empty-list-of-the-commit-images.md b/.changes/empty-list-of-the-commit-images.md new file mode 100644 index 0000000000..b78337a444 --- /dev/null +++ b/.changes/empty-list-of-the-commit-images.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: f057aaf2e6 +--- +Empty list of the commit images of Docker Compose applications on the `Rollback` page and in `GET /api/v1/applications/{uuid}/rollback-images`, since v4.5.0 ([#12446](https://github.com/coollabsio/coolify/issues/12446)) + +- The page also shows the running commit when the Compose file has services from other images diff --git a/.changes/ended-terminal-sessions-when-access-to.md b/.changes/ended-terminal-sessions-when-access-to.md new file mode 100644 index 0000000000..47e963c225 --- /dev/null +++ b/.changes/ended-terminal-sessions-when-access-to.md @@ -0,0 +1,5 @@ +--- +type: security +commit: cdc45e5736 +--- +Ended terminal sessions when access to the server is lost diff --git a/.changes/enforced-fixed-ssh-connection-options-on.md b/.changes/enforced-fixed-ssh-connection-options-on.md new file mode 100644 index 0000000000..9a91afa49a --- /dev/null +++ b/.changes/enforced-fixed-ssh-connection-options-on.md @@ -0,0 +1,5 @@ +--- +type: security +commit: cdc45e5736 +--- +Enforced fixed SSH connection options on every connection, also for health checks diff --git a/.changes/exact-start-time-in-service-backup.md b/.changes/exact-start-time-in-service-backup.md new file mode 100644 index 0000000000..6f820e845e --- /dev/null +++ b/.changes/exact-start-time-in-service-backup.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: ba966846e1 +--- +Exact start time in service backup executions that is hard to read + +- The table shows the relative time, with the exact date in a tooltip diff --git a/.changes/expired-sessions-kept-for-the-retention.md b/.changes/expired-sessions-kept-for-the-retention.md new file mode 100644 index 0000000000..9fa1699db9 --- /dev/null +++ b/.changes/expired-sessions-kept-for-the-retention.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 960471984c +--- +Expired sessions kept for the retention days of the database cleanup ([#7394](https://github.com/coollabsio/coolify/issues/7394)) + +- The cleanup deletes sessions older than the session lifetime diff --git a/.changes/failed-backups-to-s3-endpoints-with.md b/.changes/failed-backups-to-s3-endpoints-with.md new file mode 100644 index 0000000000..5c284810e5 --- /dev/null +++ b/.changes/failed-backups-to-s3-endpoints-with.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 21d1ad4874 +--- +Failed backups to S3 endpoints with a path, such as `/storage/v1/s3` of Supabase Storage ([#8530](https://github.com/coollabsio/coolify/issues/8530)) + +- Database backups, volume backups and S3 restores use curl with a presigned URL for these endpoints diff --git a/.changes/failed-clone-of-an-application-with.md b/.changes/failed-clone-of-an-application-with.md new file mode 100644 index 0000000000..de5bcce38a --- /dev/null +++ b/.changes/failed-clone-of-an-application-with.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: c94411fb92 +--- +Failed clone of an application with a fixed preview URL template, with a duplicate preview domain error and a stopped, partial clone + +- A clone does not copy preview deployments and gets its own from new pull requests diff --git a/.changes/failed-docker-install-on-ubuntu-derivatives.md b/.changes/failed-docker-install-on-ubuntu-derivatives.md new file mode 100644 index 0000000000..63e672a788 --- /dev/null +++ b/.changes/failed-docker-install-on-ubuntu-derivatives.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: d6acd9a3c5 +--- +Failed Docker install on Ubuntu derivatives such as Pop!_OS and Linux Mint, and on Raspbian 13 (Trixie) ([#9455](https://github.com/coollabsio/coolify/issues/9455)) + +- The fallback uses the Ubuntu repository with `UBUNTU_CODENAME` on Ubuntu derivatives +- It uses the Debian repository on Raspbian 13, which has no Docker repository of its own diff --git a/.changes/failed-image-pull-in-the-convex.md b/.changes/failed-image-pull-in-the-convex.md new file mode 100644 index 0000000000..2c6e28f303 --- /dev/null +++ b/.changes/failed-image-pull-in-the-convex.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 3895ca05f3 +--- +Failed image pull in the Convex service template, after the rename to the `ghcr.io/convex` namespace ([#12449](https://github.com/coollabsio/coolify/issues/12449)) diff --git a/.changes/failed-parse-of-a-docker-compose.md b/.changes/failed-parse-of-a-docker-compose.md new file mode 100644 index 0000000000..3f488940e1 --- /dev/null +++ b/.changes/failed-parse-of-a-docker-compose.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 455b22f26c +--- +Failed parse of a Docker Compose file whose `services` key is empty or is not a map, and unexplained exit code 255 in failed commands + +- The message explains that the SSH connection to the server closed before the command finished diff --git a/.changes/failed-s3-uploads-of-backups-larger.md b/.changes/failed-s3-uploads-of-backups-larger.md new file mode 100644 index 0000000000..9d292d9ccb --- /dev/null +++ b/.changes/failed-s3-uploads-of-backups-larger.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 8126a5c128 +--- +Failed S3 uploads of backups larger than about 15.6 GiB on providers that accept only 1000 parts, such as Scaleway ([#8411](https://github.com/coollabsio/coolify/issues/8411)) diff --git a/.changes/failed-sign-up-and-login-in.md b/.changes/failed-sign-up-and-login-in.md new file mode 100644 index 0000000000..e153446c3e --- /dev/null +++ b/.changes/failed-sign-up-and-login-in.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: d3ef4d1c57 +--- +Failed sign-up and login in the Rybbit service template, `/api` requests got a 404 error ([#9055](https://github.com/coollabsio/coolify/issues/9055)) + +- The template routes `/api` on the same domain to the backend on port 3001 and keeps the `/api` prefix +- For an existing service, add `SERVICE_URL_RYBBIT_3001=/api` to `rybbit_backend`, move it above `rybbit` in the Docker Compose file, set `Path prefixes` to `Keep prefixes` in the advanced settings of the backend and redeploy diff --git a/.changes/failed-startup-and-broken-public-album.md b/.changes/failed-startup-and-broken-public-album.md new file mode 100644 index 0000000000..5deb18db80 --- /dev/null +++ b/.changes/failed-startup-and-broken-public-album.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: 927e3f0b42 +--- +Failed startup and broken public album links in the Ente service templates ([#7405](https://github.com/coollabsio/coolify/issues/7405)) + +- An albums service with its own domain serves public links +- The images come from `ghcr.io/ente`, and the JWT secret is URL-safe so that Museum can decode it +- With S3, the template uses the routed `MINIO_SERVER_URL` as the S3 endpoint and allows all CORS origins diff --git a/.changes/forgejo-and-codeberg-in-the-gitea.md b/.changes/forgejo-and-codeberg-in-the-gitea.md new file mode 100644 index 0000000000..003bec0be2 --- /dev/null +++ b/.changes/forgejo-and-codeberg-in-the-gitea.md @@ -0,0 +1,8 @@ +--- +type: added +commit: a58588faa0 +--- +Forgejo and Codeberg in the Gitea App source + +- A `Provider` list fills in the Gitea.com or Codeberg URLs +- Webhooks accept the `X-Forgejo-*` headers diff --git a/.changes/generated-new-ssh-keys-as-ed25519.md b/.changes/generated-new-ssh-keys-as-ed25519.md new file mode 100644 index 0000000000..ca9854aa94 --- /dev/null +++ b/.changes/generated-new-ssh-keys-as-ed25519.md @@ -0,0 +1,5 @@ +--- +type: security +commit: cdc45e5736 +--- +Generated new SSH keys as Ed25519 and validated private keys more strictly diff --git a/.changes/generic-error-when-a-new-private.md b/.changes/generic-error-when-a-new-private.md new file mode 100644 index 0000000000..e128e8958f --- /dev/null +++ b/.changes/generic-error-when-a-new-private.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: b83b97f7e6 +--- +Generic error when a new private key cannot connect to a server ([#7980](https://github.com/coollabsio/coolify/issues/7980)) + +- The message shows the SSH error diff --git a/.changes/gitea-app-endpoints-in-the-api.md b/.changes/gitea-app-endpoints-in-the-api.md new file mode 100644 index 0000000000..3cd0153479 --- /dev/null +++ b/.changes/gitea-app-endpoints-in-the-api.md @@ -0,0 +1,7 @@ +--- +type: added +commit: 3bc82eb71d +--- +Gitea App endpoints in the API and the MCP tools, `GET /gitea-apps` and `POST /applications/private-gitea-app` ([#12459](https://github.com/coollabsio/coolify/issues/12459)) + +- `gitea_app_uuid` sets the source when you create or update an application diff --git a/.changes/github-deployments-sync-each-deployment-and.md b/.changes/github-deployments-sync-each-deployment-and.md new file mode 100644 index 0000000000..098affd211 --- /dev/null +++ b/.changes/github-deployments-sync-each-deployment-and.md @@ -0,0 +1,7 @@ +--- +type: added +commit: bf4b6ad921 +--- +GitHub Deployments sync, each deployment and preview reports to GitHub Deployments with links to the log and the environment + +- It needs a private GitHub App with `Deployments: write` diff --git a/.changes/hardened-smtp-and-terminal-connections.md b/.changes/hardened-smtp-and-terminal-connections.md new file mode 100644 index 0000000000..7d8de669f9 --- /dev/null +++ b/.changes/hardened-smtp-and-terminal-connections.md @@ -0,0 +1,8 @@ +--- +type: security +commit: cdc45e5736 +--- +Hardened SMTP and terminal connections + +- SMTP hosts are validated, and Coolify connects only to the checked address +- The error message explains why an SMTP host is refused diff --git a/.changes/health-checks-in-their-start-period.md b/.changes/health-checks-in-their-start-period.md new file mode 100644 index 0000000000..86945caf50 --- /dev/null +++ b/.changes/health-checks-in-their-start-period.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 666ac84efb +--- +Health checks in their start period shown as unknown, not as `running:starting` ([#9411](https://github.com/coollabsio/coolify/issues/9411)) diff --git a/.changes/high-cpu-use-of-dockerd-while.md b/.changes/high-cpu-use-of-dockerd-while.md new file mode 100644 index 0000000000..63b5b52740 --- /dev/null +++ b/.changes/high-cpu-use-of-dockerd-while.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 1b571f4509 +--- +High CPU use of `dockerd` while you stream the logs of a container with a large log file ([#7693](https://github.com/coollabsio/coolify/issues/7693)) diff --git a/.changes/ignored-concurrent-build-limit-of-build.md b/.changes/ignored-concurrent-build-limit-of-build.md new file mode 100644 index 0000000000..d0f2fa48d6 --- /dev/null +++ b/.changes/ignored-concurrent-build-limit-of-build.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: e0b94662e9 +--- +Ignored concurrent build limit of build servers ([#7649](https://github.com/coollabsio/coolify/issues/7649)) + +- Each build counts on the server that runs it, and a deployment reserves a build server with a free slot when it starts +- A freed build server slot starts the waiting deployments of other servers diff --git a/.changes/import-backup-from-the-resource-menu.md b/.changes/import-backup-from-the-resource-menu.md new file mode 100644 index 0000000000..27f671b4e9 --- /dev/null +++ b/.changes/import-backup-from-the-resource-menu.md @@ -0,0 +1,8 @@ +--- +type: changed +commit: 688134a994 +--- +`Import Backup` from the resource menu to an `Import backup` button on the `Backups` page of databases and services ([#11818](https://github.com/coollabsio/coolify/discussions/11818)) + +- The button opens the import in a dialog +- Old `Import Backup` links open the `Backups` page diff --git a/.changes/inconsistent-status-badges-and-s3-storage.md b/.changes/inconsistent-status-badges-and-s3-storage.md new file mode 100644 index 0000000000..1c4af5c777 --- /dev/null +++ b/.changes/inconsistent-status-badges-and-s3-storage.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: b12c965fbe +--- +Inconsistent status badges and S3 storage names in service backup tables diff --git a/.changes/instance-database-backups-running-on-the.md b/.changes/instance-database-backups-running-on-the.md new file mode 100644 index 0000000000..8422c84366 --- /dev/null +++ b/.changes/instance-database-backups-running-on-the.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: 860e786481 +--- +Instance database backups running on the wrong server when the destination of `coolify-db` points to another one ([#8139](https://github.com/coollabsio/coolify/issues/8139)) + +- The backup always runs on the Coolify host +- A failed pre-update backup shows why it failed +- The pre-update backup skips a disabled schedule diff --git a/.changes/kept-personal-data-and-credentials-out.md b/.changes/kept-personal-data-and-credentials-out.md new file mode 100644 index 0000000000..416d6c72da --- /dev/null +++ b/.changes/kept-personal-data-and-credentials-out.md @@ -0,0 +1,7 @@ +--- +type: security +commit: 3379fa448a +--- +Kept personal data and credentials out of audit events + +- The audit events of a deleted user are anonymized diff --git a/.changes/kept-stored-private-keys-out-of.md b/.changes/kept-stored-private-keys-out-of.md new file mode 100644 index 0000000000..8eea5dc17c --- /dev/null +++ b/.changes/kept-stored-private-keys-out-of.md @@ -0,0 +1,5 @@ +--- +type: security +commit: cdc45e5736 +--- +Kept stored private keys out of the browser diff --git a/.changes/kept-the-sentinel-token-out-of.md b/.changes/kept-the-sentinel-token-out-of.md new file mode 100644 index 0000000000..713b428b07 --- /dev/null +++ b/.changes/kept-the-sentinel-token-out-of.md @@ -0,0 +1,7 @@ +--- +type: security +commit: cdc45e5736 +--- +Kept the Sentinel token out of process arguments + +- Sentinel reads it from an env file diff --git a/.changes/limited-api-clients-that-keep-failing.md b/.changes/limited-api-clients-that-keep-failing.md new file mode 100644 index 0000000000..80bab0ad11 --- /dev/null +++ b/.changes/limited-api-clients-that-keep-failing.md @@ -0,0 +1,8 @@ +--- +type: security +commit: 13298591d8 +--- +Limited API clients that keep failing to 30 failed requests per minute for one token or IP + +- More failed requests get `429`, `API_FAILED_REQUEST_RATE_LIMIT` sets the limit +- The team of the token gets one notification a day when the token reaches the limit diff --git a/.changes/limited-the-cloud-provider-token-api.md b/.changes/limited-the-cloud-provider-token-api.md new file mode 100644 index 0000000000..a662548647 --- /dev/null +++ b/.changes/limited-the-cloud-provider-token-api.md @@ -0,0 +1,8 @@ +--- +type: changed +breaking: true +commit: cdc45e5736 +--- +Limited the cloud provider token API to team admins and owners + +- Other members get `403` diff --git a/.changes/litellm-service-template-with-a-master.md b/.changes/litellm-service-template-with-a-master.md new file mode 100644 index 0000000000..88b24eaca8 --- /dev/null +++ b/.changes/litellm-service-template-with-a-master.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: a487d7c9d3 +--- +LiteLLM service template with a master key that did not start with `sk-` ([#10521](https://github.com/coollabsio/coolify/issues/10521)) + +- The admin API key is `sk-` followed by the value of `SERVICE_PASSWORD_MASTERKEY` +- A separate `LITELLM_SALT_KEY` keeps stored model credentials readable when the master key changes +- `STORE_MODEL_IN_DB` is `True` by default, so you can add models in the LiteLLM UI diff --git a/.changes/live-updates-such-as-proxy-restart.md b/.changes/live-updates-such-as-proxy-restart.md new file mode 100644 index 0000000000..c9bfe53126 --- /dev/null +++ b/.changes/live-updates-such-as-proxy-restart.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: c8c639e3d6 +--- +Live updates, such as proxy restart logs, that did not reach the browser, because the WebSocket handshake failed with `413` with large cookie headers diff --git a/.changes/localhost-server-page-that-did-not.md b/.changes/localhost-server-page-that-did-not.md new file mode 100644 index 0000000000..0c9521f01c --- /dev/null +++ b/.changes/localhost-server-page-that-did-not.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: ca27224628 +--- +Localhost server page that did not refresh after its connection is validated diff --git a/.changes/locked-environment-variables-that-could-not.md b/.changes/locked-environment-variables-that-could-not.md new file mode 100644 index 0000000000..f58a7f6a9e --- /dev/null +++ b/.changes/locked-environment-variables-that-could-not.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: da7779d061 +--- +Locked environment variables that could not be changed, unlocked or deleted when Docker Compose uses them ([#6803](https://github.com/coollabsio/coolify/issues/6803)) + +- A locked value can be replaced without showing it +- The lock is permanent in the UI, the API and MCP, and `Lock` asks for confirmation diff --git a/.changes/lost-audit-events-12433-https-github.md b/.changes/lost-audit-events-12433-https-github.md new file mode 100644 index 0000000000..05a25fddf2 --- /dev/null +++ b/.changes/lost-audit-events-12433-https-github.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: cdc45e5736 +--- +Lost audit events + +- Coolify writes audit events at commit time diff --git a/.changes/lost-telegram-notifications-when-telegram-rejects.md b/.changes/lost-telegram-notifications-when-telegram-rejects.md new file mode 100644 index 0000000000..dc5cbf8e97 --- /dev/null +++ b/.changes/lost-telegram-notifications-when-telegram-rejects.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: a7b7aa350a +--- +Lost Telegram notifications when Telegram rejects a button URL, for example localhost or an IP address without a domain ([#8269](https://github.com/coollabsio/coolify/issues/8269)) + +- Coolify then sends the links as text diff --git a/.changes/lost-trailing-newline-of-multiline-environment.md b/.changes/lost-trailing-newline-of-multiline-environment.md new file mode 100644 index 0000000000..6bbb1945ec --- /dev/null +++ b/.changes/lost-trailing-newline-of-multiline-environment.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: 52a78cdb0f +--- +Lost trailing newline of multiline environment variables, so OpenSSH rejected an SSH private key with "error in libcrypto" ([#9438](https://github.com/coollabsio/coolify/issues/9438)) + +- A value with more than one line is saved exactly as you enter it, in the UI and in the API +- Single-line values still lose their edge whitespace +- Save a variable again to add the newline that Coolify removed diff --git a/.changes/lost-update-channel-in-settings-updates.md b/.changes/lost-update-channel-in-settings-updates.md new file mode 100644 index 0000000000..6950fa914c --- /dev/null +++ b/.changes/lost-update-channel-in-settings-updates.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 7e7f491749 +--- +Lost update channel in `Settings` > `Updates` and lost healthcheck `Check type` when one of them is the only change + +- The `Save changes` bar opens for them diff --git a/.changes/maintenance-mode-to-its-own-maintenance.md b/.changes/maintenance-mode-to-its-own-maintenance.md new file mode 100644 index 0000000000..25630ea198 --- /dev/null +++ b/.changes/maintenance-mode-to-its-own-maintenance.md @@ -0,0 +1,5 @@ +--- +type: changed +commit: 17a9fa624e +--- +Maintenance mode to its own `Maintenance` page of applications and services, and you must confirm the toggle diff --git a/.changes/missing-check-of-the-closest-cloudflare.md b/.changes/missing-check-of-the-closest-cloudflare.md new file mode 100644 index 0000000000..70dc51bde5 --- /dev/null +++ b/.changes/missing-check-of-the-closest-cloudflare.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: e20e652c00 +--- +Missing check of the closest Cloudflare wildcard record for deeper domain names, such as `app.apps.example.com` through `*.example.com` ([#12436](https://github.com/coollabsio/coolify/issues/12436)) diff --git a/.changes/missing-cloudflare-dns-record-when-you.md b/.changes/missing-cloudflare-dns-record-when-you.md new file mode 100644 index 0000000000..c30b763a85 --- /dev/null +++ b/.changes/missing-cloudflare-dns-record-when-you.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: daf0e1a70c +--- +Missing Cloudflare DNS record when you edit a domain to a hostname in a connected Cloudflare zone, for example a generated domain changed to your own domain + +- Coolify adds the record automatically or opens the `Configure DNS` dialog, as it did only for new domains +- This applies to the `Domains` pages, the application and service application settings and the service application API diff --git a/.changes/missing-disabled-server-message-on-the.md b/.changes/missing-disabled-server-message-on-the.md new file mode 100644 index 0000000000..7db79d7eb5 --- /dev/null +++ b/.changes/missing-disabled-server-message-on-the.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: eb5f238a45 +--- +Missing disabled server message on the localhost server page ([#12475](https://github.com/coollabsio/coolify/issues/12475)) diff --git a/.changes/missing-git-error-reason-when-coolify.md b/.changes/missing-git-error-reason-when-coolify.md new file mode 100644 index 0000000000..3982b272e0 --- /dev/null +++ b/.changes/missing-git-error-reason-when-coolify.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 3f51c251c0 +--- +Missing Git error reason when Coolify cannot read a repository diff --git a/.changes/missing-links-to-the-provider-dashboards.md b/.changes/missing-links-to-the-provider-dashboards.md new file mode 100644 index 0000000000..cdcb7c50fe --- /dev/null +++ b/.changes/missing-links-to-the-provider-dashboards.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 693cf0486d +--- +Missing links to the provider dashboards on the secret manager token forms diff --git a/.changes/missing-project-environment-and-service-in.md b/.changes/missing-project-environment-and-service-in.md new file mode 100644 index 0000000000..d821ce2054 --- /dev/null +++ b/.changes/missing-project-environment-and-service-in.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 8d07d178f0 +--- +Missing project, environment and service in Fluent Bit log drains for containers without the Coolify default variables ([#8415](https://github.com/coollabsio/coolify/issues/8415)) + +- The `fluentd` driver also sends the Coolify container labels, and the built-in configurations use them as a fallback diff --git a/.changes/missing-resource-status-changes-notification-when.md b/.changes/missing-resource-status-changes-notification-when.md new file mode 100644 index 0000000000..820c46c569 --- /dev/null +++ b/.changes/missing-resource-status-changes-notification-when.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: f21acdc3e4 +--- +Missing `Resource status changes` notification when an application, preview, service or database stops unexpectedly, for example after a crash or `docker kill` ([#9493](https://github.com/coollabsio/coolify/issues/9493)) + +- Coolify waits 2 minutes and notifies only when the resource is still stopped +- A stop, restart or redeploy from Coolify sends no notification +- The same resource sends at most one notification in 15 minutes diff --git a/.changes/missing-traefik-proxy-outdated-notification-when.md b/.changes/missing-traefik-proxy-outdated-notification-when.md new file mode 100644 index 0000000000..1b427b52e2 --- /dev/null +++ b/.changes/missing-traefik-proxy-outdated-notification-when.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 2fbe9d2abe +--- +Missing `Traefik Proxy Outdated` notification when a newer Traefik patch version of the running branch is available ([#9727](https://github.com/coollabsio/coolify/issues/9727)) + +- Coolify sends one notification for each new patch version, not one for each hourly check diff --git a/.changes/moved-the-resend-sender-to-the.md b/.changes/moved-the-resend-sender-to-the.md new file mode 100644 index 0000000000..0196275f39 --- /dev/null +++ b/.changes/moved-the-resend-sender-to-the.md @@ -0,0 +1,8 @@ +--- +type: changed +breaking: true +commit: f7867923b7 +--- +Moved the Resend sender to the new `resend_from_address` and `resend_from_name` fields, API clients must use the new fields + +- A migration copies the current values diff --git a/.changes/names-and-descriptions-saved-through-the.md b/.changes/names-and-descriptions-saved-through-the.md new file mode 100644 index 0000000000..7f09b19e8b --- /dev/null +++ b/.changes/names-and-descriptions-saved-through-the.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 32541a32ce +--- +Names and descriptions saved through the API and the MCP server that the UI rejects, so the resource could not be saved in the UI afterwards ([#12466](https://github.com/coollabsio/coolify/issues/12466)) + +- Applications, services, databases, servers and private keys use the rules and the error messages of the UI diff --git a/.changes/nested-test-key-in-the-api.md b/.changes/nested-test-key-in-the-api.md new file mode 100644 index 0000000000..c51afeec06 --- /dev/null +++ b/.changes/nested-test-key-in-the-api.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 05e52fc41d +--- +Nested `test` key in the `api` service when you update the image of a service named like `api.test` ([#12456](https://github.com/coollabsio/coolify/issues/12456)) + +- A service name that Docker cannot use as a container name prefix gets a clear error on save diff --git a/.changes/network-error-when-you-sign-in.md b/.changes/network-error-when-you-sign-in.md new file mode 100644 index 0000000000..4fe9d671af --- /dev/null +++ b/.changes/network-error-when-you-sign-in.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: e9028e1338 +--- +"Network error" when you sign in with email in AppFlowy Web ([#10545](https://github.com/coollabsio/coolify/issues/10545)) + +- The template sets `GOTRUE_CORS_ALLOWED_HEADERS=redirect_to` +- For an existing service, add this variable to the `gotrue` service in the Docker Compose file and restart the service diff --git a/.changes/new-rsa-deploy-keys-that-github.md b/.changes/new-rsa-deploy-keys-that-github.md new file mode 100644 index 0000000000..b96ea3435e --- /dev/null +++ b/.changes/new-rsa-deploy-keys-that-github.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 4bdf03db69 +--- +New RSA deploy keys that GitHub rejected, because Coolify made 2048-bit keys ([#12461](https://github.com/coollabsio/coolify/issues/12461)) + +- New RSA keys are 4096 bits diff --git a/.changes/oauth-redirect-uris-that-silently-sent.md b/.changes/oauth-redirect-uris-that-silently-sent.md new file mode 100644 index 0000000000..d9f1ae7ef4 --- /dev/null +++ b/.changes/oauth-redirect-uris-that-silently-sent.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 3dc89cfe05 +--- +OAuth redirect URIs that silently sent the user back to the login page + +- A redirect URI must point at the callback route of the provider, any other path gets a validation error diff --git a/.changes/old-cached-image-in-the-usesend.md b/.changes/old-cached-image-in-the-usesend.md new file mode 100644 index 0000000000..ea0e4d5bc1 --- /dev/null +++ b/.changes/old-cached-image-in-the-usesend.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 7ec000ab86 +--- +Old cached image in the Usesend service template, which used `latest` ([#12443](https://github.com/coollabsio/coolify/issues/12443)) + +- The template pins `usesend/usesend:v1.10.0`, which has the Next.js, Node.js 24 and nodemailer security updates diff --git a/.changes/one-email-provider-list-with-none.md b/.changes/one-email-provider-list-with-none.md new file mode 100644 index 0000000000..3e5d820d70 --- /dev/null +++ b/.changes/one-email-provider-list-with-none.md @@ -0,0 +1,5 @@ +--- +type: added +commit: f7867923b7 +--- +One email provider list with None, SMTP and Resend, more than one named SMTP server with its own sender, and SMTP templates for common providers diff --git a/.changes/outline-service-template-restarting-in-a.md b/.changes/outline-service-template-restarting-in-a.md new file mode 100644 index 0000000000..ffd8a7f246 --- /dev/null +++ b/.changes/outline-service-template-restarting-in-a.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: dd8ea3dc93 +--- +Outline service template restarting in a loop with `syntax error at or near "TRIGGER"` on `postgres:12-alpine` ([#12345](https://github.com/coollabsio/coolify/issues/12345)) + +- New services use PostgreSQL 16 +- An existing service keeps PostgreSQL 12, the Outline service documentation has the steps to upgrade it diff --git a/.changes/paid-coolify-cloud-subscriptions-that-lost.md b/.changes/paid-coolify-cloud-subscriptions-that-lost.md new file mode 100644 index 0000000000..5c5a4f92e8 --- /dev/null +++ b/.changes/paid-coolify-cloud-subscriptions-that-lost.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: d333f3f6e6 +--- +Paid Coolify Cloud subscriptions that lost their link to Stripe staying active after the customer cancels them + +- `cloud:sync-stripe-subscriptions` reports them diff --git a/.changes/pinned-the-ssh-host-key-of.md b/.changes/pinned-the-ssh-host-key-of.md new file mode 100644 index 0000000000..4b264d6697 --- /dev/null +++ b/.changes/pinned-the-ssh-host-key-of.md @@ -0,0 +1,7 @@ +--- +type: security +commit: cdc45e5736 +--- +Pinned the SSH host key of a server on the first connection, the team gets a notification when it changes + +- `SSH_HOST_KEY_CHECKING` sets the mode: `warn`, `enforce` or `off` diff --git a/.changes/pinned-the-ssh-host-keys-of.md b/.changes/pinned-the-ssh-host-keys-of.md new file mode 100644 index 0000000000..76e2f46cd9 --- /dev/null +++ b/.changes/pinned-the-ssh-host-keys-of.md @@ -0,0 +1,7 @@ +--- +type: security +commit: cdc45e5736 +--- +Pinned the SSH host keys of Git hosts across deployments + +- Coolify shows the published host keys of GitHub, GitLab and Bitbucket diff --git a/.changes/port-warning-on-a-service-domain.md b/.changes/port-warning-on-a-service-domain.md new file mode 100644 index 0000000000..4f1a4c7948 --- /dev/null +++ b/.changes/port-warning-on-a-service-domain.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 53e21abe97 +--- +Port warning on a service domain without a port, although Coolify routes it to the required port + +- The edit dialog shows the effective port, and the warning buttons name the ports diff --git a/.changes/rebuilt-docker-layers-when-a-preview.md b/.changes/rebuilt-docker-layers-when-a-preview.md new file mode 100644 index 0000000000..2faf55c5b7 --- /dev/null +++ b/.changes/rebuilt-docker-layers-when-a-preview.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: e7e952fcad +--- +Rebuilt Docker layers when a preview comes or goes, and unreachable hosts on a build server ([#12452](https://github.com/coollabsio/coolify/issues/12452)) + +- Preview containers and the hosts of the deployment server stay out of `--add-host` on a build server +- A setting of the application turns off the network hosts during the build diff --git a/.changes/rebuilt-docker-layers-when-a-short.md b/.changes/rebuilt-docker-layers-when-a-short.md new file mode 100644 index 0000000000..6748fb9593 --- /dev/null +++ b/.changes/rebuilt-docker-layers-when-a-short.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 6ab538d0f1 +--- +Rebuilt Docker layers when a short-lived container joins or leaves the network between two builds ([#12452](https://github.com/coollabsio/coolify/issues/12452)) + +- Database backup and S3 import helpers, Sentinel and the proxy error page container stay out of `--add-host` diff --git a/.changes/redis-health-check-that-always-passed.md b/.changes/redis-health-check-that-always-passed.md new file mode 100644 index 0000000000..22eebc91f4 --- /dev/null +++ b/.changes/redis-health-check-that-always-passed.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 878d77bd66 +--- +Redis health check that always passed, because it did not ping the database + +- The check pings with the password and requires `PONG` diff --git a/.changes/refused-servers-with-the-same-ip.md b/.changes/refused-servers-with-the-same-ip.md new file mode 100644 index 0000000000..48e8882afc --- /dev/null +++ b/.changes/refused-servers-with-the-same-ip.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: ecb14e6cd5 +--- +Refused servers with the same IP and different SSH ports ([#7573](https://github.com/coollabsio/coolify/issues/7573)) diff --git a/.changes/resource-limits-shown-for-docker-compose.md b/.changes/resource-limits-shown-for-docker-compose.md new file mode 100644 index 0000000000..334cd175f1 --- /dev/null +++ b/.changes/resource-limits-shown-for-docker-compose.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: cdc45e5736 +--- +Resource limits shown for Docker Compose applications diff --git a/.changes/restricted-preview-changes-rollbacks-and-repository.md b/.changes/restricted-preview-changes-rollbacks-and-repository.md new file mode 100644 index 0000000000..e50cfda6a7 --- /dev/null +++ b/.changes/restricted-preview-changes-rollbacks-and-repository.md @@ -0,0 +1,9 @@ +--- +type: security +commit: c75e7078b7 +--- +Restricted preview changes, rollbacks and repository URL changes + +- Opening a preview or changing its pull request URL, Git type or Docker tag through the API needs write access, a deploy token can only redeploy an existing preview +- A rollback goes only to a listed rollback image or a successful production deployment +- A new `git_repository` of an application is validated like in the UI diff --git a/.changes/rotate-key-on-private-keys-which.md b/.changes/rotate-key-on-private-keys-which.md new file mode 100644 index 0000000000..87d349de71 --- /dev/null +++ b/.changes/rotate-key-on-private-keys-which.md @@ -0,0 +1,10 @@ +--- +type: added +commit: cdc45e5736 +--- +`Rotate key` on private keys, which replaces a key on all servers that use it + +- Coolify adds the new key to `authorized_keys`, can undo the rotation, and removes the old key +- A server delete can remove the key from `authorized_keys` +- Coolify deletes unused SSH key files every hour +- The button shows only when a server uses the key diff --git a/.changes/sentinel-that-kept-pushing-after-its.md b/.changes/sentinel-that-kept-pushing-after-its.md new file mode 100644 index 0000000000..b564ce3dcf --- /dev/null +++ b/.changes/sentinel-that-kept-pushing-after-its.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 5c34a1b3d4 +--- +Sentinel that kept pushing after its server was deleted or its team stopped paying + +- Coolify removes the Sentinel container of unpaid servers once a day and before a server is deleted diff --git a/.changes/server-error-when-a-backup-was.md b/.changes/server-error-when-a-backup-was.md new file mode 100644 index 0000000000..594250b189 --- /dev/null +++ b/.changes/server-error-when-a-backup-was.md @@ -0,0 +1,6 @@ +--- +type: fixed +--- +Server error when a backup was uploaded in `Import Backup` for a database that the team cannot open + +- The upload now answers with `Database not found` diff --git a/.changes/server-page-that-did-not-refresh.md b/.changes/server-page-that-did-not-refresh.md new file mode 100644 index 0000000000..612cf07be3 --- /dev/null +++ b/.changes/server-page-that-did-not-refresh.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 30a8e3357a +--- +Server page that did not refresh after a background validation + +- The validation dialog that a background job opens closes when the job finishes diff --git a/.changes/server-shared-variables-that-did-not.md b/.changes/server-shared-variables-that-did-not.md new file mode 100644 index 0000000000..e5aae7a3ff --- /dev/null +++ b/.changes/server-shared-variables-that-did-not.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 106a6d7ac0 +--- +Server shared variables that did not resolve in Docker Compose files + +- Their values are redacted in the rendered Compose file diff --git a/.changes/server-usage-in-the-server-table.md b/.changes/server-usage-in-the-server-table.md new file mode 100644 index 0000000000..6b75ccb8f0 --- /dev/null +++ b/.changes/server-usage-in-the-server-table.md @@ -0,0 +1,5 @@ +--- +type: added +commit: cab5ae9903 +--- +Server usage in the server table of the dashboard on mobile diff --git a/.changes/servers-that-refused-new-ssh-logins.md b/.changes/servers-that-refused-new-ssh-logins.md new file mode 100644 index 0000000000..c3ca975f1f --- /dev/null +++ b/.changes/servers-that-refused-new-ssh-logins.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: 5431d4954d +--- +Servers that refused new SSH logins with "Maximum number of sessions (8192) reached", because each connection check opened three SSH logins ([#9148](https://github.com/coollabsio/coolify/issues/9148)) + +- The Docker and Docker Compose version checks use the multiplexed SSH connection, so each check opens one login +- To remove the stuck sessions, run `systemctl restart systemd-logind` on the server diff --git a/.changes/service-backup-path-that-could-not.md b/.changes/service-backup-path-that-could-not.md new file mode 100644 index 0000000000..e89de9c840 --- /dev/null +++ b/.changes/service-backup-path-that-could-not.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 3115980c77 +--- +Service backup path that could not be copied, and an S3 badge on runs that are not stored in S3 diff --git a/.changes/service-backup-tables-that-were-not.md b/.changes/service-backup-tables-that-were-not.md new file mode 100644 index 0000000000..052d2766e3 --- /dev/null +++ b/.changes/service-backup-tables-that-were-not.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 7fbc5d7797 +--- +Service backup tables that were not aligned or sortable by column diff --git a/.changes/service-resource-cards-showing-a-starting.md b/.changes/service-resource-cards-showing-a-starting.md new file mode 100644 index 0000000000..3267150ab4 --- /dev/null +++ b/.changes/service-resource-cards-showing-a-starting.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 50af95a70b +--- +Service resource cards showing a starting health check as success ([#9411](https://github.com/coollabsio/coolify/issues/9411)) + +- The card shows it as a warning diff --git a/.changes/service-resource-cards-that-did-not.md b/.changes/service-resource-cards-that-did-not.md new file mode 100644 index 0000000000..2b07ebf621 --- /dev/null +++ b/.changes/service-resource-cards-that-did-not.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 4d932b3078 +--- +Service resource cards that did not update their status together with the heading badge diff --git a/.changes/sftp-ftps-and-webdav-backup-storages.md b/.changes/sftp-ftps-and-webdav-backup-storages.md new file mode 100644 index 0000000000..90de297237 --- /dev/null +++ b/.changes/sftp-ftps-and-webdav-backup-storages.md @@ -0,0 +1,7 @@ +--- +type: added +commit: 0db8f60bdd +--- +SFTP, FTPS and WebDAV backup storages for database and volume backups, with downloads, retention and restores + +- SFTP restores and downloads need a host key that an admin saved with a validation diff --git a/.changes/startup-and-the-nightly-cleanup-of.md b/.changes/startup-and-the-nightly-cleanup-of.md new file mode 100644 index 0000000000..b9c85a1f4d --- /dev/null +++ b/.changes/startup-and-the-nightly-cleanup-of.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: 9c6138fe18 +--- +Startup and the nightly cleanup of stuck resources stopping with "Allowed memory size exhausted" on instances with many large deployment logs ([#12435](https://github.com/coollabsio/coolify/issues/12435)) + +- Stuck deployments, tasks and backups are marked as failed, and the proxy configuration is set up +- Database backup pages and `Settings` > `Backup` do not load all executions on each request diff --git a/.changes/supabase-studio-showing-the-api-logs.md b/.changes/supabase-studio-showing-the-api-logs.md new file mode 100644 index 0000000000..7f71aa5f05 --- /dev/null +++ b/.changes/supabase-studio-showing-the-api-logs.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: 8f7d1be7a9 +--- +Supabase Studio showing the API logs of another stack when two Supabase services run on one server ([#10324](https://github.com/coollabsio/coolify/issues/10324)) + +- The Vector container of the template reads only the logs of its own stack +- An existing service keeps its `vector.yml`, to fix it add `include_labels: ['com.docker.compose.project=${COOLIFY_RESOURCE_UUID}']` under `sources.docker_host` in `/data/coolify/services//volumes/logs/vector.yml` and restart the service diff --git a/.changes/the-data-retention-of-unsubscribed-teams.md b/.changes/the-data-retention-of-unsubscribed-teams.md new file mode 100644 index 0000000000..733deeea46 --- /dev/null +++ b/.changes/the-data-retention-of-unsubscribed-teams.md @@ -0,0 +1,7 @@ +--- +type: changed +commit: 997396b926 +--- +The data retention of unsubscribed teams on Coolify Cloud from 30 to 60 days + +- The cleanup saves the deleted users to `cloud-deleted-users.csv` on the backups disk diff --git a/.changes/the-default-image-of-new-redis.md b/.changes/the-default-image-of-new-redis.md new file mode 100644 index 0000000000..c7786157e7 --- /dev/null +++ b/.changes/the-default-image-of-new-redis.md @@ -0,0 +1,7 @@ +--- +type: changed +commit: b7dc2e435b +--- +The default image of new Redis databases to `valkey/valkey:9-alpine` + +- Existing databases keep their image diff --git a/.changes/the-default-images-of-new-databases.md b/.changes/the-default-images-of-new-databases.md new file mode 100644 index 0000000000..e898aef875 --- /dev/null +++ b/.changes/the-default-images-of-new-databases.md @@ -0,0 +1,7 @@ +--- +type: changed +commit: 70adcc2b0e +--- +The default images of new databases to PostgreSQL 18, MongoDB 9, MySQL 9.7, MariaDB 12.3 and ClickHouse 26.8 + +- Existing databases keep their image diff --git a/.changes/the-default-images-on-the-new.md b/.changes/the-default-images-on-the-new.md new file mode 100644 index 0000000000..b797991103 --- /dev/null +++ b/.changes/the-default-images-on-the-new.md @@ -0,0 +1,5 @@ +--- +type: changed +commit: 145569bea6 +--- +The default images on the new resource page to Supabase PostgreSQL 17.11.0.018 and PostGIS 18-3.7-alpine diff --git a/.changes/the-finish-time-of-recent-deployments.md b/.changes/the-finish-time-of-recent-deployments.md new file mode 100644 index 0000000000..05bfbab04c --- /dev/null +++ b/.changes/the-finish-time-of-recent-deployments.md @@ -0,0 +1,5 @@ +--- +type: added +commit: 1652a1d091 +--- +The finish time of recent deployments on the dashboard diff --git a/.changes/the-logo-and-name-of-the.md b/.changes/the-logo-and-name-of-the.md new file mode 100644 index 0000000000..7c2296175e --- /dev/null +++ b/.changes/the-logo-and-name-of-the.md @@ -0,0 +1,5 @@ +--- +type: added +commit: 973cd8684a +--- +The logo and name of the resource on the resource creation screen diff --git a/.changes/the-notifications-of-scheduled-jobs-that.md b/.changes/the-notifications-of-scheduled-jobs-that.md new file mode 100644 index 0000000000..7d32cebc62 --- /dev/null +++ b/.changes/the-notifications-of-scheduled-jobs-that.md @@ -0,0 +1,13 @@ +--- +type: changed +commit: 06c41f57d6 +--- +The notifications of scheduled jobs that keep failing, to the first failure and then at most once a day + +- This covers scheduled tasks, database backups, storage backups and Docker cleanup +- The first successful run after a failure sends one "works again" notification to the failure channels +- A database backup whose S3 upload keeps failing also notifies at most once a day +- A scheduled task disables itself after 10 failures in a row when the first failure is more than 24 hours old, one notification and the task page give the reason, and enabling the task again clears it +- The scheduled tasks API returns `consecutive_failures`, `failing_since` and `disabled_reason` +- Backups and Docker cleanup are never disabled automatically, so they start again by themselves after an outage +- A scheduled task skips a run when the application or service has no running container, without a notification and without a count toward the automatic disable diff --git a/.changes/the-orbit-service-template-a-self.md b/.changes/the-orbit-service-template-a-self.md new file mode 100644 index 0000000000..63aa31957b --- /dev/null +++ b/.changes/the-orbit-service-template-a-self.md @@ -0,0 +1,5 @@ +--- +type: added +commit: abea0747ed +--- +The Orbit service template, a self-hosted workspace for teams with tasks, docs, chat and an MCP server for AI clients diff --git a/.changes/the-security-settings-of-a-server.md b/.changes/the-security-settings-of-a-server.md new file mode 100644 index 0000000000..46a3027743 --- /dev/null +++ b/.changes/the-security-settings-of-a-server.md @@ -0,0 +1,5 @@ +--- +type: changed +commit: cdc45e5736 +--- +The security settings of a server to one `Security` page with patches, terminal access, SSH host key and image scanning diff --git a/.changes/the-sentinel-version-to-1-1.md b/.changes/the-sentinel-version-to-1-1.md new file mode 100644 index 0000000000..b87d2a3bbb --- /dev/null +++ b/.changes/the-sentinel-version-to-1-1.md @@ -0,0 +1,5 @@ +--- +type: changed +commit: 54da974045 +--- +The Sentinel version to 1.1.5 diff --git a/.changes/the-unused-minio-client-from-the.md b/.changes/the-unused-minio-client-from-the.md new file mode 100644 index 0000000000..b3c2aa8d80 --- /dev/null +++ b/.changes/the-unused-minio-client-from-the.md @@ -0,0 +1,5 @@ +--- +type: removed +commit: 91985acf42 +--- +The unused MinIO client from the Coolify image diff --git a/.changes/tooltips-on-close-and-dismiss-buttons.md b/.changes/tooltips-on-close-and-dismiss-buttons.md new file mode 100644 index 0000000000..afb91e0880 --- /dev/null +++ b/.changes/tooltips-on-close-and-dismiss-buttons.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: b1a20fabd4 +--- +Tooltips on close and dismiss buttons that stayed visible diff --git a/.changes/two-deployments-of-one-application-running.md b/.changes/two-deployments-of-one-application-running.md new file mode 100644 index 0000000000..e9f60dbe3a --- /dev/null +++ b/.changes/two-deployments-of-one-application-running.md @@ -0,0 +1,9 @@ +--- +type: fixed +commit: bb6dfd147e +--- +Two deployments of one application running at once when started from the MCP `deploy` tool or the MCP `control` start action, which could stop the application ([#12442](https://github.com/coollabsio/coolify/issues/12442)) + +- They queue behind a deployment in progress, as the API and the `Deploy` button do +- A deployment of the same commit is skipped, and the response gives the UUID of the deployment in progress +- A rolling update removes only the containers that ran before it started diff --git a/.changes/unexplained-uuid-prefix-on-the-names.md b/.changes/unexplained-uuid-prefix-on-the-names.md new file mode 100644 index 0000000000..003e46e78f --- /dev/null +++ b/.changes/unexplained-uuid-prefix-on-the-names.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: e0ae59deca +--- +Unexplained UUID prefix on the names of new volumes ([#8620](https://github.com/coollabsio/coolify/issues/8620)) + +- The dialog explains that Coolify adds the prefix so that the volume name is unique on the server diff --git a/.changes/unreadable-accent-text-and-fills-in.md b/.changes/unreadable-accent-text-and-fills-in.md new file mode 100644 index 0000000000..7fed10b446 --- /dev/null +++ b/.changes/unreadable-accent-text-and-fills-in.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: 6518734935 +--- +Unreadable accent text and fills in custom themes ([#12468](https://github.com/coollabsio/coolify/issues/12468)) + +- Accent text reaches a contrast of 4.5:1 on the page and in the sidebar, and the preview shows the real fill diff --git a/.changes/unreadable-account-menu-entries-such-as.md b/.changes/unreadable-account-menu-entries-such-as.md new file mode 100644 index 0000000000..4a1fcc31d6 --- /dev/null +++ b/.changes/unreadable-account-menu-entries-such-as.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 5eee9933ca +--- +Unreadable account menu entries, such as `Log out`, when a custom theme sidebar uses the other light or dark scheme than the page ([#12467](https://github.com/coollabsio/coolify/issues/12467)) diff --git a/.changes/upgrade-modal-that-did-not-finish.md b/.changes/upgrade-modal-that-did-not-finish.md new file mode 100644 index 0000000000..e323813ad2 --- /dev/null +++ b/.changes/upgrade-modal-that-did-not-finish.md @@ -0,0 +1,8 @@ +--- +type: fixed +commit: dec776025e +--- +Upgrade modal that did not finish when the new version answered ([#8024](https://github.com/coollabsio/coolify/issues/8024)) + +- The modal shows success as soon as the new version is up +- Livewire error pages stay hidden while an upgrade runs diff --git a/.changes/validated-webhook-urls-limited-dns-lookups.md b/.changes/validated-webhook-urls-limited-dns-lookups.md new file mode 100644 index 0000000000..fd353fda2c --- /dev/null +++ b/.changes/validated-webhook-urls-limited-dns-lookups.md @@ -0,0 +1,5 @@ +--- +type: security +commit: cdc45e5736 +--- +Validated webhook URLs, limited DNS lookups and tightened the authorization of team resources diff --git a/.changes/valkey-as-an-image-of-the.md b/.changes/valkey-as-an-image-of-the.md new file mode 100644 index 0000000000..427ae2b860 --- /dev/null +++ b/.changes/valkey-as-an-image-of-the.md @@ -0,0 +1,8 @@ +--- +type: added +commit: 41877198b3 +--- +Valkey as an image of the Redis database type + +- A new step of a new Redis database asks for Redis or Valkey +- Global search accepts "new valkey" diff --git a/.changes/volume-data-checkboxes-that-were-not.md b/.changes/volume-data-checkboxes-that-were-not.md new file mode 100644 index 0000000000..7fd257bc1e --- /dev/null +++ b/.changes/volume-data-checkboxes-that-were-not.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 19762a7950 +--- +Volume data checkboxes that were not next to their labels diff --git a/.changes/websockets-closed-by-caddy-on-every.md b/.changes/websockets-closed-by-caddy-on-every.md new file mode 100644 index 0000000000..c91088d2bc --- /dev/null +++ b/.changes/websockets-closed-by-caddy-on-every.md @@ -0,0 +1,7 @@ +--- +type: fixed +commit: f4a1e3e427 +--- +WebSockets closed by Caddy on every configuration reload ([#7942](https://github.com/coollabsio/coolify/issues/7942)) + +- The application, service and dashboard routes set `stream_close_delay` diff --git a/.changes/white-flash-of-the-dark-theme.md b/.changes/white-flash-of-the-dark-theme.md new file mode 100644 index 0000000000..275573d621 --- /dev/null +++ b/.changes/white-flash-of-the-dark-theme.md @@ -0,0 +1,5 @@ +--- +type: fixed +commit: 618d377d87 +--- +White flash of the dark theme during page loads and `wire:navigate` ([#7866](https://github.com/coollabsio/coolify/issues/7866)) diff --git a/.gitattributes b/.gitattributes index 7c081f94b1..445793c205 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,4 +9,5 @@ CHANGELOG.md merge=union /.github export-ignore CHANGELOG.md export-ignore +.changes export-ignore .styleci.yml export-ignore \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b2e995b56b..62df0e509c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,10 +184,12 @@ Before you rate a vulnerability or write an advisory, find the security boundary - Trusted by design (attacks that need these roles are low severity): - A team admin or owner, and an API token with `write` or `root` ability, already has root on every server of that team (terminal, any Compose file with host mounts, scheduled commands, all team secrets). - An instance admin (admin or owner of the root team) already controls the Coolify instance and its host. + - A member with write access on a resource (an access grant) already has root on the server of that resource. In the root team, this includes the Coolify host when the resource runs on localhost. - Repository, Dockerfile, and Compose content that a team admin deploys is trusted for that deployment. - Real boundaries: - No login -> any access. - Team member (read-only) -> write, command execution, or secrets in the own team. + - Member with access grants -> resources, servers, or credentials outside the grants (use `ServerPolicy::placeResource` for servers and `useTeamCredentials` for team credentials). - One team -> another team. - Team user -> the Coolify container or the Coolify host, when the user is not an instance admin. - Token -> more than its abilities allow. @@ -287,17 +289,61 @@ The persistent-storage implementation is the reference pattern: `Project\Service ## Git Workflow - Production branch: `main`. It takes fixes only. A new minor or major version gets to `main` as a merge of `next` or a release branch. +- Merge `next` or a release branch into `main` only right before its release, on the same day. To freeze a minor for longer, publish an RC on `next` or cut a `release/v*` branch. - Development branch: `next`. It takes features and publishes prereleases only. - Fix PRs target `main`. Feature PRs target `next`. - Name a branch after its change: `fix/` or `feat/`. Name a backport branch after the version that releases it: `fix/-v4.4.8`. - Merge `next`, `release/v*` branches, and patch branches (`v4.x`, `v4.4.x`) with a merge commit. Never squash merge them, because their commits then come back as conflicts in each later merge. - Create `release/v*` and patch branches only when the user asks. +## Change Files + +Each change that users of Coolify can see gets a file in `.changes/`, in the same commit as the change. `scripts/release.sh` writes the files into `CHANGELOG.md`, and the release notes come from there. + +Write a file with `scripts/change.sh` and its flags: + +```bash +scripts/change.sh --type fixed --summary 'Ignored concurrent build limit of build servers' --detail 'Each build counts on the server that runs it' +scripts/change.sh --type changed --breaking --summary 'Required the `delete` ability for every delete through the API' +scripts/change.sh --notes # the entries of the next release +``` + +- Write one file for each change. When a commit changes something that an unreleased change file describes, edit that file instead of adding a second one. +- Pass `--commit ` only to describe a commit that exists already. +- Tests, documentation, refactors, and CI, scripts, or release tooling that users do not see need no file. Use `maintenance` only for a change that matters to people who build or release Coolify. + +| Type | For | Next release | +| --- | --- | --- | +| `added` | Something new for users | minor | +| `changed` | Existing behavior that works differently | minor | +| `deprecated` | A feature that is going away | minor | +| `removed` | A feature that is gone | minor | +| `fixed` | A bug that users can see | patch | +| `security` | A closed vulnerability or hardening | patch | +| `maintenance` | A notable change that only maintainers see | patch | + +Add `--breaking` when users must act after the upgrade. The entry moves to `Breaking Changes`, and the next release is a major. + +The summary reads as one sentence with the heading of its section, never in the imperative of a commit subject: + +| Section | The summary | Example | +| --- | --- | --- | +| `Added`, `Changed`, `Deprecated`, `Removed` | What the heading acts on | Added `Automatic cancelling of the older queued webhook deployments` | +| `Fixed` | The bug, not the fix | Fixed `Ignored concurrent build limit of build servers`, not `Apply the concurrent build limit` | +| `Breaking Changes`, `Security`, `Maintenance` | Starts with a verb in the past tense | `Compressed the layers with zstd instead of gzip` | +| A `--detail` under any section | How it works now, in the present tense | `The default is none` | + +- Start with a capital letter or a code span, and end without a period. A reason follows after a comma. +- Write for the person who runs Coolify. Name the page, setting, or API field that changes, not the class or the file. +- Link the issues that a change fixes or closes at the end of the summary, for example `([#1234](https://github.com/coollabsio/coolify/issues/1234))`. Do not link pull requests. +- Do not use bold. Put each UI name, such as a page, tab, button, setting, or label, in its own code span, for example `Advanced` > `Deployment`. +- Do not put exploit details, payloads, or unreleased advisory IDs in a change file. + ## Releases [`RELEASE.md`](RELEASE.md) is the source of truth for releases. Read it before you do a release, a backport, or a major version, and before you change `upstream.json`, `scripts/release.sh`, `scripts/plan-version.sh`, `scripts/sync-cdn.sh`, or the release, nightly, pull request, or merge forward workflows. -- Do not edit `CHANGELOG.md`. +- Do not edit `CHANGELOG.md`. Describe each change in a change file (see [Change Files](#change-files)). - A push that changes `release.json` publishes a release. - NEVER edit `release.json` by hand. `scripts/release.sh` changes it. - `scripts/plan-version.sh` shows what the current branch releases. `scripts/plan-version.sh --nightly` shows the next nightly. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f78fc3e7..5783668d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,74 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -### Added - -- Cancel the older queued webhook deployments of an application when a newer push arrives, so only the latest commit is built. It is on by default and works per application, pull request, and server. Turn it off in **Advanced > Deployment** or with `is_auto_cancel_queued_deployments_enabled` in the applications API. Manual, API, restart, and rollback deployments are never cancelled ([#10921](https://github.com/coollabsio/coolify/discussions/10921), [#7678](https://github.com/coollabsio/coolify/discussions/7678)) -- Add an opt-in setting to also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. The applications API sets it with `is_auto_cancel_building_deployments_enabled` -- Add `cancel_previous` (`none`, `queued`, or `building`) to `/api/v1/deploy` and the MCP `deploy` tool, so a deployment from the API or MCP can cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. The default is `none`. Manual, restart, and rollback deployments are never cancelled -- Show "Not used" on the TLS certificates of the Traefik proxy page when no route and no resource on the server uses the domain, and add a **Delete unused** button that deletes all of them in one step. Traefik renews every certificate in `acme.json`, also after you delete the resource, until you delete the certificate and restart the proxy ([#12447](https://github.com/coollabsio/coolify/issues/12447)) -- Show a warning when the Docker Compose file of an application is in a subdirectory of the base directory. Coolify runs Docker Compose in the base directory, so relative paths in the file, such as `build.context: ..`, resolve from the base directory and not from the directory of the file. A button moves the directory of the file into the base directory, for example base directory `/docker` and Compose location `/docker-compose.yml` ([#9525](https://github.com/coollabsio/coolify/issues/9525)) -- Add a **Skip TLS certificate verification** setting to S3 storages, for endpoints with a self-signed or private CA certificate, for example a MinIO server on an internal network. Before, the connection test failed with "cURL error 60", because the Coolify container and the backup helper do not use the CA certificates of the host. The setting applies to the connection test, backups, restores, and the deletion of old backups. The S3 storages API sets it with `skip_tls_verification`. Browsers that download files from the storage must still trust the certificate ([#9762](https://github.com/coollabsio/coolify/issues/9762), [#4402](https://github.com/coollabsio/coolify/issues/4402)) - -### Changed - -- Stop the notification storm from scheduled jobs that keep failing. A failed scheduled task, database backup, storage backup, or Docker cleanup now notifies on the first failure, then at most once a day while it keeps failing. The first successful run after a failure sends one "works again" notification to the failure channels. A database backup whose S3 upload keeps failing is also notified at most once a day -- Disable a scheduled task automatically when it failed 10 times in a row and its first failure is more than 24 hours old, and send one notification about it. The task page shows why the task was disabled. Enable the task again to clear the reason. The scheduled tasks API returns `consecutive_failures`, `failing_since`, and `disabled_reason`. Backups and Docker cleanup are never disabled automatically, so they start again by themselves after an outage -- Skip a scheduled task when its application or service has no running container, instead of a failed run with "No containers running". A skipped run sends no notification and does not count toward the automatic disable -- Move **Import Backup** from the resource menu to an **Import backup** button on the **Backups** page of databases and services. The button opens the import in a dialog. Old **Import Backup** links open the **Backups** page ([#11818](https://github.com/coollabsio/coolify/discussions/11818)) - -### Fixed - -- Send the **Traefik Proxy Outdated** notification when a newer Traefik patch version of the running branch is available. Before, Coolify showed the patch update only on the proxy page and sent no notification. Coolify sends one notification for each new patch version, not one for each hourly check ([#9727](https://github.com/coollabsio/coolify/issues/9727)) -- Fix **Copy logs** in the runtime and deployment logs on iPhone and iPad. iOS browsers accept a clipboard write only during the tap, and Coolify wrote the logs only after it loaded them from the server. The copy did not change the clipboard, but some browsers showed "Logs copied to clipboard." Coolify now starts the clipboard write during the tap and adds the logs when they load ([#9408](https://github.com/coollabsio/coolify/issues/9408)) -- Open fewer SSH logins for the server connection check. The Docker and Docker Compose version checks now use the multiplexed SSH connection, so each check opens one new SSH login, not three. Each login makes a systemd-logind session on the server, and some systemd versions keep closed sessions in the `closing` state. After 8192 sessions the server refused new SSH logins with "Maximum number of sessions (8192) reached". To remove the stuck sessions, run `systemctl restart systemd-logind` on the server ([#9148](https://github.com/coollabsio/coolify/issues/9148)) -- Fix the Docker install fallback on Ubuntu derivatives such as Pop!_OS and Linux Mint. `get.docker.com` does not support them, and the fallback used a Docker repository with the name of the distribution (`download.docker.com/linux/pop`), which does not exist, so the Docker install failed. The fallback now uses the Ubuntu repository with `UBUNTU_CODENAME` on these systems, and the Debian repository on Raspbian 13 (Trixie), because Docker has no Raspbian Trixie repository ([#9455](https://github.com/coollabsio/coolify/issues/9455)) -- Ask for confirmation in a dialog before you change the name of a persistent volume of an application or a database. A new name does not rename the Docker volume: after the next restart, the resource uses a different volume, which Docker creates empty, and the data stays in the old volume. Before, Coolify saved the new name without a warning, so the data looked lost after the restart. The dialog tells you to copy the data into the new volume ([#8816](https://github.com/coollabsio/coolify/issues/8816)) -- Show "Not in Compose file" on the applications and databases of a service that you removed from its Docker Compose file, and do not count them in the service status. Before, each removed part showed as "Exited" and the service stayed degraded. Coolify keeps the settings of a removed part: if you add the service back under the same name, Coolify uses the part again. To remove the part, delete it in its settings. A part that a Compose error created, for example a `ports:` block at the wrong indentation, is also marked. Existing parts are marked at the next deployment or save of the service ([#9591](https://github.com/coollabsio/coolify/issues/9591)) -- Fix deployments that failed with "The container name ... is already in use" when **Use build secrets** is on. To set the real `SOURCE_COMMIT`, Coolify starts the helper container a second time with the same name. Before, Coolify stopped the old helper and started the new one immediately, but Docker removes a stopped `--rm` container in the background, so the name was sometimes still in use. Coolify now removes the old helper and waits until Docker frees the name ([#9363](https://github.com/coollabsio/coolify/issues/9363)) -- Fix Docker builds that failed with "BuildKit is enabled but the buildx component is missing or broken" when the application has a build-time variable `HOME` or `DOCKER_CONFIG`. Coolify loads the build-time variables before it runs `docker build` in the helper container, so the variable moved the Docker CLI away from its plugins in `/root/.docker/cli-plugins`. The Docker CLI in the helper now always uses `/root/.docker`. A `HOME` build argument keeps its value ([#9726](https://github.com/coollabsio/coolify/issues/9726)) -- Keep the trailing newline of multiline environment variables, for example an SSH private key that a Dockerfile mounts as a build secret. Before, Coolify removed the edge whitespace of every value, so OpenSSH rejected the key with "error in libcrypto". A value with more than one line is now saved exactly as you enter it, in the UI and in the API. Single-line values still lose their edge whitespace. Save a variable again to add the newline that Coolify removed before ([#9438](https://github.com/coollabsio/coolify/issues/9438)) -- Use the same `COOLIFY_BRANCH` value in all parts of a preview deployment: `pull//head`. Before, a variable that refers to `${COOLIFY_BRANCH}` got the branch of the application (for example `main`), but the container got `pull//head`. Other build packs got `pull//head:pr--coolify`, and applications from a public repository got the branch of the application ([#9572](https://github.com/coollabsio/coolify/issues/9572)) -- Send the **Resource status changes** notification when an application, preview, service, or database stops unexpectedly, for example after a crash or `docker kill`. Before, Coolify sent this notification only when it restarted the proxy or a database TCP proxy. Coolify waits 2 minutes and sends the notification only when the resource is still stopped, so a container that Docker restarts, or a deployment that replaces it, sends none. A stop, restart, or redeploy from Coolify sends no notification, and the same resource sends at most one notification in 15 minutes ([#9493](https://github.com/coollabsio/coolify/issues/9493)) -- Fix the "Network error" when you sign in with email in AppFlowy Web. AppFlowy Web runs on its own domain and sends a `Redirect_to` header to GoTrue on the admin domain, and GoTrue blocked the CORS preflight because it did not allow that header. The AppFlowy service template now sets `GOTRUE_CORS_ALLOWED_HEADERS=redirect_to`. For an existing AppFlowy service, add this variable to the `gotrue` service in the Docker Compose file and restart the service ([#10545](https://github.com/coollabsio/coolify/issues/10545)) -- List the commit images of Docker Compose applications on the **Rollback** page and in `GET /api/v1/applications/{uuid}/rollback-images`. Since v4.5.0 the list was always empty, because a Compose build names each service image `_`. The page also shows the running commit when the Compose file has services from other images, for example a database ([#12446](https://github.com/coollabsio/coolify/issues/12446)) -- Use PostgreSQL 17 instead of MySQL in the Bugsink service template, as the Bugsink Docker Compose sample does, and pin Bugsink to version 2. Before, the template used `mysql:latest` with binary logging on, so the binary logs filled the disk after some weeks and Bugsink stopped. Only new Bugsink services get PostgreSQL. To stop the growth in an existing service, run `PURGE BINARY LOGS BEFORE NOW();` in MySQL, then add `command: ['--disable-log-bin']` to the `mysql` service and restart it ([#10626](https://github.com/coollabsio/coolify/issues/10626)) -- Finish the startup and the nightly cleanup of stuck resources on instances with many large deployment logs. Before, the cleanup loaded the logs of all deployments and stopped with "Allowed memory size exhausted", so stuck deployments, tasks, and backups were not marked as failed and the proxy configuration was not set up ([#12435](https://github.com/coollabsio/coolify/issues/12435)) -- Open the database backup pages and **Settings > Backup** faster when a backup has many executions. Before, these pages loaded all executions of the backup on each request, but they did not show them -- Queue a deployment from the MCP `deploy` tool and the MCP `control` start action while a deployment of the same application is in progress, as the API and the **Deploy** button do. Before, both started at once, so two rolling updates could remove the new container of each other and stop the application. A deployment of the same commit is now skipped, and the response gives the UUID of the deployment in progress. A rolling update now also removes only the containers that ran before it started ([#12442](https://github.com/coollabsio/coolify/issues/12442)) -- Do not copy the preview deployments of an application when you clone it. A clone of an application with a fixed preview URL template (for example `demo-mr-{{pr_id}}.example.com`) failed with a duplicate preview domain error and left a stopped, partial clone. The clone gets its own previews from new pull requests -- Handle an existing Cloudflare DNS record with a different IP when Coolify adds the DNS record of a new domain automatically. Coolify now replaces the record when it created the record and no other resource uses it. Otherwise it opens the **Configure DNS** dialog, where you can replace the record, and the domain shows the conflict instead of "Not checked" -- Add the Cloudflare DNS record automatically, or open the **Configure DNS** dialog, also when you edit a domain to a hostname in a connected Cloudflare zone, for example when you change a generated domain to your own domain. Before, Coolify did this only when you added a new domain. This applies to the **Domains** pages, the application and service application settings, and the service application API -- Show "DNS mismatch" when a domain in a connected Cloudflare zone goes through Cloudflare to a different server. Before, the DNS check accepted every Cloudflare IP, so a proxied wildcard record that points to another server showed "DNS matches", and the domain then failed with Cloudflare error 526 and a Let's Encrypt error. Coolify now reads the Cloudflare record of the domain, or else the closest wildcard record that covers it (also for deeper names, such as `app.apps.example.com` through `*.example.com`), and compares the IP with the server. CNAME records, for example of a Cloudflare Tunnel, are still accepted ([#12436](https://github.com/coollabsio/coolify/issues/12436)) -- Add the Cloudflare DNS record automatically when you clone an application or a service, and when you create an application or a service through the API, for each domain in a Cloudflare zone with automatic DNS. A clone gets records only for the domains that the source does not use, so the record of the source stays as it is ([#12436](https://github.com/coollabsio/coolify/issues/12436)) -- Save deployments started through the MCP server as MCP deployments, not as API deployments. The deployment list shows them with the **MCP** source and filter, and the API and MCP deployment data have a new `is_mcp` field. The deployments to additional servers keep the source of the main deployment, and the audit log records actions that MCP tools do through the API as MCP events -- Save the update channel in **Settings > Updates** when you select **Preview** or **Nightly**. Before, the "Save changes" bar did not open for these channels, so the change was lost when you refreshed the page -- Save the healthcheck **Check type** when it is the only change. Before, the "Save changes" bar did not open when you changed only the check type, for example from **Container command** back to **HTTP request** -- Coolify Cloud: end a paid subscription that lost its link to Stripe when the customer cancels it. Before, it stayed active, and `cloud:sync-stripe-subscriptions` did not report it -- Keep the dashboard proxy configuration of a server with the IP `host.docker.internal` that is not the default localhost server. Before, Coolify did not see this server as localhost after it saved the server, for example after a proxy restart, and deleted `coolify.yaml`, so the dashboard was not reachable at its domain ([#12444](https://github.com/coollabsio/coolify/issues/12444)) -- Show the disk usage of a server on the **Metrics** tab, on the **Fleet** page, and in the MCP `get_server_metrics` tool when Sentinel reports the Coolify data disk under a different mount point than `/`, `/data`, or `/data/coolify`. For example, this occurs on a NAS where `/data` is a symlink into a data volume. Before, the disk usage was always empty on these servers ([#12448](https://github.com/coollabsio/coolify/issues/12448)) -- Pin the Usesend service template to `usesend/usesend:v1.10.0`, which has the Next.js, Node.js 24, and nodemailer security updates. Before, the template used `latest`, so a server kept an old cached image until you pulled the images again. The template does not use MinIO, so the MinIO-to-RustFS change of v1.10.0 does not affect it ([#12443](https://github.com/coollabsio/coolify/issues/12443)) -- Collect only the logs of its own stack in the Supabase service template. Before, the Vector container read the logs of all containers on the server, so with two Supabase services on one server (for example a clone for production), Supabase Studio showed the API logs of the other stack. An existing Supabase service keeps its `vector.yml`. To fix it, on the server, add `include_labels: ['com.docker.compose.project=${COOLIFY_RESOURCE_UUID}']` under `sources.docker_host` in `/data/coolify/services//volumes/logs/vector.yml`, then restart the service ([#10324](https://github.com/coollabsio/coolify/issues/10324)) -- Use PostgreSQL 16 in the Outline service template. New Outline versions need PostgreSQL 14 or later, so a new Outline service restarted in a loop on `postgres:12-alpine` with `syntax error at or near "TRIGGER"`. An existing Outline service keeps PostgreSQL 12. To upgrade it, follow the steps in the Outline service documentation ([#12345](https://github.com/coollabsio/coolify/issues/12345)) -- Fix the LiteLLM service template for new services. The master key now starts with `sk-`, as LiteLLM requires: use `sk-` followed by the value of `SERVICE_PASSWORD_MASTERKEY` as the admin API key. The template also sets a separate `LITELLM_SALT_KEY`, so stored model credentials stay readable when you change the master key, and sets `STORE_MODEL_IN_DB` to `True` by default, so you can add models in the LiteLLM UI ([#10521](https://github.com/coollabsio/coolify/issues/10521)) -- Send one request less to GitHub before each deployment and each list of repositories of a GitHub App. Before, Coolify always called `/zen` to compare its clock with GitHub before it requested an installation token, so a short network problem between the Coolify instance and GitHub failed the deployment with `cURL error 28: Connection timed out ... for https://api.github.com/zen`. Coolify now compares the clocks only when GitHub rejects a request, from the `Date` header of that response, and still shows "System time is out of sync with GitHub API time" ([#9785](https://github.com/coollabsio/coolify/issues/9785)) -- Retry GitHub API reads, such as the repository lookup before each deployment, up to three times when GitHub answers with a 5xx error or the connection fails. Before, one `502` or one timeout from GitHub failed the deployment. Writes, such as pull request comments, are not retried ([#9785](https://github.com/coollabsio/coolify/issues/9785)) -- Keep an application that uses a server only as an additional destination when you delete that server with **Delete all resources** or with `DELETE /api/v1/servers/{uuid}?force=true`. Before, Coolify deleted the application on all its servers, also after **Promote** made another server its main server. Coolify now removes the application only from the deleted server and stops its containers there. A server that has only such applications can be deleted without **Delete all resources**. The delete confirmation lists the resources that it deletes and the applications that keep running on another server -- Fix the sign-up and login of the Rybbit service template. The proxy sent all requests to the client container, so the `/api` requests of the browser got a 404 error. The template now routes `/api` on the same domain to the backend on port 3001, and keeps the `/api` prefix. For an existing Rybbit service, add `SERVICE_URL_RYBBIT_3001=/api` to the `rybbit_backend` service, move `rybbit_backend` above `rybbit` in the Docker Compose file, set **Path prefixes** to **Keep prefixes** in the advanced settings of the backend, and redeploy the service ([#9055](https://github.com/coollabsio/coolify/issues/9055)) -- Add SMTP templates for common email providers, such as PostShiba, Amazon SES, Mailgun, SendGrid, Postmark, and Brevo, which fill in the host, port, and encryption -- Save more than one named SMTP server on the transactional email and email notification pages, and switch between them -- Give each SMTP server and Resend its own sender name and address, because each provider sends from its own verified domain -- Disable a scheduled task automatically when it fails at least 5 times in a row and has had no successful run for 3 days. Coolify sends a notification on the scheduled task failure channels and shows the reason on the task page until you enable the task again -- Connect Forgejo instances, including Codeberg and self-hosted Forgejo, with the Gitea / Forgejo App source. A new Provider list on the create form fills in the Gitea.com or Codeberg URLs, and webhooks also accept the `X-Forgejo-*` headers - -### Changed - -- Select the email provider from one list on the transactional email and email notification pages, which then show only the settings of that provider - -### Removed - -- Remove the "Copy from instance settings" button from the email notification page. Select "Use system-wide settings" to send team email with the instance email settings - ## [v4.6.0] - 2026-11-02 ### Breaking Changes diff --git a/RELEASE.md b/RELEASE.md index ec74b4acd2..e6594fa846 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -17,6 +17,7 @@ Every release is tagged `v`, such as `v4.6.0-beta.1`, and publishes the | `v4.4.x` | backports and hotfixes, cut from the newest tag of v4.4 | stable `4.4.x` versions | - `main` takes fixes only. A new minor or major reaches it as a merge of `next` or a release branch +- Merge `next` or a release branch into `main` only right before you release it, on the same day. To freeze a minor for longer, publish an RC on `next` or cut `release/v4.7` - `next` and the release branches publish prereleases only, their stable versions are released on `main` - Name a branch after what it changes: `fix/`, `feat/`, and `fix/-v4.4.8` for a backport, after the version that releases it - Squash merge pull requests from contributors and pull requests with a single change, such as dependency updates @@ -35,11 +36,12 @@ Every release is tagged `v`, such as `v4.6.0-beta.1`, and publishes the > [!NOTE] > `next` publishes prereleases only. For its stable version, merge `next` into `main` with a merge commit and create the release on `main`. -1. Describe the changes under `## [Unreleased]` in `CHANGELOG.md`, they become the release notes -2. Check what the branch releases: +1. Describe the changes in change files with `scripts/change.sh`, they become the release notes, see [Change files](#change-files) +2. Check what the branch releases and the entries it lists: ```sh scripts/plan-version.sh + scripts/change.sh --notes ``` 3. Select the stage and the version, the script commits them and asks to push: @@ -58,6 +60,27 @@ Every release is tagged `v`, such as `v4.6.0-beta.1`, and publishes the > scripts/undo-release.sh > ``` +## Change files + +Every change has a file in `.changes/`, `AGENTS.md` describes the types and the wording: + +```markdown +--- +type: fixed +commit: e0b94662e9 +--- +Ignored concurrent build limit of build servers + +- Each build counts on the server that runs it +- A freed build server slot starts the waiting deployments of other servers +``` + +- `scripts/change.sh` writes a file, it asks for what its flags leave out, and `--notes` prints the entries of the next release +- `scripts/release.sh` preselects the version the files need and writes them into `CHANGELOG.md`, the release workflow reads its notes from there +- A prerelease writes the files under `## [Unreleased]`, keeps them and adds its version to the files that have none +- A stable release writes the files under `## [v4.6.1] - ` and removes them +- `scripts/undo-release.sh` brings the files back as they were + ## Backport a fix `main` is on 4.5, and 4.3 and 4.4 still get fixes. A fix reaches each of them through a patch branch and a pull request of its own: diff --git a/SECURITY.md b/SECURITY.md index 3a361342ab..90106b1789 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,8 +31,9 @@ Please read this section before you send a report. The severity of a report depe ### Roles and what they can already do - **Team member**: read-only access to the resources of the team. A member must not see secrets or change anything. +- **Member with access grants**: an admin can give a member more levels (such as write or deploy) on the whole team, on projects, on environments, or on resources. A member with write access on a resource can deploy any Docker Compose file for it, including privileged containers and bind mounts of the host file system. That member therefore has root access to the servers where these resources run. In the root team, this includes the Coolify host when a resource runs on the localhost server. Give write access in the root team only to people that you trust as instance admins. - **Team admin / owner**: full control of the team's servers. An admin can open a terminal on these servers, deploy any Docker Compose file (including privileged containers and bind mounts of the host file system), run scheduled commands, and read all team secrets. An admin already has root access to every server of the team. -- **API tokens**: a token has the permissions of its abilities. A token with `write` or `root` ability is equal to a team admin. +- **API tokens**: a token has the permissions of its abilities. A token with `write` or `root` ability is equal to a team admin. A token limited to projects, environments, or resources is limited like a member with access grants: with `write`, it has root access to the servers where the resources inside its limit run. - **Instance admin** (admin or owner of the root team): already controls the Coolify instance and the server it runs on. - **Repository and Compose content**: Coolify runs what the configured repository, Dockerfile, or Compose file tells it to run. People who can change this content are trusted for the deployments that use it. @@ -40,8 +41,9 @@ Please read this section before you send a report. The severity of a report depe - A user who is not logged in can read data, change data, or run code. - A team member gets write access, runs commands, or reads secrets of their own team. +- A member with access grants reads or changes resources, servers, or credentials outside the grants (for example, places a resource on a server that runs no resource of the grant). - A user of one team reads or changes resources of another team. -- A team user runs code inside the Coolify container or on the Coolify host, without being an instance admin. +- A team user runs code inside the Coolify container or on the Coolify host, without being an instance admin. Exception: a member with write access on a resource that runs on the localhost server (see the roles above). - An API token does more than its abilities allow. - A public webhook or callback can be used without a valid secret or signature. diff --git a/app/Actions/Application/GetRollbackImages.php b/app/Actions/Application/GetRollbackImages.php index f9658a9538..3a5878fdf9 100644 --- a/app/Actions/Application/GetRollbackImages.php +++ b/app/Actions/Application/GetRollbackImages.php @@ -63,6 +63,23 @@ class GetRollbackImages ]; } + /** + * Whether a rollback to the tag is allowed: the tag is a listed rollback image, or the commit of + * a successful production deployment. Any other commit (for example a commit of an unmerged pull + * request) is rejected, because a rollback builds a missing image from source. + */ + public static function allows(Application $application, string $tag): bool + { + $deployed = $application->deployment_queue() + ->where('status', ApplicationDeploymentStatus::FINISHED->value) + ->where('pull_request_id', 0) + ->where('rollback', false) + ->where('commit', $tag) + ->exists(); + + return $deployed || collect(self::run($application)['images'])->contains('tag', $tag); + } + /** * The repositories of the images that the application builds or pulls. A Docker Compose * build names the image of each service `_`, like the Compose parser does. diff --git a/app/Actions/Development/SeedDevelopmentQemuServer.php b/app/Actions/Development/SeedDevelopmentQemuServer.php index 288e1ab8c2..991db0451d 100644 --- a/app/Actions/Development/SeedDevelopmentQemuServer.php +++ b/app/Actions/Development/SeedDevelopmentQemuServer.php @@ -33,10 +33,13 @@ class SeedDevelopmentQemuServer } if ($removeOtherServers) { + // A server that still has resources stays, because its resources need it. Server::query() ->where('uuid', 'like', 'development-qemu-%') ->where('uuid', '!=', $profile['uuid']) - ->delete(); + ->get() + ->filter(fn (Server $server) => $server->definedResources()->isEmpty()) + ->each->delete(); } $server = $asLocalhost diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 730b0b0873..6efd68b632 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -38,7 +38,9 @@ class CreateNewUser implements CreatesNewUsers if (! User::query()->exists() && ! RootSetupWindow::isOpen()) { throw ValidationException::withMessages([ - 'email' => 'The setup window for the root account is closed. Restart Coolify on the server (docker restart coolify) to open it again.', + 'email' => RootSetupWindow::isPending() + ? 'Coolify has not finished starting. Wait a few seconds, then try again.' + : 'The setup window for the root account is closed. Restart Coolify on the server (docker restart coolify) to open it again.', ]); } diff --git a/app/Actions/ImageScan/ScanImage.php b/app/Actions/ImageScan/ScanImage.php index 3e3f76ac51..17fa280a7d 100644 --- a/app/Actions/ImageScan/ScanImage.php +++ b/app/Actions/ImageScan/ScanImage.php @@ -11,7 +11,6 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ImageSbom; use App\Models\ImageScan; -use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\Finding; use App\Services\ImageScan\GrypeReportParser; @@ -445,10 +444,8 @@ class ScanImage private static function updateDatabase(ScannerCommands $commands): void { - $url = InstanceSettings::get()->image_scan_db_url; - try { - $update = $commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null); + $update = $commands->dbUpdate(); } catch (InvalidArgumentException) { return; } diff --git a/app/Actions/ImageScan/SetServerImageScanning.php b/app/Actions/ImageScan/SetServerImageScanning.php index 351da4f1a8..47c77e0a32 100644 --- a/app/Actions/ImageScan/SetServerImageScanning.php +++ b/app/Actions/ImageScan/SetServerImageScanning.php @@ -3,7 +3,6 @@ namespace App\Actions\ImageScan; use App\Jobs\PrepareImageScannerJob; -use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\ImageScanningRefused; use Illuminate\Support\Facades\Cache; @@ -17,7 +16,7 @@ class SetServerImageScanning use AsAction; /** - * @throws ImageScanningRefused When the instance does not allow scanning or another instance manages the server. + * @throws ImageScanningRefused When another instance manages the server. */ public function handle(Server $server, bool $enabled): void { @@ -29,10 +28,6 @@ class SetServerImageScanning } self::ensureManagedHere($server); - if (! InstanceSettings::get()->is_image_scanning_allowed) { - throw new ImageScanningRefused('Image scanning is turned off for this Coolify instance.'); - } - $server->settings->update(['is_image_scan_enabled' => true]); self::prepareScanner($server); } diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 615dc21d20..f6b3ee9f6f 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -15,7 +15,8 @@ class CleanupUnreachableServers extends Command public function handle() { echo "Running unreachable server cleanup...\n"; - $servers = Server::where('unreachable_count', '>=', 3)->whereHas('notificationThrottles', fn ($query) => $query->where('notification', Unreachable::class))->where('updated_at', '<', now()->subDays(7))->get(); + // The localhost server is the Coolify host itself, so the cleanup never touches it. + $servers = Server::where('id', '!=', 0)->where('unreachable_count', '>=', 3)->whereHas('notificationThrottles', fn ($query) => $query->where('notification', Unreachable::class))->where('updated_at', '<', now()->subDays(7))->get(); if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; diff --git a/app/Http/Controllers/Api/ApplicationPreviewsController.php b/app/Http/Controllers/Api/ApplicationPreviewsController.php index a2c8a90d2c..9e37a0e9a1 100644 --- a/app/Http/Controllers/Api/ApplicationPreviewsController.php +++ b/app/Http/Controllers/Api/ApplicationPreviewsController.php @@ -106,7 +106,7 @@ class ApplicationPreviewsController extends Controller #[OA\Post( summary: 'Create Preview Deployment', - description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.', + description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. A token with the deploy permission can only redeploy an existing preview without changes; opening a preview or changing its pull request URL, git type, or docker tag also needs the write permission. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.', path: '/applications/{uuid}/previews', operationId: 'create-preview-deployment-by-application-uuid', security: [['bearerAuth' => []]], @@ -131,6 +131,7 @@ class ApplicationPreviewsController extends Controller new OA\Response(response: 201, description: 'Preview deployment created.', content: new OA\JsonContent(ref: '#/components/schemas/ApplicationPreviewDeployment')), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Missing permissions. Opening or changing a preview needs the write permission.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), new OA\Response(response: 429, description: 'Deployment queue is full.'), @@ -225,13 +226,27 @@ class ApplicationPreviewsController extends Controller ], 422); } + $pullRequestHtmlUrl = $request->input('pull_request_html_url'); + $changesPreview = ! $preview + || ($pullRequestHtmlUrl !== null && $pullRequestHtmlUrl !== $preview->pull_request_html_url) + || ($request->input('git_type') !== null && $request->input('git_type') !== $preview->git_type) + || ($dockerTag !== null && $dockerTag !== $preview->docker_registry_image_tag); + if ($changesPreview) { + if (! $request->user()->tokenCan('write') && ! $request->user()->tokenCan('root')) { + return response()->json([ + 'message' => 'Missing required permissions: write. Opening a preview or changing its pull request URL, git type, or docker tag needs the write permission. The deploy permission only redeploys an existing preview.', + ], 403); + } + $this->authorize('update', $application); + } + $created = ! $preview; if ($created) { $isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value; $preview = ApplicationPreview::create([ 'application_id' => $application->id, 'pull_request_id' => $pullRequestId, - 'pull_request_html_url' => $request->input('pull_request_html_url') ?? '', + 'pull_request_html_url' => $pullRequestHtmlUrl ?? '', 'git_type' => $isDockerImage ? null : $gitType, 'docker_compose_domains' => $isCompose ? $application->docker_compose_domains : null, 'docker_registry_image_tag' => $dockerTag, @@ -241,7 +256,7 @@ class ApplicationPreviewsController extends Controller : $preview->generate_preview_fqdn(); } else { $preview->fill(array_filter([ - 'pull_request_html_url' => $request->input('pull_request_html_url'), + 'pull_request_html_url' => $pullRequestHtmlUrl, 'git_type' => $isDockerImage ? null : $gitType, 'docker_registry_image_tag' => $dockerTag, ], fn (mixed $value): bool => $value !== null)); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index a7b06ff686..8fd9586e4d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1752,6 +1752,7 @@ class ApplicationsController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('placeResource', $server); if (! $server->canHostResources()) { return response()->json([ 'message' => 'Validation failed.', @@ -2081,6 +2082,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } + $this->authorize('useTeamCredentials'); $sourceRepository = $type === 'private-gh-app' ? $this->githubAppRepository($request->github_app_uuid, $request->git_repository, $teamId) : $this->giteaAppRepository($this->teamSourceByUuid(GiteaApp::class, $request->gitea_app_uuid, $teamId), $request->git_repository); @@ -2325,6 +2327,7 @@ class ApplicationsController extends Controller if (! $privateKey) { return response()->json(['message' => 'Private Key not found.'], 404); } + $this->authorize('useTeamCredentials'); $application = new Application; removeUnnecessaryFieldsFromRequest($request); @@ -3679,6 +3682,10 @@ class ApplicationsController extends Controller 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; $validationRules = array_merge(sharedDataApplications($application), $validationRules); + // A change of Gitea source checks the repository against the Gitea instance below. + if ($request->input('git_repository') !== $application->git_repository && ! $request->has('gitea_app_uuid')) { + $validationRules['git_repository'] = ['string', new ValidGitRepositoryUrl(allowRepositoryPath: true)]; + } $validationMessages = [ ...ValidationPatterns::combinedMessages(), 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', @@ -3784,6 +3791,9 @@ class ApplicationsController extends Controller if (! $newSource?->isConnected()) { return response()->json(['message' => $sourceClass === GithubApp::class ? 'Github App not found.' : 'Gitea App not found.'], 404); } + if (! $application->source?->is($newSource)) { + $this->authorize('useTeamCredentials', $application); + } } $giteaRepository = null; if ($newSource instanceof GiteaApp) { @@ -6476,6 +6486,7 @@ class ApplicationsController extends Controller if (! $destination || ! $destination->server?->canHostResources()) { return response()->json(['message' => 'Destination not found.'], 404); } + $this->authorize('placeResource', $destination->server); $overrides = ['uuid' => new_public_id()]; if ($request->filled('name')) { @@ -6577,7 +6588,7 @@ class ApplicationsController extends Controller content: new OA\JsonContent( required: ['commit'], properties: [ - new OA\Property(property: 'commit', type: 'string', description: 'Image tag / commit to roll back to.'), + new OA\Property(property: 'commit', type: 'string', description: 'Image tag / commit to roll back to. Must be a tag from the rollback images list or the commit of a successful production deployment.'), ] ) ), @@ -6643,6 +6654,13 @@ class ApplicationsController extends Controller ], 422); } + if (! GetRollbackImages::allows($application, $commit)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['commit' => ['The commit is not a rollback image or a successful deployment of this application.']], + ], 422); + } + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -6790,6 +6808,7 @@ class ApplicationsController extends Controller if (! $destination || ! $destination->server?->canHostResources()) { return response()->json(['message' => 'Destination not found.'], 404); } + $this->authorize('placeResource', $destination->server); if ($application->destination_id === $destination->id && $application->destination_type === $destination->getMorphClass()) { return response()->json(['message' => 'Destination is already the primary destination.'], 422); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 40d0c794eb..7d103d7d52 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1244,6 +1244,11 @@ class DatabasesController extends Controller if (! isset($backupData['enabled'])) { $backupData['enabled'] = true; } + $backupData['save_s3'] ??= false; + + if ($s3Storages?->isNotEmpty()) { + $this->authorize('useTeamCredentials', $database); + } $backupConfig = ScheduledDatabaseBackup::create($backupData); if ($s3Storages !== null) { @@ -1497,6 +1502,10 @@ class DatabasesController extends Controller } } + if ($storageIds !== null && $backupConfig->newS3StorageIds($storageIds)->isNotEmpty()) { + $this->authorize('useTeamCredentials', $database); + } + $backupConfig->update($backupData); if ($storageIds !== null) { $backupConfig->syncS3Storages($storageIds->all()); @@ -2204,6 +2213,7 @@ class DatabasesController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('placeResource', $server); if (! $server->canHostResources()) { return response()->json([ 'message' => 'Validation failed.', @@ -5273,6 +5283,7 @@ class DatabasesController extends Controller if (! $destination || ! $destination->server?->canHostResources()) { return response()->json(['message' => 'Destination not found.'], 404); } + $this->authorize('placeResource', $destination->server); $uuid = new_public_id(); $name = $request->filled('name') diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index b0f8e67a23..1fbea69088 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -336,7 +336,7 @@ class DeployController extends Controller new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')), new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)), new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)), - new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id. Opening a preview or changing its tag also needs the write permission.', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'cancel_previous', in: 'query', description: 'Cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. `queued` cancels the ones that wait in the queue. `building` also stops the ones that are still building; a deployment that already started to replace containers always finishes. Manual, restart, and rollback deployments are never cancelled. Default: `none`.', schema: new OA\Schema(type: 'string', enum: ['none', 'queued', 'building'], default: 'none')), ], @@ -442,7 +442,14 @@ class DeployController extends Controller continue; } - $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag); + $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + $changesPreview = $dockerTag !== null && $dockerTag !== $preview?->docker_registry_image_tag; + if ($changesPreview && ! $this->canChangePreview($resource)) { + $deployments->push(['message' => 'Missing required permissions: write. Opening a preview or changing its docker tag needs the write permission.', 'resource_uuid' => $uuid]); + + continue; + } + $preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag, $preview); $dockerTagForResource = $preview?->docker_registry_image_tag; } else { $preview = $resource->previews()->where('pull_request_id', $pr)->first(); @@ -619,10 +626,18 @@ class DeployController extends Controller return ['message' => $message, 'deployment_uuid' => $deployment_uuid]; } - private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview + /** + * Opening a preview or changing its docker tag changes what runs, so it needs the write permission and the update policy, as in the UI. + */ + private function canChangePreview(Application $application): bool { - $preview = $application->previews()->where('pull_request_id', $pullRequestId)->first(); + $user = auth()->user(); + return ($user->tokenCan('write') || $user->tokenCan('root')) && $user->can('update', $application); + } + + private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag, ?ApplicationPreview $preview): ?ApplicationPreview + { if (! $preview && $dockerTag === null) { return null; } diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index aa75fe540e..2e291911bc 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -165,7 +165,7 @@ use OpenApi\Attributes as OA; properties: [ new OA\Property(property: 'uuid', type: 'string'), new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']), - new OA\Property(property: 'off_reason', type: 'string', nullable: true, enum: ['instance', 'server', 'application'], description: 'Why the resource is not scanned. Null when it is scanned.'), + new OA\Property(property: 'off_reason', type: 'string', nullable: true, enum: ['server', 'application'], description: 'Why the resource is not scanned. Null when it is scanned.'), new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus', nullable: true), new OA\Property(property: 'images', type: 'array', items: new OA\Items(properties: [ new OA\Property(property: 'scan_uuid', type: 'string'), diff --git a/app/Http/Controllers/Api/ServerImageScanningController.php b/app/Http/Controllers/Api/ServerImageScanningController.php index 40b7fbaca0..080dbf2ec7 100644 --- a/app/Http/Controllers/Api/ServerImageScanningController.php +++ b/app/Http/Controllers/Api/ServerImageScanningController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api; use App\Actions\ImageScan\SetServerImageScanning; use App\Http\Controllers\Controller; use App\Jobs\PrepareImageScannerJob; -use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\ImageScanningRefused; use Carbon\CarbonImmutable; @@ -19,7 +18,7 @@ class ServerImageScanningController extends Controller private const ALLOWED_FIELDS = ['is_image_scan_enabled']; /** - * @return array{is_image_scanning_allowed: bool, is_image_scan_enabled: bool, scanner_state: ?string, scanner_failed_step: ?string, scanner_built_at: ?string, scanner_disk_used_bytes: ?int} + * @return array{is_image_scan_enabled: bool, scanner_state: ?string, scanner_failed_step: ?string, scanner_built_at: ?string, scanner_disk_used_bytes: ?int} */ private function transform(Server $server): array { @@ -29,7 +28,6 @@ class ServerImageScanningController extends Controller $diskUsed = Cache::get(PrepareImageScannerJob::diskUsedCacheKey($server)); return [ - 'is_image_scanning_allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed, 'is_image_scan_enabled' => (bool) $server->settings->is_image_scan_enabled, 'scanner_state' => is_string($state) ? $state : null, 'scanner_failed_step' => is_string($failedStep) ? $failedStep : null, @@ -61,7 +59,6 @@ class ServerImageScanningController extends Controller description: 'Image scanning settings.', content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean', readOnly: true, description: 'Whether the Coolify instance allows image scanning.'), new OA\Property(property: 'is_image_scan_enabled', type: 'boolean', description: 'Whether the server scans the images of its resources.'), new OA\Property(property: 'scanner_state', type: 'string', nullable: true, enum: ['pulling', 'updating_database', 'ready', 'failed'], description: 'State of the scanner on the server. Null when unknown.'), new OA\Property(property: 'scanner_failed_step', type: 'string', nullable: true, description: 'The step that failed when scanner_state is failed.'), diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index b1c9adc3dc..88bad734c2 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -488,6 +488,7 @@ class ServicesController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('placeResource', $server); if (! $server->canHostResources()) { return response()->json([ 'message' => 'Validation failed.', @@ -700,6 +701,7 @@ class ServicesController extends Controller if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('placeResource', $server); if (! $server->canHostResources()) { return response()->json([ 'message' => 'Validation failed.', @@ -3341,6 +3343,7 @@ class ServicesController extends Controller if (! $destination || ! $destination->server?->canHostResources()) { return response()->json(['message' => 'Destination not found.'], 404); } + $this->authorize('placeResource', $destination->server); $uuid = new_public_id(); $name = $request->filled('name') diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index 7d1460dec0..7b65263054 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -289,6 +289,12 @@ class VolumeBackupsController extends Controller string $resourceType, Model $resource, ): JsonResponse { + $existing = $storage->scheduledBackups()->first(); + $newStorageIds = $existing ? $existing->newS3StorageIds($s3Storages->pluck('id')) : $s3Storages->pluck('id'); + if ($newStorageIds->isNotEmpty()) { + $this->authorize('useTeamCredentials', $resource); + } + $attributes = [ 'team_id' => $teamId, 'frequency' => $request->string('frequency')->toString(), diff --git a/app/Http/Controllers/Api/VulnerabilitiesController.php b/app/Http/Controllers/Api/VulnerabilitiesController.php index 0a5e06238b..ba9837dd9e 100644 --- a/app/Http/Controllers/Api/VulnerabilitiesController.php +++ b/app/Http/Controllers/Api/VulnerabilitiesController.php @@ -54,7 +54,6 @@ class VulnerabilitiesController extends Controller description: 'Resources with their security status, worst first.', content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean'), new OA\Property(property: 'resources', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'uuid', type: 'string'), @@ -108,7 +107,6 @@ class VulnerabilitiesController extends Controller ->values(); return response()->json([ - 'is_image_scanning_allowed' => (bool) instanceSettings()->is_image_scanning_allowed, 'resources' => $resources, ]); } diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 4d4a5a080e..374017a063 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -24,7 +24,7 @@ class UploadController extends BaseController $databaseIdentifier = request()->route('databaseUuid'); $resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id')); if (is_null($resource)) { - return response()->json(['error' => 'You do not have permission for this database'], 500); + return response()->json(['error' => 'Database not found.'], 404); } $this->authorize('uploadBackup', $resource); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index cb29a80bd8..8d68902ffa 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3614,7 +3614,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $gitConfigOption = $this->application->gitHttpsCredentialConfigOption(); $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; - return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote -- '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } private function update_repository_project_id(): void diff --git a/app/Jobs/ImageRescanJob.php b/app/Jobs/ImageRescanJob.php index d331184c9d..3f2efccf7f 100644 --- a/app/Jobs/ImageRescanJob.php +++ b/app/Jobs/ImageRescanJob.php @@ -12,7 +12,6 @@ use App\Models\ApplicationPreview; use App\Models\ImageSbom; use App\Models\ImageScan; use App\Models\ImageScanIgnore; -use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\IgnoreMatcher; use App\Services\ImageScan\ResourceScans; @@ -76,12 +75,11 @@ class ImageRescanJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue } /** - * Scanning is on for the server and allowed on the instance, and this instance can reach the server. + * Scanning is on for the server, and this instance can reach the server. */ public static function canRunOn(Server $server): bool { return (bool) $server->settings?->is_image_scan_enabled - && (bool) InstanceSettings::query()->whereKey(0)->value('is_image_scanning_allowed') && ! $server->isTransferredAway() && $server->isFunctional(); } @@ -359,10 +357,8 @@ class ImageRescanJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue return false; } - $url = InstanceSettings::get()->image_scan_db_url; - try { - $output = instant_remote_process($commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null), $server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE); + $output = instant_remote_process($commands->dbUpdate(), $server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE); if ((ScannerCommands::parseOutput($output)['exit_codes']['db-update'] ?? null) === 0) { PrepareImageScannerJob::storeDetails($server, $commands); } diff --git a/app/Jobs/ImageScanDigestJob.php b/app/Jobs/ImageScanDigestJob.php index 866644026b..940834bab4 100644 --- a/app/Jobs/ImageScanDigestJob.php +++ b/app/Jobs/ImageScanDigestJob.php @@ -64,10 +64,6 @@ class ImageScanDigestJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQue public function handle(): void { - if (! instanceSettings()->is_image_scanning_allowed) { - return; - } - /** @var list> $marks */ $marks = []; $resources = []; diff --git a/app/Jobs/PrepareImageScannerJob.php b/app/Jobs/PrepareImageScannerJob.php index 53b61fc37e..535738d354 100644 --- a/app/Jobs/PrepareImageScannerJob.php +++ b/app/Jobs/PrepareImageScannerJob.php @@ -4,7 +4,6 @@ namespace App\Jobs; use App\Actions\ImageScan\ScanImage; use App\Events\ImageScanUpdated; -use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\ScannerCommands; use Carbon\CarbonImmutable; @@ -107,13 +106,12 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul return; } - $settings = InstanceSettings::get(); - if (! $settings->is_image_scanning_allowed || ! $server->settings?->is_image_scan_enabled) { + if (! $server->settings?->is_image_scan_enabled) { return; } try { - $this->prepare($server, $settings->image_scan_db_url); + $this->prepare($server); } catch (\Throwable $e) { report($e); $this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION); @@ -125,7 +123,7 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul $this->setState($this->server, self::STATE_FAILED); } - private function prepare(Server $server, ?string $dbUrl): void + private function prepare(Server $server): void { $commands = new ScannerCommands($server, Str::lower(Str::random(24))); @@ -143,7 +141,7 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul } try { - $this->prepareLocked($server, $commands, $dbUrl); + $this->prepareLocked($server, $commands); } finally { $lock->release(); } @@ -152,7 +150,7 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul /** * Runs under the server lock, so a scan or a deletion of the scanner data cannot run at the same time. */ - private function prepareLocked(Server $server, ScannerCommands $commands, ?string $dbUrl): void + private function prepareLocked(Server $server, ScannerCommands $commands): void { // The scanner data can be deleted and scanning turned off before this job got the lock. if (! $this->isStillEnabled($server)) { @@ -183,7 +181,7 @@ class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, Shoul } $this->setState($server, self::STATE_UPDATING_DATABASE); - $updated = self::run($commands->dbUpdate(is_string($dbUrl) && trim($dbUrl) !== '' ? trim($dbUrl) : null), $server, self::TIMEOUT_DB_UPDATE); + $updated = self::run($commands->dbUpdate(), $server, self::TIMEOUT_DB_UPDATE); if (($updated['exit_codes']['db-update'] ?? null) !== 0) { $this->setState($server, self::STATE_FAILED); diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 3bdbb4c3a7..b48a715c1a 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -99,10 +99,6 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue */ private function dispatchImageScanDigests(Collection $servers): void { - if (! $this->settings->is_image_scanning_allowed) { - return; - } - $servers->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled && ! $server->isTransferredAway()) ->pluck('team_id') ->unique() diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 1c86a3a79a..a2e8eac50b 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -6,9 +6,12 @@ use App\Livewire\Concerns\RemembersCanvasView; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\Star; use App\Models\TeamInvitation; use App\Services\Access\AccessResolver; use App\Services\ImageScan\TeamVulnerabilities; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -88,6 +91,7 @@ class Dashboard extends Component return view('livewire.dashboard', [ 'pendingInvitations' => $this->pendingInvitations(), + 'starredResources' => $this->starredResources(), 'dashboardProjects' => $dashboardProjects, 'dashboardServers' => $dashboardServers, 'projectResourceCounts' => $dashboardProjects->mapWithKeys(fn (Project $project) => [$project->id => $this->projectResourceCount($project)]), @@ -109,6 +113,70 @@ class Dashboard extends Component } } + public function unstar(int $starId): void + { + auth()->user()->stars()->whereKey($starId)->delete(); + } + + /** + * Saves the order of the starred cards after a drag and drop. Stars of other teams keep their positions. + * + * @param list $starIds + */ + public function reorderStars(array $starIds): void + { + $stars = auth()->user()->stars()->whereKey($starIds)->get()->keyBy('id'); + + foreach (array_values($starIds) as $position => $starId) { + $stars->get((int) $starId)?->update(['position' => $position]); + } + } + + /** + * Resources the user starred, in the current team, that the user can still see, in the order the user chose. + * + * @return Collection + */ + private function starredResources(): Collection + { + $user = auth()->user(); + $teamId = currentTeam()->id; + + return $user->stars() + ->whereIn('starrable_type', Star::STARRABLE_TYPES) + ->with(['starrable' => fn (MorphTo $morphTo) => $morphTo->morphWith( + collect(Star::STARRABLE_TYPES)->reject(Server::class)->mapWithKeys(fn (string $type) => [$type => ['environment.project']])->all() + )]) + ->orderBy('position') + ->orderBy('id') + ->get() + ->filter(function (Star $star) use ($user, $teamId): bool { + $resource = $star->starrable; + + if (! $resource) { + return false; + } + + if ($resource instanceof Server) { + return $this->showsTeamSections && $resource->team_id === $teamId && $user->can('view', $resource); + } + + return $resource->environment?->project?->team_id === $teamId && $user->can('view', $resource); + }) + ->map(fn (Star $star) => [ + 'star_id' => $star->id, + 'resource' => $star->starrable, + 'name' => $star->starrable->name, + 'context' => $star->starrable instanceof Server + ? 'Server' + : $star->starrable->environment->project->name.' / '.$star->starrable->environment->name, + 'href' => $star->starrable instanceof Server + ? route('server.show', ['server_uuid' => $star->starrable->uuid]) + : $star->starrable->link(), + ]) + ->values(); + } + private function projectResourceCount(Project $project): int { return $project->applications_count + $project->services_count + $project->postgresqls_count diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 193eff604f..d074e7c26a 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -23,6 +23,7 @@ use App\Models\StandaloneSqlite; use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class GlobalSearch extends Component @@ -819,20 +820,20 @@ class GlobalSearch extends Component 'link' => route('team.audit-log'), 'search_text' => 'team audit log activity history events', ] : null, - instanceSettings()->is_image_scanning_allowed ? [ + [ 'name' => 'Security', 'type' => 'navigation', 'description' => 'What each resource of your team needs, from its last image scan', 'link' => route('vulnerabilities'), 'search_text' => 'vulnerabilities cve ghsa image scanning fixable packages security', - ] : null, - instanceSettings()->is_image_scanning_allowed ? [ + ], + [ 'name' => 'Team Image Scanning', 'type' => 'navigation', 'description' => 'Scan rules and ignored vulnerabilities of the team', 'link' => route('team.image-scanning'), 'search_text' => 'team image scanning vulnerabilities cve scan rules alert block deploys ignored security', - ] : null, + ], isInstanceAdmin() ? [ 'name' => 'Team Admin View', 'type' => 'navigation', @@ -1480,7 +1481,7 @@ class GlobalSearch extends Component return; } $this->loadingServers = true; - $servers = Server::isUsable()->get()->sortBy('name'); + $servers = Server::isUsable()->get()->filter(fn (Server $server) => Gate::allows('placeResource', $server))->sortBy('name'); $this->availableServers = $servers->map(fn ($s) => [ 'id' => $s->id, 'name' => $s->name, diff --git a/app/Livewire/ImageScan/FleetVulnerabilities.php b/app/Livewire/ImageScan/FleetVulnerabilities.php index 1a75c6483f..cc7bd4516b 100644 --- a/app/Livewire/ImageScan/FleetVulnerabilities.php +++ b/app/Livewire/ImageScan/FleetVulnerabilities.php @@ -178,12 +178,6 @@ class FleetVulnerabilities extends Component $this->normalizeFilters(); $user = auth()->user(); $team = currentTeam(); - $allowed = (bool) instanceSettings()->is_image_scanning_allowed; - - if (! $allowed) { - return view('livewire.image-scan.fleet-vulnerabilities', ['allowed' => false]); - } - $fleet = $this->fleet($team->id); $entries = $fleet->entries; @@ -213,7 +207,6 @@ class FleetVulnerabilities extends Component $notScannedServers = $notScanned->pluck('server')->filter()->unique('id')->count(); return view('livewire.image-scan.fleet-vulnerabilities', [ - 'allowed' => true, 'items' => $this->paginate($rows), 'searching' => $searching, 'notScannedLine' => $notScanned->isEmpty() ? null : self::notScannedSentence($notScanned->count(), $notScannedServers), diff --git a/app/Livewire/ImageScan/InstanceImageScanning.php b/app/Livewire/ImageScan/InstanceImageScanning.php index 164ed9edd8..174f638675 100644 --- a/app/Livewire/ImageScan/InstanceImageScanning.php +++ b/app/Livewire/ImageScan/InstanceImageScanning.php @@ -2,68 +2,19 @@ namespace App\Livewire\ImageScan; -use App\Models\InstanceSettings; use Illuminate\Contracts\View\View; use Livewire\Component; /** - * Instance settings page for image scanning: the instance switch, the vulnerability database URL, and the default rules. + * Instance settings page for image scanning: the default rules of every team. */ class InstanceImageScanning extends Component { - public ?string $isImageScanningAllowed = '1'; - - public ?string $imageScanDbUrl = null; - public function mount() { if (! isInstanceAdmin()) { return redirect()->route('dashboard'); } - - $settings = InstanceSettings::get(); - $this->isImageScanningAllowed = $settings->is_image_scanning_allowed ? '1' : '0'; - $this->imageScanDbUrl = $settings->image_scan_db_url; - } - - public function updatedIsImageScanningAllowed(): void - { - abort_unless(isInstanceAdmin(), 403); - $this->validate(['isImageScanningAllowed' => ['required', 'in:1,0']]); - - $this->saveSettings(['is_image_scanning_allowed' => $this->isImageScanningAllowed === '1']); - } - - public function saveDatabaseUrl(): void - { - abort_unless(isInstanceAdmin(), 403); - $this->imageScanDbUrl = filled($this->imageScanDbUrl) ? trim($this->imageScanDbUrl) : null; - $this->validate( - ['imageScanDbUrl' => ['nullable', 'url:https', 'max:2048', 'regex:#\Ahttps://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=-]*)?\z#']], - ['imageScanDbUrl.regex' => 'Use an https URL without a query, a fragment, or credentials.'], - ); - - $this->saveSettings(['image_scan_db_url' => $this->imageScanDbUrl]); - } - - /** - * @param array $values - */ - private function saveSettings(array $values): void - { - $settings = InstanceSettings::get(); - $settings->fill($values); - $changedFields = auditChangedFields($settings); - $settings->save(); - if ($changedFields !== []) { - auditLog('ui.instance.settings.updated', [ - 'team_id' => null, - 'resource' => 'instance', - 'section' => 'image_scanning', - 'changed_fields' => $changedFields, - ]); - } - $this->dispatch('success', 'Saved.'); } public function render(): View diff --git a/app/Livewire/ImageScan/Packages.php b/app/Livewire/ImageScan/Packages.php index efafe936fd..cd9da690e3 100644 --- a/app/Livewire/ImageScan/Packages.php +++ b/app/Livewire/ImageScan/Packages.php @@ -147,11 +147,9 @@ class Packages extends Component public function render(): View { $resource = $this->resource(); - $scans = instanceSettings()->is_image_scanning_allowed - ? ResourceScans::current($resource, null, ['id', 'uuid', 'resource_type', 'resource_id', 'deployment_queue_id', 'image_sbom_id', 'image_ref', 'scanned_at']) - ->filter(fn (ImageScan $scan): bool => $scan->image_sbom_id !== null) - ->values() - : collect(); + $scans = ResourceScans::current($resource, null, ['id', 'uuid', 'resource_type', 'resource_id', 'deployment_queue_id', 'image_sbom_id', 'image_ref', 'scanned_at']) + ->filter(fn (ImageScan $scan): bool => $scan->image_sbom_id !== null) + ->values(); $sboms = ImageSbom::query()->whereIn('id', $scans->pluck('image_sbom_id')->unique())->get()->keyBy('id'); diff --git a/app/Livewire/ImageScan/ServerImageScanning.php b/app/Livewire/ImageScan/ServerImageScanning.php index 2c92c48217..a2ab8f7cfe 100644 --- a/app/Livewire/ImageScan/ServerImageScanning.php +++ b/app/Livewire/ImageScan/ServerImageScanning.php @@ -5,7 +5,6 @@ namespace App\Livewire\ImageScan; use App\Actions\ImageScan\ScanImage; use App\Actions\ImageScan\SetServerImageScanning; use App\Jobs\PrepareImageScannerJob; -use App\Models\InstanceSettings; use App\Models\Server; use App\Services\ImageScan\ImageRef; use App\Services\ImageScan\ImageScanningRefused; @@ -66,7 +65,7 @@ class ServerImageScanning extends Component try { SetServerImageScanning::ensureManagedHere($this->server); - if (! InstanceSettings::get()->is_image_scanning_allowed || ! $this->server->settings->refresh()->is_image_scan_enabled) { + if (! $this->server->settings->refresh()->is_image_scan_enabled) { $this->dispatch('error', message: 'Turn on image scanning first.'); return; @@ -135,7 +134,6 @@ class ServerImageScanning extends Component $diskUsed = Cache::get(PrepareImageScannerJob::diskUsedCacheKey($this->server)); return view('livewire.image-scan.server-image-scanning', [ - 'allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed, 'enabled' => (bool) $this->server->settings->is_image_scan_enabled, 'prepareState' => is_string($state) ? $state : null, 'failedStep' => is_string($failedStep) ? $failedStep : null, diff --git a/app/Livewire/ImageScan/TeamImageScanning.php b/app/Livewire/ImageScan/TeamImageScanning.php index 4da00aefd6..d166539079 100644 --- a/app/Livewire/ImageScan/TeamImageScanning.php +++ b/app/Livewire/ImageScan/TeamImageScanning.php @@ -2,7 +2,6 @@ namespace App\Livewire\ImageScan; -use App\Models\InstanceSettings; use App\Models\Team; use Illuminate\Contracts\View\View; use Livewire\Attributes\Locked; @@ -25,8 +24,6 @@ class TeamImageScanning extends Component public function render(): View { - return view('livewire.image-scan.team-image-scanning', [ - 'allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed, - ]); + return view('livewire.image-scan.team-image-scanning'); } } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 30689a924e..24d3cbbdfa 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -9,6 +9,7 @@ use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Shared\EnvironmentVariable\All; use App\Models\Application; use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use App\Services\Dns\ManagedDnsRecordCleanup; use App\Services\Dns\ResourceDnsRecords; use App\Support\ValidationPatterns; @@ -168,7 +169,9 @@ class General extends Component ? ['nullable'] : ValidationPatterns::applicationDomainRules(), 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), - 'gitRepository' => 'required', + 'gitRepository' => isset($this->application) && $this->gitRepository === $this->application->getOriginal('git_repository') + ? ['required'] + : ['required', 'string', new ValidGitRepositoryUrl(allowRepositoryPath: true)], 'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => $this->changedCommandRules('installCommand', 'install_command'), diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index 6e54ac3d38..97911abc00 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -53,6 +53,11 @@ class Rollback extends Component $this->authorize('deploy', $this->application); $commit = validateGitRef($commit, 'rollback commit'); + if (! GetRollbackImages::allows($this->application, $commit)) { + $this->dispatch('error', 'Rollback did not start.', 'The commit is not a rollback image or a successful deployment of this application.'); + + return; + } $deployment_uuid = new_public_id(); diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 6d9d32d966..ffea7a0fcf 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -8,6 +8,7 @@ use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; @@ -102,6 +103,9 @@ class Source extends Component { if ($toModel) { $this->validate(); + if ($this->gitRepository !== $this->application->getOriginal('git_repository')) { + $this->validate(['gitRepository' => ['required', 'string', new ValidGitRepositoryUrl(allowRepositoryPath: true)]]); + } $attributes = [ 'git_repository' => $this->gitRepository, 'git_branch' => $this->gitBranch, diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 14b6c8e94d..747ed1d0bc 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -15,6 +15,7 @@ use App\Support\ValidationPatterns; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class CloneMe extends Component @@ -65,7 +66,9 @@ class CloneMe extends Component $this->servers = Server::query() ->where('team_id', $this->project->team_id) ->get() - ->reject(fn ($server) => $server->isBuildServer()); + ->reject(fn ($server) => $server->isBuildServer()) + ->filter(fn ($server) => Gate::allows('placeResource', $server)) + ->values(); $this->newName = str(clone_resource_name($this->project->name, new_public_id()))->slug(); } @@ -117,6 +120,7 @@ class CloneMe extends Component if (! $selectedDestination) { throw new \Exception('Destination not found.'); } + $this->authorize('placeResource', $selectedDestination->server); auditLog('ui.project.clone_started', [ 'team_id' => $this->project->team_id, 'project_uuid' => $this->project->uuid, diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index e80619ec24..bfbd1ad164 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,14 +2,17 @@ namespace App\Livewire\Project\Database; +use App\Enums\AccessLevel; use App\Livewire\Concerns\ManagesDatabaseBackup; use App\Livewire\Project\Database\Backup\Execution; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use App\Models\StandalonePostgresql; +use App\Services\Access\AccessResolver; use App\Traits\ListensToTeamChannel; use Exception; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -186,6 +189,15 @@ class BackupEdit extends Component $this->backup->timeout = $this->timeout; $this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays; $storageIds = $this->customValidate(); + // The team of the schedule, because the instance database has no environment to read it from. + if ($this->backup->newS3StorageIds($storageIds)->isNotEmpty() + && ! app(AccessResolver::class)->allowsTeamWide(Auth::user(), AccessLevel::Write, (int) $this->backup->team_id)) { + $this->backup->refresh(); + $this->saveS3 = (bool) $this->backup->save_s3; + $this->s3StorageIds = $this->backup->selectedS3Storages()->pluck('id')->all(); + + throw new AuthorizationException('You need write access on the whole team to add an S3 storage to this backup.'); + } $changedFields = auditChangedFields($this->backup); $this->backup->save(); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index adf1b6cd31..d9e865771b 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -6,6 +6,7 @@ use App\Models\Project; use App\Models\Server; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class Select extends Component @@ -85,7 +86,7 @@ class Select extends Component if ($imageStep && $queryServerId !== null && $queryDestination) { $server = Server::isUsable()->whereKey($queryServerId)->first(); - if ($server && $this->findDestination($server, $queryDestination)) { + if ($server && Gate::allows('placeResource', $server) && $this->findDestination($server, $queryDestination)) { $this->type = $queryType; $this->server_id = $server->id; $this->destination_uuid = $queryDestination; @@ -519,7 +520,7 @@ class Select extends Component public function loadServers() { - $this->servers = Server::isUsable()->get()->sortBy('name'); + $this->servers = Server::isUsable()->get()->filter(fn (Server $server) => Gate::allows('placeResource', $server))->sortBy('name'); $this->buildServers = Server::isUsableBuildServer()->get()->sortBy('name'); $this->allServers = $this->servers->concat($this->buildServers); $this->onlyBuildServerAvailable = $this->servers->isEmpty() && $this->buildServers->isNotEmpty(); diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index a0951330bd..fba079cf9b 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -177,6 +177,27 @@ class Create extends Component public function render() { - return view('livewire.project.resource.create'); + return view('livewire.project.resource.create', $this->pendingResource()); + } + + /** + * @return array{pendingName: ?string, pendingLogo: ?string} + */ + private function pendingResource(): array + { + if (! $this->pendingType) { + return ['pendingName' => null, 'pendingLogo' => null]; + } + $type = str($this->pendingType); + if (in_array($type->value(), DATABASE_TYPES, true)) { + return ['pendingName' => $type->headline()->value(), 'pendingLogo' => null]; + } + $services = get_service_templates(); + $key = resolve_service_template_key($type->after('one-click-service-')->value(), $services); + + return [ + 'pendingName' => str($key)->headline()->value(), + 'pendingLogo' => service_logo_urls(data_get($services, "$key.logo"))['logo'], + ]; } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index dae2642825..6029e17bce 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -112,6 +112,8 @@ class Domains extends Component public ?int $requiredPort = null; + public ?int $requestedPort = null; + public bool $isCheckingDns = false; #[Locked] @@ -1603,10 +1605,10 @@ class Domains extends Component if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { - $previousFqdn = $app->getOriginal('fqdn'); foreach ($this->splitDomains($domainString) as $fqdn) { - if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) { + if ($app->portRequiresConfirmation($fqdn, $requiredPort)) { $this->requiredPort = $requiredPort; + $this->requestedPort = ServiceApplication::extractPortFromUrl($fqdn); $this->showPortWarningModal = true; $app->refresh(); diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index edfc3c0e97..43bf885457 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -84,6 +84,8 @@ class Index extends Component public $requiredPort = null; + public ?int $requestedPort = null; + public ?string $fqdn = null; public bool $isGzipEnabled = false; @@ -630,7 +632,6 @@ class Index extends Component $persistedApplication = $this->serviceApplication->fresh(); $previousDnsHostnames = app(ManagedDnsRecordCleanup::class)->hostnamesOf($persistedApplication); $previousServiceDnsHostnames = array_keys(app(ResourceDnsRecords::class)->hostnameUsers($this->service)); - $previousEditableUrls = $persistedApplication->url; $previousFqdn = $persistedApplication->fqdn; $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->serviceApplication); @@ -669,8 +670,9 @@ class Index extends Component continue; } - if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort)) { $this->requiredPort = $requiredPort; + $this->requestedPort = ServiceApplication::extractPortFromUrl($fqdn); $this->showPortWarningModal = true; $this->serviceApplication->fqdn = $previousFqdn; $this->serviceApplication->domain_port_overrides = $previousPortOverrides; diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 0b6ee1d5e1..b92e49a85b 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -12,6 +12,7 @@ use App\Traits\ListensToTeamChannel; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use Livewire\Component; class Destination extends Component @@ -48,7 +49,7 @@ class Destination extends Component $this->networks = $this->usableResourceServers() ->get() - ->filter(fn (Server $server) => $server->canHostResources()) + ->filter(fn (Server $server) => $server->canHostResources() && Gate::allows('placeResource', $server)) ->map(function ($server) { return $server->standaloneDockers; })->flatten(); @@ -215,6 +216,7 @@ class Destination extends Component if (! $server) { return; } + $this->authorize('placeResource', $server); $network = StandaloneDocker::query()->where('server_id', $server->id)->findOrFail($network_id); $reason = $this->resource instanceof Application ? $this->resource->additionalServersUnavailableReason($server) diff --git a/app/Livewire/Project/Shared/Maintenance.php b/app/Livewire/Project/Shared/Maintenance.php index 29c2263e5a..b79f9d1ff3 100644 --- a/app/Livewire/Project/Shared/Maintenance.php +++ b/app/Livewire/Project/Shared/Maintenance.php @@ -38,13 +38,17 @@ class Maintenance extends Component && in_array($server->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)); } - public function instantSaveMaintenance(): void + /** + * Turn maintenance mode on or off. The header button calls this after the user confirms. + */ + public function toggleMaintenance(): void { try { $this->authorize('update', $this->resource); - $this->resource->is_maintenance_enabled = $this->isMaintenanceEnabled; + $this->resource->is_maintenance_enabled = ! $this->resource->is_maintenance_enabled; $this->resource->save(); $this->resource->syncMaintenancePage(); + $this->isMaintenanceEnabled = (bool) $this->resource->is_maintenance_enabled; $this->dispatch('success', $this->isMaintenanceEnabled ? 'Maintenance mode enabled.' : 'Maintenance mode disabled.'); } catch (\Throwable $e) { $this->isMaintenanceEnabled = (bool) $this->resource->fresh()?->is_maintenance_enabled; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 33743305c1..caf06d380d 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -21,6 +21,7 @@ use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Livewire\Component; use RuntimeException; @@ -53,7 +54,9 @@ class ResourceOperations extends Component $teamId = $this->resourceTeamId(); $this->projects = Project::query()->where('team_id', $teamId)->orderByRaw('LOWER(name)')->get(); $servers = Server::query()->where('team_id', $teamId)->get(); - $this->servers = $servers->reject(fn ($server) => $server->isBuildServer()); + $this->servers = $servers->reject(fn ($server) => $server->isBuildServer()) + ->filter(fn ($server) => Gate::allows('placeResource', $server)) + ->values(); $this->buildServers = $servers->filter(fn ($server) => $server->isBuildServer()); } @@ -396,8 +399,12 @@ class ResourceOperations extends Component $teamId = $this->resourceTeamId(); $destination = StandaloneDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first() ?? SwarmDocker::query()->whereRelation('server', 'team_id', $teamId)->where('uuid', $uuid)->first(); + if (! $destination?->server?->canHostResources()) { + return null; + } + $this->authorize('placeResource', $destination->server); - return $destination?->server?->canHostResources() ? $destination : null; + return $destination; } public function render() diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 7edafc9a16..5351891b0a 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -453,6 +453,9 @@ class VolumeBackups extends Component private function persistBackup(bool $enabled): ScheduledVolumeBackup { $backup = $this->storage->scheduledBackups()->firstOrNew(); + if ($backup->newS3StorageIds($this->selectedS3StorageIds())->isNotEmpty()) { + $this->authorize('useTeamCredentials', $this->resource); + } $backup->fill([ 'team_id' => $this->resourceTeamId(), 'frequency' => $this->frequency, diff --git a/app/Livewire/Server/New/Concerns/SetsImageScanning.php b/app/Livewire/Server/New/Concerns/SetsImageScanning.php index ab45a49b4c..e535e47c48 100644 --- a/app/Livewire/Server/New/Concerns/SetsImageScanning.php +++ b/app/Livewire/Server/New/Concerns/SetsImageScanning.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server\New\Concerns; -use App\Models\InstanceSettings; use App\Models\Server; /** @@ -22,7 +21,7 @@ trait SetsImageScanning protected function applyImageScanning(Server $server): void { - if ($this->scan_images !== 'on' || ! InstanceSettings::get()->is_image_scanning_allowed) { + if ($this->scan_images !== 'on') { return; } diff --git a/app/Livewire/StarToggle.php b/app/Livewire/StarToggle.php new file mode 100644 index 0000000000..47fc8b78bf --- /dev/null +++ b/app/Livewire/StarToggle.php @@ -0,0 +1,81 @@ +authorize('view', $resource); + + $this->starrableType = $resource->getMorphClass(); + $this->starrableId = $resource->getKey(); + $this->starred = $this->starQuery()->exists(); + } + + public function toggle(): void + { + abort_unless(in_array($this->starrableType, Star::STARRABLE_TYPES, true), 404); + $this->authorize('view', $this->starrableType::findOrFail($this->starrableId)); + + $this->starred = ! $this->starQuery()->exists(); + + if (! $this->starred) { + $this->starQuery()->delete(); + } else { + auth()->user()->stars()->firstOrCreate([ + 'starrable_type' => $this->starrableType, + 'starrable_id' => $this->starrableId, + ], [ + 'position' => (auth()->user()->stars()->max('position') ?? -1) + 1, + ]); + } + + $this->dispatch('star-toggled', type: $this->starrableType, id: $this->starrableId, starred: $this->starred); + } + + /** + * Keeps the other star button of the same resource on the page (desktop and mobile top bars) in sync. + */ + #[On('star-toggled')] + public function syncStar(string $type, int $id, bool $starred): void + { + if ($type !== $this->starrableType || $id !== $this->starrableId || $starred === $this->starred) { + $this->skipRender(); + + return; + } + + $this->starred = $starred; + } + + private function starQuery(): HasMany + { + return auth()->user()->stars() + ->where('starrable_type', $this->starrableType) + ->where('starrable_id', $this->starrableId); + } + + public function render() + { + return view('livewire.star-toggle'); + } +} diff --git a/app/Mcp/Tools/ManageImageScanning.php b/app/Mcp/Tools/ManageImageScanning.php index b7a1ff11d7..2f5372dd66 100644 --- a/app/Mcp/Tools/ManageImageScanning.php +++ b/app/Mcp/Tools/ManageImageScanning.php @@ -14,7 +14,7 @@ class ManageImageScanning extends RestActionTool protected function actions(): array { return [ - 'get_server' => ['method' => 'GET', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'read', 'description' => 'whether the instance allows scanning, whether the server scans, and the scanner state on the server'], + 'get_server' => ['method' => 'GET', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'read', 'description' => 'whether the server scans, and the scanner state on the server'], 'update_server' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/image-scanning', 'ability' => 'write', 'description' => 'turn scanning of the server on or off (data: is_image_scan_enabled); turning it on gets the scanner on the server'], 'update_team' => ['method' => 'PATCH', 'path' => 'team', 'ability' => 'write', 'description' => 'turn scanning on or off for servers added to the team from now on (data: scan_images_on_new_servers)'], ]; diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php index fc8d501a25..1b7f35f976 100644 --- a/app/Mcp/Tools/ManagePreview.php +++ b/app/Mcp/Tools/ManagePreview.php @@ -11,7 +11,7 @@ class ManagePreview extends RestActionTool { protected string $name = 'manage_preview'; - protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create requires deploy ability, update requires write, delete requires delete. Same validation and permissions as the REST API.'; + protected string $description = 'Create (and deploy), update the domains of, or delete a pull request preview deployment of an application of the authenticated team. create requires deploy ability to redeploy an existing preview, and also write ability to open a preview or change its pull request URL, git type, or docker tag; update requires write, delete requires delete. Same validation and permissions as the REST API.'; protected function actions(): array { diff --git a/app/Models/Application.php b/app/Models/Application.php index d74fe35d0d..2699cbaf79 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -23,6 +23,7 @@ use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ReleasesManagedDnsRecords; use App\Traits\ScopedByAccessGrants; use Database\Factories\ApplicationFactory; @@ -142,7 +143,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasImageScans, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasImageScans, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, HasStars, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -1819,7 +1820,8 @@ class Application extends BaseModel ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $commands = collect([]); $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deployment_uuid}"; - $base_command = 'git ls-remote'; + // `--` ends the options, so a repository value that starts with `-` is not read as a git option. + $base_command = 'git ls-remote --'; if ($this->deploymentType() === 'source') { $source_html_url = data_get($this, 'source.html_url'); @@ -1968,7 +1970,7 @@ class Application extends BaseModel $escapedCustomRepository = escapeshellarg($customRepository); $gitConfigOption = $this->gitHttpsCredentialConfigOption(); if ($gitConfigOption) { - $base_command = "git {$gitConfigOption} ls-remote"; + $base_command = "git {$gitConfigOption} ls-remote --"; } if ($this->gitUsesSsh()) { $sshCommand = gitSshCommand($deployment_uuid, $customPort, '/root/.ssh/id_rsa', identitiesOnly: false); @@ -2162,9 +2164,10 @@ class Application extends BaseModel } } - $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch}"; + // `--` ends the options, so a repository value that starts with `-` is not read as a git option. + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch} --"; if ($only_checkout) { - $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch}"; + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch} --"; } if ($pull_request_id !== 0) { $pr_branch_name = "pr-{$pull_request_id}-coolify"; @@ -2624,9 +2627,22 @@ class Application extends BaseModel return '

Reason: '.e('The Git host sent an SSH host key that is not the saved or published key. Open Source > SSH host keys and use Fetch current host key. Compare the fingerprint with the Git host administrator before you accept it.'); } - $reason = collect(explode("\n", $errorOutput))->map(fn (string $line) => trim($line))->filter()->last(); + $lines = collect(explode("\n", $errorOutput))->map(fn (string $line) => trim($line))->filter()->values(); + // Git and SSH print progress, known-hosts and multiplexing notes, and a generic access hint around the real cause. + $reasonLines = $lines->reject(fn (string $line) => Str::startsWith($line, [ + 'Cloning into', + 'Warning: Permanently added', + 'Control socket connect(', + 'ControlSocket ', + 'mux_client_', + 'Please make sure you have the correct access rights', + 'and the repository exists.', + ]))->take(-10); + if ($reasonLines->isEmpty()) { + $reasonLines = $lines->take(-1); + } - return $reason ? '

Reason: '.e(str($reason)->limit(300)->value()) : ''; + return $reasonLines->isEmpty() ? '' : '

Reason: '.$reasonLines->map(fn (string $line) => e(str($line)->limit(300)->value()))->implode('
'); } /** diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 37b6989e1e..4a79154b13 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -759,13 +759,29 @@ class EnvironmentVariable extends BaseModel $scope = self::sharedVariableScope($resource, $reference['type']); if ($scope === null || Gate::forUser($user)->denies('viewSensitive', $scope)) { + $name = "{$reference['type']}.{$reference['key']}"; + throw ValidationException::withMessages([ - $field => "You cannot reference the shared variable {$reference['type']}.{$reference['key']}, because you cannot read its value.", + $field => self::isBlockedOnlyByTokenScope($user, $scope) + ? "This API token is limited to selected projects, environments, or resources, so it cannot reference the {$reference['type']} shared variable {$name}. Use a token for the whole team." + : "You cannot reference the shared variable {$name}, because you cannot read its value.", ]); } } } + /** + * True when the user is an admin who can read team and server variables, but the API token of the request + * is limited to selected projects, environments, or resources. + */ + private static function isBlockedOnlyByTokenScope(User $user, ?SharedEnvironmentVariable $scope): bool + { + return $scope !== null + && in_array($scope->type, ['team', 'server'], true) + && data_get($user->currentAccessToken(), 'scopes') !== null + && $user->isAdminOfTeam((int) $scope->team_id); + } + /** * An unsaved shared variable of the given type for the resource. The policy decides by the * scope, so a reference to a key that does not exist yet is checked like an existing one. diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 4eb56e12da..f92ca7a3d9 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -14,12 +14,9 @@ class InstanceSettings extends Model protected $attributes = [ 'is_dashboard_force_https_enabled' => true, - 'is_image_scanning_allowed' => true, ]; protected $fillable = [ - 'is_image_scanning_allowed', - 'image_scan_db_url', 'public_ipv4', 'public_ipv6', 'fqdn', @@ -128,7 +125,6 @@ class InstanceSettings extends Model 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', 'is_dashboard_force_https_enabled' => 'boolean', - 'is_image_scanning_allowed' => 'boolean', 'is_passkey_enabled' => 'boolean', 'server_validation_rate_limit_seconds' => 'integer', 'server_validation_max_failures_per_ip' => 'integer', diff --git a/app/Models/Server.php b/app/Models/Server.php index 0269dc3ddc..7996df3a25 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -31,6 +31,7 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasStars; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -127,7 +128,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, HasStars, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet @@ -1443,6 +1444,15 @@ $siteAddress { return $this->settings->force_disabled; } + /** + * On self-hosted, only the unreachable cleanup disables a server, so a working connection can enable it again. + * On Cloud the flag enforces the plan server limit, and a transferred server stays an archive. + */ + public function canBeReEnabled(): bool + { + return $this->settings->force_disabled === true && ! isCloud() && ! $this->isTransferredAway(); + } + /** * Server was migrated away from this Coolify instance (source side). * Must not be revalidated or re-enabled as a live managed host. @@ -2549,6 +2559,11 @@ $siteAddress { public function validateConnection(bool $justCheckingNewKey = false) { + // A validation enables a disabled self-hosted server again, but only when the connection works. + $isReEnabling = $this->canBeReEnabled() && ! $this->hasPlaceholderIp(); + if ($isReEnabling) { + $this->forceEnableServer(); + } if ($this->skipServer()) { return ['uptime' => false, 'error' => 'Server skipped.']; } @@ -2569,6 +2584,9 @@ $siteAddress { return ['uptime' => true, 'error' => null]; } catch (\Throwable $e) { + if ($isReEnabling) { + $this->forceDisableServer(); + } $error = $e->getMessage(); if ($error === SshMultiplexingHelper::HOST_KEY_CHANGED_MESSAGE && ! $this->recordHostKeyMismatch()) { $error = 'SSH host key verification failed, but the server still sends the trusted host key.'; diff --git a/app/Models/Service.php b/app/Models/Service.php index 26f5b81d46..b4af4124ca 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -12,6 +12,7 @@ use App\Traits\HasComposeVolumeWarnings; use App\Traits\HasMaintenancePage; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -47,7 +48,7 @@ use OpenApi\Attributes as OA; )] class Service extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 5f737459ab..d180d9790a 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -275,7 +275,7 @@ class ServiceApplication extends BaseModel /** * True when saving this URL should confirm that it does not use the required template port. */ - public function portRequiresConfirmation(string $fqdn, ?int $requiredPort, ?string $previousFqdn = null): bool + public function portRequiresConfirmation(string $fqdn, ?int $requiredPort): bool { if ($requiredPort === null) { return false; @@ -286,28 +286,14 @@ class ServiceApplication extends BaseModel return false; } - $canonical = DomainPortOverrides::withoutPort($fqdn); $explicit = self::extractPortFromUrl($fqdn); - if ($explicit === $requiredPort) { + // A domain without a port routes to the required port. + if ($explicit === null || $explicit === $requiredPort) { return false; } - if ($explicit === null) { - $previous = collect(explode(',', (string) $previousFqdn)) - ->filter(); - $previousUrl = $previous->first( - fn (string $url): bool => DomainPortOverrides::withoutPort(trim($url)) === $canonical - ); - - if (is_string($previousUrl) && self::extractPortFromUrl($previousUrl) !== null) { - return true; - } - - return $previousUrl === null; - } - - $existingOverride = $this->domain_port_overrides[$canonical] ?? null; + $existingOverride = $this->domain_port_overrides[DomainPortOverrides::withoutPort($fqdn)] ?? null; return (int) $existingOverride !== $explicit; } diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 68fc3e9ecd..246ba56fc4 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,7 +19,7 @@ use Throwable; class StandaloneClickhouse extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index f2711ab60f..4a80f1d207 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index c6fe307de4..dc8c4d93b4 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index a94cee3388..bc49167ec5 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,7 +19,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7c43802188..75c5fc5190 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index c714cfbe0a..590c148dcf 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 8112e01ae5..de30f5627d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 08d9768e22..19f7f9579e 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index f8989b4d35..4c66aae413 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,7 +20,7 @@ use InvalidArgumentException; class StandaloneSqlite extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; public const DATA_DIRECTORY = '/var/lib/sqlite'; diff --git a/app/Models/Star.php b/app/Models/Star.php new file mode 100644 index 0000000000..09ba4471a0 --- /dev/null +++ b/app/Models/Star.php @@ -0,0 +1,46 @@ +belongsTo(User::class); + } + + public function starrable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 64a5c6ff9c..0c3aadc1c1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -311,6 +311,11 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail return $this->hasMany(UserChangelogRead::class); } + public function stars(): HasMany + { + return $this->hasMany(Star::class); + } + public function getUnreadChangelogCount(): int { return app(ChangelogService::class)->getUnreadCountForUser($this); diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index cbc94aa855..e35295cba5 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -124,7 +124,7 @@ class DatabasePolicy private function allows(User $user, AccessLevel $level, $database): bool { if ($this->isInstanceDatabase($database)) { - return $user->isAdminOfTeam(0); + return $user->isAdminOfTeam(0) && $user->canAccess($level, $database); } return $user->canAccess($level, $database); diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index e8dd00cf9d..6ac1c10115 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -6,6 +6,7 @@ use App\Enums\AccessLevel; use App\Models\Environment; use App\Models\User; use App\Services\Access\AccessResolver; +use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Model; class ResourceCreatePolicy @@ -30,10 +31,12 @@ class ResourceCreatePolicy * yet: private keys, git sources, integration tokens, and backup files of other resources. * Needs write access on the whole team of the resource, or of the current team without a resource. */ - public function useTeamCredentials(User $user, ?Model $resource = null): bool + public function useTeamCredentials(User $user, ?Model $resource = null): Response { $teamId = $resource !== null ? $resource->team()?->id : $user->currentTeam()?->id; - return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, (int) $teamId); + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, (int) $teamId) + ? Response::allow() + : Response::deny('You need write access on the whole team to use team credentials, such as private keys, git sources, and S3 storages.'); } } diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 67e76ba45a..565eddef8a 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -6,6 +6,7 @@ use App\Enums\AccessLevel; use App\Models\Server; use App\Models\User; use App\Services\Access\AccessResolver; +use Illuminate\Auth\Access\Response; class ServerPolicy { @@ -41,6 +42,30 @@ class ServerPolicy return $user->isAdmin(); } + /** + * Determine whether the user can place a resource on the server: create, clone, or migrate one there, + * or add the server to an application. + * + * The Coolify host needs a team admin, which is an instance admin for the localhost server. + * Other servers need team-wide write, or write on a resource that already runs there. + */ + public function placeResource(User $user, Server $server): Response + { + $resolver = app(AccessResolver::class); + $teamId = (int) $server->team_id; + $teamWideWrite = $resolver->allowsTeamWide($user, AccessLevel::Write, $teamId); + + if ($server->isLocalhost()) { + return $teamWideWrite && $user->isAdminOfTeam($teamId) + ? Response::allow() + : Response::deny('Only an admin can place resources on the server that runs Coolify.'); + } + + return $teamWideWrite || $resolver->allowsOnServer($user, AccessLevel::Write, $server) + ? Response::allow() + : Response::deny('You need write access on the whole team, or on a resource that already runs on this server, to place resources on it.'); + } + /** * Determine whether the user can update the model. */ diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index 08e9ba70f4..f8f8ab6dc0 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -8,7 +8,8 @@ use App\Models\User; use App\Services\Access\AccessResolver; /** - * Team and server variables need team-wide access (owners and admins to change them). + * Team and server variables need team-wide access (owners and admins to read their values or change them). + * The API token of the request must also cover the whole team, so a token limited to some projects cannot use them. * Project and environment variables follow the access level on their project or environment. */ class SharedEnvironmentVariablePolicy @@ -95,8 +96,11 @@ class SharedEnvironmentVariablePolicy return $target !== null && $user->canAccess($level, $target); } - return $level === AccessLevel::Read - ? app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $sharedEnvironmentVariable->team_id) - : $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + $teamId = (int) $sharedEnvironmentVariable->team_id; + if ($level !== AccessLevel::Read && ! $user->isAdminOfTeam($teamId)) { + return false; + } + + return app(AccessResolver::class)->allowsTeamWide($user, $level, $teamId); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 8a2e049d97..0dd6ba4e2b 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -52,6 +52,7 @@ class FortifyServiceProvider extends ServiceProvider return view('auth.register', [ 'isFirstUser' => $isFirstUser, + 'isRootSetupWindowPending' => $isFirstUser && RootSetupWindow::isPending(), 'isRootSetupWindowClosed' => $isFirstUser && ! RootSetupWindow::isOpen(), ]); }); diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index e30fb8c22b..af8c81e42e 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -12,10 +12,16 @@ class ValidGitRepositoryUrl implements ValidationRule protected bool $allowIP; - public function __construct(bool $allowSSH = true, bool $allowIP = true) + protected bool $allowRepositoryPath; + + /** + * @param bool $allowRepositoryPath Also accept a repository path such as `owner/name`, which applications with a Git source store. + */ + public function __construct(bool $allowSSH = true, bool $allowIP = true, bool $allowRepositoryPath = false) { $this->allowSSH = $allowSSH; $this->allowIP = $allowIP; + $this->allowRepositoryPath = $allowRepositoryPath; } /** @@ -195,6 +201,9 @@ class ValidGitRepositoryUrl implements ValidationRule return; } + } elseif ($this->allowRepositoryPath && preg_match('/^[a-zA-Z0-9_][a-zA-Z0-9._-]*(\/[a-zA-Z0-9._-]+)+$/', $value) === 1) { + // A repository path (owner/name, or group/subgroup/name) that the Git source turns into a URL. + return; } else { $fail('The :attribute must start with https://, http://, git://, ssh://, or be an SSH URL (user@host:path).'); diff --git a/app/Services/Access/AccessResolver.php b/app/Services/Access/AccessResolver.php index 84ed397853..7fa160cf56 100644 --- a/app/Services/Access/AccessResolver.php +++ b/app/Services/Access/AccessResolver.php @@ -9,8 +9,10 @@ use App\Models\Environment; use App\Models\PersonalAccessToken; use App\Models\Project; use App\Models\Scopes\VisibleThroughAccessGrants; +use App\Models\Server; use App\Models\Service; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -19,6 +21,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Models\SwarmDocker; use App\Models\User; use Closure; use Illuminate\Database\Eloquent\Builder; @@ -227,6 +230,57 @@ class AccessResolver return $this->isMember($user, $teamId); } + /** + * True when the user (and the current token) has the level on at least one resource that runs on the server, + * as its main destination or as an additional server of an application. + */ + public function allowsOnServer(User $user, AccessLevel $level, Server $server): bool + { + $teamId = (int) $server->team_id; + if (! $this->isMember($user, $teamId)) { + return false; + } + + $layers = $this->layersFor($user, $teamId); + if ($layers === []) { + return true; + } + + $environmentProjects = $this->teamEnvironments($teamId)->pluck('environments.project_id', 'environments.id')->all(); + if ($environmentProjects === []) { + return false; + } + + foreach (self::RESOURCE_MODELS as $modelClass) { + $resources = $modelClass::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('environment_id', array_keys($environmentProjects)) + ->where(function (Builder $query) use ($modelClass, $server) { + $query->whereHasMorph('destination', [StandaloneDocker::class, SwarmDocker::class], fn (Builder $destination) => $destination->where('server_id', $server->id)); + if ($modelClass === Application::class) { + $query->orWhereHas('additional_servers', fn (Builder $servers) => $servers->whereKey($server->id)); + } + }) + ->get(['uuid', 'environment_id']); + + foreach ($resources as $resource) { + $environmentId = (int) $resource->environment_id; + $projectId = $environmentProjects[$environmentId]; + $allowed = true; + foreach ($layers as $layer) { + if (! $layer->allows($level, $projectId, $environmentId, $resource->uuid)) { + $allowed = false; + break; + } + } + if ($allowed) { + return true; + } + } + } + + return false; + } + /** * True when the user has every level of every grant, at that scope or above it. * A token can never get more access than the user who owns it. diff --git a/app/Services/ImageScan/ImageScanPolicyResolver.php b/app/Services/ImageScan/ImageScanPolicyResolver.php index 2afc7cf57e..12842e2f06 100644 --- a/app/Services/ImageScan/ImageScanPolicyResolver.php +++ b/app/Services/ImageScan/ImageScanPolicyResolver.php @@ -6,7 +6,6 @@ use App\Enums\ImageScanGateMode; use App\Enums\ImageScanOnError; use App\Models\Application; use App\Models\ImageScanPolicy; -use App\Models\InstanceSettings; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -214,8 +213,8 @@ class ImageScanPolicyResolver } /** - * Why the resource is not scanned now: "instance" (the instance does not allow scanning), "server" (scanning is - * off for its server), "application" (the app turned its scan off), or null when it is scanned. Every surface + * Why the resource is not scanned now: "server" (scanning is off for its server), "application" (the app turned + * its scan off), or null when it is scanned. Every surface * that shows results (resource page, sidebar count, markers, team page, dashboard) uses this rule. Reads loaded * relations, so eager load the server settings (and the app settings) for lists. */ @@ -224,7 +223,6 @@ class ImageScanPolicyResolver $scanServer ??= self::mainServer($resource); return match (true) { - ! InstanceSettings::get()->is_image_scanning_allowed => 'instance', ! $scanServer instanceof Server || ! $scanServer->settings?->is_image_scan_enabled => 'server', $resource instanceof Application && ! $resource->settings?->is_image_scan_enabled => 'application', default => null, @@ -232,13 +230,11 @@ class ImageScanPolicyResolver } /** - * The instance allows scanning and an app did not turn its scan off. Reads no rules and no server, so a caller can - * stop before it resolves them. + * An app did not turn its scan off. Reads no rules and no server, so a caller can stop before it resolves them. */ public static function isAllowedFor(Model $resource): bool { - return (bool) InstanceSettings::get()->is_image_scanning_allowed - && (! $resource instanceof Application || (bool) $resource->settings?->is_image_scan_enabled); + return ! $resource instanceof Application || (bool) $resource->settings?->is_image_scan_enabled; } public static function isScanOn(Model $resource): bool diff --git a/app/Services/ImageScan/ScannerCommands.php b/app/Services/ImageScan/ScannerCommands.php index 14e6e6174f..17c8aaaa1d 100644 --- a/app/Services/ImageScan/ScannerCommands.php +++ b/app/Services/ImageScan/ScannerCommands.php @@ -145,12 +145,9 @@ class ScannerCommands /** * @return list */ - public function dbUpdate(?string $dbUrl): array + public function dbUpdate(): array { $env = ['GRYPE_DB_CACHE_DIR' => '/db', 'TMPDIR' => '/db/tmp']; - if ($dbUrl !== null) { - $env['GRYPE_DB_UPDATE_URL'] = $this->validDatabaseUrl($dbUrl); - } return [ ...$this->start(['scratch']), @@ -889,16 +886,4 @@ class ScannerCommands throw new InvalidArgumentException('Invalid SBOM name.'); } } - - /** - * No `&`, `|`, `$`, or quotes: the sudo parser rewrites `&&`, `||`, and `$(` even inside quotes. - */ - private function validDatabaseUrl(string $url): string - { - if (preg_match('#\Ahttps?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=-]*)?\z#', $url) !== 1) { - throw new InvalidArgumentException('Invalid vulnerability database URL.'); - } - - return $url; - } } diff --git a/app/Services/ImageScan/SecuritySentences.php b/app/Services/ImageScan/SecuritySentences.php index df28d2b459..23cd0da5c5 100644 --- a/app/Services/ImageScan/SecuritySentences.php +++ b/app/Services/ImageScan/SecuritySentences.php @@ -92,7 +92,6 @@ final class SecuritySentences SecurityStatus::REASON_DB_OUTDATED => 'the vulnerability database is out of date', SecurityStatus::REASON_IMAGE_CHANGED => 'the running image is not the checked image', SecurityStatus::REASON_VERSIONS_UNCHECKED => 'Coolify has not checked for newer versions yet', - 'instance' => 'Image scanning is off for this Coolify instance.', 'server' => 'Image scanning is off for this server.', 'application' => 'Image scanning is off for this app.', default => 'This image has not been scanned yet.', diff --git a/app/Services/ImageScan/TeamVulnerabilities.php b/app/Services/ImageScan/TeamVulnerabilities.php index 3f365172a5..e94c13fcf9 100644 --- a/app/Services/ImageScan/TeamVulnerabilities.php +++ b/app/Services/ImageScan/TeamVulnerabilities.php @@ -189,10 +189,6 @@ final class TeamVulnerabilities */ public static function updateNowResources(int $teamId): array { - if (! instanceSettings()->is_image_scanning_allowed) { - return []; - } - $owners = ImageScan::query() // Whether a resource is scanned is decided by the shared rule, not by the server of its scans. ->whereIn('server_id', Server::query()->where('team_id', $teamId)->select('id')) diff --git a/app/Services/ImageScan/VulnerabilityMarkers.php b/app/Services/ImageScan/VulnerabilityMarkers.php index f4eb19b419..dc58198ba6 100644 --- a/app/Services/ImageScan/VulnerabilityMarkers.php +++ b/app/Services/ImageScan/VulnerabilityMarkers.php @@ -31,7 +31,7 @@ final class VulnerabilityMarkers public static function for(Collection $resources): array { $resources = $resources->filter(fn ($resource): bool => $resource instanceof Model)->values(); - if ($resources->isEmpty() || ! instanceSettings()->is_image_scanning_allowed) { + if ($resources->isEmpty()) { return []; } diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index af031c94ef..1c70c7f995 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -70,6 +70,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Models\Star; use App\Models\Subscription; use App\Models\SwarmDocker; use App\Models\Tag; @@ -356,6 +357,7 @@ final class ServerTransferCoverage OauthIdentity::class => 'User login data of the source instance.', PersonalAccessToken::class => 'API tokens of the source instance.', UserChangelogRead::class => 'User data of the source instance.', + Star::class => 'User data of the source instance: users are not moved with a server.', AuditEvent::class => 'Audit log of the source instance.', NotificationThrottle::class => 'Runtime state of the source instance.', ScheduledJobState::class => 'Scheduler state of the source instance.', diff --git a/app/Support/RootSetupWindow.php b/app/Support/RootSetupWindow.php index f07180176f..728e34092f 100644 --- a/app/Support/RootSetupWindow.php +++ b/app/Support/RootSetupWindow.php @@ -13,19 +13,30 @@ class RootSetupWindow { public const MINUTES = 10; - private const CACHE_KEY = 'root-setup-window'; + private const CACHE_KEY = 'root-setup-window-closes-at'; + /** + * The key has no TTL: app:init clears the cache before it calls this, so a missing key + * means that Coolify has not finished starting, not that the window is closed. + */ public static function openIfNoUserExists(): void { if (User::query()->exists()) { return; } - Cache::put(self::CACHE_KEY, true, now()->addMinutes(self::MINUTES)); + Cache::forever(self::CACHE_KEY, now()->addMinutes(self::MINUTES)->getTimestamp()); } public static function isOpen(): bool { - return Cache::has(self::CACHE_KEY); + $closesAt = Cache::get(self::CACHE_KEY); + + return is_int($closesAt) && now()->getTimestamp() < $closesAt; + } + + public static function isPending(): bool + { + return ! Cache::has(self::CACHE_KEY); } } diff --git a/app/Traits/HasS3Destinations.php b/app/Traits/HasS3Destinations.php index 59668f3961..06696e7bd0 100644 --- a/app/Traits/HasS3Destinations.php +++ b/app/Traits/HasS3Destinations.php @@ -52,6 +52,22 @@ trait HasS3Destinations ); } + /** + * The storage IDs that are not a stored destination of this schedule yet. + * Reads the saved state, so call it before the schedule changes. + * + * @param iterable $storageIds + * @return Collection + */ + public function newS3StorageIds(iterable $storageIds): Collection + { + $currentIds = $this->exists + ? $this->s3Storages()->pluck('s3_storages.id')->push($this->getOriginal('s3_storage_id'))->filter()->map(fn ($id): int => (int) $id) + : collect(); + + return collect($storageIds)->map(fn ($id): int => (int) $id)->filter()->unique()->diff($currentIds)->values(); + } + /** * Replaces the selected destinations. The current primary destination stays primary when it is still selected. * diff --git a/app/Traits/HasStars.php b/app/Traits/HasStars.php new file mode 100644 index 0000000000..07ff1adb1d --- /dev/null +++ b/app/Traits/HasStars.php @@ -0,0 +1,19 @@ + $model->stars()->delete()); + } + + public function stars(): MorphMany + { + return $this->morphMany(Star::class, 'starrable'); + } +} diff --git a/app/Traits/ListensToTeamChannel.php b/app/Traits/ListensToTeamChannel.php index c3feef8488..d2498102db 100644 --- a/app/Traits/ListensToTeamChannel.php +++ b/app/Traits/ListensToTeamChannel.php @@ -43,7 +43,8 @@ trait ListensToTeamChannel } try { - $payload = decrypt($event['payload']); + // The payload comes from the browser: unserialize it without classes. + $payload = unserialize(decrypt($event['payload'], false), ['allowed_classes' => false]); } catch (DecryptException) { return []; } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 37b45df470..4b37525ffd 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -434,6 +434,7 @@ function migrateResourceToDestination(Request $request, $resource, string $resou if (! $destination || ! $destination->server?->canHostResources()) { return response()->json(['message' => 'Destination not found.'], 404); } + Gate::authorize('placeResource', $destination->server); $sourceDestination = $resource->destination; $migrateVolumes = $request->boolean('migrate_volumes', true); diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 176a4e1153..488c04b261 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -79,7 +79,10 @@ function domainConflictKey(string $domain): string ? str($domain)->beforeLast('/')->toString() : $domain; - return preg_replace('#^https?://#i', '', $domain) ?? $domain; + $domain = preg_replace('#^https?://#i', '', $domain) ?? $domain; + + // The URL port is the container port. The public address does not include it. + return preg_replace('#^([^/:\[]+|\[[^\]]+\]):\d+#', '$1', $domain) ?? $domain; } /** diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 22a0f1d005..ee5a023dd0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -54,6 +54,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; @@ -606,6 +607,8 @@ function find_resource_destination_for_current_team(?string $uuid): StandaloneDo return null; } + Gate::authorize('placeResource', $destination->server); + return $destination; } diff --git a/database/migrations/2026_10_29_100000_create_image_scanning_tables.php b/database/migrations/2026_10_29_100000_create_image_scanning_tables.php index dcc04e45ba..0a6ec808ee 100644 --- a/database/migrations/2026_10_29_100000_create_image_scanning_tables.php +++ b/database/migrations/2026_10_29_100000_create_image_scanning_tables.php @@ -121,11 +121,6 @@ return new class extends Migration $table->date('image_candidate_scans_date')->nullable(); }); - Schema::table('instance_settings', function (Blueprint $table) { - $table->boolean('is_image_scanning_allowed')->default(true); - $table->string('image_scan_db_url')->nullable(); - }); - Schema::table('teams', function (Blueprint $table) { $table->boolean('scan_images_on_new_servers')->default(false); }); @@ -185,10 +180,6 @@ return new class extends Migration $table->dropColumn('scan_images_on_new_servers'); }); - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn(['is_image_scanning_allowed', 'image_scan_db_url']); - }); - Schema::table('server_settings', function (Blueprint $table) { $table->dropColumn(['is_image_scan_enabled', 'image_candidate_scans_today', 'image_candidate_scans_date']); }); diff --git a/database/migrations/2026_11_06_165709_create_stars_table.php b/database/migrations/2026_11_06_165709_create_stars_table.php new file mode 100644 index 0000000000..9d45e09676 --- /dev/null +++ b/database/migrations/2026_11_06_165709_create_stars_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('starrable'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + + $table->unique(['user_id', 'starrable_type', 'starrable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('stars'); + } +}; diff --git a/database/migrations/2026_11_08_120000_drop_image_scanning_columns_from_instance_settings.php b/database/migrations/2026_11_08_120000_drop_image_scanning_columns_from_instance_settings.php new file mode 100644 index 0000000000..978c0a6170 --- /dev/null +++ b/database/migrations/2026_11_08_120000_drop_image_scanning_columns_from_instance_settings.php @@ -0,0 +1,28 @@ +dropColumn($column); + }); + } + } + } + + public function down(): void + { + // + } +}; diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index e53d73e82c..14ab1dd4f2 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -56,10 +56,7 @@ RUN --mount=type=cache,target=/tmp/cache \ USER www-data -# Temporary: the node:24.22.1 multi-arch manifest is not published yet, pull the per-arch image instead -FROM amd64/node:${NODE_VERSION}-alpine3.24 AS nodejs-amd64 -FROM arm64v8/node:${NODE_VERSION}-alpine3.24 AS nodejs-arm64 -FROM nodejs-${TARGETARCH} AS nodejs +FROM node:${NODE_VERSION}-alpine3.24 AS nodejs # ================================================================= # Stage 2: Frontend assets compilation @@ -74,12 +71,7 @@ COPY . . RUN npm run build # ================================================================= -# Stage 3: Get MinIO client -# ================================================================= -FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client - -# ================================================================= -# Stage 4: Terminal dependencies (match the production Node.js ABI and libc) +# Stage 3: Terminal dependencies (match the production Node.js ABI and libc) # ================================================================= FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION} AS terminal-dependencies @@ -220,10 +212,6 @@ RUN mkdir -p /etc/nginx/conf.d && \ chown -R www-data:www-data /etc/nginx && \ chmod -R 755 /etc/nginx -# Install MinIO client -COPY --from=minio-client /usr/bin/mc /usr/bin/mc -RUN chmod +x /usr/bin/mc - ARG SERVERSIDEUP_PHP_VERSION ARG NODE_VERSION ARG CREATED diff --git a/openapi.json b/openapi.json index 0e44663be1..eceaa73d0f 100644 --- a/openapi.json +++ b/openapi.json @@ -62,7 +62,7 @@ "Applications" ], "summary": "Create Preview Deployment", - "description": "Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.", + "description": "Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. A token with the deploy permission can only redeploy an existing preview without changes; opening a preview or changing its pull request URL, git type, or docker tag also needs the write permission. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.", "operationId": "create-preview-deployment-by-application-uuid", "parameters": [ { @@ -167,6 +167,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "Missing permissions. Opening or changing a preview needs the write permission." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -6649,7 +6652,7 @@ ], "properties": { "commit": { - "description": "Image tag \/ commit to roll back to.", + "description": "Image tag \/ commit to roll back to. Must be a tag from the rollback images list or the commit of a successful production deployment.", "type": "string" } }, @@ -11645,7 +11648,7 @@ { "name": "docker_tag", "in": "query", - "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.", + "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id. Opening a preview or changing its tag also needs the write permission.", "schema": { "type": "string" } @@ -19534,11 +19537,6 @@ "application\/json": { "schema": { "properties": { - "is_image_scanning_allowed": { - "description": "Whether the Coolify instance allows image scanning.", - "type": "boolean", - "readOnly": true - }, "is_image_scan_enabled": { "description": "Whether the server scans the images of its resources.", "type": "boolean" @@ -26623,9 +26621,6 @@ "application\/json": { "schema": { "properties": { - "is_image_scanning_allowed": { - "type": "boolean" - }, "resources": { "type": "array", "items": { @@ -27748,7 +27743,6 @@ "null" ], "enum": [ - "instance", "server", "application" ] diff --git a/openapi.yaml b/openapi.yaml index 6fd1432ce3..6113596743 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -42,7 +42,7 @@ paths: tags: - Applications summary: 'Create Preview Deployment' - description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.' + description: 'Open a preview deployment for a pull request and queue its deployment. When the preview already exists, it is redeployed. A token with the deploy permission can only redeploy an existing preview without changes; opening a preview or changing its pull request URL, git type, or docker tag also needs the write permission. Git based applications need git_type unless they use a GitHub or GitLab App source; Bitbucket also needs commit. Docker Image applications need docker_tag for a new preview.' operationId: create-preview-deployment-by-application-uuid parameters: - @@ -104,6 +104,8 @@ paths: $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' + '403': + description: 'Missing permissions. Opening or changing a preview needs the write permission.' '404': $ref: '#/components/responses/404' '422': @@ -4369,7 +4371,7 @@ paths: - commit properties: commit: - description: 'Image tag / commit to roll back to.' + description: 'Image tag / commit to roll back to. Must be a tag from the rollback images list or the commit of a successful production deployment.' type: string type: object responses: @@ -7637,7 +7639,7 @@ paths: - name: docker_tag in: query - description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.' + description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id. Opening a preview or changing its tag also needs the write permission.' schema: type: string - @@ -12418,7 +12420,6 @@ paths: application/json: schema: properties: - is_image_scanning_allowed: { description: 'Whether the Coolify instance allows image scanning.', type: boolean, readOnly: true } is_image_scan_enabled: { description: 'Whether the server scans the images of its resources.', type: boolean } scanner_state: { description: 'State of the scanner on the server. Null when unknown.', type: [string, 'null'], enum: [pulling, updating_database, ready, failed] } scanner_failed_step: { description: 'The step that failed when scanner_state is failed.', type: [string, 'null'] } @@ -16819,7 +16820,6 @@ paths: application/json: schema: properties: - is_image_scanning_allowed: { type: boolean } resources: { type: array, items: { properties: { uuid: { type: string }, name: { type: string }, type: { type: string, enum: [application, service, database] }, project_uuid: { type: [string, 'null'] }, environment_uuid: { type: [string, 'null'] }, server_uuid: { type: [string, 'null'] }, images: { properties: { update: { type: integer }, total: { type: integer } }, type: object }, security: { $ref: '#/components/schemas/SecurityStatus' } }, type: object } } type: object '401': @@ -17557,7 +17557,6 @@ components: - string - 'null' enum: - - instance - server - application security: diff --git a/public/svgs/orbit.svg b/public/svgs/orbit.svg new file mode 100644 index 0000000000..db17fbe2a8 --- /dev/null +++ b/public/svgs/orbit.svg @@ -0,0 +1 @@ + diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 3e652ff50e..e856db066e 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -14,7 +14,15 @@ $email = getOldOrLocal('email', 'test3@example.com');
- @if ($isRootSetupWindowClosed) + @if ($isRootSetupWindowPending) + +

Coolify is starting

+

Coolify has not finished its start-up tasks. + Wait a few seconds, then reload this page. If this message stays, run this command on the + server:

+ docker restart coolify +
+ @elseif ($isRootSetupWindowClosed)

Setup window closed

You can create the root account only in the diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index f94f010b3d..77e82c869d 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -7,8 +7,6 @@ 'application_uuid' => $application->uuid, ]; - $imageScanningAllowed = (bool) instanceSettings()->is_image_scanning_allowed; - $configurationMenuItems = [ [ 'label' => 'General', @@ -119,6 +117,11 @@ 'route' => 'project.application.resource-operations', 'active' => $currentRoute === 'project.application.resource-operations', ], + [ + 'label' => 'Maintenance Mode', + 'route' => 'project.application.maintenance', + 'active' => $currentRoute === 'project.application.maintenance', + ], [ 'label' => 'Metrics', 'route' => 'project.application.metrics', @@ -133,8 +136,7 @@ 'label' => 'Security', 'route' => 'project.application.vulnerabilities', 'active' => $currentRoute === 'project.application.vulnerabilities', - 'visible' => $imageScanningAllowed, - 'security' => $imageScanningAllowed ? \App\Services\ImageScan\SecurityStatus::stored($application)->status->value : null, + 'security' => \App\Services\ImageScan\SecurityStatus::stored($application)->status->value, ], [ 'label' => 'Tags', @@ -175,6 +177,7 @@ 'Rollback' => 'time-back', 'Resource Limits' => 'cpu', 'Resource Operations' => 'server-update', + 'Maintenance Mode' => 'bandage', 'Metrics' => 'graph', 'Analytics' => 'analytics', 'Security' => 'shield-alert', @@ -188,7 +191,7 @@ 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Files', 'Metrics', 'Analytics', 'Security'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], - 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags'], + 'Operations' => ['Resource Operations', 'Maintenance Mode', 'Resource Limits', 'Rollback', 'Tags'], 'Danger Zone' => ['Danger Zone'], ]; $groupedMenuItems = collect($menuGroups) diff --git a/resources/views/components/database/configuration-sidebar.blade.php b/resources/views/components/database/configuration-sidebar.blade.php index 4059040aac..575bc40817 100644 --- a/resources/views/components/database/configuration-sidebar.blade.php +++ b/resources/views/components/database/configuration-sidebar.blade.php @@ -7,8 +7,6 @@ 'database_uuid' => $database->uuid, ]; - $imageScanningAllowed = (bool) instanceSettings()->is_image_scanning_allowed; - $configurationItems = collect([ ['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'], ['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'], @@ -23,8 +21,8 @@ ['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'], ['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'], ['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'], - ['label' => 'Security', 'route' => 'project.database.vulnerabilities', 'icon' => 'shield-alert', 'visible' => $imageScanningAllowed, - 'security' => $imageScanningAllowed ? \App\Services\ImageScan\SecurityStatus::stored($database)->status->value : null], + ['label' => 'Security', 'route' => 'project.database.vulnerabilities', 'icon' => 'shield-alert', + 'security' => \App\Services\ImageScan\SecurityStatus::stored($database)->status->value], ['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'], ['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'danger'], ])->filter(fn (array $item): bool => $item['visible'] ?? true) diff --git a/resources/views/components/icon-tooltip.blade.php b/resources/views/components/icon-tooltip.blade.php index e3f1a203e5..02ef92c810 100644 --- a/resources/views/components/icon-tooltip.blade.php +++ b/resources/views/components/icon-tooltip.blade.php @@ -7,8 +7,16 @@ below: false, wrap: false, activeTarget: null, + label(target) { + return target.dataset.tooltip || target.getAttribute('aria-label') || target.title; + }, + {{-- Close and dismiss (X) buttons are obvious, so they get no tooltip. --}} + isDismiss(target) { + return /^(close|dismiss)\b/i.test(this.label(target) || ''); + }, isIconAction(target) { if (target.matches('[data-icon-tooltip-ignore]')) return false; + if (this.isDismiss(target)) return false; if (target.matches('[data-tooltip], .icon-button')) return true; return target.hasAttribute('aria-label') && target.childElementCount === 1 @@ -16,9 +24,11 @@ }, prepare(root) { root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => { - if (!this.isIconAction(target)) return; - target.dataset.iconTooltip = target.dataset.tooltip || target.getAttribute('aria-label') || target.title; - if (!target.getAttribute('aria-label')) target.setAttribute('aria-label', target.dataset.iconTooltip); + const dismiss = this.isDismiss(target); + if (!dismiss && !this.isIconAction(target)) return; + const label = this.label(target); + if (!dismiss) target.dataset.iconTooltip = label; + if (!target.getAttribute('aria-label')) target.setAttribute('aria-label', label); target.removeAttribute('title'); }); }, diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 996f6b3a4b..b3c108a4be 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -109,7 +109,6 @@ @php // Members limited to some projects only see project pages; team-level pages return 403 for them. $teamWide = app(App\Services\Access\AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); - $imageScanningAllowed = (bool) instanceSettings()->is_image_scanning_allowed; // Project and environment variables need read access there; opening a parent of a granted resource is not enough. $readableProjects = $teamWide ? null : App\Models\Project::ownedByCurrentTeamCached(); $canReadProjectVariables = $teamWide || $readableProjects->contains(fn ($project) => auth()->user()->can('viewSharedVariables', $project)); @@ -119,11 +118,11 @@ 'Workspace' => [ ['label' => 'Dashboard', 'icon' => 'dashboard', 'href' => '/', 'active' => request()->is('/', 'dashboard/*', 'canvas')], ['label' => 'Projects', 'icon' => 'projects', 'href' => route('project.index'), 'active' => request()->is('projects', 'projects/*', 'project/*')], - ['label' => 'Monitoring', 'icon' => 'graph', 'visible' => $teamWide || $imageScanningAllowed, 'children' => [ + ['label' => 'Monitoring', 'icon' => 'graph', 'children' => [ ['label' => 'Analytics', 'icon' => 'analytics', 'href' => route('analytics'), 'active' => request()->is('analytics'), 'visible' => $teamWide], ['label' => 'Metrics', 'icon' => 'graph', 'href' => route('metrics'), 'active' => request()->is('metrics'), 'visible' => $teamWide], // Members limited to some projects see the resources they have access to. - ['label' => 'Security', 'icon' => 'shield-alert', 'href' => route('vulnerabilities'), 'active' => request()->is('vulnerabilities'), 'visible' => $imageScanningAllowed], + ['label' => 'Security', 'icon' => 'shield-alert', 'href' => route('vulnerabilities'), 'active' => request()->is('vulnerabilities')], ]], ['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => $teamWide && auth()->user()?->can('canAccessTerminal')], ], @@ -148,7 +147,7 @@ ['label' => 'General', 'icon' => 'settings', 'href' => route('team.index'), 'active' => request()->is('team')], ['label' => 'Members', 'icon' => 'teams', 'href' => route('team.member.index'), 'active' => request()->is('team/members*')], ['label' => 'Audit log', 'icon' => 'time-back', 'href' => route('team.audit-log'), 'active' => request()->is('team/audit-log'), 'visible' => auth()->user()?->isAdminOfTeam(currentTeam()->id)], - ['label' => 'Image Scanning', 'icon' => 'shield-alert', 'href' => route('team.image-scanning'), 'active' => request()->is('team/image-scanning'), 'visible' => $imageScanningAllowed], + ['label' => 'Image Scanning', 'icon' => 'shield-alert', 'href' => route('team.image-scanning'), 'active' => request()->is('team/image-scanning')], ['label' => 'Admin View', 'icon' => 'admin', 'href' => route('team.admin-view'), 'active' => request()->is('team/admin'), 'visible' => isInstanceAdmin()], ['label' => 'Danger Zone', 'icon' => 'danger', 'href' => route('team.danger-zone'), 'active' => request()->is('team/danger'), 'visible' => auth()->user()?->can('delete', currentTeam()), 'sectionStart' => true], ]], diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 8b5ffca818..0dddad5e99 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -87,6 +87,9 @@ 'broom' => '', 'shield-star' => '', 'cloudflare' => '', + 'star' => '', + 'star-filled' => '', + 'grip-vertical' => '', 'network' => '', ]; diff --git a/resources/views/components/service/configuration-sidebar.blade.php b/resources/views/components/service/configuration-sidebar.blade.php index 258b48abd1..2dee57c9ec 100644 --- a/resources/views/components/service/configuration-sidebar.blade.php +++ b/resources/views/components/service/configuration-sidebar.blade.php @@ -7,8 +7,6 @@ 'service_uuid' => $service->uuid, ]; - $imageScanningAllowed = (bool) instanceSettings()->is_image_scanning_allowed; - $configurationItems = collect([ ['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'], ['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'], @@ -22,8 +20,9 @@ ['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'], ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'webhook'], ['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'], - ['label' => 'Security', 'route' => 'project.service.vulnerabilities', 'icon' => 'shield-alert', 'visible' => $imageScanningAllowed, - 'security' => $imageScanningAllowed ? \App\Services\ImageScan\SecurityStatus::stored($service)->status->value : null], + ['label' => 'Maintenance Mode', 'route' => 'project.service.maintenance', 'icon' => 'bandage'], + ['label' => 'Security', 'route' => 'project.service.vulnerabilities', 'icon' => 'shield-alert', + 'security' => \App\Services\ImageScan\SecurityStatus::stored($service)->status->value], ['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'], ['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'danger'], ])->filter(fn (array $item): bool => $item['visible'] ?? true) @@ -40,7 +39,7 @@ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Analytics', 'Security'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], - 'Operations' => ['Resource Operations', 'Tags'], + 'Operations' => ['Resource Operations', 'Maintenance Mode', 'Tags'], 'Danger Zone' => ['Danger Zone'], ]; diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php index 739a73da91..ee42809a71 100644 --- a/resources/views/components/team/settings-layout.blade.php +++ b/resources/views/components/team/settings-layout.blade.php @@ -18,12 +18,12 @@ 'active' => request()->routeIs('team.audit-log'), 'icon' => 'time-back', ] : null, - instanceSettings()->is_image_scanning_allowed ? [ + [ 'label' => 'Image Scanning', 'route' => 'team.image-scanning', 'active' => request()->routeIs('team.image-scanning'), 'icon' => 'shield-alert', - ] : null, + ], isInstanceAdmin() ? [ 'label' => 'Admin View', 'route' => 'team.admin-view', diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index 4ca440c8eb..41d05fe3b0 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -282,6 +282,8 @@ :wire:key="'service-status-'.$currentService->uuid" /> @endif

+ @endif diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 77dd378f33..602d09dda3 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -151,6 +151,8 @@
+ {{-- Resource and server pages put their star button here. --}} +
@if (isInstanceAdmin() && !isCloud()) diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index c13c6bc02a..ea17384139 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -36,32 +36,112 @@ @endif - @if (instanceSettings()->is_image_scanning_allowed) - @php - $securityCount = count($securityUpdateNow); - $securityKey = 'coolify.image-scan.dashboard-update-now-dismissed.'.currentTeam()->id; - @endphp - {{-- Dismissed until a resource that was not dismissed needs an update now (resources/js/security-callout.js). - It also renders without resources, so the dismissed set is cleared and the next one shows again. --}} -
- @if ($securityCount > 0) - - Review - - @endif -
+ @php + $securityCount = count($securityUpdateNow); + $securityKey = 'coolify.image-scan.dashboard-update-now-dismissed.'.currentTeam()->id; + @endphp + {{-- Dismissed until a resource that was not dismissed needs an update now (resources/js/security-callout.js). + It also renders without resources, so the dismissed set is cleared and the next one shows again. --}} +
+ @if ($securityCount > 0) + + Review + + @endif +
+ + {{-- First on every screen size, so starred resources are one tap away on phones too. --}} + @if ($starredResources->isNotEmpty()) +
+ + {{-- Pointer events, so the same code works for mouse, touch, and pen. The handle has touch-none, so a drag on a phone does not scroll the page. --}} +
+ @foreach ($starredResources as $starred) +
+ + +
+ @if ($starred['resource'] instanceof \App\Models\Server) + + @else + + @endif +
+
+

+ {{ $starred['name'] }} +

+

+ {{ $starred['context'] }} +

+
+ +
+ @endforeach +
+
@endif {{-- Wide screens: projects and servers fill the left, activity is a fixed-width column on the right. @@ -395,7 +475,7 @@ @endphp + class="grid {{ $serverTableColumns }} gap-x-4 gap-y-2 @2xl:min-w-[480px] hover:no-underline {{ $tableRow }}">
@@ -409,10 +489,14 @@ {{ filled($server->description) ? $server->description : '-' }}

@if ($showServerUsage) -
@endif - @if ($allowed) -
! $enabled || $prepareState !== 'ready', - ])> - -
- @endif +
! $enabled || $prepareState !== 'ready', + ])> + +
diff --git a/resources/views/livewire/image-scan/team-image-scanning.blade.php b/resources/views/livewire/image-scan/team-image-scanning.blade.php index 27da9d338d..88bdf48af2 100644 --- a/resources/views/livewire/image-scan/team-image-scanning.blade.php +++ b/resources/views/livewire/image-scan/team-image-scanning.blade.php @@ -5,14 +5,8 @@
- @if ($allowed) - - @else - - - - @endif +
diff --git a/resources/views/livewire/image-scan/vulnerabilities.blade.php b/resources/views/livewire/image-scan/vulnerabilities.blade.php index f020f13b4e..dccad6f584 100644 --- a/resources/views/livewire/image-scan/vulnerabilities.blade.php +++ b/resources/views/livewire/image-scan/vulnerabilities.blade.php @@ -9,10 +9,7 @@ @endphp
- @if ($offState === 'instance') - - @elseif ($offState === 'server') + @if ($offState === 'server') @if ($serverUrl !== null) diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 0d5c61e8bf..69136feb16 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -14,6 +14,7 @@ @elseif ($currentRoute === 'project.application.domains') + @elseif ($currentRoute === 'project.application.maintenance') @elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm()) @@ -51,9 +52,7 @@
- @if (instanceSettings()->is_image_scanning_allowed) - - @endif +
@elseif ($currentRoute === 'project.application.tags') diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 00eb07346d..fffebfbbf9 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -1,5 +1,8 @@