Compare commits

...

55 commits

Author SHA1 Message Date
rosslh
3c030b69ae feat(branding): disable FQDN and IP fields instead of hiding/leaving editable
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m23s
2026-03-29 20:54:36 -04:00
rosslh
b5ea1d9621 fix(versioning): move version bumping to CI build time
Version was never bumped because git rebase doesn't run pre-commit
hooks. CI now appends a YYYYMMDDHHmm timestamp to the upstream base
version and injects it into the Docker image via build arg.
2026-03-29 20:54:36 -04:00
rosslh
fc4230518d feat(branding): yellow scrollbar and hide FQDN settings field
- Change scrollbar thumb from Coolify red to MapleDeploy yellow
- Hide FQDN field from settings UI (managed during provisioning)
2026-03-29 20:54:36 -04:00
rosslh
d38b312c21 feat(auth): notify MapleDeploy on first-user registration
After the first user registers, clears the setup token and dispatches
NotifySetupCompleteJob to POST the token to MapleDeploy's callback URL.
Adds setup_callback_url column to instance_settings.
2026-03-29 20:54:36 -04:00
rosslh
c488121207 feat(auth): gate first-user registration with setup token
Require a setup_token query parameter to access the registration page
when no users exist, preventing admin hijacking on new instances.
2026-03-29 20:54:35 -04:00
rosslh
14b518770a fix(dns): replace Cloudflare defaults with CIRA Canadian Shield
Replace hardcoded 1.1.1.1/1.0.0.1 (Cloudflare) DNS defaults with
CIRA Canadian Shield (149.112.121.10, 149.112.122.10) to maintain
Canadian data sovereignty for DNS resolution.
2026-03-29 20:54:35 -04:00
rosslh
f7c81735b1 chore(branding): remove sponsorship popup remnants 2026-03-29 20:54:35 -04:00
rosslh
643b7bd83b style(branding): replace Coolify purple and yellow with MapleDeploy palette
Replace #6b16ed (Coolify purple) with #d52b1e (MapleDeploy red) and
form focus states, dirty indicators, chart colors, and theme tokens.
Also fix warning color scale to use standard Tailwind yellow values.
2026-03-29 20:54:35 -04:00
rosslh
6ec5d002ff feat(onboarding): skip boarding wizard for new users
Customers land directly on the dashboard instead of the setup wizard,
which shows a misleading localhost vs remote server warning that
conflicts with MapleDeploy's single-VM model.
2026-03-29 20:54:35 -04:00
rosslh
97f89eeeb7 style(branding): add header logo gap and use stone-900 background
Add 4px gap between logo and header elements with flex-shrink support.
Change base background from near-black (#0c0a09) to stone-900 (#1c1917)
to match the marketing site's darkest dark-mode color.
2026-03-29 20:54:35 -04:00
rosslh
32486dce98 fix(branding): preserve sidebar logo aspect ratio when constrained 2026-03-29 20:54:35 -04:00
rosslh
c74d8f4227 fix(branding): replace "self-hosted" subtitle on dashboard 2026-03-29 20:54:35 -04:00
rosslh
840314e680 fix(branding): constrain sidebar logo to prevent overflow
Replace max-w-none with max-w-full so the logo respects the sidebar
container width instead of overflowing it.
2026-03-29 20:54:35 -04:00
rosslh
41f4d4ce79 fix(branding): preserve sidebar logo aspect ratio, bump to 463.2
Add max-w-none to logo images to prevent Tailwind base styles from
constraining the image width within the narrow sidebar flex container.
2026-03-29 20:54:35 -04:00
rosslh
e91373120d chore: remove broken feedback form and all entrypoints
The feedback form relies on instance SMTP being configured, which
MapleDeploy customers won't do. Without SMTP it silently logs to
the server and shows a misleading success message. Removed the
navbar button, onboarding modal, subscription page link, API
endpoint, and all associated dead code.
2026-03-29 20:54:35 -04:00
rosslh
4beab4e6c8 fix(ci): recreate CDN storage zone in EU (DE) region
Storage zone was accidentally created in NY. Recreated as
'coolify-update' in DE (Falkenstein) to keep update artifacts
in EU jurisdiction, consistent with Bunny.net data sovereignty
justification. Updated storage zone name, pull zone ID, and
reverted storage endpoint to default (storage.bunnycdn.com).
2026-03-29 20:54:35 -04:00
rosslh
0182935e67 fix(ci): use NY region storage endpoint for Bunny CDN uploads 2026-03-29 20:54:35 -04:00
rosslh
0171f17e2e fix(ci): use apk instead of apt-get for curl install 2026-03-29 20:54:35 -04:00
rosslh
5678892dc2 fix(ci): install curl in runner for CDN uploads 2026-03-29 20:54:35 -04:00
rosslh
5d54f3bc2f feat: add update pipeline with MapleDeploy CDN and versioning
Route all Coolify update artifacts (versions.json, upgrade.sh,
compose files) through updates.mapledeploy.ca instead of upstream
cdn.coollabs.io. Extend CI to publish artifacts to Bunny CDN
storage zone and purge cache on each build.

- Point CDN_URL, versions_url, upgrade_script_url to updates.mapledeploy.ca
- Hardcode helper/realtime images to ghcr.io (not mirrored to Forgejo)
- Pass registry_url as 3rd arg to upgrade.sh for main image pulls
- Adopt versioning scheme 4.0.0-beta.X.N (bump to 4.0.0-beta.463.1)
- Add CI steps: generate versions.json, upload to Bunny, purge cache
2026-03-29 20:54:35 -04:00
rosslh
85f4a62aa1 feat(branding): apply MapleDeploy branding to Coolify
Replace Coolify branding with MapleDeploy throughout the UI: logos,
favicon, fonts (Overlock 900), color scheme, help links, and page
titles. Remove GitHub Actions workflows and add Forgejo CI build
workflow. Strip cloud-only features (subscription prompts, sponsor
links, server creation cloud options).
2026-03-29 20:54:35 -04:00
Andras Bacsai
575b0766d1
v4.0.0-beta.470 (#9139) 2026-03-24 22:00:28 +01:00
Andras Bacsai
bf306ffad3 chore: bump version to 4.0.0-beta.470 2026-03-24 21:57:40 +01:00
Andras Bacsai
5c460dd2a1
fix(proxy): validate stored config matches proxy type (#9146) 2026-03-24 21:53:48 +01:00
Andras Bacsai
4f1f8713e9
fix(parsers): preserve ${VAR} references in compose instead of resolving to DB values (#9147) 2026-03-24 21:53:11 +01:00
Andras Bacsai
6a14a12a58 fix(parsers): preserve ${VAR} references in compose instead of resolving to DB values
Do not replace self-referencing environment variables (e.g., DATABASE_URL: ${DATABASE_URL})
with saved DB values in the compose environment section. Keeping the reference intact allows
Docker Compose to resolve from .env at deploy time, preventing stale values from overriding
user updates that haven't been re-parsed.

Fixes #9136
2026-03-24 21:52:36 +01:00
Andras Bacsai
b8e52c6a45 feat(proxy): validate stored config matches current proxy type
Add validation in GetProxyConfiguration to detect when stored proxy config
belongs to a different proxy type (e.g., Traefik config on a Caddy server)
and trigger regeneration with a warning log. Clear cached proxy configuration
and settings when proxy type is changed to prevent stale configs from being
reused. Includes tests verifying config rejection on type mismatch and
graceful fallback on invalid YAML.
2026-03-24 21:32:34 +01:00
Andras Bacsai
eebb8609a7
Add EspoCRM, provided by the official team (#8658) 2026-03-24 14:27:41 +01:00
Andras Bacsai
1af20a9567
chore(service): disable Booklore service (#9105) 2026-03-24 14:27:21 +01:00
Andras Bacsai
534b8be8d0 refactor(docker): simplify installation and remove version pinning
- Remove hardcoded Docker version constraints (27.0 → latest)
- Use official Docker install script (get.docker.com) instead of rancher URLs
- Simplify installation logic by removing nested version fallback checks
- Consolidate OS-specific installation methods and improve Arch Linux upgrade handling
2026-03-24 14:17:05 +01:00
Andras Bacsai
efcd5e7dbf docs(readme): add PetroSky Cloud to sponsors 2026-03-24 12:37:11 +01:00
Andras Bacsai
a980b1352f chore(versions): bump sentinel to 0.0.21 2026-03-24 11:48:04 +01:00
Andras Bacsai
d3beeb2d00 fix(subscription): prevent duplicate subscriptions with updateOrCreate
- Replace manual subscription create/update logic with updateOrCreate() and firstOrCreate() to eliminate race conditions
- Add validation in PricingPlans to prevent subscribing if team already has active subscription
- Improve error handling for missing team_id in customer.subscription.updated event
- Add tests verifying subscriptions are updated rather than duplicated
2026-03-24 10:52:41 +01:00
Andras Bacsai
233f53494e
fix(team): resolve server limit checks for API token authentication (#9123) 2026-03-24 08:12:56 +01:00
Andras Bacsai
520e048ed5 refactor(team): update serverOverflow to use static serverLimit 2026-03-24 08:08:57 +01:00
Andras Bacsai
45114c8165
fix(validation): make hostname validation case-insensitive and expand allowed name characters (#9134) 2026-03-24 08:03:50 +01:00
Andras Bacsai
988dd57cf4 feat(validation): make hostname validation case-insensitive and expand allowed characters
- Normalize hostnames to lowercase for RFC 1123 compliance while accepting uppercase input
- Expand NAME_PATTERN to allow parentheses, hash, comma, colon, and plus characters
- Add fallback to random name generation when application name doesn't meet minimum requirements
- Add comprehensive test coverage for validation patterns and edge cases
2026-03-24 08:03:08 +01:00
🏔️ Peak
ffd69c1b54
ci: update pr-quality.yaml 2026-03-23 22:45:18 +01:00
Andras Bacsai
0aee5a41ae
fix(deployment): properly escape shell arguments in nixpacks commands (#9122) 2026-03-23 21:58:04 +01:00
Andras Bacsai
e37cb98c7c refactor(team): make server limit methods accept optional team parameter
Allow serverLimit() and serverLimitReached() to accept an optional team
parameter instead of relying solely on the current session. This improves
testability and makes the methods more flexible by allowing them to work
without session state.

Add comprehensive tests covering various scenarios including no session,
team at limit, and team under limit.
2026-03-23 21:56:50 +01:00
Andras Bacsai
dac940807a fix(deployment): properly escape shell arguments in nixpacks commands
Add escapeShellValue() helper function to safely escape shell values by wrapping
them in single quotes and escaping embedded quotes. Use this function throughout
the nixpacks command building to prevent shell injection vulnerabilities when
passing user-provided build commands, start commands, and environment variables.

This fixes unsafe string concatenation that could allow command injection when
user input contains special shell characters like &&, |, ;, etc.
2026-03-23 21:55:46 +01:00
Andras Bacsai
8457e22863
fix(github-webhook): handle unsupported event types gracefully (#9119) 2026-03-23 21:34:29 +01:00
Andras Bacsai
b931418c1e fix(github-webhook): handle unsupported event types gracefully
Add validation in manual and normal webhook handlers to reject GitHub
event types other than 'push' and 'pull_request'. Unsupported events
now return a graceful response instead of potentially causing
downstream errors. Includes tests for ping events, unsupported event
types, and unknown events.
2026-03-23 21:33:40 +01:00
Andras Bacsai
c09d7e412e feat(monitoring): add Laravel Nightwatch monitoring support
- Install laravel/nightwatch package for application monitoring
- Create Nightwatch console command to start the monitoring agent
- Add NIGHTWATCH_ENABLED and NIGHTWATCH_TOKEN environment variables
- Configure nightwatch settings in config/constants.php
- Set up Docker s6-overlay services for both development and production
- Disable Nightwatch by default in test environment
2026-03-23 15:36:47 +01:00
Andras Bacsai
ae33447994 feat(storage): add storage endpoints and UUID support for databases and services
- Add storage endpoints (list, create, update, delete) to DatabasesController
- Add storage endpoints (list, create, update, delete) to ServicesController
- Add UUID field and migration for local_persistent_volumes table
- Update LocalPersistentVolume model to extend BaseModel
- Support UUID-based storage identification in ApplicationsController
- Update OpenAPI documentation with new storage endpoints and schemas
- Fix application name generation to extract repo name from full git path
- Add comprehensive tests for storage API operations
2026-03-23 15:15:02 +01:00
Claude
23b52487c4
Disable booklore service template
Add `# ignore: true` to the booklore compose file so the service
template generator skips it, hiding it from the UI.

https://claude.ai/code/session_01Y7ZeGwqPp97oXwyLCPja9k
2026-03-23 09:53:07 +00:00
Andras Bacsai
3d5fee4d36 fix(environment-variable): guard refresh against missing or stale variables
Add early return in refresh() to skip sync operations if the environment variable no longer exists or is not fresh, preventing errors when refreshing stale or deleted variables.
2026-03-23 10:52:59 +01:00
Andras Bacsai
f8f27fff13 refactor(scheduler): extract cron scheduling logic to shared helper
Extract the shouldRunNow() method from ScheduledJobManager and ServerManagerJob into
a reusable shouldRunCronNow() helper function. This centralizes cron scheduling logic
and enables consistent deduplication behavior across all scheduled job types.

- Create shouldRunCronNow() helper in bootstrap/helpers/shared.php with timezone
  and dedup support
- Refactor ScheduledJobManager and ServerManagerJob to use the shared helper
- Add ScheduledJobDiagnostics command for inspecting cache state and scheduling
  decisions across all scheduled jobs
- Simplify shouldRunNow tests to directly test the helper function
- Add DockerCleanupJob test for error handling and execution tracking
- Increase scheduled log retention from 1 to 7 days
2026-03-23 10:37:49 +01:00
Andras Bacsai
89f2b83104 style(modal-confirmation): improve mobile responsiveness
Make modal full-screen on mobile devices with responsive padding,
border radius, and dimensions. Modal is now full-screen on small
screens and constrained to max-width/max-height on larger screens.
2026-03-23 10:36:08 +01:00
Andras Bacsai
f0ed05b399 fix(docker): log failed cleanup attempts when server is not functional 2026-03-23 10:35:47 +01:00
Andras Bacsai
069bf4cc82 chore(versions): bump coolify, sentinel, and traefik versions 2026-03-23 10:35:16 +01:00
Andras Bacsai
820ee1c03c docs(sponsors): update Brand.dev to Context.dev 2026-03-23 10:34:58 +01:00
Taras Machyshyn
c4279a6bcb Define static versions 2026-03-16 18:23:47 +02:00
Taras Machyshyn
a2540bd233 Admin password 2026-02-27 20:42:20 +02:00
Taras Machyshyn
30e65abf1b Added EspoCRM 2026-02-27 20:23:24 +02:00
218 changed files with 5290 additions and 2144 deletions

View file

@ -24,6 +24,10 @@ RAY_ENABLED=false
# Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false
# Enable Laravel Nightwatch monitoring
NIGHTWATCH_ENABLED=false
NIGHTWATCH_TOKEN=
# Selenium Driver URL for Dusk
DUSK_DRIVER_URL=http://selenium:4444

View file

@ -15,4 +15,5 @@ ROOT_USERNAME=
ROOT_USER_EMAIL=
ROOT_USER_PASSWORD=
REGISTRY_URL=ghcr.io
REGISTRY_URL=forgejo.mapledeploy.ca
CDN_URL=https://updates.mapledeploy.ca

View file

@ -0,0 +1,103 @@
name: Build MapleDeploy Coolify Image
on:
push:
branches: [mapledeploy]
paths-ignore:
- "*.md"
- ".github/**"
- "templates/**"
env:
REGISTRY: forgejo.mapledeploy.ca
CDN_STORAGE_ZONE: coolify-update
CDN_PULL_ZONE_ID: "5338895"
CDN_BASE_URL: https://updates.mapledeploy.ca
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get version
id: version
run: |
BASE_VERSION=$(sed -n "s/.*'version' => '\([^']*\)'.*/\1/p" config/constants.php)
TIMESTAMP=$(date -u +%Y%m%d%H%M)
VERSION="${BASE_VERSION}.${TIMESTAMP}"
HELPER_VERSION=$(sed -n "s/.*'helper_version' => '\([^']*\)'.*/\1/p" config/constants.php)
REALTIME_VERSION=$(sed -n "s/.*'realtime_version' => '\([^']*\)'.*/\1/p" config/constants.php)
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
echo "HELPER_VERSION=${HELPER_VERSION}" >> "$GITHUB_OUTPUT"
echo "REALTIME_VERSION=${REALTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Building version: ${VERSION} (helper: ${HELPER_VERSION}, realtime: ${REALTIME_VERSION})"
- name: Login to Forgejo registry
run: |
echo "${{ secrets.FORGEJO_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.repository_owner }} --password-stdin
- name: Build image
run: |
DOCKER_BUILDKIT=1 docker build -f docker/production/Dockerfile \
--build-arg MAPLEDEPLOY_VERSION=${{ steps.version.outputs.VERSION }} \
-t ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }} \
-t ${{ env.REGISTRY }}/${{ github.repository }}:latest \
.
- name: Push image
run: |
docker push ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }}
docker push ${{ env.REGISTRY }}/${{ github.repository }}:latest
- name: Generate versions.json
run: |
cat > versions.json <<EOF
{
"coolify": {
"v4": {
"version": "${{ steps.version.outputs.VERSION }}"
},
"helper": {
"version": "${{ steps.version.outputs.HELPER_VERSION }}"
},
"realtime": {
"version": "${{ steps.version.outputs.REALTIME_VERSION }}"
}
}
}
EOF
echo "Generated versions.json:"
cat versions.json
- name: Install curl
run: apk add --no-cache curl
- name: Upload artifacts to Bunny CDN
run: |
STORAGE_URL="https://storage.bunnycdn.com/${{ env.CDN_STORAGE_ZONE }}/coolify"
upload() {
local file="$1"
local dest="$2"
echo "Uploading ${file} -> ${dest}"
curl -fsSL -X PUT "${STORAGE_URL}/${dest}" \
-H "AccessKey: ${{ secrets.BUNNY_CDN_STORAGE_KEY }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${file}"
}
upload versions.json versions.json
upload scripts/upgrade.sh upgrade.sh
upload docker-compose.yml docker-compose.yml
upload docker-compose.prod.yml docker-compose.prod.yml
upload .env.production .env.production
echo "All artifacts uploaded."
- name: Purge CDN cache
run: |
curl -fsSL -X POST "https://api.bunny.net/pullzone/${{ env.CDN_PULL_ZONE_ID }}/purgeCache" \
-H "AccessKey: ${{ secrets.BUNNY_API_KEY }}" \
-H "Content-Type: application/json"
echo "CDN cache purged."

View file

@ -1,22 +0,0 @@
name: Lock closed Issues, Discussions, and PRs
on:
schedule:
- cron: '0 1 * * *'
permissions:
issues: write
discussions: write
pull-requests: write
jobs:
lock-threads:
runs-on: ubuntu-latest
steps:
- name: Lock threads after 30 days of inactivity
uses: dessant/lock-threads@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'

View file

@ -1,32 +0,0 @@
name: Manage Stale Issues and PRs
on:
schedule:
- cron: '0 2 * * *'
permissions:
issues: write
pull-requests: write
jobs:
manage-stale:
runs-on: ubuntu-latest
steps:
- name: Manage stale issues and PRs
uses: actions/stale@v9
id: stale
with:
stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.'
stale-pr-message: 'This pull request requires attention. If no changes or response is received within the next few days, it will be automatically closed. Please update your PR or leave a comment with the requested information.'
close-issue-message: 'This issue has been automatically closed due to inactivity.'
close-pr-message: 'Thank you for your contribution. Due to inactivity, this PR was automatically closed. If you would like to continue working on this change in the future, feel free to reopen this PR or submit a new one.'
days-before-stale: 14
days-before-close: 7
stale-issue-label: '⏱︎ Stale'
stale-pr-label: '⏱︎ Stale'
only-labels: '💤 Waiting for feedback, 💤 Waiting for changes'
remove-stale-when-updated: true
operations-per-run: 100
labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback, 💤 Waiting for changes'
close-issue-reason: 'not_planned'
exempt-all-milestones: false

View file

@ -1,49 +0,0 @@
name: Add comment based on label
on:
pull_request_target:
types:
- labeled
permissions:
pull-requests: write
jobs:
add-comment:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- label: "⚙️ Service"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are either adding a new service or making changes to an existing one.
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
- label: "🛠️ Feature"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are adding a new feature to Coolify.
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
# - label: "✨ Enhancement"
# body: |
# It appears to us that you are making an enhancement to Coolify.
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
# This will help ensure that our documentation remains accurate and up-to-date for all users.
steps:
- name: Add comment
if: github.event.label.name == matrix.label
run: gh pr comment "$NUMBER" --body "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.pull_request.number }}
BODY: ${{ matrix.body }}

View file

@ -1,37 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: '--model opus'

View file

@ -1,22 +0,0 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch:
permissions:
packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
steps:
- name: Delete untagged ${{ matrix.package }} images
uses: actions/delete-package-versions@v5
with:
package-name: ${{ matrix.package }}
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -1,117 +0,0 @@
name: Coolify Helper Image Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-helper-next.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,116 +0,0 @@
name: Coolify Helper Image
on:
push:
branches: [ "v4.x" ]
paths:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,122 +0,0 @@
name: Production Build (v4)
on:
push:
branches: ["v4.x"]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,120 +0,0 @@
name: Coolify Realtime Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-realtime-next.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,120 +0,0 @@
name: Coolify Realtime
on:
push:
branches: [ "v4.x" ]
paths:
- .github/workflows/coolify-realtime.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,134 +0,0 @@
name: Staging Build
on:
push:
branches-ignore:
- v4.x
- v3.x
- '**v5.x**'
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,104 +0,0 @@
name: Coolify Testing Host
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-testing-host.yml
- docker/testing-host/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-testing-host"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,42 +0,0 @@
name: Generate Changelog
on:
push:
branches: [ v4.x ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
workflow_dispatch:
permissions:
contents: write
jobs:
changelog:
name: Generate changelog
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --verbose
env:
OUTPUT: CHANGELOG.md
GITHUB_REPO: ${{ github.repository }}
- name: Commit
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git commit -m "docs: update changelog"
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git v4.x

View file

@ -40,7 +40,10 @@ jobs:
max-emoji-count: 2
max-code-references: 5
require-linked-issue: false
blocked-terms: "STRAWBERRY"
blocked-terms: |
STRAWBERRY
🤖 Generated with Claude Code
Generated with Claude Code
blocked-issue-numbers: 8154
# PR Template Checks
@ -97,7 +100,7 @@ jobs:
exempt-pr-milestones: ""
# PR Success Actions
success-add-pr-labels: "quality/verified"
success-add-pr-labels: ""
# PR Failure Actions
failure-remove-pr-labels: ""

View file

@ -70,7 +70,7 @@ ### Big Sponsors
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
@ -90,6 +90,7 @@ ### Big Sponsors
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use App\Jobs\NotifySetupCompleteJob;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@ -35,6 +36,14 @@ public function create(array $input): User
])->validate();
if (User::count() == 0) {
// MapleDeploy: validate setup token for first user registration
if ($settings->setup_token) {
$providedToken = $input['setup_token'] ?? null;
if (! $providedToken || ! hash_equals($settings->setup_token, $providedToken)) {
abort(403);
}
}
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
$user = User::create([
@ -48,7 +57,18 @@ public function create(array $input): User
// Disable registration after first user is created
$settings = instanceSettings();
$settings->is_registration_enabled = false;
// MapleDeploy: notify control plane that setup is complete
// Capture token before clearing so the job can authenticate with it
$callbackUrl = $settings->setup_callback_url;
$token = $settings->setup_token;
$settings->setup_token = null;
$settings->setup_callback_url = null;
$settings->save();
if ($callbackUrl && $token) {
NotifySetupCompleteJob::dispatch($token, $callbackUrl);
}
} else {
$user = User::create([
'name' => $input['name'],

View file

@ -102,7 +102,8 @@ public function handle(Server $server, $fromUI = false): bool
foreach ($conflicts as $port => $conflict) {
if ($conflict) {
if ($fromUI) {
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
// MapleDeploy branding: support links
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Support: <a target='_blank' class='dark:text-white hover:underline' href='https://mapledeploy.ca/contact'>https://mapledeploy.ca/contact</a>");
} else {
return false;
}

View file

@ -2,10 +2,12 @@
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class GetProxyConfiguration
{
@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string
// Primary source: database
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
// Validate stored config matches current proxy type
if (! empty(trim($proxy_configuration ?? ''))) {
if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) {
Log::warning('Stored proxy config does not match current proxy type, will regenerate', [
'server_id' => $server->id,
'proxy_type' => $proxyType,
]);
$proxy_configuration = null;
}
}
// Backfill: existing servers may not have DB config yet — read from disk once
if (empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = $this->backfillFromDisk($server);
@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string
return $proxy_configuration;
}
/**
* Check that the stored docker-compose YAML contains the expected service
* for the server's current proxy type. Returns false if the config belongs
* to a different proxy type (e.g. Traefik config on a CADDY server).
*/
private function configMatchesProxyType(string $proxyType, string $configuration): bool
{
try {
$yaml = Yaml::parse($configuration);
$services = data_get($yaml, 'services', []);
return match ($proxyType) {
ProxyTypes::TRAEFIK->value => isset($services['traefik']),
ProxyTypes::CADDY->value => isset($services['caddy']),
ProxyTypes::NGINX->value => isset($services['nginx']),
default => true,
};
} catch (\Throwable $e) {
// If YAML is unparseable, don't block — let the existing flow handle it
return true;
}
}
/**
* Backfill: read config from disk for servers that predate DB storage.
* Stores the result in the database so future reads skip SSH entirely.

View file

@ -11,11 +11,8 @@ class InstallDocker
{
use AsAction;
private string $dockerVersion;
public function handle(Server $server)
{
$this->dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
@ -118,7 +115,7 @@ public function handle(Server $server)
private function getDebianDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'. /etc/os-release && '.
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '.
@ -131,7 +128,7 @@ private function getDebianDockerInstallCommand(): string
private function getRhelDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
@ -141,7 +138,7 @@ private function getRhelDockerInstallCommand(): string
private function getSuseDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
'zypper refresh && '.
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
@ -152,10 +149,6 @@ private function getSuseDockerInstallCommand(): string
private function getArchDockerInstallCommand(): string
{
// Use -Syu to perform full system upgrade before installing Docker
// Partial upgrades (-Sy without -u) are discouraged on Arch Linux
// as they can lead to broken dependencies and system instability
// Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
'systemctl enable docker.service && '.
'systemctl start docker.service';
@ -163,6 +156,6 @@ private function getArchDockerInstallCommand(): string
private function getGenericDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
return 'curl -fsSL https://get.docker.com | sh';
}
}

View file

@ -119,9 +119,11 @@ private function update()
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = config('constants.coolify.registry_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion $registryUrl",
], $this->server);
}
}

View file

@ -263,15 +263,11 @@ private function restoreCoolifyDbBackup()
}
}
// MapleDeploy branding: telemetry disabled — no phone-home signal
private function sendAliveSignal()
{
$id = config('app.id');
$version = config('constants.coolify.version');
try {
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
} catch (\Throwable $e) {
echo "Error in sending live signal: {$e->getMessage()}\n";
}
// Disabled for MapleDeploy: do not send telemetry to coolify.io
return;
}
private function replaceSlashInEnvironmentName()

View file

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class Nightwatch extends Command
{
protected $signature = 'start:nightwatch';
protected $description = 'Start Nightwatch';
public function handle(): void
{
if (config('constants.nightwatch.is_nightwatch_enabled')) {
$this->info('Nightwatch is enabled on this server.');
$this->call('nightwatch:agent');
}
exit(0);
}
}

View file

@ -0,0 +1,255 @@
<?php
namespace App\Console\Commands;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
class ScheduledJobDiagnostics extends Command
{
protected $signature = 'scheduled:diagnostics
{--type=all : Type to inspect: docker-cleanup, backups, tasks, server-jobs, all}
{--server= : Filter by server ID}';
protected $description = 'Inspect dedup cache state and scheduling decisions for all scheduled jobs';
public function handle(): int
{
$type = $this->option('type');
$serverFilter = $this->option('server');
$this->outputHeartbeat();
if (in_array($type, ['all', 'docker-cleanup'])) {
$this->inspectDockerCleanups($serverFilter);
}
if (in_array($type, ['all', 'backups'])) {
$this->inspectBackups();
}
if (in_array($type, ['all', 'tasks'])) {
$this->inspectTasks();
}
if (in_array($type, ['all', 'server-jobs'])) {
$this->inspectServerJobs($serverFilter);
}
return self::SUCCESS;
}
private function outputHeartbeat(): void
{
$heartbeat = Cache::get('scheduled-job-manager:heartbeat');
if ($heartbeat) {
$age = Carbon::parse($heartbeat)->diffForHumans();
$this->info("Scheduler heartbeat: {$heartbeat} ({$age})");
} else {
$this->error('Scheduler heartbeat: MISSING — ScheduledJobManager may not be running');
}
$this->newLine();
}
private function inspectDockerCleanups(?string $serverFilter): void
{
$this->info('=== Docker Cleanup Jobs ===');
$servers = $this->getServers($serverFilter);
$rows = [];
foreach ($servers as $server) {
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "docker-cleanup:{$server->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$lastExecution = DockerCleanupExecution::where('server_id', $server->id)
->latest()
->first();
$rows[] = [
$server->id,
$server->name,
$timezone,
$frequency,
$dedupKey,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
$lastExecution ? $lastExecution->status.' @ '.$lastExecution->created_at : 'never',
];
}
$this->table(
['ID', 'Server', 'TZ', 'Frequency', 'Dedup Key', 'Cache Value', 'Would Fire', 'Last Execution'],
$rows
);
$this->newLine();
}
private function inspectBackups(): void
{
$this->info('=== Scheduled Backups ===');
$backups = ScheduledDatabaseBackup::with(['database'])
->where('enabled', true)
->get();
$rows = [];
foreach ($backups as $backup) {
$server = $backup->server();
$frequency = $backup->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "scheduled-backup:{$backup->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$backup->id,
$backup->database_type ?? 'unknown',
$server?->name ?? 'N/A',
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
$this->table(
['Backup ID', 'DB Type', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function inspectTasks(): void
{
$this->info('=== Scheduled Tasks ===');
$tasks = ScheduledTask::with(['service', 'application'])
->where('enabled', true)
->get();
$rows = [];
foreach ($tasks as $task) {
$server = $task->server();
$frequency = $task->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "scheduled-task:{$task->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$task->id,
$task->name,
$server?->name ?? 'N/A',
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
$this->table(
['Task ID', 'Name', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function inspectServerJobs(?string $serverFilter): void
{
$this->info('=== Server Manager Jobs ===');
$servers = $this->getServers($serverFilter);
$rows = [];
foreach ($servers as $server) {
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$dedupKeys = [
"sentinel-restart:{$server->id}" => '0 0 * * *',
"server-patch-check:{$server->id}" => '0 0 * * 0',
"server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
"server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
];
foreach ($dedupKeys as $dedupKey => $frequency) {
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$cacheValue = Cache::get($dedupKey);
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$server->id,
$server->name,
$dedupKey,
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
}
$this->table(
['Server ID', 'Server', 'Dedup Key', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function getServers(?string $serverFilter): \Illuminate\Support\Collection
{
$query = Server::with('settings')->where('ip', '!=', '1.2.3.4');
if ($serverFilter) {
$query->where('id', $serverFilter);
}
if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)?->servers()->with('settings')->get() ?? collect();
return $servers->merge($own);
}
return $query->get();
}
}

View file

@ -11,6 +11,8 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@ -4026,9 +4028,10 @@ public function storages(Request $request): JsonResponse
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['id', 'type'],
required: ['type'],
properties: [
'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'],
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
@ -4078,7 +4081,7 @@ public function update_storage(Request $request): JsonResponse
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@ -4089,7 +4092,8 @@ public function update_storage(Request $request): JsonResponse
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'id' => 'required|integer',
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
@ -4098,7 +4102,7 @@ public function update_storage(Request $request): JsonResponse
'content' => 'string|nullable',
]);
$allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@ -4114,10 +4118,23 @@ public function update_storage(Request $request): JsonResponse
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
if ($request->type === 'persistent') {
$storage = $application->persistentStorages->where('id', $request->id)->first();
$storage = $application->persistentStorages->where($lookupField, $lookupValue)->first();
} else {
$storage = $application->fileStorages->where('id', $request->id)->first();
$storage = $application->fileStorages->where($lookupField, $lookupValue)->first();
}
if (! $storage) {
@ -4183,4 +4200,254 @@ public function update_storage(Request $request): JsonResponse
return response()->json($storage);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for an application.',
path: '/applications/{uuid}/storages',
operationId: 'create-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'name' => 'string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $application->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
}
return response()->json($storage, 201);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$storageUuid = $request->route('storage_uuid');
$storage = $application->persistentStorages->where('uuid', $storageUuid)->first();
if (! $storage) {
$storage = $application->fileStorages->where('uuid', $storageUuid)->first();
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -12,11 +12,14 @@
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
@ -3298,4 +3301,520 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable deleted.']);
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by database UUID.',
path: '/databases/{uuid}/storages',
operationId: 'list-storages-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by database UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('view', $database);
$persistentStorages = $database->persistentStorages->sortBy('id')->values();
$fileStorages = $database->fileStorages->sortBy('id')->values();
return response()->json([
'persistent_storages' => $persistentStorages,
'file_storages' => $fileStorages,
]);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for a database.',
path: '/databases/{uuid}/storages',
operationId: 'create-storage-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'name' => 'string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $database->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
}
return response()->json($storage, 201);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by database UUID.',
path: '/databases/{uuid}/storages',
operationId: 'update-storage-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
if ($request->type === 'persistent') {
$storage = $database->persistentStorages->where($lookupField, $lookupValue)->first();
} else {
$storage = $database->fileStorages->where($lookupField, $lookupValue)->first();
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by database UUID.',
path: '/databases/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
$storageUuid = $request->route('storage_uuid');
$storage = $database->persistentStorages->where('uuid', $storageUuid)->first();
if (! $storage) {
$storage = $database->fileStorages->where('uuid', $storageUuid)->first();
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -586,7 +586,8 @@ public function createServer(Request $request)
}
// Check server limit
if (Team::serverLimitReached()) {
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}

View file

@ -4,8 +4,9 @@
use OpenApi\Attributes as OA;
#[OA\Info(title: 'Coolify', version: '0.1')]
#[OA\Server(url: 'https://app.coolify.io/api/v1', description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.')]
// MapleDeploy branding: API documentation
#[OA\Info(title: 'MapleDeploy', version: '0.1')]
#[OA\Server(url: '/api/v1', description: 'MapleDeploy API. Powered by Coolify.')]
#[OA\SecurityScheme(
type: 'http',
scheme: 'bearer',

View file

@ -4,7 +4,6 @@
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use OpenApi\Attributes as OA;
class OtherController extends Controller
@ -145,19 +144,6 @@ public function disable_api(Request $request)
return response()->json(['message' => 'API disabled.'], 200);
}
public function feedback(Request $request)
{
$content = $request->input('content');
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
Http::post($webhook_url, [
'content' => $content,
]);
}
return response()->json(['message' => 'Feedback sent.'], 200);
}
#[OA\Get(
summary: 'Healthcheck',
description: 'Healthcheck endpoint.',

View file

@ -8,9 +8,12 @@
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@ -1849,4 +1852,606 @@ public function action_restart(Request $request)
200
);
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'list-storages-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by service UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('view', $service);
$persistentStorages = collect();
$fileStorages = collect();
foreach ($service->applications as $app) {
$persistentStorages = $persistentStorages->merge(
$app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
$fileStorages = $fileStorages->merge(
$app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
}
foreach ($service->databases as $db) {
$persistentStorages = $persistentStorages->merge(
$db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
$fileStorages = $fileStorages->merge(
$db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
}
return response()->json([
'persistent_storages' => $persistentStorages->sortBy('id')->values(),
'file_storages' => $fileStorages->sortBy('id')->values(),
]);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for a service sub-resource.',
path: '/services/{uuid}/storages',
operationId: 'create-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path', 'resource_uuid'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'resource_uuid' => 'required|string',
'name' => 'string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$subResource = $service->applications()->where('uuid', $request->resource_uuid)->first();
if (! $subResource) {
$subResource = $service->databases()->where('uuid', $request->resource_uuid)->first();
}
if (! $subResource) {
return response()->json(['message' => 'Service resource not found.'], 404);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $subResource->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $subResource->id,
'resource_type' => $subResource->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
}
return response()->json($storage, 201);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'update-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => 'string',
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
$storage = null;
if ($request->type === 'persistent') {
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
} else {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$storageUuid = $request->route('storage_uuid');
$storage = null;
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -55,6 +55,9 @@ public function manual(Request $request)
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
@ -246,6 +249,9 @@ public function normal(Request $request)
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}

View file

@ -2334,13 +2334,13 @@ private function nixpacks_build_cmd()
$this->generate_nixpacks_env_variables();
$nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}";
if ($this->application->build_command) {
$nixpacks_command .= " --build-cmd \"{$this->application->build_command}\"";
$nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
}
if ($this->application->start_command) {
$nixpacks_command .= " --start-cmd \"{$this->application->start_command}\"";
$nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
}
if ($this->application->install_command) {
$nixpacks_command .= " --install-cmd \"{$this->application->install_command}\"";
$nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command);
}
$nixpacks_command .= " {$this->workdir}";
@ -2353,13 +2353,15 @@ private function generate_nixpacks_env_variables()
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
}
@ -2369,7 +2371,7 @@ private function generate_nixpacks_env_variables()
$coolify_envs->each(function ($value, $key) {
// Only add environment variables with non-null and non-empty values
if (! is_null($value) && $value !== '') {
$this->env_nixpacks_args->push("--env {$key}={$value}");
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}"));
}
});

View file

@ -46,14 +46,20 @@ public function __construct(
public function handle(): void
{
try {
if (! $this->server->isFunctional()) {
return;
}
$this->execution_log = DockerCleanupExecution::create([
'server_id' => $this->server->id,
]);
if (! $this->server->isFunctional()) {
$this->execution_log->update([
'status' => 'failed',
'message' => 'Server is not functional (unreachable, unusable, or disabled)',
'finished_at' => Carbon::now()->toImmutable(),
]);
return;
}
$this->usageBefore = $this->server->getDiskUsage();
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {

View file

@ -0,0 +1,55 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Notify MapleDeploy that the first user has registered on this Coolify instance.
*
* Sends the setup token as a Bearer token so MapleDeploy can verify authenticity
* and clear its stored copy. The token acts as a one-time shared secret.
*/
class NotifySetupCompleteJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5;
public array $backoff = [10, 30, 60, 120, 300];
public int $maxExceptions = 5;
public function __construct(
public string $setupToken,
public string $callbackUrl
) {
$this->onQueue('high');
}
public function handle(): void
{
$response = Http::withToken($this->setupToken)
->timeout(15)
->post($this->callbackUrl);
if (! $response->successful()) {
Log::warning('Setup-complete callback failed', [
'status' => $response->status(),
'url' => $this->callbackUrl,
]);
// Throw so the job retries
throw new \RuntimeException(
"Setup-complete callback returned HTTP {$response->status()}"
);
}
}
}

View file

@ -6,7 +6,6 @@
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -185,7 +184,7 @@ private function processScheduledBackups(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) {
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
@ -239,7 +238,7 @@ private function processScheduledTasks(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
continue;
}
@ -336,51 +335,6 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
return null;
}
/**
* Determine if a cron schedule should run now.
*
* When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
* instead of isDue(). This is resilient to queue delays even if the job is delayed
* by minutes, it still catches the missed cron window. Without dedupKey, falls back
* to simple isDue() check.
*/
private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool
{
$cron = new CronExpression($frequency);
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
// No dedup key → simple isDue check
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
// Get the most recent time this cron was due (including current minute)
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
if ($lastDispatched === null) {
// First run after restart or cache loss: only fire if actually due right now.
// Seed the cache so subsequent runs can use tolerance/catch-up logic.
$isDue = $cron->isDue($executionTime);
if ($isDue) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
}
return $isDue;
}
// Subsequent runs: fire if there's been a due time since last dispatch
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
return true;
}
return false;
}
private function processDockerCleanups(): void
{
// Get all servers that need cleanup checks
@ -411,7 +365,7 @@ private function processDockerCleanups(): void
}
// Use the frozen execution time for consistent evaluation
if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) {
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
DockerCleanupJob::dispatch(
$server,
false,

View file

@ -5,7 +5,6 @@
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -14,7 +13,6 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
@ -81,7 +79,7 @@ private function getServers(): Collection
private function dispatchConnectionChecks(Collection $servers): void
{
if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) {
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) {
try {
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
@ -130,13 +128,13 @@ private function processServerTasks(Server $server): void
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) {
if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
ServerCheckJob::dispatch($server);
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}");
$shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
@ -150,7 +148,7 @@ private function processServerTasks(Server $server): void
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}");
$shouldRunStorageCheck = shouldRunCronNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}", $this->executionTime);
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
@ -158,7 +156,7 @@ private function processServerTasks(Server $server): void
}
// Dispatch ServerPatchCheckJob if due (weekly)
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}");
$shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime);
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
@ -167,45 +165,4 @@ private function processServerTasks(Server $server): void
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
/**
* Determine if a cron schedule should run now.
*
* When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
* instead of isDue(). This is resilient to queue delays even if the job is delayed
* by minutes, it still catches the missed cron window.
*/
private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool
{
$cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
if ($lastDispatched === null) {
$isDue = $cron->isDue($executionTime);
if ($isDue) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
}
return $isDue;
}
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
return true;
}
return false;
}
}

View file

@ -73,25 +73,15 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
if ($subscription) {
// send_internal_notification('Old subscription activated for team: '.$teamId);
$subscription->update([
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
} else {
// send_internal_notification('New subscription for team: '.$teamId);
Subscription::create([
'team_id' => $teamId,
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
}
]
);
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
@ -227,18 +217,14 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
if ($subscription) {
// send_internal_notification("Subscription already exists for team: {$teamId}");
throw new \RuntimeException("Subscription already exists for team: {$teamId}");
} else {
Subscription::create([
'team_id' => $teamId,
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
]);
}
]
);
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
@ -254,20 +240,19 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
if ($status === 'incomplete_expired') {
// send_internal_notification('Subscription incomplete expired');
throw new \RuntimeException('Subscription incomplete expired');
}
if ($teamId) {
$subscription = Subscription::create([
'team_id' => $teamId,
if (! $teamId) {
throw new \RuntimeException('No subscription and team id found');
}
$subscription = Subscription::firstOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
]);
} else {
// send_internal_notification('No subscription and team id found');
throw new \RuntimeException('No subscription and team id found');
}
]
);
}
$cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end');
$feedback = data_get($data, 'cancellation_details.feedback');

View file

@ -1,58 +0,0 @@
<?php
namespace App\Livewire;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Help extends Component
{
use WithRateLimiting;
#[Validate(['required', 'min:10', 'max:1000'])]
public string $description;
#[Validate(['required', 'min:3'])]
public string $subject;
public function submit()
{
try {
$this->validate();
$this->rateLimit(3, 30);
$settings = instanceSettings();
$mail = new MailMessage;
$mail->view(
'emails.help',
[
'description' => $this->description,
]
);
$mail->subject("[HELP]: {$this->subject}");
$type = set_transanctional_email_settings($settings);
// Sending feedback through Cloud API
if (blank($type)) {
$url = 'https://app.coolify.io/api/feedback';
Http::post($url, [
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
]);
} else {
send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
}
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
$this->reset('description', 'subject');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.help')->layout('layouts.app');
}
}

View file

@ -98,6 +98,9 @@ public function getResourceProperty()
public function refresh()
{
if (! $this->env->exists || ! $this->env->fresh()) {
return;
}
$this->syncData();
$this->checkEnvs();
}

View file

@ -28,8 +28,7 @@ class Advanced extends Component
public ?string $allowed_ips = null;
#[Validate('boolean')]
public bool $is_sponsorship_popup_enabled;
// MapleDeploy branding: is_sponsorship_popup_enabled removed (popup removed)
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
@ -46,7 +45,6 @@ public function rules()
'custom_dns_servers' => 'nullable|string',
'is_api_enabled' => 'boolean',
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
];
@ -65,7 +63,6 @@ public function mount()
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
}
@ -147,7 +144,6 @@ public function instantSave()
$this->settings->custom_dns_servers = $this->custom_dns_servers;
$this->settings->is_api_enabled = $this->is_api_enabled;
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->save();

View file

@ -11,6 +11,12 @@ class PricingPlans extends Component
{
public function subscribeStripe($type)
{
if (currentTeam()->subscription?->stripe_invoice_paid) {
$this->dispatch('error', 'Team already has an active subscription.');
return;
}
Stripe::setApiKey(config('subscription.stripe_api_key'));
$priceId = match ($type) {

View file

@ -3,10 +3,9 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model
class LocalPersistentVolume extends BaseModel
{
protected $guarded = [];

View file

@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true)
if ($validProxyTypes->contains(str($proxyType)->lower())) {
$this->proxy->set('type', str($proxyType)->upper());
$this->proxy->set('status', 'exited');
$this->proxy->set('last_saved_proxy_configuration', null);
$this->proxy->set('last_saved_settings', null);
$this->proxy->set('last_applied_settings', null);
$this->save();
if ($this->proxySet()) {
if ($async) {

View file

@ -89,10 +89,13 @@ protected static function booted()
});
}
public static function serverLimitReached()
public static function serverLimitReached(?Team $team = null)
{
$serverLimit = Team::serverLimit();
$team = currentTeam();
$team = $team ?? currentTeam();
if (! $team) {
return true;
}
$serverLimit = Team::serverLimit($team);
$servers = $team->servers->count();
return $servers >= $serverLimit;
@ -109,19 +112,23 @@ public function subscriptionPastOverDue()
public function serverOverflow()
{
if ($this->serverLimit() < $this->servers->count()) {
if (Team::serverLimit($this) < $this->servers->count()) {
return true;
}
return false;
}
public static function serverLimit()
public static function serverLimit(?Team $team = null)
{
if (currentTeam()->id === 0 && isDev()) {
$team = $team ?? currentTeam();
if (! $team) {
return 0;
}
if ($team->id === 0 && isDev()) {
return 9999999;
}
$team = Team::find(currentTeam()->id);
$team = Team::find($team->id);
if (! $team) {
return 0;
}

View file

@ -81,7 +81,7 @@ protected static function boot()
$team = [
'name' => $user->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($user->id === 0) {
$team['id'] = 0;
@ -184,7 +184,7 @@ public function recreate_personal_team()
$team = [
'name' => $this->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($this->id === 0) {
$team['id'] = 0;

View file

@ -44,15 +44,24 @@ public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::registerView(function () {
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
return redirect()->route('login');
}
$isFirstUser = User::count() === 0;
// MapleDeploy: token-gated registration for first user
if ($isFirstUser && $settings->setup_token) {
$token = request()->query('setup_token');
if (! $token || ! hash_equals($settings->setup_token, $token)) {
abort(403);
}
}
return view('auth.register', [
'isFirstUser' => $isFirstUser,
'setupToken' => request()->query('setup_token'),
]);
});
@ -61,7 +70,15 @@ public function boot(): void
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// If there are no users, redirect to registration
// MapleDeploy: don't redirect to register if setup token is required
if ($settings->setup_token) {
return view('auth.login', [
'setup_pending' => true,
'is_registration_enabled' => false,
'enabled_oauth_providers' => collect(),
]);
}
return redirect()->route('register');
}

View file

@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// Ignore errors when facades are not available (e.g., in unit tests)
}
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
return;
}
}
// Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive)
$hostname = strtolower($hostname);
// Additional validation: hostname should not start or end with a dot
if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) {
$fail('The :attribute cannot start or end with a dot.');
@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return;
}
// Check if label contains only valid characters (lowercase letters, digits, hyphens)
// Check if label contains only valid characters (letters, digits, hyphens)
if (! preg_match('/^[a-z0-9-]+$/', $label)) {
$fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
return;
}

View file

@ -10,7 +10,7 @@ class ValidationPatterns
/**
* Pattern for names excluding all dangerous characters
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u';
/**
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
@ -96,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength =
public static function nameMessages(): array
{
return [
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &',
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];

View file

@ -137,6 +137,11 @@ function checkMinimumDockerEngineVersion($dockerVersion)
return $dockerVersion;
}
function escapeShellValue(string $value): string
{
return "'".str_replace("'", "'\\''", $value)."'";
}
function executeInDocker(string $containerId, string $command)
{
$escapedCommand = str_replace("'", "'\\''", $command);

View file

@ -990,16 +990,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if ($key->value() === $parsedValue->value()) {
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
// Ensure the variable exists in DB for .env generation and UI display
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
// Add the variable to the environment using the saved DB value
$environment[$key->value()] = $envVar->value;
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
// a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
@ -2341,8 +2342,8 @@ function serviceParser(Service $resource): Collection
}
if ($key->value() === $parsedValue->value()) {
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
// Ensure the variable exists in DB for .env generation and UI display
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -2350,8 +2351,9 @@ function serviceParser(Service $resource): Collection
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$key->value()] = $envVar->value;
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
// a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;

View file

@ -339,7 +339,18 @@ function generate_application_name(string $git_repository, string $git_branch, ?
$cuid = new Cuid2;
}
return Str::kebab("$git_repository:$git_branch-$cuid");
$repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository;
$name = Str::kebab("$repo_name:$git_branch-$cuid");
// Strip characters not allowed by NAME_PATTERN
$name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name);
if (empty($name) || mb_strlen($name) < 3) {
return generate_random_name($cuid);
}
return $name;
}
/**
@ -466,6 +477,36 @@ function validate_cron_expression($expression_to_validate): bool
return $isValid;
}
/**
* Determine if a cron schedule should run now, with deduplication.
*
* Uses getPreviousRunDate() + last-dispatch tracking to be resilient to queue delays.
* Even if the job runs minutes late, it still catches the missed cron window.
* Without a dedupKey, falls back to a simple isDue() check.
*/
function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
{
$cron = new \Cron\CronExpression($frequency);
$executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
if ($dedupKey === null) {
return $cron->isDue($executionTime);
}
$previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
$lastDispatched = Cache::get($dedupKey);
$shouldFire = $lastDispatched === null
? $cron->isDue($executionTime)
: $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
// Always write: seeds on first miss, refreshes on dispatch.
// 30-day static TTL covers all intervals; orphan keys self-clean.
Cache::put($dedupKey, ($shouldFire ? $executionTime : $previousDue)->toIso8601String(), 2592000);
return $shouldFire;
}
function validate_timezone(string $timezone): bool
{
return in_array($timezone, timezone_identifiers_list());

View file

@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
"laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",
"laravel/sanctum": "^4.3.0",

98
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "19bb661d294e5cf623e68830604e4f60",
"content-hash": "40bddea995c1744e4aec517263109a2f",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2065,6 +2065,100 @@
},
"time": "2026-02-21T14:20:09+00:00"
},
{
"name": "laravel/nightwatch",
"version": "v1.24.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/nightwatch.git",
"reference": "127e9bb9928f0fcf69b52b244053b393c90347c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8",
"reference": "127e9bb9928f0fcf69b52b244053b393c90347c8",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"guzzlehttp/promises": "^2.0",
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
"monolog/monolog": "^3.6",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.2",
"psr/http-message": "^1.0|^2.0",
"psr/log": "^1.0|^2.0|^3.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-foundation": "^6.0|^7.0|^8.0",
"symfony/polyfill-php84": "^1.29"
},
"require-dev": {
"aws/aws-sdk-php": "^3.349",
"ext-pcntl": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.0",
"guzzlehttp/psr7": "^2.0",
"laravel/horizon": "^5.4",
"laravel/pint": "1.21.0",
"laravel/vapor-core": "^2.38.2",
"livewire/livewire": "^2.0|^3.0",
"mockery/mockery": "^1.0",
"mongodb/laravel-mongodb": "^4.0|^5.0",
"orchestra/testbench": "^8.0|^9.0|^10.0",
"orchestra/testbench-core": "^8.0|^9.0|^10.0",
"orchestra/workbench": "^8.0|^9.0|^10.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^10.0|^11.0|^12.0",
"singlestoredb/singlestoredb-laravel": "^1.0|^2.0",
"spatie/laravel-ignition": "^2.0",
"symfony/mailer": "^6.0|^7.0|^8.0",
"symfony/mime": "^6.0|^7.0|^8.0",
"symfony/var-dumper": "^6.0|^7.0|^8.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Nightwatch": "Laravel\\Nightwatch\\Facades\\Nightwatch"
},
"providers": [
"Laravel\\Nightwatch\\NightwatchServiceProvider"
]
}
},
"autoload": {
"files": [
"agent/helpers.php"
],
"psr-4": {
"Laravel\\Nightwatch\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The official Laravel Nightwatch package.",
"homepage": "https://nightwatch.laravel.com",
"keywords": [
"Insights",
"laravel",
"monitoring"
],
"support": {
"docs": "https://nightwatch.laravel.com/docs",
"issues": "https://github.com/laravel/nightwatch/issues",
"source": "https://github.com/laravel/nightwatch"
},
"time": "2026-03-18T23:25:05+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.6",
@ -17209,5 +17303,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View file

@ -17,7 +17,7 @@
|
*/
'name' => env('APP_NAME', 'Coolify'),
'name' => env('APP_NAME', 'MapleDeploy'), // MapleDeploy branding
/*
|--------------------------------------------------------------------------

View file

@ -1,26 +1,27 @@
<?php
return [
// MapleDeploy branding: registry pointed to Forgejo, auto-update disabled by default
'coolify' => [
'version' => '4.0.0-beta.469',
'version' => '4.0.0-beta.470',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'autoupdate' => env('AUTOUPDATE', false),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
'registry_url' => env('REGISTRY_URL', 'ghcr.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'registry_url' => env('REGISTRY_URL', 'forgejo.mapledeploy.ca'),
'helper_image' => env('HELPER_IMAGE', 'ghcr.io/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', 'ghcr.io/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'cdn_url' => env('CDN_URL', 'https://updates.mapledeploy.ca'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://updates.mapledeploy.ca').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://updates.mapledeploy.ca').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
],
'urls' => [
'docs' => 'https://coolify.io/docs',
'contact' => 'https://coolify.io/docs/contact',
'docs' => 'https://mapledeploy.ca/docs',
'contact' => 'https://mapledeploy.ca/contact',
],
'services' => [
@ -55,6 +56,10 @@
'is_scheduler_enabled' => env('SCHEDULER_ENABLED', true),
],
'nightwatch' => [
'is_nightwatch_enabled' => env('NIGHTWATCH_ENABLED', false),
],
'docker' => [
'minimum_required_version' => '24.0',
],
@ -85,12 +90,12 @@
'verification_code_expiry_minutes' => 10,
],
// MapleDeploy branding: telemetry disabled
'sentry' => [
'sentry_dsn' => env('SENTRY_DSN'),
'sentry_dsn' => null,
],
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),
],

View file

@ -118,7 +118,7 @@
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#ffff00',
'progress_bar_color' => '#fde047',
],
/*

View file

@ -123,7 +123,7 @@
'driver' => 'daily',
'path' => storage_path('logs/scheduled.log'),
'level' => 'debug',
'days' => 1,
'days' => 7,
],
'scheduled-errors' => [

View file

@ -13,7 +13,7 @@ public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_dns_validation_enabled')->default(true);
$table->string('custom_dns_servers')->nullable()->default('1.1.1.1');
$table->string('custom_dns_servers')->nullable()->default('149.112.121.10,149.112.122.10');
});
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('setup_token')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('setup_token');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('setup_callback_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('setup_callback_url');
});
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Visus\Cuid2\Cuid2;
return new class extends Migration
{
public function up(): void
{
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->string('uuid')->nullable()->after('id');
});
DB::table('local_persistent_volumes')
->whereNull('uuid')
->orderBy('id')
->chunk(1000, function ($volumes) {
foreach ($volumes as $volume) {
DB::table('local_persistent_volumes')
->where('id', $volume->id)
->update(['uuid' => (string) new Cuid2]);
}
});
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->string('uuid')->nullable(false)->unique()->change();
});
}
public function down(): void
{
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View file

@ -1,6 +1,6 @@
services:
coolify:
image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
image: "${REGISTRY_URL:-forgejo.mapledeploy.ca}/rosslh/coolify:${LATEST_IMAGE:-latest}"
volumes:
- type: bind
source: /data/coolify/source/.env
@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.11'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.11'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -0,0 +1,12 @@
#!/command/execlineb -P
# Use with-contenv to ensure environment variables are available
with-contenv
cd /var/www/html
foreground {
php
artisan
start:nightwatch
}

View file

@ -0,0 +1 @@
longrun

View file

@ -116,6 +116,16 @@ COPY --chown=www-data:www-data composer.json composer.lock ./
COPY --chown=www-data:www-data app ./app
COPY --chown=www-data:www-data bootstrap ./bootstrap
COPY --chown=www-data:www-data config ./config
# MapleDeploy: inject build version into constants.php at build time.
# The version in git stays at the upstream value to avoid rebase conflicts.
# CI passes the timestamped version (e.g. 4.0.0-beta.468.202603140006) as a build arg.
ARG MAPLEDEPLOY_VERSION=""
RUN if [ -n "$MAPLEDEPLOY_VERSION" ]; then \
sed -i "s/'version' => '[^']*'/'version' => '$MAPLEDEPLOY_VERSION'/" config/constants.php && \
chown www-data:www-data config/constants.php; \
fi
COPY --chown=www-data:www-data database ./database
COPY --chown=www-data:www-data lang ./lang
COPY --chown=www-data:www-data public ./public

View file

@ -0,0 +1,11 @@
#!/command/execlineb -P
# Use with-contenv to ensure environment variables are available
with-contenv
cd /var/www/html
foreground {
php
artisan
start:nightwatch
}

View file

@ -0,0 +1 @@
longrun

View file

@ -3502,6 +3502,105 @@
}
]
},
"post": {
"tags": [
"Applications"
],
"summary": "Create Storage",
"description": "Create a persistent storage or file storage for an application.",
"operationId": "create-storage-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"type",
"mount_path"
],
"properties": {
"type": {
"type": "string",
"enum": [
"persistent",
"file"
],
"description": "The type of storage."
},
"name": {
"type": "string",
"description": "Volume name (persistent only, required for persistent)."
},
"mount_path": {
"type": "string",
"description": "The container mount path."
},
"host_path": {
"type": "string",
"nullable": true,
"description": "The host path (persistent only, optional)."
},
"content": {
"type": "string",
"nullable": true,
"description": "File content (file only, optional)."
},
"is_directory": {
"type": "boolean",
"description": "Whether this is a directory mount (file only, default false)."
},
"fs_path": {
"type": "string",
"description": "Host directory path (required when is_directory is true)."
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "Storage created.",
"content": {
"application\/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Applications"
@ -3527,13 +3626,16 @@
"application\/json": {
"schema": {
"required": [
"id",
"type"
],
"properties": {
"uuid": {
"type": "string",
"description": "The UUID of the storage (preferred)."
},
"id": {
"type": "integer",
"description": "The ID of the storage."
"description": "The ID of the storage (deprecated, use uuid instead)."
},
"type": {
"type": "string",
@ -3603,6 +3705,70 @@
]
}
},
"\/applications\/{uuid}\/storages\/{storage_uuid}": {
"delete": {
"tags": [
"Applications"
],
"summary": "Delete Storage",
"description": "Delete a persistent storage or file storage by application UUID.",
"operationId": "delete-storage-by-application-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "storage_uuid",
"in": "path",
"description": "UUID of the storage.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Storage deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/cloud-tokens": {
"get": {
"tags": [
@ -6513,6 +6679,333 @@
]
}
},
"\/databases\/{uuid}\/storages": {
"get": {
"tags": [
"Databases"
],
"summary": "List Storages",
"description": "List all persistent storages and file storages by database UUID.",
"operationId": "list-storages-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "All storages by database UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"persistent_storages": {
"type": "array",
"items": {
"type": "object"
}
},
"file_storages": {
"type": "array",
"items": {
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Databases"
],
"summary": "Create Storage",
"description": "Create a persistent storage or file storage for a database.",
"operationId": "create-storage-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"type",
"mount_path"
],
"properties": {
"type": {
"type": "string",
"enum": [
"persistent",
"file"
],
"description": "The type of storage."
},
"name": {
"type": "string",
"description": "Volume name (persistent only, required for persistent)."
},
"mount_path": {
"type": "string",
"description": "The container mount path."
},
"host_path": {
"type": "string",
"nullable": true,
"description": "The host path (persistent only, optional)."
},
"content": {
"type": "string",
"nullable": true,
"description": "File content (file only, optional)."
},
"is_directory": {
"type": "boolean",
"description": "Whether this is a directory mount (file only, default false)."
},
"fs_path": {
"type": "string",
"description": "Host directory path (required when is_directory is true)."
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "Storage created.",
"content": {
"application\/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Databases"
],
"summary": "Update Storage",
"description": "Update a persistent storage or file storage by database UUID.",
"operationId": "update-storage-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"type"
],
"properties": {
"uuid": {
"type": "string",
"description": "The UUID of the storage (preferred)."
},
"id": {
"type": "integer",
"description": "The ID of the storage (deprecated, use uuid instead)."
},
"type": {
"type": "string",
"enum": [
"persistent",
"file"
],
"description": "The type of storage: persistent or file."
},
"is_preview_suffix_enabled": {
"type": "boolean",
"description": "Whether to add -pr-N suffix for preview deployments."
},
"name": {
"type": "string",
"description": "The volume name (persistent only, not allowed for read-only storages)."
},
"mount_path": {
"type": "string",
"description": "The container mount path (not allowed for read-only storages)."
},
"host_path": {
"type": "string",
"nullable": true,
"description": "The host path (persistent only, not allowed for read-only storages)."
},
"content": {
"type": "string",
"nullable": true,
"description": "The file content (file only, not allowed for read-only storages)."
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"200": {
"description": "Storage updated.",
"content": {
"application\/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/storages\/{storage_uuid}": {
"delete": {
"tags": [
"Databases"
],
"summary": "Delete Storage",
"description": "Delete a persistent storage or file storage by database UUID.",
"operationId": "delete-storage-by-database-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "storage_uuid",
"in": "path",
"description": "UUID of the storage.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Storage deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/deployments": {
"get": {
"tags": [
@ -11238,6 +11731,338 @@
]
}
},
"\/services\/{uuid}\/storages": {
"get": {
"tags": [
"Services"
],
"summary": "List Storages",
"description": "List all persistent storages and file storages by service UUID.",
"operationId": "list-storages-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "All storages by service UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"persistent_storages": {
"type": "array",
"items": {
"type": "object"
}
},
"file_storages": {
"type": "array",
"items": {
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Services"
],
"summary": "Create Storage",
"description": "Create a persistent storage or file storage for a service sub-resource.",
"operationId": "create-storage-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"type",
"mount_path",
"resource_uuid"
],
"properties": {
"type": {
"type": "string",
"enum": [
"persistent",
"file"
],
"description": "The type of storage."
},
"resource_uuid": {
"type": "string",
"description": "UUID of the service application or database sub-resource."
},
"name": {
"type": "string",
"description": "Volume name (persistent only, required for persistent)."
},
"mount_path": {
"type": "string",
"description": "The container mount path."
},
"host_path": {
"type": "string",
"nullable": true,
"description": "The host path (persistent only, optional)."
},
"content": {
"type": "string",
"nullable": true,
"description": "File content (file only, optional)."
},
"is_directory": {
"type": "boolean",
"description": "Whether this is a directory mount (file only, default false)."
},
"fs_path": {
"type": "string",
"description": "Host directory path (required when is_directory is true)."
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "Storage created.",
"content": {
"application\/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Services"
],
"summary": "Update Storage",
"description": "Update a persistent storage or file storage by service UUID.",
"operationId": "update-storage-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"type"
],
"properties": {
"uuid": {
"type": "string",
"description": "The UUID of the storage (preferred)."
},
"id": {
"type": "integer",
"description": "The ID of the storage (deprecated, use uuid instead)."
},
"type": {
"type": "string",
"enum": [
"persistent",
"file"
],
"description": "The type of storage: persistent or file."
},
"is_preview_suffix_enabled": {
"type": "boolean",
"description": "Whether to add -pr-N suffix for preview deployments."
},
"name": {
"type": "string",
"description": "The volume name (persistent only, not allowed for read-only storages)."
},
"mount_path": {
"type": "string",
"description": "The container mount path (not allowed for read-only storages)."
},
"host_path": {
"type": "string",
"nullable": true,
"description": "The host path (persistent only, not allowed for read-only storages)."
},
"content": {
"type": "string",
"nullable": true,
"description": "The file content (file only, not allowed for read-only storages)."
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"200": {
"description": "Storage updated.",
"content": {
"application\/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/storages\/{storage_uuid}": {
"delete": {
"tags": [
"Services"
],
"summary": "Delete Storage",
"description": "Delete a persistent storage or file storage by service UUID.",
"operationId": "delete-storage-by-service-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "storage_uuid",
"in": "path",
"description": "UUID of the storage.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Storage deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/teams": {
"get": {
"tags": [

View file

@ -2204,6 +2204,73 @@ paths:
security:
-
bearerAuth: []
post:
tags:
- Applications
summary: 'Create Storage'
description: 'Create a persistent storage or file storage for an application.'
operationId: create-storage-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- type
- mount_path
properties:
type:
type: string
enum: [persistent, file]
description: 'The type of storage.'
name:
type: string
description: 'Volume name (persistent only, required for persistent).'
mount_path:
type: string
description: 'The container mount path.'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, optional).'
content:
type: string
nullable: true
description: 'File content (file only, optional).'
is_directory:
type: boolean
description: 'Whether this is a directory mount (file only, default false).'
fs_path:
type: string
description: 'Host directory path (required when is_directory is true).'
type: object
additionalProperties: false
responses:
'201':
description: 'Storage created.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Applications
@ -2225,12 +2292,14 @@ paths:
application/json:
schema:
required:
- id
- type
properties:
uuid:
type: string
description: 'The UUID of the storage (preferred).'
id:
type: integer
description: 'The ID of the storage.'
description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
@ -2272,6 +2341,48 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/storages/{storage_uuid}':
delete:
tags:
- Applications
summary: 'Delete Storage'
description: 'Delete a persistent storage or file storage by application UUID.'
operationId: delete-storage-by-application-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the storage.'
required: true
schema:
type: string
responses:
'200':
description: 'Storage deleted.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/cloud-tokens:
get:
tags:
@ -4209,6 +4320,219 @@ paths:
security:
-
bearerAuth: []
'/databases/{uuid}/storages':
get:
tags:
- Databases
summary: 'List Storages'
description: 'List all persistent storages and file storages by database UUID.'
operationId: list-storages-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
responses:
'200':
description: 'All storages by database UUID.'
content:
application/json:
schema:
properties:
persistent_storages: { type: array, items: { type: object } }
file_storages: { type: array, items: { type: object } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Databases
summary: 'Create Storage'
description: 'Create a persistent storage or file storage for a database.'
operationId: create-storage-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- type
- mount_path
properties:
type:
type: string
enum: [persistent, file]
description: 'The type of storage.'
name:
type: string
description: 'Volume name (persistent only, required for persistent).'
mount_path:
type: string
description: 'The container mount path.'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, optional).'
content:
type: string
nullable: true
description: 'File content (file only, optional).'
is_directory:
type: boolean
description: 'Whether this is a directory mount (file only, default false).'
fs_path:
type: string
description: 'Host directory path (required when is_directory is true).'
type: object
additionalProperties: false
responses:
'201':
description: 'Storage created.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Databases
summary: 'Update Storage'
description: 'Update a persistent storage or file storage by database UUID.'
operationId: update-storage-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
requestBody:
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
required: true
content:
application/json:
schema:
required:
- type
properties:
uuid:
type: string
description: 'The UUID of the storage (preferred).'
id:
type: integer
description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
description: 'The type of storage: persistent or file.'
is_preview_suffix_enabled:
type: boolean
description: 'Whether to add -pr-N suffix for preview deployments.'
name:
type: string
description: 'The volume name (persistent only, not allowed for read-only storages).'
mount_path:
type: string
description: 'The container mount path (not allowed for read-only storages).'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, not allowed for read-only storages).'
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages).'
type: object
additionalProperties: false
responses:
'200':
description: 'Storage updated.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/storages/{storage_uuid}':
delete:
tags:
- Databases
summary: 'Delete Storage'
description: 'Delete a persistent storage or file storage by database UUID.'
operationId: delete-storage-by-database-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the storage.'
required: true
schema:
type: string
responses:
'200':
description: 'Storage deleted.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/deployments:
get:
tags:
@ -7070,6 +7394,223 @@ paths:
security:
-
bearerAuth: []
'/services/{uuid}/storages':
get:
tags:
- Services
summary: 'List Storages'
description: 'List all persistent storages and file storages by service UUID.'
operationId: list-storages-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
responses:
'200':
description: 'All storages by service UUID.'
content:
application/json:
schema:
properties:
persistent_storages: { type: array, items: { type: object } }
file_storages: { type: array, items: { type: object } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Services
summary: 'Create Storage'
description: 'Create a persistent storage or file storage for a service sub-resource.'
operationId: create-storage-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- type
- mount_path
- resource_uuid
properties:
type:
type: string
enum: [persistent, file]
description: 'The type of storage.'
resource_uuid:
type: string
description: 'UUID of the service application or database sub-resource.'
name:
type: string
description: 'Volume name (persistent only, required for persistent).'
mount_path:
type: string
description: 'The container mount path.'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, optional).'
content:
type: string
nullable: true
description: 'File content (file only, optional).'
is_directory:
type: boolean
description: 'Whether this is a directory mount (file only, default false).'
fs_path:
type: string
description: 'Host directory path (required when is_directory is true).'
type: object
additionalProperties: false
responses:
'201':
description: 'Storage created.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
patch:
tags:
- Services
summary: 'Update Storage'
description: 'Update a persistent storage or file storage by service UUID.'
operationId: update-storage-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
requestBody:
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
required: true
content:
application/json:
schema:
required:
- type
properties:
uuid:
type: string
description: 'The UUID of the storage (preferred).'
id:
type: integer
description: 'The ID of the storage (deprecated, use uuid instead).'
type:
type: string
enum: [persistent, file]
description: 'The type of storage: persistent or file.'
is_preview_suffix_enabled:
type: boolean
description: 'Whether to add -pr-N suffix for preview deployments.'
name:
type: string
description: 'The volume name (persistent only, not allowed for read-only storages).'
mount_path:
type: string
description: 'The container mount path (not allowed for read-only storages).'
host_path:
type: string
nullable: true
description: 'The host path (persistent only, not allowed for read-only storages).'
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages).'
type: object
additionalProperties: false
responses:
'200':
description: 'Storage updated.'
content:
application/json:
schema:
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/storages/{storage_uuid}':
delete:
tags:
- Services
summary: 'Delete Storage'
description: 'Delete a persistent storage or file storage by service UUID.'
operationId: delete-storage-by-service-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the storage.'
required: true
schema:
type: string
responses:
'200':
description: 'Storage deleted.'
content:
application/json:
schema:
properties:
message: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/teams:
get:
tags:

View file

@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S")
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
ENV_FILE="/data/coolify/source/.env"
DOCKER_VERSION="27.0"
DOCKER_VERSION="latest"
# TODO: Ask for a user
CURRENT_USER=$USER
@ -499,13 +499,10 @@ fi
install_docker() {
set +e
curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true
curl -fsSL https://get.docker.com | sh 2>&1 || true
if ! [ -x "$(command -v docker)" ]; then
curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo "Automated Docker installation failed. Trying manual installation."
install_docker_manually
fi
echo "Automated Docker installation failed. Trying manual installation."
install_docker_manually
fi
set -e
}
@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
"almalinux")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
systemctl start docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then
exit 1
fi
;;
"centos" | "fedora" | "rhel" | "tencentos")
if [ -x "$(command -v dnf5)" ]; then
# dnf5 is available
dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1
else
# dnf5 is not available, use dnf
dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1
fi
"almalinux" | "tencentos")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"ubuntu" | "debian" | "raspbian")
"ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
install_docker
if ! [ -x "$(command -v docker)" ]; then
echo " - Automated Docker installation failed. Trying manual installation."
install_docker_manually
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
;;
*)
install_docker
if ! [ -x "$(command -v docker)" ]; then
echo " - Automated Docker installation failed. Trying manual installation."
install_docker_manually
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
;;
esac
@ -627,6 +609,19 @@ else
echo " - Docker is installed."
fi
# Verify minimum Docker version
MIN_DOCKER_VERSION=24
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
exit 1
else
echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
fi
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.469"
"version": "4.0.0-beta.470"
},
"nightly": {
"version": "4.0.0"
@ -13,17 +13,17 @@
"version": "1.0.11"
},
"sentinel": {
"version": "0.0.19"
"version": "0.0.21"
}
},
"traefik": {
"v3.6": "3.6.5",
"v3.6": "3.6.11",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.32"
"v2.11": "2.11.40"
}
}

View file

@ -22,6 +22,7 @@
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
<source>
<include>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

82
public/svgs/espocrm.svg Normal file
View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="379.36536"
height="83.256203"
enable-background="new 0 0 307.813 75"
overflow="visible"
version="1.1"
viewBox="0 0 303.49228 66.604962"
xml:space="preserve"
id="svg20"
sodipodi:docname="logo2.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs24" /><sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
scale-x="0.8"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="2.172956"
inkscape:cx="109.75832"
inkscape:cy="79.384949"
inkscape:window-width="1920"
inkscape:window-height="1074"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg20" />
<switch
transform="matrix(1.089,0,0,1.089,-14.949525,-4.9304545)"
id="switch18">
<foreignObject
width="1"
height="1"
requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/">
</foreignObject>
<g
transform="matrix(0.96767,0,0,0.96767,3.9659,-1.2011)"
id="g16">
<path
d="m 169.53,21.864 c -7.453,2.972 -9.569,11.987 -9.005,19.212 1.587,2.982 3.845,5.562 5.783,8.312 l 4.262,-1.083 c -1.796,-4.447 -1.689,-9.424 -0.806,-14.066 0.585,-3.001 2.309,-6.476 5.634,-7.032 5.307,-0.847 10.733,-0.271 16.088,-0.369 0.091,-2.196 0.115,-4.392 0.107,-6.585 -7.333,0.387 -15.043,-1.038 -22.063,1.611 z m 52.714,-1.294 c -8.12,-0.952 -16.332,-0.149 -24.492,-0.387 -0.021,6.43 -0.003,12.854 0.078,19.274 2.625,-0.849 5.251,-1.739 7.909,-2.532 0.042,-3.272 0.028,-6.527 -0.071,-9.789 4.869,-0.029 9.874,-0.757 14.639,0.451 1.838,0.298 2.051,2.25 2.687,3.641 2.541,-0.891 5.111,-1.717 7.672,-2.574 -0.703,-4.246 -4.129,-7.633 -8.422,-8.084 z m 23.522,-0.593 c -3.954,0.072 -7.912,0.064 -11.864,0.047 0.051,2.544 0.063,5.074 0.072,7.617 4.263,-1.482 8.553,-2.889 12.848,-4.268 -0.35,-1.128 -0.706,-2.268 -1.056,-3.396 z"
fill="#6a3201"
id="path2" />
<path
d="m 161.96,69.125 c 7.886,-3.717 15.757,-7.463 23.72,-11.018 5.563,0.359 11.146,0.021 16.722,0.193 1.14,-0.036 2.292,-0.061 3.432,-0.088 -0.011,-3.195 -0.025,-6.38 -0.082,-9.564 3.428,-1.502 10.227,-4.623 10.227,-4.623 l 15.215,13.941 11.096,0.106 -0.715,-26.236 0.803,-0.211 9.005,26.344 8.834,-0.066 8.99,-28.394 -0.308,28.434 8.074,-0.021 -0.231,-37.932 -9.279,0.071 30.625,-14.141 c 0,0 -37.593,14.279 -56.404,21.385 -2.996,1.022 -5.878,2.315 -8.853,3.394 -2.278,0.867 -4.558,1.713 -6.834,2.58 -20.071,7.526 -39.945,15.604 -60.126,22.803 C 159.094,45.56 150.557,36.228 144.103,25.497 Z m 72.116,-17.961 c -0.108,0.154 -0.324,0.458 -0.429,0.611 -3.448,-3.018 -6.765,-6.189 -10.21,-9.205 1.745,-1.096 3.47,-2.242 5.026,-3.597 1.625,-1.386 3.479,-2.469 5.345,-3.499 0.293,5.227 0.258,10.452 0.268,15.69 z m 23.942,-9.67 c -0.857,2.578 -1.825,5.137 -2.793,7.682 -1.644,-6.217 -3.94,-12.238 -5.856,-18.383 -0.119,-0.52 -0.366,-1.574 -0.487,-2.093 3.428,-1.709 10.585,-4.854 15.229,-6.815 -1.647,5.969 -4.306,14.029 -6.093,19.609 z"
fill="#ffb300"
id="path4" />
<g
fill="#6a3201"
id="g14">
<path
d="M 45.672,58.148 H 27.146 c -2.861,0 -5.614,-0.651 -8.257,-1.953 -2.861,-1.409 -5.043,-3.651 -6.547,-6.725 -1.503,-3.074 -2.254,-6.455 -2.254,-10.145 0,-3.652 0.724,-6.961 2.173,-9.926 1.594,-3.219 3.803,-5.569 6.628,-7.052 1.557,-0.795 3.052,-1.355 4.482,-1.682 1.43,-0.325 3.07,-0.488 4.917,-0.488 h 17.168 v 6.789 H 29.57 c -1.415,0 -2.602,0.187 -3.563,0.558 -0.961,0.372 -1.912,1.037 -2.855,1.994 -0.943,0.957 -1.597,1.887 -1.959,2.791 -0.363,0.902 -0.543,2.027 -0.543,3.375 h 25.023 v 6.789 H 20.648 c 0,1.24 0.164,2.325 0.491,3.256 0.327,0.93 0.919,1.887 1.776,2.871 0.856,0.985 1.749,1.732 2.677,2.242 0.929,0.512 2.03,0.767 3.306,0.767 h 16.774 z"
id="path6" />
<path
d="m 76.499,49.519 c 0,2.397 -0.771,4.449 -2.312,6.154 -1.541,1.706 -3.49,2.56 -5.846,2.56 H 49.688 V 53.12 h 15.326 c 1.087,0 2.001,-0.272 2.744,-0.817 0.743,-0.545 1.115,-1.327 1.115,-2.345 0,-2.362 -1.595,-3.543 -4.783,-3.543 h -7.825 c -1.666,0 -3.278,-0.79 -4.836,-2.369 -1.559,-1.58 -2.336,-3.287 -2.336,-5.119 0,-2.585 0.579,-4.667 1.738,-6.248 1.34,-1.794 3.313,-2.692 5.922,-2.692 h 17.928 v 5.364 H 58.743 c -0.614,0 -1.147,0.289 -1.599,0.868 -0.452,0.579 -0.677,1.235 -0.677,1.972 0,0.807 0.298,1.498 0.896,2.076 0.597,0.579 1.311,0.867 2.144,0.867 h 8.415 c 2.643,0 4.733,0.79 6.271,2.369 1.536,1.579 2.306,3.584 2.306,6.016 z"
id="path8" />
<path
d="m 109.29,43.414 c 0,4.495 -1.166,8.074 -3.497,10.738 -2.331,2.664 -5.395,3.996 -9.188,3.996 H 88.419 V 68.457 H 80.792 V 29.985 h 15.09 c 4.27,0 7.6,1.269 9.989,3.806 2.279,2.428 3.419,5.637 3.419,9.623 z m -7.627,0.405 c 0,-2.356 -0.754,-4.286 -2.262,-5.793 -1.509,-1.505 -3.388,-2.258 -5.641,-2.258 h -5.341 v 16.429 h 5.886 c 2.179,0 3.951,-0.771 5.313,-2.313 1.363,-1.54 2.045,-3.562 2.045,-6.065 z"
id="path10" />
<path
d="m 145.1,43.967 c 0,4.896 -1.557,8.65 -4.669,11.261 -2.86,2.394 -6.751,3.591 -11.673,3.591 -4.923,0 -8.742,-1.087 -11.456,-3.264 -3.15,-2.502 -4.724,-6.401 -4.724,-11.696 0,-4.424 1.701,-7.906 5.104,-10.446 3.04,-2.283 6.786,-3.427 11.238,-3.427 4.887,0 8.805,1.225 11.754,3.673 2.949,2.448 4.426,5.884 4.426,10.308 z m -8.382,-0.065 c 0,-2.285 -0.716,-4.197 -2.146,-5.738 -1.432,-1.54 -3.379,-2.312 -5.841,-2.312 -2.246,0 -4.103,0.79 -5.57,2.366 -1.467,1.577 -2.2,3.563 -2.2,5.955 0,2.756 0.743,4.949 2.228,6.581 1.485,1.632 3.405,2.448 5.76,2.448 2.679,0 4.673,-0.852 5.977,-2.557 1.193,-1.557 1.792,-3.805 1.792,-6.743 z"
id="path12" />
</g>
</g>
</switch>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -13,33 +13,35 @@
@custom-variant dark (&:where(.dark, .dark *));
/* MapleDeploy branding: Canadian red accent, stone greys */
@theme {
--font-sans: Inter, sans-serif;
--font-display: 'Overlock', sans-serif;
--color-base: #101010;
--color-warning: #fcd452;
--color-base: #1c1917;
--color-warning: #fde047;
--color-warning-50: #fefce8;
--color-warning-100: #fef9c3;
--color-warning-200: #fef08a;
--color-warning-300: #fde047;
--color-warning-400: #fcd452;
--color-warning-500: #facc15;
--color-warning-400: #facc15;
--color-warning-500: #eab308;
--color-warning-600: #ca8a04;
--color-warning-700: #a16207;
--color-warning-800: #854d0e;
--color-warning-900: #713f12;
--color-success: #22C55E;
--color-error: #dc2626;
--color-coollabs-50: #f5f0ff;
--color-coollabs: #6b16ed;
--color-coollabs-100: #7317ff;
--color-coollabs-200: #5a12c7;
--color-coollabs-300: #4a0fa3;
--color-coolgray-100: #181818;
--color-coolgray-200: #202020;
--color-coolgray-300: #242424;
--color-coolgray-400: #282828;
--color-coolgray-500: #323232;
--color-coollabs-50: #fef3f2;
--color-coollabs: #d52b1e;
--color-coollabs-100: #f34d40;
--color-coollabs-200: #bc2519;
--color-coollabs-300: #9c2118;
--color-coolgray-100: #1c1917;
--color-coolgray-200: #292524;
--color-coolgray-300: #44403c;
--color-coolgray-400: #57534e;
--color-coolgray-500: #78716c;
}
/*
@ -122,19 +124,19 @@ button[isHighlighted]:not(:disabled) {
}
h1 {
@apply text-3xl font-bold dark:text-white;
@apply text-3xl font-bold font-display dark:text-white;
}
h2 {
@apply text-xl font-bold dark:text-white;
@apply text-xl font-bold font-display dark:text-white;
}
h3 {
@apply text-lg font-bold dark:text-white;
@apply text-lg font-bold font-display dark:text-white;
}
h4 {
@apply text-base font-bold dark:text-white;
@apply text-base font-bold font-display dark:text-white;
}
a {

View file

@ -70,3 +70,12 @@ @font-face {
src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2');
}
/* MapleDeploy branding: Overlock for headings */
@font-face {
font-display: swap;
font-family: 'Overlock';
font-style: normal;
font-weight: 900;
src: url('../fonts/overlock-v19-latin-900.woff2') format('woff2'),
url('../fonts/overlock-v19-latin-900.ttf') format('truetype');
}

View file

@ -40,11 +40,11 @@ @utility input-sticky {
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 1px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1e, inset 0 0 0 1px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 1px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 1px #242424;
}
}
@ -82,11 +82,11 @@ @utility input {
@apply focus-visible:outline-none;
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1e, inset 0 0 0 2px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 2px #242424;
}
&:read-only {
@ -113,11 +113,11 @@ @utility select {
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
box-shadow: inset 4px 0 0 #d52b1e, inset 0 0 0 2px #e5e5e5;
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
box-shadow: inset 4px 0 0 #fde047, inset 0 0 0 2px #242424;
}
}
@ -209,7 +209,8 @@ @utility icon {
}
@utility scrollbar {
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin;
/* MapleDeploy branding: yellow scrollbar thumb instead of Coolify red */
@apply scrollbar-thumb-warning scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin;
}
@utility main {
@ -269,7 +270,8 @@ @utility description {
}
@utility bg-coollabs-gradient {
@apply from-purple-500 via-pink-500 to-red-500 bg-linear-to-r;
/* MapleDeploy branding */
@apply from-red-700 via-red-500 to-red-400 bg-linear-to-r;
}
@utility text-helper {
@ -333,7 +335,7 @@ @utility log-warning {
}
@utility log-debug {
@apply bg-purple-500/10 dark:bg-purple-500/15;
@apply bg-stone-500/10 dark:bg-stone-500/15;
}
@utility log-info {

Binary file not shown.

Binary file not shown.

View file

@ -3,9 +3,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Confirm Your Password
</p>

View file

@ -3,9 +3,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.forgot_password_heading') }}
</p>

View file

@ -3,12 +3,33 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
</div>
<div class="space-y-6">
@if (!empty($setup_pending))
{{-- MapleDeploy: setup token required but not provided --}}
<div class="mb-6 p-4 bg-warning/10 border border-warning rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning">Setup pending</p>
<p class="text-sm dark:text-white text-black">
Initial setup has not been completed. Please use the setup link from your
<a href="https://app.mapledeploy.ca" class="underline hover:text-warning">MapleDeploy dashboard</a>.
</p>
</div>
</div>
</div>
@else
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
@ -95,6 +116,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
@endforeach
</div>
@endif
@endif {{-- end setup_pending --}}
</div>
</div>
</div>

View file

@ -15,9 +15,10 @@ function getOldOrLocal($key, $localValue)
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Create your account
</p>
@ -52,6 +53,9 @@ function getOldOrLocal($key, $localValue)
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
@if (isset($setupToken))
<input type="hidden" name="setup_token" value="{{ $setupToken }}" />
@endif
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
label="{{ __('input.name') }}" />
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"

View file

@ -3,9 +3,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.reset_password') }}
</p>

View file

@ -47,9 +47,10 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Two-Factor Authentication
</p>

View file

@ -48,7 +48,7 @@
{!! $icon !!}
</div>
<div class="ml-3 {{ $dismissible ? 'pr-8' : '' }}">
<div class="text-base font-bold {{ $colorScheme['title'] }}">
<div class="text-base font-bold font-display {{ $colorScheme['title'] }}">
{{ $title }}
</div>
<div class="mt-2 text-sm {{ $colorScheme['text'] }}">

View file

@ -1,6 +1,7 @@
{{ Illuminate\Mail\Markdown::parse('---') }}
Thank you,<br>
{{ config('app.name') ?? 'Coolify' }}
{{ config('app.name') ?? 'MapleDeploy' }}
{{ Illuminate\Mail\Markdown::parse('[Contact Support](https://coolify.io/docs/contact)') }}
{{-- MapleDeploy branding: support link --}}
{{ Illuminate\Mail\Markdown::parse('[Contact Support](https://mapledeploy.ca/contact)') }}

View file

@ -99,9 +99,10 @@
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text px-1 text-black dark:text-white"
{{-- MapleDeploy branding: red accent instead of Coolify purple --}}
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const accent = isDark ? '#fde047' : '#d52b1e';
const border = isDark ? '#242424' : '#e5e5e5';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
@ -110,7 +111,7 @@ class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50"
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
@ -231,9 +232,10 @@ class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:
{{-- Input Container --}}
<div @click="openDropdown()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text text-black dark:text-white"
{{-- MapleDeploy branding: red accent instead of Coolify purple --}}
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const accent = isDark ? '#fde047' : '#d52b1e';
const border = isDark ? '#242424' : '#e5e5e5';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
@ -241,7 +243,7 @@ class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-whit
})()"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">

View file

@ -36,7 +36,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
@endif
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
@ -47,7 +47,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
@else
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"

View file

@ -12,7 +12,7 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif>
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif>
{{ $slot }}
</select>
@error($modelBinding)

View file

@ -45,16 +45,16 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@endif
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
@ -64,9 +64,9 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}"
{{ $allowTab ? '@keydown.tab=handleKeydown' : '' }} placeholder="{{ $placeholder }}"
{{ !$spellcheck ? 'spellcheck=false' : '' }} {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]"
@else
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#d52b1e,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fde047,inset_0_0_0_2px_#242424]" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>

View file

@ -1,5 +1,5 @@
<div class="flex flex-col items-center justify-center h-32">
<span class="text-xl font-bold dark:text-white">You have reached the limit of {{ $name }} you can create.</span>
<span class="text-xl font-bold font-display dark:text-white">You have reached the limit of {{ $name }} you can create.</span>
<span>Please <a class="dark:text-white underline" {{ wireNavigate() }} href="{{ route('subscription.show') }}">upgrade your
subscription</a> to create more
{{ $name }}.</span>

View file

@ -190,7 +190,7 @@ class="relative w-auto h-auto">
@endif
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4" x-cloak>
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-0 sm:p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
</div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
@ -199,7 +199,7 @@ class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-scree
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
class="relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex justify-between items-center py-6 px-7 shrink-0">
<h3 class="pr-8 text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen = false; resetModal()"

View file

@ -77,10 +77,14 @@
}
}
}">
<div class="flex lg:pt-6 pt-4 pb-4 pl-2">
<div class="flex flex-col w-full">
<a href="/" {{ wireNavigate() }} class="text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
<x-version />
<div class="flex lg:pt-6 pt-4 pb-4 pl-2 gap-1">
{{-- MapleDeploy branding --}}
<div class="flex flex-col min-w-0 flex-1">
<a href="/" {{ wireNavigate() }} class="hover:opacity-80 transition-opacity">
<img src="https://mapledeploy.ca/api/logo/lockup?height=40" alt="MapleDeploy" class="max-h-6 w-auto max-w-full dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=40&dark=true" alt="MapleDeploy" class="hidden max-h-6 w-auto max-w-full dark:block" />
</a>
<span class="text-xs opacity-75 dark:text-neutral-400">Powered by Coolify</span>
</div>
<div>
<!-- Search button that triggers global search modal -->
@ -291,20 +295,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}
<span class="menu-item-label">Teams</span>
</a>
</li>
@if (isCloud() && auth()->user()->isAdmin())
<li>
<a title="Subscription" {{ wireNavigate() }}
class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('subscription.show') }}">
<svg class="menu-item-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="M3 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 2h18M7 15h.01M11 15h2" />
</svg>
<span class="menu-item-label">Subscription</span>
</a>
</li>
@endif
{{-- MapleDeploy branding: Cloud subscription menu removed --}}
@if (isInstanceAdmin())
<li>
@ -324,20 +315,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
</li>
@endif
@if (isCloud() || isDev())
@if (isInstanceAdmin() || session('impersonating'))
<li>
<a title="Admin" class="menu-item" href="/admin" {{ wireNavigate() }}>
<svg class="text-pink-500 menu-item-icon" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M177.62 159.6a52 52 0 0 1-34 34a12.2 12.2 0 0 1-3.6.55a12 12 0 0 1-3.6-23.45a28 28 0 0 0 18.32-18.32a12 12 0 0 1 22.9 7.2ZM220 144a92 92 0 0 1-184 0c0-28.81 11.27-58.18 33.48-87.28a12 12 0 0 1 17.9-1.33l19.69 19.11L127 19.89a12 12 0 0 1 18.94-5.12C168.2 33.25 220 82.85 220 144m-24 0c0-41.71-30.61-78.39-52.52-99.29l-20.21 55.4a12 12 0 0 1-19.63 4.5L80.71 82.36C67 103.38 60 124.06 60 144a68 68 0 0 0 136 0" />
</svg>
<span class="menu-item-label">Admin</span>
</a>
</li>
@endif
@endif
{{-- MapleDeploy branding: Cloud admin menu removed --}}
<div class="flex-1"></div>
@if (isInstanceAdmin() && !isCloud())
@persist('upgrade')
@ -357,39 +335,19 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
Onboarding
</a>
</li> --}}
{{-- MapleDeploy branding: AGPL source code link (license requirement) --}}
<li>
<a title="Sponsor us" class="menu-item" href="https://coolify.io/sponsorships"
<a title="Source code (AGPL-3.0)" class="menu-item" href="https://forgejo.mapledeploy.ca/rosslh/coolify"
target="_blank">
<svg class="text-pink-500 menu-item-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<path d="M19.5 12.572L12 20l-7.5-7.428A5 5 0 1 1 12 6.006a5 5 0 1 1 7.5 6.572" />
<path
d="M12 6L8.707 9.293a1 1 0 0 0 0 1.414l.543.543c.69.69 1.81.69 2.5 0l1-1a3.182 3.182 0 0 1 4.5 0l2.25 2.25m-7 3l2 2M15 13l2 2" />
</g>
<svg class="menu-item-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="M16 18l6-6-6-6M8 6l-6 6 6 6" />
</svg>
<span class="menu-item-label">Sponsor us</span>
<span class="menu-item-label">Source code</span>
</a>
</li>
@endif
@if (!isSubscribed() && isCloud() && auth()->user()->teams()->get()->count() > 1)
<livewire:navbar-delete-team />
@endif
<li>
<x-modal-input title="How can we help?">
<x-slot:content>
<div title="Send us feedback or get help!" class="cursor-pointer menu-item"
wire:click="help">
<svg class="menu-item-icon" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</svg>
<span class="menu-item-label">Feedback</span>
</div>
</x-slot:content>
<livewire:help />
</x-modal-input>
</li>
<li>
<form action="/logout" method="POST">
@csrf

Some files were not shown because too many files have changed in this diff Show more