Compare commits

...

147 commits

Author SHA1 Message Date
rosslh
900083f636 security: coolify-base-branch-migration — fix version injection for env() constant shape, guard all version extractions (H7 partial)
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 58s
2026-09-11 11:33:20 -04:00
rosslh
81e9ea48be feat(cleanup): default to threshold-mode docker cleanup every 10 minutes 2026-09-11 11:33:20 -04:00
rosslh
383a37e567 chore(ci): use Forgejo build and CDN publishing 2026-09-11 11:33:20 -04:00
rosslh
e68d1220c0 feat(auth): add dashboard-managed Coolify access 2026-09-11 11:33:20 -04:00
rosslh
58adf61184 fix(dns): use Canadian Shield DNS defaults 2026-09-11 11:33:20 -04:00
rosslh
2899d3a6bc fix(telemetry): disable upstream telemetry 2026-09-11 11:33:20 -04:00
rosslh
303cc70548 fix(update): use MapleDeploy CDN and registry artifacts 2026-09-11 11:33:20 -04:00
rosslh
ee45620324 style(theme): apply MapleDeploy palette and fonts 2026-09-11 11:33:19 -04:00
rosslh
345c7023a7 feat(branding): apply MapleDeploy UI branding 2026-09-11 11:33:19 -04:00
Andras Bacsai
33f453912a chore(sentinel): bump sentinel version from 0.0.22 to 1.0.1 2026-09-10 17:41:29 +02:00
Andras Bacsai
fc9e61b7b4 feat(services): open resource settings in a modal with restart limits
Move service application and database settings into an embedded modal with a footer, subtitle helper, and Docker restart-count control. Accept max_restart_count on the service applications API, cap compose YAML collection aliases, and tighten status, backup, and database sidebar layouts.
2026-09-10 17:39:56 +02:00
Andras Bacsai
d24ee35824 fix(s3): allow uppercase letters in S3 bucket names
S3-compatible and legacy buckets may include uppercase letters. Relax the bucket name pattern, validation messages, and tests so those names are accepted while still rejecting invalid characters and formats.
2026-09-10 15:12:53 +02:00
Andras Bacsai
3f1235158d fix(domains): detect Docker Compose domain conflicts
Normalize schemes when comparing routing identities and include per-service
Compose domains in conflict checks. Show the conflicting service name and
align the service domains heading layout.
2026-09-10 14:53:01 +02:00
Andras Bacsai
3229141126 fix(domains): compact service cards at medium widths 2026-09-10 14:08:46 +02:00
Andras Bacsai
6028461f92 fix(domains): unify editing and queued DNS checks
Add indexing, redirect, and hostname regeneration controls to domain editors. Queue DNS checks only for scheme or hostname changes and show progress consistently across application, service, and preview domains.
2026-09-10 14:00:50 +02:00
Andras Bacsai
a0a8c86752 fix(domains): ignore hostless URLs in deployment and port overrides 2026-09-10 12:33:50 +02:00
Andras Bacsai
36befcfecd fix(service): avoid storing inherited ports as overrides 2026-09-10 12:08:06 +02:00
Andras Bacsai
0134401f7c fix(service): write compose .env files atomically via temp rename
Replace direct .env overwrite with a unique temp file and mv so a
partial write cannot leave a truncated environment file on disk.
2026-09-10 09:24:37 +02:00
Andras Bacsai
d85a7800fa feat(notifications): allow toggling restart-limit email events
Add restartLimitReached to the notification event toggle list so admins can disable restart-limit email notifications. Cover the toggle with an authorization test.
2026-09-10 09:06:17 +02:00
Andras Bacsai
361d5a3c8c feat(deploy): pull compose images before stopping containers
Pull image-based Docker Compose services with --ignore-buildable before stopping the current deployment so image-only services are ready first. Fail the deploy if pull fails, and run the pull on the runtime server when a build server is used.
2026-09-10 09:03:28 +02:00
Andras Bacsai
285684ad3c style(ui): unify view toggles at h-8 and View all buttons
Extract a shared view-toggle utility so list/grid switchers use the same 32px control height instead of mixed h-8/h-9 styles. Point section-heading View all links at the shared button class so they match other actions.
2026-09-10 08:40:41 +02:00
Andras Bacsai
fbb569a4b3 fix(ui): respect sidebar toggle and show application domains
Honor an explicit sidebarCollapsed preference instead of always auto-collapsing with a second nav. Show the primary domain on the application access card, use a persisted GitLab name in the page title after delete, and shrink the compose view switcher.
2026-09-10 08:36:15 +02:00
Andras Bacsai
424dbd36ff feat(sentinel): make sentinel mandatory on regular servers
Remove the enable/disable toggle from the server UI, logs page, and
Sentinel API so is_sentinel_enabled is derived and read-only. Enable
existing regular servers via migration, start Sentinel after validate-
and-install, and drop the daily ServerManagerJob restart.
2026-09-09 06:30:32 +02:00
Andras Bacsai
d71a72a45d fix(applications): flatten mobile config nav and pin domain save
Replace the collapsible mobile configuration menu with an always-visible
responsive grid, stack the mobile heading, and swap the domain editor
unsaved bar for a sticky Save footer with a scrollable form body.
2026-09-08 22:21:32 +02:00
Andras Bacsai
b39928d631
fix: support generic SSH Git usernames (#11685) 2026-09-08 20:51:15 +02:00
Andras Bacsai
83714ea395 fix(git): parse generic scp-style SSH URLs with custom users
Centralize scp-style Git URL parsing so user@host:path (including custom
usernames and embedded ports) is accepted and converted to HTTPS for
public clones, API create, webhooks, validation, and commit/branch links.
2026-09-08 20:33:45 +02:00
peaklabs-dev
d25edb8551
fix(terminal): distinguish application containers across servers 2026-09-08 19:43:05 +02:00
Florian Pfitzer
e9bf2551ed fix: support generic SSH Git usernames 2026-09-08 16:11:56 +02:00
Andras Bacsai
1e8a27b084
feat(teams): persist active team and add team selection screen (#11503) 2026-09-08 14:58:58 +02:00
Aditya Tripathi
e52390ec03 fix(team): resolve stored team on deletion and impersonation
Use resolveStoredTeam() instead of teams()->first() when picking the
next active team after a team deletion or when an admin switches into
a user's account, so a valid stored preference wins over an arbitrary
first team. DeleteTeam now returns null when the deletion leaves the
owner with multiple teams, deferring to the selection screen instead
of guessing. refreshSession also stops persisting current_team_id
while impersonating, so viewing another user's account no longer
overwrites their real last-active team.
2026-09-08 14:44:42 +02:00
Aditya Tripathi
f511921895 fix(teams): guard current_team_id clear against concurrent writes
Make clearStoredTeamIfMatches perform an atomic conditional UPDATE
so a concurrent team switch isn't clobbered, and call it for the
deleting owner in DeleteTeam so their stored team id doesn't point
at a deleted team. refreshSession now falls back to
resolveStoredTeam() instead of an arbitrary first team. Add a
return type to SelectTeam::render() and tests covering owner
deletion and concurrent-selection preservation.
2026-09-08 14:44:39 +02:00
Aditya Tripathi
2b92fb86b5 fix(teams): clear stale current_team_id when membership ends
Reset the user's persisted current_team_id when they are removed from
a team, when their team is deleted, or when refreshSession finds no
team left, so a dangling reference is never restored on next login.
2026-09-08 14:44:39 +02:00
Aditya Tripathi
12498b4b86 feat(teams): persist active team and add team selection screen
Add current_team_id to users so the last active team is restored on
login instead of always defaulting to the personal team. When a user
belongs to multiple teams and has no valid stored choice, redirect
them to a new team.select screen (SelectTeam Livewire component) to
pick one, rather than silently choosing the first team. Update
Fortify and OAuth login flows to use the new resolveStoredTeam()
logic.
2026-09-08 14:44:39 +02:00
peaklabs-dev
b81381e85b
fix(profile): show an enabled badge for two-factor authentication 2026-09-08 13:07:40 +02:00
Andras Bacsai
0615c784ac fix(server): remove duplicate Disabled badge from metrics empty state
The metrics-disabled empty state already titles itself "Metrics are disabled", so the extra status badge was redundant. Cover the Sentinel-enabled branch so that badge does not return.
2026-09-08 12:53:57 +02:00
🏔️ Peak
ff24e82fd2
fix: prevent duplicate 2FA challenge submissions causing HTTP 419 (#11223) 2026-09-08 12:23:11 +02:00
peaklabs-dev
37ddbdc37f
fix(auth): redirect authenticated stale-token login submissions to the dashboard 2026-09-08 12:14:29 +02:00
peaklabs-dev
0088b55f48
chore(auth): remove unneeded return 2026-09-08 12:13:45 +02:00
peaklabs-dev
aff973cce9
test(auth): cover the 2FA duplicate submit guard 2026-09-08 12:12:59 +02:00
peaklabs-dev
9eace1529a
Merge remote-tracking branch 'origin/main' into fix/duplicate-2fa-challenge-submissions 2026-09-08 11:54:46 +02:00
Andras Bacsai
3271fbdd67 fix(ui): render service domain groups as separate cards
Move the settings-section body class onto each domain group so apps
render as stacked cards instead of a single bordered list. Update the
ServiceDomains view assertions to match.
2026-09-08 11:53:11 +02:00
Andras Bacsai
4a3dc11958 feat(ui): polish env editor, error buttons, and modal layout 2026-09-08 11:49:02 +02:00
Andras Bacsai
abc701ac65
fix(application): allow general saves with wildcard domains (#11683) 2026-09-08 10:23:51 +02:00
Andras Bacsai
4cb806b472 feat(ui): move preview deployment toggle into section actions
Replace the preview deployments listbox with an Enable/Disable action
button that calls togglePreviewDeployments and persists the setting.
2026-09-08 10:21:34 +02:00
Andras Bacsai
22ce20a045 fix(ui): weld the deployment log sidebar to the main sidebar
Remove :flush="true" from the deployment show configuration sidebar so it stays fixed beside the main nav instead of sitting flush in the page layout.
2026-09-08 10:19:22 +02:00
Andras Bacsai
7f25af3785 feat(ui): capitalize button labels and speed depth transitions
Standard buttons now capitalize each word in their labels. Movement and
depth-shadow changes use an 80ms duration while color transitions stay
at 120ms.
2026-09-08 10:14:03 +02:00
Andras Bacsai
0aba440a94 fix(ui): match dark-mode button depth to the regular border
Neutral buttons in dark mode now use a white 8% depth edge so it matches
the regular border. Highlighted buttons keep the accent-mixed edge.
Document the behavior and assert the dark-mode selector in CSS tests.
2026-09-08 09:47:54 +02:00
Andras Bacsai
3ae1eb9d10 feat(ui): add raised hover and pressed depth to standard buttons
Standard .button controls now use a compact 2px bottom edge, lift 1px on
hover, and press down with the depth removed. Highlighted buttons mix the
accent with black for a matching edge, and focus-visible keeps the accent
ring. Other button-like controls still use the lighter scale press.
2026-09-08 09:40:19 +02:00
Andras Bacsai
caa07574b1
feat(ui): WCAG contrast pass, neutral oklch surface system, and mobile nav overhaul (#11659) 2026-09-08 09:23:55 +02:00
Andras Bacsai
0afa59bc02 fix(ui): keep settings subnav in-page and persist sidebar collapse
Scroll active application settings sections with buttons instead of Livewire navigation, persist sidebar collapse even with auto-collapse, drop the unused modal id, and right-align destination status.
2026-09-08 09:20:38 +02:00
Aditya Tripathi
0cdb98d969 fix(ui): use dropdown-level shadow for popovers and floating menus
Replace inline modal-shadow styles on toasts, popups, and warning
banners with a shared `surface-popover` utility that uses the lighter
dropdown shadow instead of the heavier modal shadow. Apply the same
`shadow-dropdown` swap to the deployments indicator pill and the
server/deployment log floating menus. Remove now-unused console
header and error-contact-link rules from app.css, and add a test
asserting these surfaces consistently use the dropdown shadow.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
9fbeb352a4 feat(ui): auto-collapse sidebar on settings pages; fixed full-height rail
Auto-collapse the main sidebar on pages that have a settings rail (follows
the page when enabled; expanded elsewhere), with an animated slide and a
profile-menu toggle (localStorage) to disable it.

Rework the settings rail to position:fixed like the main sidebar: full
height (3rem -> viewport bottom), left tracks the sidebar's collapsing
width via --sidebar-w, content pinned to grid column 2. Fixes the sticky
scroll crop and top/bottom gaps.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
fc93001cb8 feat(ui): weld settings rail to main sidebar, full-height, unify width
Turn the floating card settings rail into a flat, full-height rail welded
to the main sidebar (3rem -> bottom), surface bled across main's gutter.
Unify the application config grid to the canonical 210px/gap-8 (was the
lone 236px/gap-4 outlier). Affects all shared settings rails.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
6bb8fcd5c7 feat(theme): extract shared Alpine theme controls into components
Introduce theme-controls and theme-controls/picker Blade components
housing the theme/page-width/custom-color logic previously duplicated
in top-user-menu and profile/appearance, and fix custom theme surface
colors to use oklch mixes instead of raw white/black/hex for correct
contrast.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
d9cafc0dd7 feat(ui): card-style header band and rounded body for connected terminal
Give the connected terminal (System theme) the same construction as the
target-picker card: an attached elevated header band holding the target and
theme selectors, a hairline frame, and a body panel whose ring forms the
divider and rounds into the header at the corners. The rounded-corner notch
now shows the elevated (header) surface while the terminal keeps a base fill.
Colorful console themes are unchanged.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
160be6f571 feat(ui): white settings rail in light mode, unify shadows, mobile sheet polish
- Settings rail (application-settings-navigation) is now white in light mode
  instead of a gray fill; separation comes from the hairline ring + soft lift.
- Mobile slide-over: replace the heavy shadow-modal with a soft left-casting
  shadow (scrim handles content dimming, shadcn-style) + rounded-l corners.
- Unify scattered shadow-lg usages onto the shadow-dropdown / shadow-modal
  tokens across dropdowns, tooltips, popups and slide-over.
- Light-mode semantic text + tertiary/nav contrast tuning for WCAG AA.
- status-summary: support right-aligned dropdown; use it on the app heading.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
fd928537e3 feat(ui): unify motion/transition system across interactive components
Add shared easing curves and press/focus transitions for buttons, inputs,
and form controls in app.css. Refine dropdown, modal, tooltip, and
slide-over enter/leave transitions to use consistent easing and durations,
and remove the focus-visible box-shadow ring on inputs/selects in favor of
border-color transitions.
2026-09-08 07:05:01 +02:00
Aditya Tripathi
28fe3384ed feat(ui): section-heading component, dashboard spacing, sidebar-toggle icon
- Add reusable <x-section-heading> (title, muted subtitle, optional action)
  with a compact contained "View all" button; migrate dashboard Projects/
  Servers/Deployments and shared-variables environment headers to it.
- Dashboard: hide the empty active-deployments node so it no longer adds a
  gap above the first section; align section actions with the title row.
- Mobile: use the panel-left sidebar-toggle icon (matching desktop) as the
  drawer trigger instead of a hamburger.
2026-09-08 07:04:48 +02:00
Aditya Tripathi
d91d2fa35d feat(ui): WCAG contrast, neutral oklch surface system, and mobile nav overhaul
Design tokens
- Fix WCAG AA text failures: darken light muted text and lighten dark
  tertiary text so 13-14px copy clears 4.5:1 on every surface.
- Rebuild the dark surface ladder in correct values (oklch compresses to
  near-black below ~15%), giving visible steps: content, chrome, cards.
- Establish a 3-layer shell (deep content, lighter sidebar/topbar chrome,
  lifted cards) with crisp hairline rings and a restrained card shadow.
- Unify every surface to one neutral temperature (pure gray); neutralize
  the cool-tinted sidebar text and legacy surface tokens.
- Convert all color tokens to oklch (neutrals + brand).
- Unify inputs to the recessed token; fix invisible dark placeholders.
- One shared --shadow-dropdown for all menus/listboxes/command palette.

Settings sidebar
- Sticky contained-card rail with subtle scrollbar; collapsible sub-section
  groups (active open by default), cross-page section links.
- Mobile: collapsible disclosure with animated open, outside/Escape dismiss,
  press feedback, and reduced-motion support.

Mobile
- Consolidate the resource header (title + status + links on one row).
- Tighten settings-page vertical rhythm.
- Rebuild the main sidebar as an animated shadcn-style sheet (slide/fade,
  Escape, scroll lock, in-panel close, close-on-navigate).

Fixes
- #11532: selected server invisible in the light-mode terminal toolbar.

Docs: update DESIGN.md tokens, shell layering, and temperature rules.
2026-09-08 07:04:48 +02:00
Andras Bacsai
ba30c64d7d
fix(stripe): prevent duplicate subscription checkouts (#11666) 2026-09-07 20:25:06 +02:00
Andras Bacsai
8a03647a70 feat(domains): compact mobile domain rows and isolate preview modals
Show routing summaries on domain tables below 600px instead of squeezing desktop columns. Scope preview add/edit Livewire events by preview id, authorize preview domain actions, and add search plus unsaved-edit handling.
2026-09-07 20:24:46 +02:00
Andras Bacsai
d75881fa96 feat(domains): keep domain drafts and move preview settings
Preserve in-progress domain edits and redirects across Livewire
refreshes, copy www port overrides for service redirect pairs, and
relocate preview deployment toggles from Advanced to Previews.
Unsaved bars can stay dirty via Alpine while a modal is closed, and
service domain tables stack at narrow widths.
2026-09-07 18:29:58 +02:00
Andras Bacsai
b123356acd feat: add backup controls, resource search, and Sentinel enablement
Unify service backup history with pagination, schedule settings actions, S3 destination details, and team-safe access checks. Display server built-ins as read-only variables and add loading and empty states for searchable resources.
2026-09-07 16:46:37 +02:00
Andras Bacsai
183e28682c fix(domains): route Compose domains using service ports
Resolve routing ports per Compose service, preserve legacy embedded ports, and ignore non-TCP declarations.
2026-09-07 15:17:23 +02:00
Andras Bacsai
e5c75db84a Merge remote-tracking branch 'origin/main' 2026-09-07 14:55:31 +02:00
Andras Bacsai
0b462f6a58
feat(backups): alert when scheduled backups are missing (#11433) 2026-09-07 14:09:42 +02:00
Andras Bacsai
4766a662fe fix: preserve shell negation in sudo commands 2026-09-07 14:08:11 +02:00
Andras Bacsai
778a698553 fix(api): align service domain validation with UI rules 2026-09-07 14:01:48 +02:00
Andras Bacsai
6293cd418c
feat(backups): stream S3-only volume archives directly (#11642) 2026-09-05 21:10:10 +02:00
Andras Bacsai
08f68016dd
fix(storage): prevent PR suffix dropdown clipping (#11637) 2026-09-05 15:58:12 +02:00
Andras Bacsai
3764771293
fix(webhooks): handle closed PRs after base branch changes (#11634) 2026-09-05 15:18:59 +02:00
Andras Bacsai
b351e94c98
fix(api): return task execution duration as float (#11636) 2026-09-05 15:18:51 +02:00
Andras Bacsai
bf874029d8
fix(terminal): preserve PATH for SSH proxy commands (#11638) 2026-09-05 15:18:37 +02:00
Andras Bacsai
2a25e0490e
fix(storage): persist S3 settings for new volume backups (#11635) 2026-09-05 15:18:26 +02:00
Andras Bacsai
47c61feb84 fix(api): persist Docker Compose domain ports as overrides
Normalize Compose domains on create and update, retaining explicit ports in
`domain_port_overrides` while storing port-free domain values. Preserve empty
Compose FQDNs and cover both API flows with feature tests.
2026-09-05 14:24:24 +02:00
Andras Bacsai
208f7720bc fix: avoid inherited compose ports and defer archive inspection
Prevent multi-service Compose domains from inheriting the application port, and defer PostgreSQL custom-format archive inspection to pg_restore.
2026-09-05 14:19:38 +02:00
Devrim Tunçer
16295e1ab3
fix(ui): keep modal content across re-renders (#11294) 2026-09-05 14:10:21 +02:00
🏔️ Peak
94bf7910ad
fix(service): persist service database public access (#11633) 2026-09-05 14:00:58 +02:00
Bo Sundgaard
fb393da352
fix(docker): raise nginx request header buffers above the 8k default (#11404)
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
2026-09-05 13:00:04 +02:00
🏔️ Peak
cfbcb7737b
fix(notifications): add missing toWebhook methods to notifications (#11528) 2026-09-05 12:35:36 +02:00
peaklabs-dev
9f6ed823ce
fix(notifications): add toWebhook payload to Hetzner deletion failure notification 2026-09-05 12:33:06 +02:00
peaklabs-dev
5303ff1246
fix(notifications): hetzner deletion failure channel name 2026-09-05 12:32:25 +02:00
peaklabs-dev
27c7dc6a24
chore(notifications): remove broken notification interface 2026-09-05 12:29:14 +02:00
Andras Bacsai
3c0e43f482 feat(settings): configure CDN URL for stored images
Add a persisted instance setting for S3 image CDN URLs and use it when building image links. Cache profile avatars and project icons with immutable one-year headers.
2026-09-04 21:15:06 +02:00
Andras Bacsai
9c3fb1da39 fix(domains): limit instance addresses to localhost DNS hints 2026-09-04 20:56:08 +02:00
peaklabs-dev
0ae8539b80
Merge remote-tracking branch 'origin/main' into pr/11528 2026-09-04 18:25:18 +02:00
Andras Bacsai
dba3d114e5 chore(release): bump Coolify version to 4.3.18 2026-09-04 18:09:53 +02:00
Andras Bacsai
5ff2110397 fix(environment-variables): preserve generated Compose variables
Exclude protected Compose variables from bulk deletion and developer views, and propagate deletion failures correctly.
2026-09-04 17:01:42 +02:00
Andras Bacsai
851a902346 fix(domains): preserve removed compose service domains 2026-09-04 16:47:12 +02:00
Andras Bacsai
a04c2ecb44 fix(docker): preserve restart policies when stopping containers 2026-09-04 16:38:24 +02:00
peaklabs-dev
92c0035002
ci: sync main to next once a day instead of on every push
merging main into next after every commit clutters the next branch with too many merge commits.
2026-09-04 16:34:40 +02:00
🏔️ Peak
faea1d5436
fix(docker): reap orphaned helper containers regardless of registry (#11540) 2026-09-04 16:30:57 +02:00
peaklabs-dev
aafef73aa6
fix(docker): match helper containers at any registry depth 2026-09-04 16:29:24 +02:00
peaklabs-dev
89814743c8
fix(applications): TypeError when stopping an application 2026-09-04 15:45:55 +02:00
Andras Bacsai
2ebbc5113e fix: return null for invalid repository URLs 2026-09-04 15:44:18 +02:00
Andras Bacsai
053b030c42 fix: remove database restart limits and clear stale Traefik state
Drop restart-limit fields and enforcement from database resources, clear cached Traefik version data when proxies change, and show missing service environment variables from disabled deploy actions.
2026-09-04 15:42:26 +02:00
peaklabs-dev
8dc35e2b5f
fix: TypeError when adding a new scheduled task 2026-09-04 15:27:24 +02:00
🏔️ Peak
7ca584877a
fix: build notification and deployment log links from the instance url (#11623) 2026-09-04 15:20:03 +02:00
Andras Bacsai
51cab061be fix(domains): sync Docker Compose domains for noindex settings 2026-09-04 15:19:21 +02:00
peaklabs-dev
94995c2a0e
fix(deployments): build deployment log links from the instance url 2026-09-04 15:08:08 +02:00
peaklabs-dev
0eb9d5e5a8
fix(notifications): urls for ssl renewal notifications 2026-09-04 15:07:26 +02:00
peaklabs-dev
85ec70643f
refactor(notifications): build scheduled task links from the instance url
- replace the taskLink() helpers on Application and Service with links built from base_url() in the task notifications
2026-09-04 15:04:51 +02:00
peaklabs-dev
306d4833a9
fix(notifications): build restart limit links from the instance url 2026-09-04 15:03:27 +02:00
peaklabs-dev
6abbf84520
fix(notifications): build api token expiry notification link from the instance url 2026-09-04 14:44:07 +02:00
Andras Bacsai
89a66dd255 fix(service): show disabled deploy action when variables are missing 2026-09-04 06:30:35 +02:00
Andras Bacsai
d0b77b2637 chore(release): bump Coolify version to 4.3.17 2026-09-03 21:51:53 +02:00
Andras Bacsai
986ece457d fix(domains): use Compose service ports for internal routing
Detect Docker Compose service ports for domain internal ports and proxy labels, with application ports as a fallback.
2026-09-03 20:38:19 +02:00
Andras Bacsai
1d0d6b4a79 fix(scheduler): run stuck resource cleanup in background at 03:17
Bump the Coolify release version to 4.3.16 and update schedule coverage.
2026-09-03 19:23:08 +02:00
Devin Dissanayaka
45466b5069
fix(notifications): send traefik outdated alerts to the correct topic id (#11526) 2026-09-03 18:35:07 +02:00
ShadowArcanist
76414296a6
fix(ui): typo on storage delete modal (#11541) 2026-09-03 18:12:06 +02:00
peaklabs-dev
2fc73bc357
fix(ux): show api token description inline 2026-09-03 17:23:17 +02:00
🏔️ Peak
4f5b39b748
fix: update livewire to fix wire:dirty reactivity (#11612) 2026-09-03 17:13:30 +02:00
peaklabs-dev
ba19435af3
fix: update livewire to fix wire:dirty reactivity 2026-09-03 17:05:35 +02:00
peaklabs-dev
a1a2d7551c
chore(deps): cleanup version constraint 2026-09-03 17:05:06 +02:00
Andras Bacsai
b8866b87e8
fix(livewire): authorize exposed resource mutations (#11596) 2026-09-02 19:58:28 +02:00
Andras Bacsai
e2e91fbb85
feat(domains): support per-domain internal port overrides (#11594) 2026-09-02 19:39:19 +02:00
Andras Bacsai
7a7564e6b3 fix(ui): improve compose editor layout on mobile screens 2026-09-02 10:22:44 +02:00
Andras Bacsai
8ace0d18e3 fix(database): warn about PostgreSQL password overwrite on full restore 2026-09-01 20:48:39 +02:00
Andras Bacsai
f06aadc4a0 fix(service): warn before removing required ports 2026-09-01 20:33:50 +02:00
Andras Bacsai
7c3b05e2d2
refactor(livewire): make data-sync helpers private (#11578)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 18:44:18 +02:00
Andras Bacsai
2018e7f329
Validate build-time environment variable names before writing the build .env file (#11575)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:14:24 +02:00
Andras Bacsai
e4146a6314
feat(backups): unify service backup management (#11574) 2026-08-31 22:51:30 +02:00
Andras Bacsai
ff8b019296 fix(application): defer Docker Compose loading and preserve domains
Load the compose file only after the user confirms its location, initialize the default compose path when switching build packs, and retain existing application domains.
2026-08-31 21:12:28 +02:00
Andras Bacsai
c97649bda2 docs(security): require scoped authorization checks and regression tests 2026-08-31 17:57:29 +02:00
Andras Bacsai
2bce16e4d4 fix(resources): remove empty-domain placeholder from grid 2026-08-31 17:37:10 +02:00
Andras Bacsai
d67d42039a fix(ui): keep sponsorship reminder listeners functional 2026-08-31 16:52:53 +02:00
Andras Bacsai
adb255dc5c Merge remote-tracking branch 'origin/main' 2026-08-31 13:16:48 +02:00
Andras Bacsai
44bb132833 feat(settings): add development sponsorship reminder preview 2026-08-31 13:16:35 +02:00
Andras Bacsai
67b33c27a9
fix(applications): persist restart limit state for stopped containers (#11566) 2026-08-31 13:13:26 +02:00
Andras Bacsai
e514a2d61f fix(resources): dispatch deletion cleanup after response 2026-08-30 20:37:05 +02:00
Devin Dissanayaka
e0d233f933
fix(deployments): keep every domain intact in COOLIFY_URL and COOLIFY… (#11527)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
2026-08-30 16:53:23 +02:00
Andras Bacsai
ccea7287bf feat(previews): add preview domain management and DNS status tracking 2026-08-30 16:20:16 +02:00
Andras Bacsai
8d675f2e21 fix: update resource forms 2026-08-28 15:08:25 +02:00
Andras Bacsai
0dfbc42c0f chore(release): bump Coolify to 4.3.15 and realtime to 1.0.18 2026-08-28 14:51:30 +02:00
Andras Bacsai
78f9c676ba chore: prepare v4.3.15 2026-08-28 14:46:57 +02:00
Andras Bacsai
aff581043f fix(realtime): harden terminal command handling 2026-08-28 14:41:48 +02:00
Andras Bacsai
51a8a97d87 fix(docker): create standalone networks without the swarm driver
Use the default bridge network driver for standalone destinations and bump the Coolify version to 4.3.14.
2026-08-28 10:49:05 +02:00
Andras Bacsai
28fb694fcb fix: preserve custom names and use Livewire redirects
Resolve custom container names consistently, clean up legacy container names during deployments, and route Livewire resource redirects through the navigation helper.
2026-08-28 09:46:57 +02:00
Andras Bacsai
1639941da7 fix(domains): reject single-label hostnames for app and service domains 2026-08-28 09:34:10 +02:00
Andras Bacsai
b357522f94 fix(livewire): prevent stale keys and refresh races
Use stable identities for dynamic rows and nested components so reordering does not target the wrong item. Scope storage count and volume-list refreshes, preserve PostgreSQL script identity when renaming files, use Livewire-aware destination redirects, and bump the version to 4.3.13.
2026-08-28 09:26:57 +02:00
kashik0i
b7f00e7267 fix(docker): detect unqualified helper images 2026-08-28 02:45:17 +03:00
Andras Bacsai
97831ef06e feat(media): serve avatars and project icons from S3 or CDN 2026-08-27 22:32:16 +02:00
Andras Bacsai
ac9b1c68c1 fix(server): allow sentinel startup migrations to finish 2026-08-27 19:38:01 +02:00
Andras Bacsai
7b7d5a0aad fix(services): add CDN and default logo fallbacks 2026-08-27 13:45:26 +02:00
Andras Bacsai
c2a23466ca
feat(services): add Executor one-click deployment template (#11530) 2026-08-27 10:39:38 +02:00
Ousama Ben Younes
f9f53f2fea fix(notifications): implement toWebhook() for always-send notifications
WebhookChannel::send() calls toWebhook() unconditionally, but the five
notifications reachable through alwaysSendEvents did not implement it, so
enabling the webhook channel turned those events into fatal queued jobs.
2026-08-27 04:45:39 +00:00
Paulo Matos
953726f847 fix: prevent duplicate 2FA challenge submissions causing HTTP 419 2026-08-12 16:19:54 -03:00
731 changed files with 25470 additions and 7188 deletions

View file

@ -1,7 +1,84 @@
# Lessons # Lessons
## Confirm which surface becomes the modal
- When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal.
- Do not make one child page a modal inside the other child page when the user wants both child URLs removed.
- When the modal itself supplies the title and subtitle, do not repeat page-style section cards inside it. Use a flat input layout and one footer for actions.
- Put destructive actions on the footer's left. Put conversion and the primary Save action on the right, with Save last.
- Do not repeat domain-port guidance in a resource settings modal when domain ports have their own input in the domain editor.
- A flat modal form can still use a bordered summary box for a distinct linked resource, such as the domain count and Manage domains action.
- For compact modal headers, show the descriptive subtitle as hover text on an underlined title instead of adding a second visible line.
- Reuse `x-helper` and the plain `underline underline-offset-4` trigger for title help. Do not use a native `title` tooltip or a dotted underline when the project already has a shared title-tooltip pattern.
## Alpine x-transition + tw-animate-css exit animations flash at the end ## Alpine x-transition + tw-animate-css exit animations flash at the end
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears. - Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. - Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`. - Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one. - Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
## Displayed defaults must not become stored overrides
- When an edit form shows an inherited or computed default, trace an unchanged save and a related-field edit through persistence.
- Preserve the inherited state when the displayed value still equals the computed default; store an override only when the user selects a different value.
## Prove regressions against the unchanged baseline
- For a bug fix, run the same regression test before and after the production change. Use a stash when requested so the failure and success come from the exact same test.
## Apply shared domain UX to every supported resource type
- When a user asks for domain-management behavior, inventory every resource that can edit domains before implementation.
- Do not stop at the resource type named in the original report when the requested UX is meant to be consistent across Coolify.
## Verify manual and generated domain paths separately
- Domain regeneration and manual hostname edits must start the same post-save DNS check.
- Add explicit regression coverage for both entry paths across every active domain editor.
## Do not treat a runtime restart as behavior verification
- A healthy restarted container proves only that the process started.
- For a reported UI failure, verify the exact user flow and inspect the resulting persisted state before claiming the fix works.
## Prove the reported live flow before reporting a UI fix
- Do not use unit tests or a healthy process as proof for a reported live UI failure.
- After the user repeats the flow, inspect the exact persisted record, request logs, queue state, and deployed source before stating that it works.
## Start DNS checks only for DNS-relevant edits
- Compare the previous and saved scheme and hostname before a post-save DNS check.
- Do not restart DNS checks for indexing, redirect, path, or internal-port-only changes.
## Include automatically added domains in post-save DNS checks
- Compare the configured domain list before and after Save.
- Start checks for each newly added counterpart, even when the edited domain itself did not change.
## Use one DNS progress pattern
- All DNS check entry points must set the domain badge to the same `checking` state.
- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator.
- Verify the rendered badge uses the spinner slot instead of the default status dot.
## Confirm whether old reports still apply before changing code
- For an old issue, first test the current branch and inspect later fixes. Do not assume that the historical reproduction still needs a new code change.
## Compare routing identity, not complete domain URLs
- Domain-conflict checks must treat `http://host` and `https://host` as the same routing identity.
- Reproduce reports with the exact stored schemes before stating that duplicate detection works.
## Verify reported fixes against the running development app
- When a user asks for before-and-after verification, test the unchanged and fixed production code against the same Jean Run environment.
- Cover each requested interface, such as UI and API, and record the exact URL, response, persisted state, and relevant logs.
## Do not infer that “Pro” means paid
- When the user calls a setting “Pro,” confirm whether it means advanced-user functionality or a subscription entitlement.
- Do not add billing or Cloud-only checks unless the user explicitly requests them.
## Verify compound status layouts visually
- When a status component can render more than one badge, give its root an explicit horizontal flex layout.
- Inspect the real top-bar layout with every conditional badge visible before calling a UI change complete.
## Keep a requested security control at its stated scope
- If the user specifies one team-level redaction flag, do not introduce per-secret policy questions.
- Explain storage constraints as implementation details, then preserve the requested single control.
## Use shared section title helpers in edit modals
- When modal section descriptions should appear on hover, use `x-application.settings-section` instead of a manual heading and visible paragraph.
- Keep text labels for direct actions such as Back up now. Use a standard icon button with a tooltip for familiar secondary actions such as settings.
## Keep modal actions in the footer
- When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action.

View file

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

View file

@ -0,0 +1,131 @@
name: Build MapleDeploy Coolify Image
on:
push:
branches: [mapledeploy]
paths-ignore:
- "*.md"
- ".github/**"
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: |
# Reads one version constant out of config/constants.php.
# The match is anchored at the start of the line, so a nested key on
# another line (e.g. "'nightly' => ['version' => '5.9.9']") cannot be
# picked up, and the trailing greedy .* takes the LAST quoted string on
# the line, so both upstream shapes work:
# 'version' => '4.2.0',
# 'version' => env('COOLIFY_VERSION') ?: '4.3.12',
# Empty or non-version results fail the build instead of tagging the
# image and versions.json with garbage.
extract_version() {
key="$1"
value=$(sed -n "s/^[[:space:]]*'${key}' => .*'\([^']*\)'.*/\1/p" config/constants.php | head -1)
if [ -z "$value" ]; then
echo "ERROR: could not extract '${key}' from config/constants.php" >&2
grep -n "'${key}' =>" config/constants.php >&2 || true
exit 1
fi
if ! printf '%s' "$value" | grep -Eq '^[0-9]+(\.[0-9]+)+([-.][0-9A-Za-z.]+)?$'; then
echo "ERROR: extracted ${key} '${value}' does not look like a version." >&2
echo " The '${key}' entry in config/constants.php probably changed shape;" >&2
echo " see docs/operations/COOLIFY_FORK.md (How version bumping works)." >&2
exit 1
fi
printf '%s' "$value"
}
# `exit 1` inside the function only leaves the command substitution's
# subshell, so each call needs its own `|| exit 1`.
BASE_VERSION=$(extract_version version) || exit 1
HELPER_VERSION=$(extract_version helper_version) || exit 1
REALTIME_VERSION=$(extract_version realtime_version) || exit 1
TIMESTAMP=$(date -u +%Y%m%d%H%M)
VERSION="${BASE_VERSION}.${TIMESTAMP}"
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 scripts/upgrade-postgres.sh upgrade-postgres.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@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'

View file

@ -1,182 +0,0 @@
name: Manage PR Branch
# Runs *after* the "PR Quality" workflow finishes. This is required because
# PR Quality may close a PR that fails its checks, so we must wait for it to
# complete before deciding whether to retarget the PR's base branch.
on:
workflow_run:
workflows: ["PR Quality"]
types:
- completed
permissions:
contents: read
pull-requests: write
concurrency:
group: manage-pr-branch-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
jobs:
manage-branch:
runs-on: ubuntu-latest
steps:
- name: Retarget PR base branch based on category
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run;
// Branch routing based on the "Category" section of the PR body.
// Bug fixes and one-click service changes ship in patch releases -> main.
// Everything else (features, improvements) -> next.
const MAIN_BRANCH = 'main';
const NEXT_BRANCH = 'next';
// Maintainers/collaborators are trusted to pick their own base branch.
const EXEMPT_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
// Resolve the open PR from the triggering run.
//
// PR Quality runs on `pull_request_target`, so `run.head_sha` is the
// *base* branch tip, not the PR head — a commit-based lookup finds
// nothing. Instead match on the source branch (`head_branch`) and its
// owner (`head_repository.owner.login`), which uniquely identify the PR
// via the `owner:branch` head filter. This also works for forked PRs,
// where `workflow_run.pull_requests` is empty.
const headOwner = run.head_repository?.owner?.login;
const headBranch = run.head_branch;
let prRef;
if (headOwner && headBranch) {
const { data: openPrs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${headOwner}:${headBranch}`,
per_page: 100,
});
prRef = openPrs[0];
}
// Fallback: same-repo PRs may also be resolvable by commit association.
if (!prRef) {
const { data: associated } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: run.head_sha,
});
prRef = associated.find(pr => pr.state === 'open');
}
if (!prRef) {
core.info('No open PR associated with this run (possibly closed by PR Quality). Skipping.');
return;
}
// Fetch the full PR to get an up-to-date body, base ref, and state.
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prRef.number,
});
if (pr.state !== 'open') {
core.info(`PR #${pr.number} is not open. Skipping.`);
return;
}
// Skip PRs opened by owners/members/collaborators — they choose their own base.
if (EXEMPT_ASSOCIATIONS.has(pr.author_association)) {
core.info(`PR #${pr.number} author association is ${pr.author_association}. Skipping.`);
return;
}
// Skip if a maintainer has already changed the base branch manually.
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
const baseChanges = timeline.filter(e => e.event === 'base_ref_changed');
for (const change of baseChanges) {
const actor = change.actor?.login;
if (!actor) {
continue;
}
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: actor,
});
// admin/maintain/write => trusted maintainer.
if (['admin', 'maintain', 'write'].includes(perm.permission)) {
core.info(`Base branch was changed manually by ${actor} (${perm.permission}). Skipping.`);
return;
}
} catch (error) {
core.info(`Could not resolve permission for ${actor}: ${error.message}`);
}
}
// Parse the checked category checkboxes from the PR body.
const body = pr.body ?? '';
const checked = [];
const checkboxRegex = /^\s*-\s*\[([ xX])\]\s*(.+?)\s*$/gm;
let match;
while ((match = checkboxRegex.exec(body)) !== null) {
if (match[1].toLowerCase() === 'x') {
checked.push(match[2].toLowerCase());
}
}
const includesAny = (labels) => labels.some(label => checked.some(c => c.includes(label)));
const mainCategories = ['bug fix', 'adding new one click service', 'fixing or updating existing one click service'];
const nextCategories = ['improvement', 'new feature'];
const wantsMain = includesAny(mainCategories);
const wantsNext = includesAny(nextCategories);
if (!wantsMain && !wantsNext) {
core.info('No category selected in the PR body. Skipping.');
return;
}
// If categories from both groups are checked, prefer next: features and
// improvements can only be released from the development branch.
const targetBranch = wantsNext ? NEXT_BRANCH : MAIN_BRANCH;
if (pr.base.ref === targetBranch) {
core.info(`PR #${pr.number} already targets ${targetBranch}. Nothing to do.`);
return;
}
const previousBranch = pr.base.ref;
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
base: targetBranch,
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: [
`Based on the selected category, this PR's base branch was automatically changed from \`${previousBranch}\` to \`${targetBranch}\`.`,
'',
targetBranch === MAIN_BRANCH
? 'Bug fixes and one-click service changes target `main`.'
: 'New features and improvements target `next`.',
'',
'If you believe this is incorrect, please let a maintainer know.',
].join('\n'),
});
core.info(`Retargeted PR #${pr.number}: ${previousBranch} -> ${targetBranch}.`);

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,52 +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 || github.event.label.name == '📑 Waiting for Docs PR')
&& contains(github.event.pull_request.labels.*.name, matrix.label)
&& contains(github.event.pull_request.labels.*.name, '📑 Waiting for Docs PR')
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,161 +0,0 @@
name: Coolify Helper Image
on:
workflow_dispatch:
push:
branches: [ "main" ]
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"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-24.04
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: Ensure version is not published
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)
VERSION="${BASE_VERSION}"
for registry in "${DOCKER_REGISTRY}" "${GITHUB_REGISTRY}"; do
IMAGE="${registry}/${IMAGE_NAME}:${VERSION}"
if output=$(docker buildx imagetools inspect "$IMAGE" 2>&1); then
echo "::error::Version $VERSION already exists in $registry"
exit 1
fi
if ! grep -Eqi 'manifest unknown|not found|no such manifest' <<< "$output"; then
echo "::error::Could not verify $IMAGE: $output"
exit 1
fi
done
echo "Version $VERSION is available in both registries"
build-push:
needs: check-version
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,152 +0,0 @@
name: Build Coolify Next
on:
push:
branches: [next]
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
concurrency:
group: coolify-next-build
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
rc_version: ${{ steps.version.outputs.rc_version }}
short_sha: ${{ steps.version.outputs.short_sha }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Resolve next version
id: version
run: |
RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Invalid next RC version: ${RC_VERSION}"
exit 1
fi
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${RC_VERSION}.${SHORT_SHA}"
echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
build:
needs: prepare
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
- 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 next image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.prepare.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
publish:
needs: [prepare, build]
runs-on: ubuntu-24.04
steps:
- 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: Publish next manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish next manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"

View file

@ -1,304 +0,0 @@
name: Release Coolify RC
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft prerelease tag (for example, v4.4-rc.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-rc-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside next
if: ${{ github.ref != 'refs/heads/next' }}
run: |
echo "RC releases must run from the next branch, not ${{ github.ref }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Unsupported RC tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft prerelease
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (!release.prerelease) {
core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
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
- 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 RC image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
revalidate:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Revalidate draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
} catch (error) {
if (error.status !== 404) throw error;
}
publish:
needs: [validate, build, revalidate]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- 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: Publish RC and next on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish RC and next on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish reviewed draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
draft: false,
});

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,161 +0,0 @@
name: Coolify Realtime
on:
push:
branches: [ "main" ]
paths:
- .github/workflows/coolify-realtime.yml
- docker/coolify-realtime/**
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-24.04
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: Ensure version is not published
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)
VERSION="${BASE_VERSION}"
for registry in "${DOCKER_REGISTRY}" "${GITHUB_REGISTRY}"; do
IMAGE="${registry}/${IMAGE_NAME}:${VERSION}"
if output=$(docker buildx imagetools inspect "$IMAGE" 2>&1); then
echo "::error::Version $VERSION already exists in $registry"
exit 1
fi
if ! grep -Eqi 'manifest unknown|not found|no such manifest' <<< "$output"; then
echo "::error::Could not verify $IMAGE: $output"
exit 1
fi
done
echo "Version $VERSION is available in both registries"
build-push:
needs: check-version
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,259 +0,0 @@
name: Release Coolify Stable
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft release tag (for example, v4.3.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-fix-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside the production branch
if: ${{ github.ref_name != 'main' }}
run: |
echo "Stable releases must run from main, not ${{ github.ref_name }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported fix release tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "Release tag ${VERSION} does not match config version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft release
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft release for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (release.prerelease) {
core.setFailed(`Fix release ${process.env.TAG_NAME} cannot be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft release ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
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
- 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 release image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
publish:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- 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: Publish version and latest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:latest"
- name: Publish version and latest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:latest"
- name: Publish reviewed draft release
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || release.prerelease) {
core.setFailed(`Draft release ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft release ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft release ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
draft: false,
});

View file

@ -1,109 +0,0 @@
name: Build Coolify (SHA)
on:
push:
branches: ["main"]
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
outputs:
short_sha: ${{ steps.version.outputs.short_sha }}
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: Resolve internal version
id: version
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT"
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- 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
build-args: |
COOLIFY_VERSION=${{ steps.version.outputs.version }}
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- 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 }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:sha-${SHA}-amd64" \
"${IMAGE}:sha-${SHA}-aarch64" \
--tag "${IMAGE}:sha-${SHA}"
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:sha-${SHA}-amd64" \
"${IMAGE}:sha-${SHA}-aarch64" \
--tag "${IMAGE}:sha-${SHA}"

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: [ main ]
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 HEAD:${GITHUB_REF_NAME}

View file

@ -1,108 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 4
# PR Branch Checks
allowed-target-branches: ""
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: ""
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 2500
max-emoji-count: 2
max-code-references: 5
require-linked-issue: false
blocked-terms: |
STRAWBERRY
🤖 Generated with Claude Code
Generated with Claude Code
blocked-issue-numbers: 8154
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: "Contributor Agreement"
optional-pr-template-sections: "Issues,Preview"
max-additional-pr-template-sections: 2
# Commit Message Checks
max-commit-message-length: 500
require-conventional-commits: false
require-commit-author-match: true
blocked-commit-authors: ""
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
README.md
SECURITY.md
LICENSE
CODE_OF_CONDUCT.md
templates/service-templates-latest.json
templates/service-templates.json
require-final-newline: true
max-added-comments: 10
# User Checks
detect-spam-usernames: true
min-account-age: 30
max-daily-forks: 7
min-profile-completeness: 4
# Merge Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
# Exemptions
exempt-draft-prs: false
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-users: ""
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
exempt-milestones: ""
exempt-pr-milestones: ""
# PR Success Actions
success-add-pr-labels: ""
# PR Failure Actions
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
close-pr: true
lock-pr: false

View file

@ -1,62 +0,0 @@
name: Sync main to next
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-main-to-next
cancel-in-progress: false
jobs:
sync:
name: Merge main into next
runs-on: ubuntu-latest
steps:
- name: Checkout next
uses: actions/checkout@v5
with:
ref: next
fetch-depth: 0
- name: Merge main into next
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git fetch origin main next
if git merge --no-edit origin/main; then
git push origin HEAD:next
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
git merge --abort
if [ -z "$conflicts" ]; then
echo 'The merge failed without conflicts, so no pull request was created.'
exit 1
fi
sync_branch='automation/sync-main-to-next'
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
if [ -n "$existing_pr" ]; then
echo "A main to next pull request already exists: $existing_pr"
else
git push --force origin origin/main:"refs/heads/$sync_branch"
gh pr create \
--base next \
--head "$sync_branch" \
--title 'chore: merge main into next' \
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
fi
echo 'main could not be merged into next without conflicts.'
exit 1

1
.gitignore vendored
View file

@ -40,6 +40,7 @@ CHANGELOG.md
/.workspaces /.workspaces
/.superpowers/ /.superpowers/
/docs/superpowers/plans/ /docs/superpowers/plans/
/.ai/todo.md
tests/Browser/Screenshots tests/Browser/Screenshots
tests/v4/Browser/Screenshots tests/v4/Browser/Screenshots
ref ref

View file

@ -148,6 +148,11 @@ ### Authorization
- Custom gates: `createAnyResource`, `canAccessTerminal` - Custom gates: `createAnyResource`, `canAccessTerminal`
- Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods - Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods
- Multi-tenancy via Teams — team auto-initializes notification settings on creation - Multi-tenancy via Teams — team auto-initializes notification settings on creation
- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security.
- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team.
- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data.
- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team.
- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data.
### Event Broadcasting ### Event Broadcasting
- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) - Soketi WebSocket server for real-time updates (ports 6001-6002 in dev)
@ -191,6 +196,23 @@ ### Laravel 10 Structure (NOT Laravel 11+ slim structure)
- Exception handler: `app/Exceptions/Handler.php` - Exception handler: `app/Exceptions/Handler.php`
- Service providers in `app/Providers/` - Service providers in `app/Providers/`
## Livewire conventions
### Dynamic lists and snapshot errors
When an add, delete, or conversion leaves controls unresponsive and the browser reports `Snapshot missing on Livewire component`, inspect both component keys and refresh events. Stable keys alone may not fix it.
- Give every Livewire component rendered in a loop a stable key based on the record ID, UUID, filename, or another immutable identity. Never include a collection count, `$loop->index`, or a reindexed array position in the key.
- Pass the same stable identity to edit/delete actions. A keyed row can survive reordering while a `wire:ignore` or teleported Alpine modal keeps its original `submitAction`; an action such as `removeItem($index)` then targets a stale position after the first deletion. Resolve the current row server-side from an ID, UUID, or stable row hash instead.
- Do not broadcast one refresh event to both a parent list component and children that the parent may insert, remove, or hide during the same operation. This can queue a child update after its snapshot has been removed from the DOM.
- Split refresh responsibilities into targeted events. Refresh the parent for counts and tab visibility, and refresh an existing child list with a separate event. Use `$this->dispatch('event')->to(Component::class)` instead of a page-wide event when possible.
- Before targeting a child list, confirm that it existed before the mutation, still exists afterward, and is on the active tab. A newly inserted child loads current data during `mount()` and does not need an immediate refresh. A removed or hidden child must not receive one.
- A child that deletes itself should finish its own update, then target only the parent to refresh counts. The parent should not send a refresh back to that child when the list became empty.
- Apply the same pattern to file, directory, conversion, and external reload paths such as Compose edits. One remaining broad event can reproduce the race.
- Add regression tests that assert the scoped event names, assert the old broad event is not dispatched, and verify that keys do not depend on counts or positions. Manually repeat add/delete operations while watching the browser console.
The persistent-storage implementation is the reference pattern: `Project\Service\Storage` handles `storageCountsChanged`, while `Project\Shared\Storages\All` handles `refreshVolumeList`.
## Key Conventions ## Key Conventions
- Use `php artisan make:*` commands with `--no-interaction` to create files - Use `php artisan make:*` commands with `--no-interaction` to create files

View file

@ -34,7 +34,9 @@ ## 1. Visual direction
- near-neutral layered surfaces instead of large bordered boxes; - near-neutral layered surfaces instead of large bordered boxes;
- 1314px UI typography and 32px controls; - 1314px UI typography and 32px controls;
- hairline rings instead of heavy borders; - crisp hairline rings plus a restrained card lift (single 1px ring +
`0 1px 2px rgb(0 0 0 / 0.05)`) so cards and tables separate from the canvas,
never heavy borders or a strong floating shadow;
- full-width data tables for dense collections; - full-width data tables for dense collections;
- outline Reicon glyphs through `<x-reicon>`; - outline Reicon glyphs through `<x-reicon>`;
- the Coolify purple brand accent in light mode; - the Coolify purple brand accent in light mode;
@ -49,6 +51,19 @@ ## 1. Visual direction
dividers, native browser selects, and isolated colored buttons that do not dividers, native browser selects, and isolated colored buttons that do not
match the current action styles. match the current action styles.
Standard `.button` controls use a compact 2px bottom depth. Hover raises the
button face by 1px and increases the visible depth to 3px. Pressing moves the
face down 2px into the edge and removes the depth until release, keeping the
overall bottom position stable. Disabled controls stay flat,
and focus-visible controls retain the accent ring alongside the depth.
Movement and depth-shadow changes transition over 80ms; color transitions keep
the shared 120ms duration.
Standard button labels use `capitalize`, giving each word an initial capital.
Highlighted buttons mix the accent equally with black for a pronounced bottom
edge, so custom theme colors produce a matching edge instead of a generic one.
Dark mode matches the depth edge of neutral buttons to their regular border
color. Highlighted buttons keep their dark, color-matched accent edge.
--- ---
## 2. Development and cascade notes ## 2. Development and cascade notes
@ -89,14 +104,14 @@ ## 3. Tokens and color behavior
| Token | Light | Dark | Use | | Token | Light | Dark | Use |
|---|---|---|---| |---|---|---|---|
| `--coollabs-canvas` | near white | 10% neutral | page canvas | | `--coollabs-canvas` | 97% off-white | 10% neutral | page canvas (kept below card fills so cards lift) |
| `--coollabs-elevated` | 98% neutral | 15% neutral | shells and card headers | | `--coollabs-elevated` | 98% neutral | 15% neutral | shells and card headers |
| `--coollabs-base` | white | 17% neutral | nested card bodies | | `--coollabs-base` | white | 17% neutral | nested card bodies |
| `--coollabs-recessed` | 96% neutral | 20% neutral | inputs and listboxes | | `--coollabs-recessed` | 96% neutral | 20% neutral | inputs and listboxes |
| `--coollabs-fill` | 92.2% neutral | 26.9% neutral | dividers and passive fills | | `--coollabs-fill` | 92.2% neutral | 26.9% neutral | dividers and passive fills |
| `--coollabs-line` | translucent dark | 32% neutral | control borders | | `--coollabs-line` | translucent dark | 32% neutral | control borders |
| `--coollabs-hairline` | 93.5% neutral | 26.9% neutral | shell rings | | `--coollabs-hairline` | 85.5% neutral | 32% neutral | shell rings (crisp enough to read as a card edge, ~1.5:1) |
| `--coollabs-subtle` | 55.6% neutral | 70.8% neutral | labels and muted titles | | `--coollabs-subtle` | 50% neutral | 70.8% neutral | labels and muted titles (light darkened for WCAG AA 4.5:1) |
Accent behavior is intentionally theme-aware: Accent behavior is intentionally theme-aware:
@ -116,6 +131,38 @@ ## 3. Tokens and color behavior
The filled top-level action/tab treatment uses the same palette at a restrained The filled top-level action/tab treatment uses the same palette at a restrained
opacity rather than a fully saturated fill. opacity rather than a fully saturated fill.
### Shell layering
The app shell is three distinct surface layers, not one flat color. Chrome
lifts, content is the base, cards lift off the content:
- **Content canvas** is the base layer: `bg-app` in dark (deepest,
`--color-app` `#0a0a0b`), `bg-gray-50` in light. The `<main>` content area
and page body use it.
- **Sidebar and topbar chrome** use `bg-panel` in dark (`--color-panel`
`#141418`, a clear step lighter than the content canvas) and `bg-white` in
light, so the chrome reads as a separate panel from the content.
Dark surface tokens are hex, not oklch. oklch lightness compresses toward pure
black below ~15% (oklch(10%) renders as sRGB 3, oklch(15%) as sRGB 11), so oklch
values there give no visible step between layers. The dark ladder is
`--color-app` 10, `--coollabs-elevated` 22, `--coollabs-base` 28,
`--coollabs-recessed` 34 (sRGB), which reads as distinct surfaces.
Temperature: every panel is **pure neutral gray** (r=g=b), one consistent
temperature across the sidebar, tables, cards, inputs, dividers, borders, and
text, in both modes. Do not give one surface a cool (blue) or warm cast while
the others stay neutral. The light page canvas uses `bg-neutral-50` (not
`bg-gray-50`, which is faintly cool) so it matches the neutral cards and chrome.
The only intentional color is the purple/yellow brand accent.
- **Cards, tables, and collection tiles** lift off the content canvas with
`dark:bg-white/[0.05]` plus the crisp `--coollabs-hairline` ring; in light
they are `bg-white` with the ring and the restrained card lift.
Do not paint the content area with the same `bg-panel` as the sidebar, and do
not drop card fills below `dark:bg-white/[0.05]`; both make surfaces read as one
color. Row-hover states keep the lighter `dark:hover:bg-white/[0.025]`.
--- ---
## 4. Page shells and navigation ## 4. Page shells and navigation
@ -419,6 +466,12 @@ ### Dropdowns
the option content left-aligned and size the panel to its content or trigger; the option content left-aligned and size the panel to its content or trigger;
do not create an unnecessarily wide menu. do not create an unnecessarily wide menu.
Every dropdown, menu, listbox panel, and the command palette uses the shared
`--shadow-dropdown` token (`0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px
rgb(0 0 0 / 0.08)`) for a restrained, consistent lift. Do not hand-roll a
heavier `shadow-lg` / `0 18px 50px` / `0.45`-alpha drop shadow on a menu.
Reserve the stronger `--shadow-modal` for actual modals, dialogs, and toasts.
Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The
selected option is indicated inside the menu, not repeated on the trigger. selected option is indicated inside the menu, not repeated on the trigger.
@ -520,7 +573,10 @@ ### Unsaved changes
Deferred fields in one Livewire component use one floating unsaved bar and one Deferred fields in one Livewire component use one floating unsaved bar and one
submit action. Do not add a separate “Save configuration” button to every submit action. Do not add a separate “Save configuration” button to every
card. Selectors that are safe to persist independently should use the existing card. Selectors that are safe to persist independently should use the existing
instant-save pattern. instant-save pattern. When those requests share a component with a modal draft,
pass the unsaved bar a `dirty` Alpine expression comparing that draft with its
initial values, so unrelated saves do not hide pending changes. Mount modal save
bars only while the modal is open to avoid inactive keyboard shortcuts.
--- ---
@ -567,6 +623,15 @@ ## 7. Dense tables
--- ---
### Domain rows on mobile
Domain tables become compact summary cards below 600px. Keep the public URL on
its own line, followed by a short routing summary such as `HTTP → HTTPS · Port
80 · Noindex`. Put DNS status and the existing icon actions on the final row.
Do not squeeze desktop label/value columns into a mobile card or move settings
behind an overflow menu. Long domains wrap, and icon actions retain 40px touch
targets.
## 8. Modals, confirmations, and toasts ## 8. Modals, confirmations, and toasts
### Modals ### Modals
@ -626,8 +691,9 @@ ### Toasts
- Reicon status tile for success, info, warning, danger, or default; - Reicon status tile for success, info, warning, danger, or default;
- title plus optional description; - title plus optional description;
- dismiss and copy-details actions; - dismiss and copy-details actions;
- up to four stacked notifications; - normally up to four stacked notifications, without evicting persistent notices;
- four-second dismissal, paused while hovered; - four-second dismissal, paused while hovered;
- `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button;
- support for all six screen positions and sanitized custom HTML. - support for all six screen positions and sanitized custom HTML.
Do not bring back the old oversized dark rectangle. Do not bring back the old oversized dark rectangle.

View file

@ -13,8 +13,9 @@ class StopApplication
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true) public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainers = true): ?string
{ {
$containerPresent = ! $removeContainers;
$servers = collect([$application->destination->server]); $servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) { if ($application?->additional_servers?->count() > 0) {
$servers = $servers->merge($application->additional_servers); $servers = $servers->merge($application->additional_servers);
@ -26,6 +27,7 @@ public function handle(Application $application, bool $previewDeployments = fals
} }
if ($server->isSwarm()) { if ($server->isSwarm()) {
$containerPresent = false;
instant_remote_process(["docker stack rm {$application->uuid}"], $server); instant_remote_process(["docker stack rm {$application->uuid}"], $server);
continue; continue;
@ -39,13 +41,15 @@ public function handle(Application $application, bool $previewDeployments = fals
$timeout = $application->settings->stopGracePeriodSeconds(); $timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) { foreach ($containersToStop as $containerName) {
instant_remote_process(command: [ $commands = [dockerStopCommand($timeout, $containerName, $server)];
dockerStopCommand($timeout, $containerName, $server), if ($removeContainers) {
"docker rm -f $containerName", $commands[] = "docker rm -f $containerName";
], server: $server, throwError: false); }
instant_remote_process(command: $commands, server: $server, throwError: false);
} }
if ($application->build_pack === 'dockercompose') { if ($removeContainers && $application->build_pack === 'dockercompose') {
$application->deleteConnectedNetworks(); $application->deleteConnectedNetworks();
} }
@ -57,16 +61,22 @@ public function handle(Application $application, bool $previewDeployments = fals
} }
} }
$status = ['status' => 'exited']; $status = [
'status' => 'exited',
'container_present' => $containerPresent,
];
if ($resetRestartCount) { if ($resetRestartCount) {
$status = array_merge($status, [ $status = array_merge($status, [
'restart_count' => 0, 'restart_count' => 0,
'last_restart_at' => null, 'last_restart_at' => null,
'last_restart_type' => null, 'last_restart_type' => null,
'restart_limit_reached' => false,
]); ]);
} }
$application->update($status); $application->update($status);
ServiceStatusChanged::dispatch($application->environment->project->team->id); ServiceStatusChanged::dispatch($application->environment->project->team->id);
return null;
} }
} }

View file

@ -0,0 +1,34 @@
<?php
namespace App\Actions\Application;
use App\Events\ServiceStatusChanged;
use App\Models\ApplicationPreview;
use Lorisleiva\Actions\Concerns\AsAction;
class StopApplicationPreview
{
use AsAction;
public function handle(ApplicationPreview $preview, bool $resetRestartCount = true, bool $removeContainer = true): void
{
$application = $preview->application;
$server = $application->destination->server;
$containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id);
foreach ($containers->pluck('Names') as $containerName) {
$commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)];
if ($removeContainer) {
$commands[] = "docker rm -f $containerName";
}
instant_remote_process($commands, $server, false);
}
$preview->update(['status' => 'exited']);
if ($resetRestartCount) {
$preview->resetRestartLimit();
}
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
}

View file

@ -28,6 +28,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
if (! $server->isFunctional()) { if (! $server->isFunctional()) {
return 'Server is not functional'; return 'Server is not functional';
} }
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
switch ($database->getMorphClass()) { switch ($database->getMorphClass()) {
case StandalonePostgresql::class: case StandalonePostgresql::class:
$activity = StartPostgresql::run($database); $activity = StartPostgresql::run($database);

View file

@ -4,6 +4,7 @@
use App\Actions\Server\CleanupDocker; use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged; use App\Events\ServiceStatusChanged;
use App\Models\BaseModel;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
@ -18,7 +19,7 @@ class StopDatabase
{ {
use AsAction; use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string
{ {
try { try {
$server = $database->destination->server; $server = $database->destination->server;
@ -26,15 +27,17 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
return 'Server is not functional'; return 'Server is not functional';
} }
$this->stopContainer($database, $database->uuid, 30); $this->stopContainer($database, $database->uuid, 30, $removeContainer);
// Reset restart tracking when database is manually stopped // Reset restart tracking when database is manually stopped
$database->update([ $database->update(['status' => 'exited']);
'status' => 'exited', if ($resetRestartCount) {
'restart_count' => 0, $database->update([
'last_restart_at' => null, 'restart_count' => 0,
'last_restart_type' => null, 'last_restart_at' => null,
]); 'last_restart_type' => null,
]);
}
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false); CleanupDocker::dispatch($server, false, false);
@ -53,12 +56,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
} }
private function stopContainer($database, string $containerName, int $timeout = 30): void private function stopContainer(BaseModel $database, string $containerName, int $timeout = 30, bool $removeContainer = true): void
{ {
$server = $database->destination->server; $server = $database->destination->server;
instant_remote_process(command: [ $commands = [dockerStopCommand($timeout, $containerName, $server)];
dockerStopCommand($timeout, $containerName, $server), if ($removeContainer) {
"docker rm -f $containerName", $commands[] = "docker rm -f $containerName";
], server: $server, throwError: false); }
instant_remote_process(command: $commands, server: $server, throwError: false);
} }
} }

View file

@ -3,15 +3,19 @@
namespace App\Actions\Docker; namespace App\Actions\Docker;
use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Shared\ComplexStatusCheck; use App\Actions\Shared\ComplexStatusCheck;
use App\Events\ServiceChecked; use App\Events\ServiceChecked;
use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Services\RestartCountTracker;
use App\Traits\CalculatesExcludedStatus; use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -37,8 +41,12 @@ class GetContainersStatus
protected ?Collection $applicationContainerRestartCounts; protected ?Collection $applicationContainerRestartCounts;
protected ?Collection $previewContainerRestartCounts;
protected ?Collection $serviceContainerStatuses; protected ?Collection $serviceContainerStatuses;
protected ?Collection $serviceContainerRestartCounts;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{ {
$this->containers = $containers; $this->containers = $containers;
@ -117,6 +125,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$containerStatus = "$containerStatus:$healthSuffix"; $containerStatus = "$containerStatus:$healthSuffix";
} }
$labels = Arr::undot(format_docker_labels_to_json($labels)); $labels = Arr::undot(format_docker_labels_to_json($labels));
if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
continue;
}
$applicationId = data_get($labels, 'coolify.applicationId'); $applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) { if ($applicationId) {
$pullRequestId = data_get($labels, 'coolify.pullRequestId'); $pullRequestId = data_get($labels, 'coolify.pullRequestId');
@ -133,6 +144,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} else { } else {
$preview->update(['last_online_at' => now()]); $preview->update(['last_online_at' => now()]);
} }
$key = $applicationId.':'.$pullRequestId;
$this->previewContainerRestartCounts ??= collect();
$this->previewContainerRestartCounts->push([
'key' => $key,
'count' => (int) data_get($container, 'RestartCount', 0),
]);
} else { } else {
// Notify user that this container should not be there. // Notify user that this container should not be there.
} }
@ -140,6 +157,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applications->where('id', $applicationId)->first();
if ($application) { if ($application) {
$foundApplications[] = $application->id; $foundApplications[] = $application->id;
if ($application->container_present !== true) {
$application->update(['container_present' => true]);
}
// Store container status for aggregation // Store container status for aggregation
if (! isset($this->applicationContainerStatuses)) { if (! isset($this->applicationContainerStatuses)) {
$this->applicationContainerStatuses = collect(); $this->applicationContainerStatuses = collect();
@ -220,23 +240,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
// Track restart count for databases (single-container) // Track restart count for databases (single-container)
$restartCount = data_get($container, 'RestartCount', 0); $restartCount = data_get($container, 'RestartCount', 0);
$previousRestartCount = $database->restart_count ?? 0;
if ($statusFromDb !== $containerStatus) { if ($statusFromDb !== $containerStatus) {
$updateData = ['status' => $containerStatus]; $updateData = ['status' => $containerStatus];
} else { } else {
$updateData = ['last_online_at' => now()]; $updateData = ['last_online_at' => now()];
} }
// Update restart tracking if restart count increased
if ($restartCount > $previousRestartCount) {
$updateData['restart_count'] = $restartCount;
$updateData['last_restart_at'] = now();
$updateData['last_restart_type'] = 'crash';
}
$database->update($updateData); $database->update($updateData);
if ($restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if ($isPublic) { if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) { if ($this->server->isSwarm()) {
@ -292,6 +311,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$containerName = data_get($labels, 'com.docker.compose.service'); $containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) { if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->serviceContainerRestartCounts ??= collect();
if (! $this->serviceContainerRestartCounts->has($key)) {
$this->serviceContainerRestartCounts->put($key, collect());
}
$this->serviceContainerRestartCounts->get($key)->put($containerName, (int) data_get($container, 'RestartCount', 0));
} }
// Mark service as found // Mark service as found
@ -335,46 +359,37 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue; continue;
} }
$name = data_get($exitedService, 'name'); if ($exitedService instanceof ServiceDatabase) {
$fqdn = data_get($exitedService, 'fqdn'); $exitedService->update(['status' => 'exited']);
if ($name) { } elseif (! $exitedService->stoppedAfterRestartLimit()) {
if ($fqdn) { $exitedService->update([
$containerName = "$name, available at $fqdn"; 'status' => 'exited',
} else { 'restart_count' => 0,
$containerName = $name; 'restart_limit_reached' => false,
} 'last_restart_at' => null,
} else { 'last_restart_type' => null,
if ($fqdn) { ]);
$containerName = $fqdn;
} else {
$containerName = null;
}
} }
$projectUuid = data_get($service, 'environment.project.uuid');
$serviceUuid = data_get($service, 'uuid');
$environmentName = data_get($service, 'environment.name');
if ($projectUuid && $serviceUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid;
} else {
$url = null;
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
$exitedService->update(['status' => 'exited']);
} }
$notRunningApplications = $this->applications->pluck('id')->diff($foundApplications); $notRunningApplications = $this->applications->pluck('id')->diff($foundApplications);
foreach ($notRunningApplications as $applicationId) { foreach ($notRunningApplications as $applicationId) {
$application = $this->applications->where('id', $applicationId)->first(); $application = $this->applications->where('id', $applicationId)->first();
if (str($application->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed // Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) { if ($this->containers->isEmpty()) {
continue; continue;
} }
if (str($application->status)->startsWith('exited')) {
$application->update([
'container_present' => false,
'restart_limit_reached' => false,
]);
continue;
}
// If container was recently restarting (crash loop), keep it as degraded for a grace period // If container was recently restarting (crash loop), keep it as degraded for a grace period
// This prevents false "exited" status during the brief moment between container removal and recreation // This prevents false "exited" status during the brief moment between container removal and recreation
$recentlyRestarted = $application->restart_count > 0 && $recentlyRestarted = $application->restart_count > 0 &&
@ -388,9 +403,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
// Reset restart count when application exits completely // Reset restart count when application exits completely
$application->update([ $application->update([
'status' => 'exited', 'status' => 'exited',
'container_present' => false,
'restart_count' => 0, 'restart_count' => 0,
'last_restart_at' => null, 'last_restart_at' => null,
'last_restart_type' => null, 'last_restart_type' => null,
'restart_limit_reached' => false,
]); ]);
} }
} }
@ -433,23 +450,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
StopDatabaseProxy::run($database); StopDatabaseProxy::run($database);
} }
$name = data_get($database, 'name');
$fqdn = data_get($database, 'fqdn');
$containerName = $name;
$projectUuid = data_get($database, 'environment.project.uuid');
$environmentName = data_get($database, 'environment.name');
$databaseUuid = data_get($database, 'uuid');
if ($projectUuid && $databaseUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid;
} else {
$url = null;
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
} }
$this->trackPreviewRestartCounts($previews);
// Aggregate multi-container application statuses // Aggregate multi-container application statuses
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
@ -470,21 +474,21 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) { DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
$previousRestartCount = $application->restart_count ?? 0; $previousRestartCount = $application->restart_count ?? 0;
$restartState = (new RestartCountTracker)->evaluate(
previousRestartCount: $previousRestartCount,
observedRestartCount: $maxRestartCount,
maxRestartCount: $application->max_restart_count ?? 0,
);
if ($maxRestartCount > $previousRestartCount) { if ($restartState['restart_count_changed']) {
// Restart count increased - this is a crash restart $hasCrashRestarts = $restartState['restart_count'] > 0;
$application->update([ $application->update([
'restart_count' => $maxRestartCount, 'restart_count' => $restartState['restart_count'],
'last_restart_at' => now(), 'last_restart_at' => $hasCrashRestarts ? now() : null,
'last_restart_type' => 'crash', 'last_restart_type' => $hasCrashRestarts ? 'crash' : null,
]); ]);
// Check if restart limit has been reached
$maxAllowedRestarts = $application->max_restart_count ?? 0;
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
$restartLimitReached = true;
}
} }
$restartLimitReached = $restartState['restart_limit_reached'];
// Aggregate status after tracking restart counts // Aggregate status after tracking restart counts
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount); $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
@ -499,9 +503,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}); });
if ($restartLimitReached) { if ($restartLimitReached) {
$application->refresh(); $restartLimitClaimed = Application::query()
StopApplication::dispatch($application, false, true, false); ->whereKey($application->getKey())
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); ->where('restart_limit_reached', false)
->update(['restart_limit_reached' => true]) === 1;
if ($restartLimitClaimed) {
$application->refresh();
StopApplication::dispatch(
application: $application,
previewDeployments: false,
dockerCleanup: false,
resetRestartCount: false,
removeContainers: false,
);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
} }
} }
} }
@ -562,6 +579,16 @@ private function aggregateServiceContainerStatuses($services)
continue; continue;
} }
$restartCount = isset($this->serviceContainerRestartCounts)
? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0)
: 0;
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
continue;
}
// Parse docker compose from service to check for excluded containers // Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw'); $dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
@ -602,4 +629,24 @@ private function aggregateServiceContainerStatuses($services)
} }
} }
} }
private function trackPreviewRestartCounts(Collection $previews): void
{
if (! isset($this->previewContainerRestartCounts)) {
return;
}
$this->previewContainerRestartCounts
->groupBy('key')
->each(function (Collection $counts, string $key) use ($previews): void {
[$applicationId, $pullRequestId] = explode(':', $key);
$preview = $previews->first(fn (ApplicationPreview $preview): bool => (string) $preview->application_id === $applicationId
&& (string) $preview->pull_request_id === $pullRequestId
);
if ($preview?->trackRestartCount((int) $counts->max('count'))) {
StopApplicationPreview::dispatch($preview, false, false);
$preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview));
}
});
}
} }

View file

@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords; use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords class ResetUserPassword implements ResetsUserPasswords
@ -17,6 +18,13 @@ class ResetUserPassword implements ResetsUserPasswords
*/ */
public function reset(User $user, array $input): void public function reset(User $user, array $input): void
{ {
if ($user->isMapledeployRevoked()) {
// MapleDeploy branding: dashboard-managed revocation is restored only by mapledeploy:user:set-password.
throw ValidationException::withMessages([
'email' => [trans('passwords.user')],
]);
}
Validator::make($input, [ Validator::make($input, [
'password' => ['required', Password::defaults(), 'confirmed'], 'password' => ['required', Password::defaults(), 'confirmed'],
])->validate(); ])->validate();

View file

@ -102,7 +102,8 @@ public function handle(Server $server, $fromUI = false): bool
foreach ($conflicts as $port => $conflict) { foreach ($conflicts as $port => $conflict) {
if ($conflict) { if ($conflict) {
if ($fromUI) { 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 { } else {
return false; return false;
} }

View file

@ -26,7 +26,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$endpoint = $server->settings->ensureSentinelUrl(); $endpoint = $server->settings->ensureSentinelUrl();
$debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel'; $mountDir = '/data/coolify/sentinel';
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version; // MapleDeploy branding: Sentinel is not mirrored to our Forgejo registry, so pull from ghcr.io directly (upstream image)
$image = 'ghcr.io/coollabsio/sentinel:'.$version;
$environments = [ $environments = [
'TOKEN' => $token, 'TOKEN' => $token,
'DEBUG' => $debug ? 'true' : 'false', 'DEBUG' => $debug ? 'true' : 'false',
@ -48,7 +49,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
} }
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
instant_remote_process([ instant_remote_process([
'docker rm -f coolify-sentinel || true', 'docker rm -f coolify-sentinel || true',

View file

@ -118,7 +118,8 @@ private function update()
{ {
$latestHelperImageVersion = getHelperVersion(); $latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); $upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl(); // MapleDeploy branding: always use the fork registry default, ignoring per-instance overrides
$registryUrl = config('constants.coolify.registry_url');
remote_process([ remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",

View file

@ -24,6 +24,7 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
} }
$service->saveComposeConfigs(); $service->saveComposeConfigs();
$service->isConfigurationChanged(save: true); $service->isConfigurationChanged(save: true);
$service->applications()->get()->each->resetRestartLimit();
$workdir = $service->workdir(); $workdir = $service->workdir();
// $commands[] = "cd {$workdir}"; // $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'"; $commands[] = "echo 'Saved configuration files to {$workdir}.'";

View file

@ -49,8 +49,13 @@ public function handle(Service $service, bool $deleteConnectedNetworks = false,
$this->stopContainersInParallel($containersToStop, $server); $this->stopContainersInParallel($containersToStop, $server);
} }
$applications->each->update(['status' => 'exited']); $applications->each(function ($application): void {
$dbs->each->update(['status' => 'exited']); $application->update(['status' => 'exited']);
$application->resetRestartLimit();
});
$dbs->each(function ($database): void {
$database->update(['status' => 'exited']);
});
if ($deleteConnectedNetworks) { if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks(); $service->deleteConnectedNetworks();

View file

@ -13,17 +13,23 @@ class StopServiceApplication
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $resetRestartCount = true, bool $removeContainer = false): void
{ {
$service = $serviceApplication->service; $service = $serviceApplication->service;
$server = $service->destination->server; $server = $service->destination->server;
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
instant_remote_process([ if ($removeContainer) {
"docker stop {$containerName}", $commands = ["docker rm -f {$containerName}"];
], $server); } else {
$commands = ["docker stop {$containerName}"];
}
instant_remote_process($commands, $server, throwError: ! $removeContainer);
$serviceApplication->update(['status' => 'exited']); $serviceApplication->update(['status' => 'exited']);
if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) {
$serviceApplication->resetRestartLimit();
}
ServiceStatusChanged::dispatch($service->environment->project->team->id); ServiceStatusChanged::dispatch($service->environment->project->team->id);
} }
} }

View file

@ -56,7 +56,7 @@ public function execute(ServiceApplication $serviceApplication, Request $request
} }
} }
$serviceApplication->fqdn = $parsed['normalized']; $serviceApplication->setEditableUrls($parsed['normalized']);
} }
if (array_key_exists('noindex_domains', $payload)) { if (array_key_exists('noindex_domains', $payload)) {
@ -92,6 +92,11 @@ public function execute(ServiceApplication $serviceApplication, Request $request
$serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN); $serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN);
} }
if (array_key_exists('max_restart_count', $payload)) {
$serviceApplication->max_restart_count = $payload['max_restart_count'];
$serviceApplication->restart_limit_reached = false;
}
if (array_key_exists('is_log_drain_enabled', $payload)) { if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server; $server = $serviceApplication->service->destination->server;

View file

@ -0,0 +1,221 @@
<?php
namespace App\Actions\Stripe;
use App\Exceptions\CheckoutUnavailableException;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\StripeClient;
use Throwable;
class CreateCheckoutSession
{
private const BLOCKING_SUBSCRIPTION_STATUSES = [
'active',
'incomplete',
'past_due',
'paused',
'trialing',
'unpaid',
];
private const RECOVERABLE_SUBSCRIPTION_STATUSES = [
'incomplete',
'past_due',
'paused',
'unpaid',
];
public function __construct(private ?StripeClient $stripe = null)
{
$this->stripe ??= app(StripeClient::class);
}
public static function lockKey(int $teamId): string
{
return "stripe-checkout:team:{$teamId}";
}
public function execute(Team $team, User $user, string $priceId): object
{
$lock = Cache::lock(self::lockKey($team->id), 30);
if (! $lock->get()) {
throw new CheckoutUnavailableException('A subscription checkout is already being created for this team.');
}
$previousMaxNetworkRetries = Stripe::getMaxNetworkRetries();
Stripe::setMaxNetworkRetries(2);
try {
return $this->createOrReuseSession($team, $user, $priceId);
} finally {
Stripe::setMaxNetworkRetries($previousMaxNetworkRetries);
$lock->release();
}
}
private function createOrReuseSession(Team $team, User $user, string $priceId): object
{
$subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]);
$customerId = $subscription->stripe_customer_id;
if (! $customerId) {
$customer = $this->stripe->customers->create([
'email' => $user->email,
'metadata' => [
'team_id' => $team->id,
],
], [
'idempotency_key' => "coolify-team-{$team->id}-customer",
]);
$customerId = $customer->id;
$subscription->stripe_customer_id = $customerId;
$subscription->save();
Log::info('Stripe customer assigned for subscription checkout.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
]);
}
$blockingSubscription = null;
foreach ($this->stripe->subscriptions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'all',
])->autoPagingIterator() as $stripeSubscription) {
if (in_array($stripeSubscription->status, self::BLOCKING_SUBSCRIPTION_STATUSES, true)) {
$blockingSubscription = $stripeSubscription;
break;
}
}
$this->throwIfBlockingSubscription($team, $customerId, $blockingSubscription);
$sessions = $this->stripe->checkout->sessions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'open',
]);
$subscriptionSessions = collect($sessions->data)->filter(
fn (object $session): bool => ($session->mode ?? null) === 'subscription'
);
$openSession = $subscriptionSessions->first(
fn (object $session): bool => ($session->status ?? null) === 'open'
);
if ($openSession) {
$lineItems = $this->stripe->checkout->sessions->allLineItems($openSession->id);
if (count($lineItems->data) === 1 && data_get($lineItems, 'data.0.price.id') === $priceId) {
Log::info('Reusing pending Stripe subscription checkout.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_checkout_session_id' => $openSession->id,
'stripe_subscription_id' => $openSession->subscription ?? null,
]);
return $openSession;
}
$this->stripe->checkout->sessions->expire($openSession->id);
}
$session = $this->stripe->checkout->sessions->create([
'allow_promotion_codes' => true,
'billing_address_collection' => 'required',
'client_reference_id' => $user->id.':'.$team->id,
'customer' => $customerId,
'customer_update' => [
'name' => 'auto',
'address' => 'auto',
],
'line_items' => [[
'price' => $priceId,
'adjustable_quantity' => [
'enabled' => true,
'minimum' => 2,
],
'quantity' => 2,
]],
'tax_id_collection' => [
'enabled' => true,
],
'automatic_tax' => [
'enabled' => true,
],
'subscription_data' => [
'metadata' => [
'user_id' => $user->id,
'team_id' => $team->id,
],
],
'payment_method_collection' => 'if_required',
'mode' => 'subscription',
'expires_at' => now()->addMinutes(35)->timestamp,
'success_url' => route('dashboard', ['success' => true]),
'cancel_url' => route('subscription.index', ['cancelled' => true]),
]);
Log::info('Stripe subscription checkout created.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_checkout_session_id' => $session->id,
'stripe_subscription_id' => $session->subscription ?? null,
]);
return $session;
}
private function throwIfBlockingSubscription(Team $team, string $customerId, ?object $blockingSubscription): void
{
if (! $blockingSubscription) {
return;
}
Log::warning('Stripe subscription checkout blocked by existing subscription.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_subscription_id' => $blockingSubscription->id,
'stripe_subscription_status' => $blockingSubscription->status,
]);
$portalUrl = in_array($blockingSubscription->status, self::RECOVERABLE_SUBSCRIPTION_STATUSES, true)
? $this->billingPortalUrl($customerId)
: null;
throw new CheckoutUnavailableException(
$this->blockingSubscriptionMessage($blockingSubscription->status),
$portalUrl,
);
}
private function blockingSubscriptionMessage(string $status): string
{
return match ($status) {
'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.",
'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.",
'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.",
'paused' => "This team's subscription is paused. Resume it in the billing portal.",
default => 'Team already has an active subscription.',
};
}
private function billingPortalUrl(string $customerId): ?string
{
try {
$session = $this->stripe->billingPortal->sessions->create([
'customer' => $customerId,
'return_url' => route('subscription.show'),
]);
} catch (Throwable) {
return null;
}
return is_string($session->url ?? null) ? $session->url : null;
}
}

View file

@ -50,12 +50,22 @@ public function handle(Team $team, User $user): ?Team
->get() ->get()
->each(function (User $member) use ($team): void { ->each(function (User $member) use ($team): void {
$member->teams()->detach($team); $member->teams()->detach($team);
$member->clearStoredTeamIfMatches($team->id);
DB::table('sessions')->where('user_id', $member->id)->delete(); DB::table('sessions')->where('user_id', $member->id)->delete();
}); });
// The deleting owner is excluded from the loop above; clear their
// stored team too so the deleted id is not restored on next login.
$user->clearStoredTeamIfMatches($team->id);
$team->delete(); $team->delete();
return $user->teams()->first(); // Resolve the next active team the same way login does: the user's
// stored choice when still valid, or their sole remaining team.
// Returns null for a multi-team user whose active team was just
// deleted, so refreshSession sends them to the selection screen
// instead of silently dropping them into an arbitrary first team.
return User::query()->find($user->id)?->resolveStoredTeam();
}); });
Cache::forget("user:{$user->id}:team:{$team->id}"); Cache::forget("user:{$user->id}:team:{$team->id}");

View file

@ -265,15 +265,11 @@ private function restoreCoolifyDbBackup()
} }
} }
// MapleDeploy branding: telemetry disabled — no phone-home signal
private function sendAliveSignal() private function sendAliveSignal()
{ {
$id = config('app.id'); // Disabled for MapleDeploy: do not send telemetry to coolify.io
$version = config('constants.coolify.version'); return;
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";
}
} }
private function replaceSlashInEnvironmentName() private function replaceSlashInEnvironmentName()

View file

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserCreate extends Command
{
protected $signature = 'mapledeploy:user:create
{--email= : User email address}
{--name= : User display name}
{--admin : Create the first root admin user}
{--team-role=member : Root team role for non-admin users}';
protected $description = 'Create a Coolify user for MapleDeploy dashboard access management';
public function handle(): int
{
$password = $this->readPassword();
$input = [
'email' => $this->option('email'),
'name' => $this->option('name'),
'password' => $password,
'team_role' => $this->option('team-role'),
];
$validator = Validator::make($input, [
'email' => ['required', 'string', 'email', 'max:255'],
'name' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:8'],
'team_role' => ['required', Rule::in([Role::ADMIN->value, Role::MEMBER->value])],
]);
if ($validator->fails()) {
return $this->failWith('INVALID_INPUT');
}
$input['email'] = Str::lower((string) $input['email']);
if (User::whereEmail($input['email'])->exists()) {
return $this->failWith('EMAIL_EXISTS');
}
if ($this->option('admin')) {
return $this->createAdmin($input);
}
return $this->createMember($input);
}
private function createAdmin(array $input): int
{
if (User::count() !== 0) {
return $this->failWith('USERS_ALREADY_EXIST');
}
$user = DB::transaction(function () use ($input) {
$user = (new User)->forceFill([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->save();
$user->markEmailAsVerified();
$settings = instanceSettings();
$settings->is_registration_enabled = false;
$attributes = $settings->getAttributes();
if (array_key_exists('setup_token', $attributes)) {
$settings->setup_token = null;
}
if (array_key_exists('setup_callback_url', $attributes)) {
$settings->setup_callback_url = null;
}
$settings->save();
return $user;
});
return $this->succeedWithUser($user);
}
private function createMember(array $input): int
{
$rootTeam = Team::find(0);
if (! $rootTeam) {
return $this->failWith('ROOT_TEAM_MISSING');
}
$user = DB::transaction(function () use ($input, $rootTeam) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->markEmailAsVerified();
$this->deletePersonalTeams($user);
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => $input['team_role']],
]);
return $user;
});
return $this->succeedWithUser($user);
}
private function deletePersonalTeams(User $user): void
{
// MapleDeploy branding: dashboard-managed users should only see the
// managed instance root team, not an empty personal Coolify team.
$personalTeams = Team::query()
->where('teams.id', '!=', 0)
->where('personal_team', true)
->whereHas('members', fn ($query) => $query->whereKey($user->id))
->get();
foreach ($personalTeams as $team) {
DB::table('team_user')
->where('team_id', $team->id)
->where('user_id', $user->id)
->delete();
DB::table('teams')->where('id', $team->id)->delete();
}
}
private function readPassword(): string
{
return rtrim((string) stream_get_contents(STDIN), "\n");
}
private function succeedWithUser(User $user): int
{
$this->line(json_encode([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class UserDelete extends Command
{
protected $signature = 'mapledeploy:user:delete {user_id : Coolify user id}';
protected $description = 'Delete a Coolify user for MapleDeploy dashboard access management';
public function handle(): int
{
$rawUserId = $this->argument('user_id');
$userId = filter_var($rawUserId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
if ($userId === false) {
return $this->failWith('INVALID_USER_ID');
}
if ($userId === 0) {
return $this->failWith('CANNOT_DELETE_ROOT_USER');
}
$user = User::find($userId);
if (! $user) {
$this->line(json_encode([
'deleted' => null,
'alreadyDeleted' => true,
'id' => $userId,
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
$deleted = [
'id' => $user->id,
'email' => $user->email,
];
DB::transaction(function () use ($user) {
$user->tokens()->delete();
// MapleDeploy branding: deletion must end any active browser sessions.
DB::table('sessions')->where('user_id', $user->id)->delete();
$user->delete();
});
$this->line(json_encode(['deleted' => $deleted], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
class UserList extends Command
{
protected $signature = 'mapledeploy:user:list';
protected $description = 'List Coolify users for MapleDeploy dashboard access management';
public function handle(): int
{
$users = User::with('teams')
->orderBy('id')
->get()
->map(fn (User $user) => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'created_at' => $user->created_at?->toISOString(),
'teams' => $user->teams
->map(fn ($team) => [
'id' => $team->id,
'name' => $team->name,
'role' => $team->pivot?->role,
])
->values()
->all(),
])
->values()
->all();
$this->line(json_encode(['users' => $users], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserRevoke extends Command
{
protected $signature = 'mapledeploy:user:revoke {user_id : Coolify user id}';
protected $description = 'Revoke a Coolify user login for MapleDeploy dashboard access management';
public function handle(): int
{
$userId = (int) $this->argument('user_id');
if ($userId === 0) {
return $this->failWith('CANNOT_REVOKE_ROOT_USER');
}
$user = User::find($userId);
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$user->forceFill([
'password' => Hash::make(Str::random(64)),
// MapleDeploy branding: OAuth login matches by email, so keep a
// persistent marker that the callback can reject after revocation.
'remember_token' => 'mapledeploy-revoked:'.Str::random(40),
])->save();
$user->tokens()->delete();
// MapleDeploy branding: revocation must end any active browser sessions.
DB::table('sessions')->where('user_id', $user->id)->delete();
$this->line(json_encode([
'revoked' => [
'id' => $user->id,
'email' => $user->email,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserSetPassword extends Command
{
protected $signature = 'mapledeploy:user:set-password
{user_id : Coolify user id}
{--email= : New user email address}
{--name= : New user display name}';
protected $description = 'Set a Coolify user password for MapleDeploy dashboard access management';
public function handle(): int
{
$password = rtrim((string) stream_get_contents(STDIN), "\n");
$updatesOwner = $this->option('email') !== null || $this->option('name') !== null;
$input = [
'password' => $password,
'email' => $this->option('email'),
'name' => $this->option('name'),
];
$rules = ['password' => ['required', 'string', 'min:8']];
if ($updatesOwner) {
$rules['email'] = ['required', 'string', 'email', 'max:255'];
$rules['name'] = ['required', 'string', 'max:255'];
}
$validator = Validator::make($input, $rules);
if ($validator->fails()) {
return $this->failWith('INVALID_INPUT');
}
$user = User::find($this->argument('user_id'));
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$rootTeam = null;
if ((int) $user->id !== 0) {
$rootTeam = Team::find(0);
if (! $rootTeam) {
return $this->failWith('ROOT_TEAM_MISSING');
}
}
$changes = [
'password' => Hash::make($password),
// MapleDeploy branding: clear the revocation marker when the
// dashboard intentionally restores this Coolify login.
'remember_token' => null,
];
if ($updatesOwner) {
$email = Str::lower((string) $input['email']);
if (User::whereEmail($email)->whereKeyNot($user->id)->exists()) {
return $this->failWith('EMAIL_EXISTS');
}
// MapleDeploy branding: claiming root admin transfers the Coolify
// account identity so the previous email holder cannot recover it.
$changes['email'] = $email;
$changes['name'] = $input['name'];
}
DB::transaction(function () use ($user, $changes, $updatesOwner, $rootTeam) {
$user->forceFill($changes)->save();
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
if ($rootTeam) {
// MapleDeploy branding: matching an existing Coolify user by
// email must grant the same root-team admin access as a newly
// dashboard-created user.
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => Role::ADMIN->value],
]);
}
// MapleDeploy branding: password resets from the dashboard should
// end browser sessions authenticated with the previous password.
DB::table('sessions')->where('user_id', $user->id)->delete();
});
$this->line(json_encode([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
],
], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
private function failWith(string $code): int
{
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
return self::FAILURE;
}
}

View file

@ -9,6 +9,7 @@
use App\Models\Team; use App\Models\Team;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class ScheduledJobDiagnostics extends Command class ScheduledJobDiagnostics extends Command
@ -203,7 +204,6 @@ private function inspectServerJobs(?string $serverFilter): void
} }
$dedupKeys = [ $dedupKeys = [
"sentinel-restart:{$server->id}" => '0 0 * * *',
"server-patch-check:{$server->id}" => '0 0 * * 0', "server-patch-check:{$server->id}" => '0 0 * * 0',
"server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *', "server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
"server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'), "server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
@ -235,7 +235,7 @@ private function inspectServerJobs(?string $serverFilter): void
$this->newLine(); $this->newLine();
} }
private function getServers(?string $serverFilter): \Illuminate\Support\Collection private function getServers(?string $serverFilter): Collection
{ {
$query = Server::with('settings')->where('ip', '!=', '1.2.3.4'); $query = Server::with('settings')->where('ip', '!=', '1.2.3.4');

View file

@ -5,6 +5,7 @@
use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckMissingDatabaseBackupsJob;
use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupOrphanedPreviewContainersJob;
@ -47,11 +48,13 @@ protected function schedule(Schedule $schedule): void
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('cleanup:stucked-resources') $this->scheduleInstance->command('cleanup:stucked-resources')
->daily() ->dailyAt('03:17')
->onOneServer() ->onOneServer()
->withoutOverlapping(60); ->withoutOverlapping(60)
->runInBackground();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
$this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer();
if (isDev()) { if (isDev()) {
// Instance Jobs // Instance Jobs

View file

@ -0,0 +1,18 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class CheckoutUnavailableException extends Exception
{
public function __construct(
string $message = '',
public readonly ?string $billingPortalUrl = null,
int $code = 0,
?Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}

View file

@ -4,9 +4,11 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Session\TokenMismatchException;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use RuntimeException; use RuntimeException;
use Sentry\Laravel\Integration; use Sentry\Laravel\Integration;
@ -69,6 +71,11 @@ protected function unauthenticated($request, AuthenticationException $exception)
*/ */
public function render($request, Throwable $e) public function render($request, Throwable $e)
{ {
// A duplicate login or 2FA submission carries a stale token on an already authenticated session, see https://github.com/coollabsio/coolify/issues/10670
if ($e instanceof TokenMismatchException && $request->routeIs('login.store', 'two-factor.login.store') && $request->user()) {
return redirect()->intended(RouteServiceProvider::HOME);
}
// Handle authorization exceptions for API routes. Exceptions carrying // Handle authorization exceptions for API routes. Exceptions carrying
// an explicit status (e.g. denyAsNotFound) keep it via parent::render. // an explicit status (e.g. denyAsNotFound) keep it via parent::render.
if ($e instanceof AuthorizationException && ! $e->hasStatus()) { if ($e instanceof AuthorizationException && ! $e->hasStatus()) {

View file

@ -23,9 +23,11 @@
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl; use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
use App\Support\DomainPortOverrides;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -1454,9 +1456,17 @@ private function create_application(Request $request, $type)
$request->offsetUnset('docker_compose_domains'); $request->offsetUnset('docker_compose_domains');
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson);
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
$application->domain_port_overrides = $domainPortOverrides;
} }
$repository_url_parsed = Url::fromString($request->git_repository); $gitRepository = $application->git_repository;
$httpsRepository = scpStyleGitUrlToHttps($gitRepository);
if (is_string($httpsRepository)) {
$gitRepository = $httpsRepository;
$application->git_repository = $httpsRepository;
}
$repository_url_parsed = Url::fromString($gitRepository);
$git_host = $repository_url_parsed->getHost(); $git_host = $repository_url_parsed->getHost();
if ($git_host === 'github.com') { if ($git_host === 'github.com') {
$application->source_type = GithubApp::class; $application->source_type = GithubApp::class;
@ -1618,11 +1628,7 @@ private function create_application(Request $request, $type)
return response()->json(['message' => 'Failed to generate Github App token.'], 400); return response()->json(['message' => 'Failed to generate Github App token.'], 400);
} }
$gitRepository = $request->git_repository; $gitRepository = gitRepositorySlug($request->git_repository);
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
}
$gitRepository = str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
// Use direct API call to verify repository access instead of loading all repositories // Use direct API call to verify repository access instead of loading all repositories
// This is much faster and avoids timeouts for GitHub Apps with many repositories // This is much faster and avoids timeouts for GitHub Apps with many repositories
@ -1718,7 +1724,9 @@ private function create_application(Request $request, $type)
$request->offsetUnset('docker_compose_domains'); $request->offsetUnset('docker_compose_domains');
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson);
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
$application->domain_port_overrides = $domainPortOverrides;
} }
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->git_repository = str($gitRepository)->trim()->toString(); $application->git_repository = str($gitRepository)->trim()->toString();
@ -1949,7 +1957,9 @@ private function create_application(Request $request, $type)
$request->offsetUnset('docker_compose_domains'); $request->offsetUnset('docker_compose_domains');
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson);
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
$application->domain_port_overrides = $domainPortOverrides;
} }
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->private_key_id = $privateKey->id; $application->private_key_id = $privateKey->id;
@ -2487,6 +2497,256 @@ public function logs_by_uuid(Request $request)
]); ]);
} }
#[OA\Patch(
summary: 'Update Preview Domains',
description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.',
path: '/applications/{uuid}/previews/{pull_request_id}',
operationId: 'update-preview-domains-by-pull-request-id',
security: [['bearerAuth' => []]],
tags: ['Applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(
properties: [
new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'),
new OA\Property(
property: 'docker_compose_domains',
type: 'array',
nullable: true,
items: new OA\Items(properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'domain', type: 'string', nullable: true),
new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']),
], type: 'object'),
),
new OA\Property(property: 'force_domain_override', type: 'boolean', default: false),
],
)),
responses: [
new OA\Response(response: 200, description: 'Preview domains updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, ref: '#/components/responses/403'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Domain conflict.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update_preview_by_pull_request_id(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);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$preview = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', (int) $pullRequestIdRaw)
->first();
if (! $preview) {
return response()->json(['message' => 'Preview not found.'], 404);
}
$isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value;
$validationRules = ['force_domain_override' => 'boolean'];
if ($isCompose) {
$validationRules = array_merge($validationRules, [
'domains' => 'missing',
'docker_compose_domains' => 'present|array',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'required|string|distinct',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
]);
} else {
$validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()];
$validationRules['docker_compose_domains'] = 'missing';
}
$validator = Validator::make($request->all(), $validationRules);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$dockerComposeDomains = null;
$dockerComposeDomainsResponse = null;
if ($isCompose) {
try {
$compose = Yaml::parse($application->docker_compose_raw ?? '');
} catch (\Throwable) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'],
], 422);
}
$services = data_get($compose, 'services');
if (! is_array($services) || $services === []) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'],
], 422);
}
$composeServices = collect($services)
->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image')))
->keys()
->map(fn (mixed $name): string => (string) $name)
->values();
$requestedServices = collect($request->input('docker_compose_domains'))->pluck('name');
if ($requestedServices->diff($composeServices)->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'],
], 422);
}
$existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: [];
$dockerComposeDomains = $composeServices
->mapWithKeys(function (string $service) use ($existingComposeDomains): array {
$entry = ['domain' => ''];
$redirect = $existingComposeDomains[$service]['redirect'] ?? null;
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
return [$service => $entry];
})
->all();
foreach ($request->input('docker_compose_domains') as $item) {
$entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? ''];
$redirect = array_key_exists('redirect', $item)
? data_get($item, 'redirect')
: ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null);
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomains[data_get($item, 'name')] = $entry;
}
$domains = collect($dockerComposeDomains)
->pluck('domain')
->filter()
->implode(',') ?: null;
} else {
$domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains'));
}
$submittedUrls = collect(ValidationPatterns::applicationDomainList($domains))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain));
if ($submittedUrls->duplicates()->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
$isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.',
],
], 422);
}
$normalized = DomainPortOverrides::normalize($domains, null);
$portlessDomains = $normalized['fqdn'];
if ($isCompose) {
foreach ($dockerComposeDomains as $service => $entry) {
$dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain']))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->implode(',');
}
$dockerComposeDomainsResponse = collect($dockerComposeDomains)
->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry])
->values()
->all();
}
$urls = collect(ValidationPatterns::applicationDomainList($portlessDomains));
$conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
if (isset($conflicts['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']],
], 422);
}
if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $conflicts['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
$hostCandidates = $urls
->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST))
->filter();
$conflictingPreview = null;
if ($hostCandidates->isNotEmpty()) {
$conflictingPreview = ApplicationPreview::query()
->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)
->withoutGlobalScope('withRelations')
->reorder()
->select('applications.id'))
->whereKeyNot($preview->id)
->whereNotNull('fqdn')
->where(function ($query) use ($hostCandidates): void {
foreach ($hostCandidates as $host) {
$query->orWhere('fqdn', 'like', '%'.$host.'%');
}
})
->get(['uuid', 'pull_request_id', 'fqdn'])
->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->intersect($urls)
->isNotEmpty());
}
if ($conflictingPreview && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => [[
'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->intersect($urls)
->first(),
'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id,
'resource_uuid' => $conflictingPreview->uuid,
'resource_type' => 'application',
'message' => 'Domain is already in use by another preview deployment.',
]],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
$preview->domain_port_overrides = $normalized['overrides'];
$preview->fqdn = $portlessDomains;
if ($isCompose) {
$preview->docker_compose_domains = json_encode($dockerComposeDomains);
}
$preview->save();
auditLog('api.application.preview_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'pull_request_id' => $preview->pull_request_id,
'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'],
]);
return response()->json([
'uuid' => $preview->uuid,
'pull_request_id' => $preview->pull_request_id,
'domains' => $preview->fqdn,
'docker_compose_domains' => $dockerComposeDomainsResponse,
'domain_port_overrides' => $preview->domain_port_overrides,
]);
}
#[OA\Delete( #[OA\Delete(
summary: 'Delete', summary: 'Delete',
description: 'Delete application by UUID.', description: 'Delete application by UUID.',
@ -2818,6 +3078,7 @@ public function update_by_uuid(Request $request)
'http_basic_auth_username' => 'string', 'http_basic_auth_username' => 'string',
'http_basic_auth_password' => 'string', 'http_basic_auth_password' => 'string',
'include_source_commit_in_build' => 'boolean', 'include_source_commit_in_build' => 'boolean',
'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/',
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [ $validationMessages = [
@ -2826,10 +3087,10 @@ public function update_by_uuid(Request $request)
$validator = Validator::make($request->all(), $validationRules, $validationMessages); $validator = Validator::make($request->all(), $validationRules, $validationMessages);
// Validate ports_exposes // Validate ports_exposes
if ($request->has('ports_exposes')) { if ($request->filled('ports_exposes')) {
$ports = explode(',', $request->ports_exposes); $ports = explode(',', $request->ports_exposes);
foreach ($ports as $port) { foreach ($ports as $port) {
if (! is_numeric($port)) { if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => [ 'errors' => [
@ -3117,7 +3378,12 @@ public function update_by_uuid(Request $request)
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
[$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts(
$dockerComposeDomainsJson,
$application->domain_port_overrides,
);
data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson));
data_set($data, 'domain_port_overrides', $domainPortOverrides);
} }
$requestHasNoindexDomains = $request->has('noindex_domains'); $requestHasNoindexDomains = $request->has('noindex_domains');
data_forget($data, 'noindex_domains'); data_forget($data, 'noindex_domains');
@ -5152,7 +5418,7 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$this->authorize('delete', $application); $this->authorize('delete', $application);
$pullRequestIdRaw = $request->route('pull_request_id'); $pullRequestIdRaw = $request->route('pull_request_id');
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422); return response()->json(['message' => 'Invalid pull_request_id.'], 422);
} }
$pullRequestId = (int) $pullRequestIdRaw; $pullRequestId = (int) $pullRequestIdRaw;
@ -5864,4 +6130,28 @@ public function remove_destination(Request $request): JsonResponse
return response()->json(['message' => 'Destination detached.']); return response()->json(['message' => 'Destination detached.']);
} }
/**
* @param Collection<string, array{domain: ?string, redirect?: string}> $domains
* @param array<string, int|string>|null $existingOverrides
* @return array{Collection<string, array{domain: ?string, redirect?: string}>, ?array<string, int>}
*/
private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array
{
$allDomains = $domains
->pluck('domain')
->filter()
->implode(',');
$normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides);
$domains = $domains->map(function (array $entry): array {
$entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->implode(',');
return $entry;
});
return [$domains, $normalized['overrides']];
}
} }

View file

@ -769,6 +769,7 @@ public function update_by_uuid(Request $request)
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365, 'default' => 0],
], ],
), ),
) )
@ -805,7 +806,7 @@ public function update_by_uuid(Request $request)
)] )]
public function create_backup(Request $request) public function create_backup(Request $request)
{ {
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -833,6 +834,7 @@ public function create_backup(Request $request)
'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000', 'timeout' => 'integer|min:60|max:36000',
'missing_backup_notification_days' => 'integer|min:0|max:365',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -1025,6 +1027,7 @@ public function create_backup(Request $request)
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365],
], ],
), ),
) )
@ -1054,7 +1057,7 @@ public function create_backup(Request $request)
)] )]
public function update_backup(Request $request) public function update_backup(Request $request)
{ {
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -1080,6 +1083,7 @@ public function update_backup(Request $request)
'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000', 'timeout' => 'integer|min:60|max:36000',
'missing_backup_notification_days' => 'integer|min:0|max:365',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
@ -4885,6 +4889,8 @@ function () use ($database) {
'id', 'id',
'created_at', 'created_at',
'updated_at', 'updated_at',
'last_execution_at',
'missing_backup_notification_sent_at',
])->fill([ ])->fill([
'uuid' => new_public_id(), 'uuid' => new_public_id(),
'database_id' => $newDatabase->id, 'database_id' => $newDatabase->id,

View file

@ -15,6 +15,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
class NotificationsController extends Controller class NotificationsController extends Controller
@ -45,6 +46,7 @@ private function channelConfig(string $channel): array
'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_success_email_notifications' => 'sometimes|boolean',
'deployment_failure_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean',
'status_change_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean',
'restart_limit_reached_email_notifications' => 'sometimes|boolean',
'backup_success_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean',
'backup_failure_email_notifications' => 'sometimes|boolean', 'backup_failure_email_notifications' => 'sometimes|boolean',
'scheduled_task_success_email_notifications' => 'sometimes|boolean', 'scheduled_task_success_email_notifications' => 'sometimes|boolean',
@ -66,6 +68,7 @@ private function channelConfig(string $channel): array
'deployment_success_discord_notifications' => 'sometimes|boolean', 'deployment_success_discord_notifications' => 'sometimes|boolean',
'deployment_failure_discord_notifications' => 'sometimes|boolean', 'deployment_failure_discord_notifications' => 'sometimes|boolean',
'status_change_discord_notifications' => 'sometimes|boolean', 'status_change_discord_notifications' => 'sometimes|boolean',
'restart_limit_reached_discord_notifications' => 'sometimes|boolean',
'backup_success_discord_notifications' => 'sometimes|boolean', 'backup_success_discord_notifications' => 'sometimes|boolean',
'backup_failure_discord_notifications' => 'sometimes|boolean', 'backup_failure_discord_notifications' => 'sometimes|boolean',
'scheduled_task_success_discord_notifications' => 'sometimes|boolean', 'scheduled_task_success_discord_notifications' => 'sometimes|boolean',
@ -88,6 +91,7 @@ private function channelConfig(string $channel): array
'deployment_success_slack_notifications' => 'sometimes|boolean', 'deployment_success_slack_notifications' => 'sometimes|boolean',
'deployment_failure_slack_notifications' => 'sometimes|boolean', 'deployment_failure_slack_notifications' => 'sometimes|boolean',
'status_change_slack_notifications' => 'sometimes|boolean', 'status_change_slack_notifications' => 'sometimes|boolean',
'restart_limit_reached_slack_notifications' => 'sometimes|boolean',
'backup_success_slack_notifications' => 'sometimes|boolean', 'backup_success_slack_notifications' => 'sometimes|boolean',
'backup_failure_slack_notifications' => 'sometimes|boolean', 'backup_failure_slack_notifications' => 'sometimes|boolean',
'scheduled_task_success_slack_notifications' => 'sometimes|boolean', 'scheduled_task_success_slack_notifications' => 'sometimes|boolean',
@ -110,6 +114,7 @@ private function channelConfig(string $channel): array
'deployment_success_telegram_notifications' => 'sometimes|boolean', 'deployment_success_telegram_notifications' => 'sometimes|boolean',
'deployment_failure_telegram_notifications' => 'sometimes|boolean', 'deployment_failure_telegram_notifications' => 'sometimes|boolean',
'status_change_telegram_notifications' => 'sometimes|boolean', 'status_change_telegram_notifications' => 'sometimes|boolean',
'restart_limit_reached_telegram_notifications' => 'sometimes|boolean',
'backup_success_telegram_notifications' => 'sometimes|boolean', 'backup_success_telegram_notifications' => 'sometimes|boolean',
'backup_failure_telegram_notifications' => 'sometimes|boolean', 'backup_failure_telegram_notifications' => 'sometimes|boolean',
'scheduled_task_success_telegram_notifications' => 'sometimes|boolean', 'scheduled_task_success_telegram_notifications' => 'sometimes|boolean',
@ -124,6 +129,7 @@ private function channelConfig(string $channel): array
'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_restart_limit_reached_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255',
@ -146,6 +152,7 @@ private function channelConfig(string $channel): array
'deployment_success_pushover_notifications' => 'sometimes|boolean', 'deployment_success_pushover_notifications' => 'sometimes|boolean',
'deployment_failure_pushover_notifications' => 'sometimes|boolean', 'deployment_failure_pushover_notifications' => 'sometimes|boolean',
'status_change_pushover_notifications' => 'sometimes|boolean', 'status_change_pushover_notifications' => 'sometimes|boolean',
'restart_limit_reached_pushover_notifications' => 'sometimes|boolean',
'backup_success_pushover_notifications' => 'sometimes|boolean', 'backup_success_pushover_notifications' => 'sometimes|boolean',
'backup_failure_pushover_notifications' => 'sometimes|boolean', 'backup_failure_pushover_notifications' => 'sometimes|boolean',
'scheduled_task_success_pushover_notifications' => 'sometimes|boolean', 'scheduled_task_success_pushover_notifications' => 'sometimes|boolean',
@ -167,6 +174,7 @@ private function channelConfig(string $channel): array
'deployment_success_webhook_notifications' => 'sometimes|boolean', 'deployment_success_webhook_notifications' => 'sometimes|boolean',
'deployment_failure_webhook_notifications' => 'sometimes|boolean', 'deployment_failure_webhook_notifications' => 'sometimes|boolean',
'status_change_webhook_notifications' => 'sometimes|boolean', 'status_change_webhook_notifications' => 'sometimes|boolean',
'restart_limit_reached_webhook_notifications' => 'sometimes|boolean',
'backup_success_webhook_notifications' => 'sometimes|boolean', 'backup_success_webhook_notifications' => 'sometimes|boolean',
'backup_failure_webhook_notifications' => 'sometimes|boolean', 'backup_failure_webhook_notifications' => 'sometimes|boolean',
'scheduled_task_success_webhook_notifications' => 'sometimes|boolean', 'scheduled_task_success_webhook_notifications' => 'sometimes|boolean',
@ -249,7 +257,7 @@ private function updateChannel(Request $request, string $channel): JsonResponse
$body = $request->json()->all(); $body = $request->json()->all();
$config = $this->channelConfig($channel); $config = $this->channelConfig($channel);
$validator = customApiValidator($body, $config['rules']); $validator = Validator::make($body, $config['rules']);
$extraFields = array_diff(array_keys($body), $allowedFields); $extraFields = array_diff(array_keys($body), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {

View file

@ -4,8 +4,9 @@
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
#[OA\Info(title: 'Coolify', version: '0.1')] // MapleDeploy branding: API documentation
#[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.')] #[OA\Info(title: 'MapleDeploy', version: '0.1')]
#[OA\Server(url: '/api/v1', description: 'MapleDeploy API. Powered by Coolify.')]
#[OA\SecurityScheme( #[OA\SecurityScheme(
type: 'http', type: 'http',
scheme: 'bearer', scheme: 'bearer',

View file

@ -5,7 +5,6 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
class OtherController extends Controller class OtherController extends Controller
@ -273,23 +272,6 @@ public function disable_mcp(Request $request)
return response()->json(['message' => 'MCP server disabled.'], 200); return response()->json(['message' => 'MCP server disabled.'], 200);
} }
public function feedback(Request $request)
{
$data = $request->validate([
'content' => ['required', 'string', 'min:10', 'max:2000'],
]);
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
Http::timeout(5)->post($webhook_url, [
'content' => $data['content'],
'allowed_mentions' => ['parse' => []],
]);
}
return response()->json(['message' => 'Feedback sent.'], 200);
}
#[OA\Get( #[OA\Get(
summary: 'Healthcheck', summary: 'Healthcheck',
description: 'Healthcheck endpoint.', description: 'Healthcheck endpoint.',

View file

@ -138,7 +138,7 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
/** /**
* Build a stable hash of container state. * Build a stable hash of container state.
* *
* Covers [name, state] only metrics, filesystem_usage_root, and * Covers [name, state, restart_count] only metrics, filesystem_usage_root, and
* health_status are excluded on purpose. Disk % churns constantly, and * health_status are excluded on purpose. Disk % churns constantly, and
* health checks can flap between starting/healthy/unhealthy while the * health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat * container lifecycle state remains unchanged. Both would otherwise defeat
@ -153,6 +153,7 @@ private function containerStateHash(array $data): string
->map(fn ($c) => [ ->map(fn ($c) => [
'name' => data_get($c, 'name'), 'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'), 'state' => data_get($c, 'state'),
'restart_count' => data_get($c, 'restart_count'),
]) ])
->sortBy('name') ->sortBy('name')
->values() ->values()

View file

@ -12,7 +12,6 @@
class ServerSentinelController extends Controller class ServerSentinelController extends Controller
{ {
private const ALLOWED_FIELDS = [ private const ALLOWED_FIELDS = [
'is_sentinel_enabled',
'is_metrics_enabled', 'is_metrics_enabled',
'is_sentinel_debug_enabled', 'is_sentinel_debug_enabled',
'sentinel_token', 'sentinel_token',
@ -36,7 +35,7 @@ private function transform(Server $server): array
{ {
$settings = $server->settings; $settings = $server->settings;
$payload = [ $payload = [
'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled, 'is_sentinel_enabled' => $server->isSentinelEnabled(),
'is_metrics_enabled' => (bool) $settings->is_metrics_enabled, 'is_metrics_enabled' => (bool) $settings->is_metrics_enabled,
'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled, 'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled,
'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds, 'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds,
@ -69,7 +68,7 @@ private function transform(Server $server): array
description: 'Sentinel settings.', description: 'Sentinel settings.',
content: new OA\JsonContent( content: new OA\JsonContent(
properties: [ properties: [
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'),
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'), new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'),
@ -118,7 +117,6 @@ public function show(Request $request): JsonResponse
required: true, required: true,
content: new OA\JsonContent( content: new OA\JsonContent(
properties: [ properties: [
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
new OA\Property(property: 'sentinel_token', type: 'string'), new OA\Property(property: 'sentinel_token', type: 'string'),
@ -158,7 +156,6 @@ public function update(Request $request): JsonResponse
$this->authorize('update', $server); $this->authorize('update', $server);
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'is_sentinel_enabled' => 'boolean',
'is_metrics_enabled' => 'boolean', 'is_metrics_enabled' => 'boolean',
'is_sentinel_debug_enabled' => 'boolean', 'is_sentinel_debug_enabled' => 'boolean',
'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'], 'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'],
@ -189,29 +186,12 @@ public function update(Request $request): JsonResponse
} }
$settings = $server->settings; $settings = $server->settings;
$enablingSentinel = $request->has('is_sentinel_enabled')
&& $request->boolean('is_sentinel_enabled')
&& ! $settings->is_sentinel_enabled;
if ($enablingSentinel && $server->isBuildServer()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']],
], 422);
}
foreach (self::ALLOWED_FIELDS as $field) { foreach (self::ALLOWED_FIELDS as $field) {
if ($request->has($field)) { if ($request->has($field)) {
$settings->{$field} = $request->input($field); $settings->{$field} = $request->input($field);
} }
} }
// Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel).
if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) {
$settings->is_metrics_enabled = false;
$settings->is_sentinel_debug_enabled = false;
}
$settings->save(); $settings->save();
auditLog('api.server.sentinel.updated', [ auditLog('api.server.sentinel.updated', [

View file

@ -9,6 +9,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -257,6 +258,7 @@ public function show(Request $request): JsonResponse
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true),
'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'),
] ]
) )
) )
@ -330,10 +332,11 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
'is_gzip_enabled', 'is_gzip_enabled',
'is_stripprefix_enabled', 'is_stripprefix_enabled',
'is_force_https_enabled', 'is_force_https_enabled',
'max_restart_count',
]; ];
$validationRules = [ $validationRules = [
'url' => 'nullable|string', 'url' => ValidationPatterns::applicationDomainRules(),
'noindex_domains' => 'sometimes|array|nullable', 'noindex_domains' => 'sometimes|array|nullable',
'noindex_domains.*' => 'string', 'noindex_domains.*' => 'string',
'human_name' => 'nullable|string|max:255', 'human_name' => 'nullable|string|max:255',
@ -344,6 +347,7 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
'is_gzip_enabled' => 'sometimes|boolean', 'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean', 'is_stripprefix_enabled' => 'sometimes|boolean',
'is_force_https_enabled' => 'sometimes|boolean', 'is_force_https_enabled' => 'sometimes|boolean',
'max_restart_count' => 'sometimes|integer|min:0',
]; ];
$validator = Validator::make($payload, $validationRules); $validator = Validator::make($payload, $validationRules);

View file

@ -386,7 +386,7 @@ public function create_service(Request $request)
'urls' => 'array|nullable', 'urls' => 'array|nullable',
'urls.*' => 'array:name,url', 'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required', 'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable', 'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable', 'tags' => 'array|nullable',
@ -602,7 +602,7 @@ public function create_service(Request $request)
'urls' => 'array|nullable', 'urls' => 'array|nullable',
'urls.*' => 'array:name,url', 'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required', 'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable', 'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable', 'tags' => 'array|nullable',
@ -1187,7 +1187,7 @@ public function update_by_uuid(Request $request)
'urls' => 'array|nullable', 'urls' => 'array|nullable',
'urls.*' => 'array:name,url', 'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required', 'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable', 'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
]; ];

View file

@ -82,6 +82,11 @@ public function forgot_password(Request $request)
return response()->json(['message' => 'Transactional emails are not active'], 400); return response()->json(['message' => 'Transactional emails are not active'], 400);
} }
$request->validate([Fortify::email() => 'required|email']); $request->validate([Fortify::email() => 'required|email']);
$user = User::where('email', $request->input(Fortify::email()))->first();
if ($user?->isMapledeployRevoked()) {
// MapleDeploy branding: only the dashboard set-password path can restore revoked users.
return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
}
$status = Password::broker(config('fortify.passwords'))->sendResetLink( $status = Password::broker(config('fortify.passwords'))->sendResetLink(
$request->only(Fortify::email()) $request->only(Fortify::email())
); );

View file

@ -25,6 +25,12 @@ public function callback(string $provider)
} }
$email = strtolower($email); $email = strtolower($email);
$user = User::whereEmail($email)->first(); $user = User::whereEmail($email)->first();
// MapleDeploy branding: dashboard revocation scrambles passwords,
// clears sessions, and marks the user so email-matched OAuth cannot
// reopen access.
if ($user?->isMapledeployRevoked()) {
abort(403, 'User access has been revoked');
}
if (! $user) { if (! $user) {
$settings = instanceSettings(); $settings = instanceSettings();
if (! $settings->is_registration_enabled) { if (! $settings->is_registration_enabled) {
@ -38,6 +44,14 @@ public function callback(string $provider)
} }
Auth::login($user); Auth::login($user);
$team = $user->resolveStoredTeam();
if (! $team && $user->teams()->count() === 0) {
$team = $user->recreate_personal_team();
}
if ($team) {
session(['currentTeam' => $user->currentTeam = $team]);
}
return redirect('/'); return redirect('/');
} catch (\Exception $e) { } catch (\Exception $e) {
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';

View file

@ -14,7 +14,7 @@ public function __invoke(AvatarStorageService $avatarStorage): Response
return response($contents, 200, [ return response($contents, 200, [
'Content-Type' => 'image/jpeg', 'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=300', 'Cache-Control' => 'private, max-age=31536000, immutable',
]); ]);
} }
} }

View file

@ -15,6 +15,9 @@ public function __invoke(string $project_uuid, ProjectIconStorageService $iconSt
abort_if($contents === null, 404); abort_if($contents === null, 404);
return response($contents)->header('Content-Type', 'image/jpeg'); return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=31536000, immutable',
]);
} }
} }

View file

@ -5,7 +5,6 @@
use App\Models\Application; use App\Models\Application;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
trait MatchesManualWebhookApplications trait MatchesManualWebhookApplications
{ {
@ -79,12 +78,8 @@ protected function canonicalManualWebhookRepository(?string $gitRepository): ?st
if (is_array($parts) && isset($parts['scheme'])) { if (is_array($parts) && isset($parts['scheme'])) {
$path = data_get($parts, 'path'); $path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { } elseif (($scp = parseScpStyleGitUrl($gitRepository)) !== null) {
$path = Str::after($gitRepository, ':'); $path = $scp['path'];
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
// Strip the leading numeric port segment so the path matches the webhook
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
} else { } else {
$path = $gitRepository; $path = $gitRepository;
} }

View file

@ -83,7 +83,10 @@ public function manual(Request $request)
} }
} }
if ($x_github_event === 'pull_request') { if ($x_github_event === 'pull_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
} }
@ -334,7 +337,10 @@ public function normal(Request $request)
} }
} }
if ($x_github_event === 'pull_request') { if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get(); if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $applications->get();
if ($applications->isEmpty()) { if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'."); return response("Nothing to do. No applications found with branch '$base_branch'.");
} }

View file

@ -17,6 +17,7 @@
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\RejectMapledeployRevokedUser;
use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts; use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies; use App\Http\Middleware\TrustProxies;
@ -75,6 +76,7 @@ class Kernel extends HttpKernel
ShareErrorsFromSession::class, ShareErrorsFromSession::class,
VerifyCsrfToken::class, VerifyCsrfToken::class,
SubstituteBindings::class, SubstituteBindings::class,
RejectMapledeployRevokedUser::class,
CheckForcePasswordReset::class, CheckForcePasswordReset::class,
DecideWhatToDoWithUser::class, DecideWhatToDoWithUser::class,

View file

@ -16,11 +16,36 @@ public function handle(Request $request, Closure $next): Response
$currentTeam = auth()->user()?->recreate_personal_team(); $currentTeam = auth()->user()?->recreate_personal_team();
refreshSession($currentTeam); refreshSession($currentTeam);
} }
$preferredTeam = auth()?->user()?->mapledeployPreferredTeam();
if (
$preferredTeam &&
$preferredTeam->id === 0 &&
auth()?->user()?->currentTeam()?->id !== 0
) {
// MapleDeploy branding: repair sessions that landed in the empty
// personal team before dashboard-managed root-team access existed.
refreshSession($preferredTeam);
}
if (auth()?->user()?->currentTeam()) { if (auth()?->user()?->currentTeam()) {
refreshSession(auth()->user()->currentTeam()); refreshSession(auth()->user()->currentTeam());
// A team is already active; the selection screen no longer applies.
if ($request->routeIs('team.select')) {
return redirect()->route('dashboard');
}
} elseif (auth()?->user()?->teams?->count() > 0) { } elseif (auth()?->user()?->teams?->count() > 0) {
// User's session team is invalid (e.g., removed from team), switch to first available team // No active team in the session (fresh login or invalidated selection).
refreshSession(auth()->user()->teams->first()); // Restore the last active team, or the sole team of a single-team user.
$resolvedTeam = auth()->user()->resolveStoredTeam();
if ($resolvedTeam) {
refreshSession($resolvedTeam);
} elseif ($request->routeIs('team.select') || $request->routeIs('*livewire.update')) {
// Ambiguous choice: let the user pick a team on the selection screen.
// Livewire's update endpoint must pass through too, otherwise the
// selection action's AJAX call is redirected to HTML and never runs.
return $next($request);
} else {
return redirect()->route('team.select');
}
} }
if (! auth()->user() || ! isCloud()) { if (! auth()->user() || ! isCloud()) {
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) { if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RejectMapledeployRevokedUser
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = auth()->user();
if (! $user?->isMapledeployRevoked()) {
return $next($request);
}
// MapleDeploy branding: revocation is marked on the user row so old
// browser sessions are rejected even when SESSION_DRIVER is not database.
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->routeIs('login') || $request->path() === 'login') {
return $next($request);
}
return redirect()->route('login')->withErrors([
'email' => __('auth.failed'),
]);
}
}

View file

@ -44,6 +44,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env'; public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env';
private const BUILD_SCRIPT_PATH = '/artifacts/build.sh'; private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
@ -201,6 +205,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerSecretsSupported = false; private bool $dockerSecretsSupported = false;
private bool $dockerSecretsAvailable = false;
private bool $useBuildtimeEnvironmentLauncher = false;
private bool $skip_build = false; private bool $skip_build = false;
private Collection|string $build_secrets; private Collection|string $build_secrets;
@ -261,14 +269,7 @@ public function __construct(public int $application_deployment_queue_id)
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id); $this->container_name = $this->resolveContainerName();
if ($this->application->settings->custom_internal_name && ! $this->application->settings->is_consistent_container_name_enabled) {
if ($this->pull_request_id === 0) {
$this->container_name = $this->application->settings->custom_internal_name;
} else {
$this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
}
}
$this->saved_outputs = collect(); $this->saved_outputs = collect();
@ -425,6 +426,11 @@ public function handle(): void
private function detectBuildKitCapabilities(): void private function detectBuildKitCapabilities(): void
{ {
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->dockerSecretsAvailable = false;
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@ -475,18 +481,19 @@ private function detectBuildKitCapabilities(): void
} }
} }
// If build secrets are enabled and BuildKit is available, verify --secret flag support if ($this->dockerBuildkitSupported) {
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process( $secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck $serverToCheck
); );
if (trim($secretsTest) === 'supported') { if (trim($secretsTest) === 'supported') {
$this->dockerSecretsSupported = true; $this->dockerSecretsAvailable = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); if ($this->application->settings->use_build_secrets) {
} else { $this->dockerSecretsSupported = true;
$this->dockerSecretsSupported = false; $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
}
} elseif ($this->application->settings->use_build_secrets) {
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
} }
} }
@ -494,6 +501,7 @@ private function detectBuildKitCapabilities(): void
$this->dockerBuildkitSupported = false; $this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false; $this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false; $this->dockerSecretsSupported = false;
$this->dockerSecretsAvailable = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
} }
} }
@ -800,6 +808,8 @@ private function deploy_docker_compose_buildpack()
// This overwrites the build-time .env with ALL variables (build-time + runtime) // This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables(); $this->save_runtime_environment_variables();
$this->pull_docker_compose_images();
$this->stop_running_container(force: true); $this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.'); $this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid; $networkId = $this->application->uuid;
@ -903,6 +913,26 @@ private function deploy_docker_compose_buildpack()
$this->application_deployment_queue->addLogEntry('New container started.'); $this->application_deployment_queue->addLogEntry('New container started.');
} }
private function pull_docker_compose_images(): void
{
$this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.');
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->mainServer;
$workdir = $this->application->workdir();
$command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable";
} else {
$workdir = $this->workdir;
$command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable");
}
$this->execute_remote_command([
$command,
'hidden' => true,
]);
}
private function deploy_dockerfile_buildpack() private function deploy_dockerfile_buildpack()
{ {
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
@ -1638,11 +1668,14 @@ private function generate_buildtime_environment_variables()
} }
foreach ($planVariables as $key => $value) { foreach ($planVariables as $key => $value) {
$key = (string) $key;
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
continue; continue;
} }
$key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan');
$escapedValue = escapeBashEnvValue($value); $escapedValue = escapeBashEnvValue($value);
$envs_dict[$key] = $escapedValue; $envs_dict[$key] = $escapedValue;
@ -1830,6 +1863,7 @@ private function generate_buildtime_environment_variables()
// Convert dictionary back to collection in KEY=VALUE format // Convert dictionary back to collection in KEY=VALUE format
$envs = collect([]); $envs = collect([]);
foreach ($envs_dict as $key => $value) { foreach ($envs_dict as $key => $value) {
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment');
$envs->push($key.'='.$value); $envs->push($key.'='.$value);
} }
@ -1843,44 +1877,130 @@ private function generate_buildtime_environment_variables()
return $envs; return $envs;
} }
private function save_buildtime_environment_variables() private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
{ {
// Generate build-time environment variables locally try {
$environment_variables = $this->generate_buildtime_environment_variables(); if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
// Save .env file for build phase in /artifacts to prevent it from being copied into Docker images
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
]
);
if (isDev()) {
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
'hidden' => true,
]
);
} }
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
$this->execute_remote_command( return $key;
[ } catch (\InvalidArgumentException $exception) {
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), $this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin);
]
throw new DeploymentException(
"Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.',
previous: $exception,
); );
} }
} }
private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void
{
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
$this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr');
$this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr');
$this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info');
if ($origin === 'the Nixpacks plan') {
$this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info');
$this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info');
$this->logSuggestedShellEnvironmentVariableKey($key);
$this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info');
$this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info');
} else {
$this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info');
$this->logSuggestedShellEnvironmentVariableKey($key);
}
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
}
private function logSuggestedShellEnvironmentVariableKey(string $key): void
{
$suggestedKey = str_replace('.', '_', $key);
if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) {
return;
}
$displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey);
$this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info');
}
private function save_buildtime_environment_variables()
{
$environment_variables = $this->generate_buildtime_environment_variables();
[$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool {
[$key] = explode('=', $environmentVariable, 2);
return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1;
});
if ($dotted_environment_variables->isEmpty()) {
$this->useBuildtimeEnvironmentLauncher = false;
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
]);
if (isDev()) {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
'hidden' => true,
]);
}
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
]);
}
return;
}
$this->useBuildtimeEnvironmentLauncher = true;
$launcher = [
'#!/bin/bash',
'set -a',
'source '.self::BUILD_TIME_SHELL_ENV_PATH,
'set +a',
];
$launcher[] = 'exec env \\';
foreach ($dotted_environment_variables as $environmentVariable) {
$launcher[] = " {$environmentVariable} \\";
}
$launcher[] = ' "$@"';
$files = [
self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"),
self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"),
self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n",
];
$this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true);
foreach ($files as $path => $contents) {
$contents_base64 = base64_encode($contents);
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"),
]);
}
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH),
]);
}
private function elixir_finetunes() private function elixir_finetunes()
{ {
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
@ -1986,6 +2106,19 @@ private function rolling_update()
} }
} }
private function resolveContainerName(): string
{
if (str($this->application->settings->custom_internal_name)->isEmpty()) {
return generateApplicationContainerName($this->application, $this->pull_request_id);
}
if ($this->pull_request_id === 0) {
return $this->application->settings->custom_internal_name;
}
return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
}
private function health_check() private function health_check()
{ {
try { try {
@ -2251,12 +2384,8 @@ private function deploy_to_additional_destinations()
destination: $destination, destination: $destination,
no_questions_asked: true, no_questions_asked: true,
); );
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [ $deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}";
'project_uuid' => data_get($this->application, 'environment.project.uuid'), $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}");
'application_uuid' => data_get($this->application, 'uuid'),
'deployment_uuid' => $deployment_uuid,
'environment_uuid' => data_get($this->application, 'environment.uuid'),
]));
} }
} }
@ -2274,15 +2403,20 @@ private function set_coolify_variables()
$fqdn = $this->preview->fqdn; $fqdn = $this->preview->fqdn;
} }
if (isset($fqdn)) { if (isset($fqdn)) {
$url = Url::fromString($fqdn); $domains = str($fqdn)->explode(',')
$fqdn = $url->getHost(); ->map(fn (string $domain) => trim($domain))
$url = $url->withHost($fqdn)->withPort(null)->__toString(); ->filter()
if ((int) $this->application->compose_parsing_version >= 3) { ->filter(fn (string $domain) => isValidDomainUrl($domain));
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; if ($domains->isNotEmpty()) {
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; $url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
} else { $fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(',');
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' '; if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' '; $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
} }
} }
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
@ -3428,6 +3562,9 @@ private function generate_compose_file()
$custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options);
if ((bool) $this->application->settings->is_consistent_container_name_enabled) { if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
$docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name];
if ($this->container_name !== $this->application->uuid) {
unset($docker_compose['services'][$this->container_name]);
}
if (count($custom_compose) > 0) { if (count($custom_compose) > 0) {
$ipv4 = data_get($custom_compose, 'ip.0'); $ipv4 = data_get($custom_compose, 'ip.0');
$ipv6 = data_get($custom_compose, 'ip6.0'); $ipv6 = data_get($custom_compose, 'ip6.0');
@ -3644,7 +3781,13 @@ private function build_static_image()
*/ */
private function wrap_build_command_with_env_export(string $build_command): string private function wrap_build_command_with_env_export(string $build_command): string
{ {
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; if (! $this->useBuildtimeEnvironmentLauncher) {
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
}
$escapedBuildCommand = escapeBashEnvValue($build_command);
return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}";
} }
private function build_image() private function build_image()
@ -4027,7 +4170,10 @@ private function stop_running_container(bool $force = false)
$this->application_deployment_queue->addLogEntry('Removing old containers.'); $this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) { if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->graceful_shutdown_container($this->container_name); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
$this->graceful_shutdown_container($containerName);
});
} else { } else {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
@ -4066,6 +4212,16 @@ private function stop_running_container(bool $force = false)
} }
} }
private function containerNamesToRemove(Collection $containers): Collection
{
return $containers
->pluck('Names')
->push($this->container_name)
->filter()
->unique()
->values();
}
private function start_by_compose_file() private function start_by_compose_file()
{ {
try { try {
@ -4154,6 +4310,21 @@ private function generate_build_env_variables()
$this->analyzeBuildTimeVariables($variables); $this->analyzeBuildTimeVariables($variables);
} }
$requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks'
&& $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.'));
if ($requiresDottedEnvironmentSecrets) {
if (! $this->dockerSecretsAvailable) {
$dottedKeys = $variables->keys()
->filter(fn ($key): bool => str_contains((string) $key, '.'))
->implode(', ');
throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server.");
}
$this->dockerSecretsSupported = true;
}
if ($this->dockerSecretsSupported) { if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables); $this->generate_build_secrets($variables);
$this->build_args = ''; $this->build_args = '';
@ -4418,7 +4589,7 @@ private function add_build_env_variables_to_dockerfile()
private function modify_dockerfile_for_secrets($dockerfile_path) private function modify_dockerfile_for_secrets($dockerfile_path)
{ {
// Only process if build secrets are enabled and we have secrets to mount // Only process if build secrets are enabled and we have secrets to mount
if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { if (empty($this->build_secrets)) {
return; return;
} }
@ -4442,18 +4613,51 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
$this->generate_env_variables(); $this->generate_env_variables();
} }
$variables = $this->env_args; $variables = $this->application->build_pack === 'nixpacks'
? collect($this->nixpacks_plan_json->get('variables'))
: $this->env_args;
if ($variables->isEmpty()) { if ($variables->isEmpty()) {
return; return;
} }
$dottedKeys = $variables->keys()
->map(fn ($key): string => (string) $key)
->filter(fn (string $key): bool => str_contains($key, '.'));
if ($dottedKeys->isNotEmpty()) {
$originalDockerfile = $dockerfile;
$dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string {
$trimmedLine = trim($line);
if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) {
return $line;
}
[$instruction, $arguments] = explode(' ', $trimmedLine, 2);
$filteredArguments = collect(preg_split('/\s+/', $arguments))
->reject(function (string $argument) use ($dottedKeys): bool {
$key = str($argument)->before('=')->toString();
return $dottedKeys->contains($key);
});
if ($filteredArguments->isEmpty()) {
return null;
}
return $instruction.' '.$filteredArguments->implode(' ');
})->filter()->values();
$modified = $dockerfile->all() !== $originalDockerfile->values()->all();
}
// Generate mount strings for all secrets // Generate mount strings for all secrets
$mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation // Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$modified = false; $modified ??= false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line); $trimmed = ltrim($line);
@ -4950,11 +5154,21 @@ private function handleSuccessfulDeployment(): void
// Reset restart count after successful deployment // Reset restart count after successful deployment
// This is done here (not in Livewire) to avoid race conditions // This is done here (not in Livewire) to avoid race conditions
// with GetContainersStatus reading old container restart counts // with GetContainersStatus reading old container restart counts
$this->application->update([ $restartState = [
'restart_count' => 0, 'restart_count' => 0,
'last_restart_at' => null, 'last_restart_at' => null,
'last_restart_type' => null, 'last_restart_type' => null,
]); ];
if ($this->pull_request_id === 0) {
$restartState['restart_limit_reached'] = false;
}
if ($this->pull_request_id === 0) {
$this->application->update($restartState);
} else {
$this->preview?->resetRestartLimit();
}
try { try {
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);

View file

@ -4,6 +4,7 @@
use App\Actions\Shared\CheckDomainDns; use App\Actions\Shared\CheckDomainDns;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -23,7 +24,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
public int $timeout = 30; public int $timeout = 30;
public function __construct( public function __construct(
public Application|ServiceApplication $resource, public Application|ApplicationPreview|ServiceApplication $resource,
public string $statusKey, public string $statusKey,
public string $url, public string $url,
public ?Server $server, public ?Server $server,

View file

@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\Database\BackupMissing;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
ScheduledDatabaseBackup::query()
->with(['team', 'database', 'latest_log'])
->where('enabled', true)
->where('missing_backup_notification_days', '>', 0)
->chunkById(100, function ($backups): void {
foreach ($backups as $backup) {
$this->notifyIfMissing($backup);
}
});
}
private function notifyIfMissing(ScheduledDatabaseBackup $backup): void
{
$lastExecutionAt = $backup->last_execution_at ?? $backup->latest_log?->created_at;
$lastActivityAt = $lastExecutionAt ?? $backup->created_at;
if (! $lastActivityAt || $lastActivityAt->isAfter(now()->subDays($backup->missing_backup_notification_days))) {
return;
}
if ($backup->missing_backup_notification_sent_at?->greaterThanOrEqualTo($lastActivityAt)) {
return;
}
if (! $backup->team) {
Log::warning("Cannot send missing backup notification for backup {$backup->id}: team not found");
return;
}
if ($backup->team->getEnabledChannels('backup_failure') === []) {
return;
}
$backup->team->notify(new BackupMissing($backup, $lastExecutionAt));
$backup->forceFill(['missing_backup_notification_sent_at' => now()])->save();
}
}

View file

@ -2,6 +2,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI; use App\Events\ProxyStatusChangedUI;
use App\Models\Server; use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated; use App\Notifications\Server\TraefikVersionOutdated;
@ -33,8 +35,13 @@ public function __construct(
*/ */
public function handle(): void public function handle(): void
{ {
$this->server->refresh();
$this->clearOutdatedInfo(); $this->clearOutdatedInfo();
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) {
return;
}
// Detect current version (makes SSH call) // Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server); $currentVersion = getTraefikVersionFromDockerCompose($this->server);
@ -116,7 +123,10 @@ public function handle(): void
private function clearOutdatedInfo(): void private function clearOutdatedInfo(): void
{ {
$this->server->update(['traefik_outdated_info' => null]); $this->server->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
} }
/** /**

View file

@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void public function handle(): void
{ {
Server::query()
->where(function ($query) {
$query->whereNull('proxy')
->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value);
})
->where(function ($query) {
$query->whereNotNull('detected_traefik_version')
->orWhereNotNull('traefik_outdated_info');
})
->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
// Load versions from cached data // Load versions from cached data
$traefikVersions = get_traefik_versions(); $traefikVersions = get_traefik_versions();

View file

@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
private static function helperContainersCommand(): string
{
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
}
public function handle(): void public function handle(): void
{ {
try { try {
@ -36,7 +41,7 @@ public function handle(): void
'active_deployment_uuids' => $activeDeployments, 'active_deployment_uuids' => $activeDeployments,
]); ]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false); $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
$helperContainers = collect(json_decode($containers)); $helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) { if ($helperContainers->count() > 0) {

View file

@ -322,6 +322,7 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false, 'local_storage_deleted' => false,
]); ]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_postgresql($database); $this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) { } elseif (str($databaseType)->contains('mongo')) {
if ($database === '*') { if ($database === '*') {
@ -343,6 +344,7 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false, 'local_storage_deleted' => false,
]); ]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mongodb($database); $this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) { } elseif (str($databaseType)->contains('mysql')) {
$this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -357,6 +359,7 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false, 'local_storage_deleted' => false,
]); ]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mysql($database); $this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) { } elseif (str($databaseType)->contains('mariadb')) {
$this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -371,6 +374,7 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false, 'local_storage_deleted' => false,
]); ]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mariadb($database); $this->backup_standalone_mariadb($database);
} elseif ($this->database instanceof StandaloneClickhouse) { } elseif ($this->database instanceof StandaloneClickhouse) {
$this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
@ -382,6 +386,7 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false, 'local_storage_deleted' => false,
]); ]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_clickhouse($database); $this->backup_standalone_clickhouse($database);
} else { } else {
throw new \Exception('Unsupported database type'); throw new \Exception('Unsupported database type');
@ -480,14 +485,14 @@ public function handle(): void
} catch (Throwable $e) { } catch (Throwable $e) {
throw $e; throw $e;
} finally { } finally {
if ($this->team) {
BackupCreated::dispatch($this->team->id);
}
if ($this->backup_log) { if ($this->backup_log) {
$this->backup_log->update([ $this->backup_log->update([
'finished_at' => Carbon::now()->toImmutable(), 'finished_at' => Carbon::now()->toImmutable(),
]); ]);
} }
if ($this->team) {
BackupCreated::dispatch($this->team->id);
}
} }
} }

View file

@ -2,11 +2,14 @@
namespace App\Jobs; namespace App\Jobs;
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StartProxy;
use App\Actions\Server\StartLogDrain; use App\Actions\Server\StartLogDrain;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Shared\ComplexStatusCheck; use App\Actions\Shared\ComplexStatusCheck;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
@ -23,8 +26,10 @@
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Models\SwarmDocker; use App\Models\SwarmDocker;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Notifications\Container\ContainerRestarted; use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Services\RestartCountTracker;
use App\Traits\CalculatesExcludedStatus; use App\Traits\CalculatesExcludedStatus;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -95,8 +100,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $applicationContainerStatuses; public Collection $applicationContainerStatuses;
public Collection $applicationContainerRestartCounts;
public Collection $serviceContainerStatuses; public Collection $serviceContainerStatuses;
public Collection $previewContainerRestartCounts;
public Collection $serviceContainerRestartCounts;
public bool $foundProxy = false; public bool $foundProxy = false;
public bool $foundLogDrainContainer = false; public bool $foundLogDrainContainer = false;
@ -122,7 +133,10 @@ public function __construct(public Server $server, public $data)
$this->foundApplicationPreviewsIds = collect(); $this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect(); $this->foundServiceDatabaseIds = collect();
$this->applicationContainerStatuses = collect(); $this->applicationContainerStatuses = collect();
$this->applicationContainerRestartCounts = collect();
$this->serviceContainerStatuses = collect(); $this->serviceContainerStatuses = collect();
$this->previewContainerRestartCounts = collect();
$this->serviceContainerRestartCounts = collect();
$this->allApplicationIds = collect(); $this->allApplicationIds = collect();
$this->allDatabaseUuids = collect(); $this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect(); $this->allTcpProxyUuids = collect();
@ -140,7 +154,10 @@ public function handle()
{ {
// Defensive initialization for Collection properties to handle queue deserialization edge cases // Defensive initialization for Collection properties to handle queue deserialization edge cases
$this->serviceContainerStatuses ??= collect(); $this->serviceContainerStatuses ??= collect();
$this->previewContainerRestartCounts ??= collect();
$this->serviceContainerRestartCounts ??= collect();
$this->applicationContainerStatuses ??= collect(); $this->applicationContainerStatuses ??= collect();
$this->applicationContainerRestartCounts ??= collect();
$this->foundApplicationIds ??= collect(); $this->foundApplicationIds ??= collect();
$this->foundDatabaseUuids ??= collect(); $this->foundDatabaseUuids ??= collect();
$this->foundServiceApplicationIds ??= collect(); $this->foundServiceApplicationIds ??= collect();
@ -231,6 +248,9 @@ public function handle()
if (! $coolify_managed) { if (! $coolify_managed) {
continue; continue;
} }
if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
continue;
}
$name = data_get($container, 'name'); $name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
@ -241,6 +261,10 @@ public function handle()
$pullRequestId = $labels->get('coolify.pullRequestId', '0'); $pullRequestId = $labels->get('coolify.pullRequestId', '0');
try { try {
if ($pullRequestId === '0') { if ($pullRequestId === '0') {
$application = $this->applicationsById->get((string) $applicationId);
if ($application && $application->container_present !== true) {
$application->update(['container_present' => true]);
}
if ($this->allApplicationIds->contains($applicationId)) { if ($this->allApplicationIds->contains($applicationId)) {
$this->foundApplicationIds->push($applicationId); $this->foundApplicationIds->push($applicationId);
} }
@ -251,6 +275,13 @@ public function handle()
$containerName = $labels->get('com.docker.compose.service'); $containerName = $labels->get('com.docker.compose.service');
if ($containerName) { if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
$restartCount = data_get($container, 'restart_count');
if (is_numeric($restartCount)) {
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
$this->applicationContainerRestartCounts->put($applicationId, collect());
}
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, (int) $restartCount);
}
} }
} else { } else {
$previewKey = $applicationId.':'.$pullRequestId; $previewKey = $applicationId.':'.$pullRequestId;
@ -258,6 +289,13 @@ public function handle()
$this->foundApplicationPreviewsIds->push($previewKey); $this->foundApplicationPreviewsIds->push($previewKey);
} }
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus); $this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
$restartCount = data_get($container, 'restart_count');
if (is_numeric($restartCount)) {
$this->previewContainerRestartCounts->push([
'key' => $previewKey,
'count' => (int) $restartCount,
]);
}
} }
} catch (\Exception $e) { } catch (\Exception $e) {
} }
@ -278,6 +316,7 @@ public function handle()
$containerName = $labels->get('com.docker.compose.service'); $containerName = $labels->get('com.docker.compose.service');
if ($containerName) { if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count'));
} }
} elseif ($subType === 'database') { } elseif ($subType === 'database') {
$this->foundServiceDatabaseIds->push($subId); $this->foundServiceDatabaseIds->push($subId);
@ -289,6 +328,7 @@ public function handle()
$containerName = $labels->get('com.docker.compose.service'); $containerName = $labels->get('com.docker.compose.service');
if ($containerName) { if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count'));
} }
} }
} else { } else {
@ -302,9 +342,9 @@ public function handle()
$this->foundDatabaseUuids->push($uuid); $this->foundDatabaseUuids->push($uuid);
// TCP proxy should only be started/managed when database is actually running // TCP proxy should only be started/managed when database is actually running
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) { if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true); $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: true);
} else { } else {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false); $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: false);
} }
} }
} }
@ -317,6 +357,9 @@ public function handle()
$this->updateProxyStatus(); $this->updateProxyStatus();
Application::whereIn('id', $this->foundApplicationIds->unique())
->update(['container_present' => true]);
$this->updateNotFoundApplicationStatus(); $this->updateNotFoundApplicationStatus();
$this->updateNotFoundApplicationPreviewStatus(); $this->updateNotFoundApplicationPreviewStatus();
$this->updateNotFoundDatabaseStatus(); $this->updateNotFoundDatabaseStatus();
@ -324,6 +367,8 @@ public function handle()
$this->updateAdditionalServersStatus(); $this->updateAdditionalServersStatus();
$this->trackPreviewRestartCounts();
// Aggregate multi-container application statuses // Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses(); $this->aggregateMultiContainerStatuses();
@ -349,11 +394,18 @@ private function loadApplications(): Collection
'uuid', 'uuid',
'name', 'name',
'status', 'status',
'container_present',
'build_pack', 'build_pack',
'docker_compose_raw', 'docker_compose_raw',
'environment_id',
'destination_id', 'destination_id',
'destination_type', 'destination_type',
'last_online_at', 'last_online_at',
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
]) ])
->withCount('additional_servers') ->withCount('additional_servers')
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
@ -372,11 +424,18 @@ private function loadApplications(): Collection
'uuid', 'uuid',
'name', 'name',
'status', 'status',
'container_present',
'build_pack', 'build_pack',
'docker_compose_raw', 'docker_compose_raw',
'environment_id',
'destination_id', 'destination_id',
'destination_type', 'destination_type',
'last_online_at', 'last_online_at',
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
]) ])
->withCount('additional_servers') ->withCount('additional_servers')
->whereIn('id', $additionalApplicationIds) ->whereIn('id', $additionalApplicationIds)
@ -402,6 +461,11 @@ private function loadPreviews(): Collection
'pull_request_id', 'pull_request_id',
'status', 'status',
'last_online_at', 'last_online_at',
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
]) ])
->whereIn('application_id', $applicationIds) ->whereIn('application_id', $applicationIds)
->get(); ->get();
@ -417,7 +481,7 @@ private function loadServices(): Collection
'docker_compose_raw', 'docker_compose_raw',
]) ])
->with([ ->with([
'applications:id,service_id,status,last_online_at', 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
'databases:id,service_id,status,last_online_at,is_public,name', 'databases:id,service_id,status,last_online_at,is_public,name',
]) ])
->get(); ->get();
@ -495,6 +559,53 @@ private function aggregateMultiContainerStatuses()
continue; continue;
} }
$maxRestartCount = 0;
$restartCountsAvailable = $this->applicationContainerRestartCounts->has($applicationId);
if ($restartCountsAvailable) {
$maxRestartCount = $this->applicationContainerRestartCounts->get($applicationId)->max() ?? 0;
$restartState = (new RestartCountTracker)->evaluate(
previousRestartCount: $application->restart_count ?? 0,
observedRestartCount: $maxRestartCount,
maxRestartCount: $application->max_restart_count ?? 0,
);
if ($restartState['restart_count_changed']) {
$hasCrashRestarts = $restartState['restart_count'] > 0;
$application->update([
'restart_count' => $restartState['restart_count'],
'last_restart_at' => $hasCrashRestarts ? now() : null,
'last_restart_type' => $hasCrashRestarts ? 'crash' : null,
]);
}
if ($restartState['restart_limit_reached']) {
$restartLimitClaimed = Application::query()
->whereKey($application->getKey())
->where('restart_limit_reached', false)
->update(['restart_limit_reached' => true]) === 1;
if ($restartLimitClaimed) {
$application->refresh();
StopApplication::dispatch(
application: $application,
previewDeployments: false,
dockerCleanup: false,
resetRestartCount: false,
removeContainers: false,
);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
}
}
if ($application->stoppedAfterRestartLimit() && $containerStatuses->every(
fn (string $status): bool => str($status)->contains('exited')
)) {
$application->update(['status' => 'exited']);
continue;
}
// Parse docker compose to check for excluded containers // Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw'); $dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
@ -519,7 +630,7 @@ private function aggregateMultiContainerStatuses()
// Use ContainerStatusAggregator service for state machine logic // Use ContainerStatusAggregator service for state machine logic
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator; $aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
// Update application status with aggregated result // Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) { if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
@ -560,6 +671,14 @@ private function aggregateServiceContainerStatuses()
continue; continue;
} }
$restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0;
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
continue;
}
// Parse docker compose from service to check for excluded containers // Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw'); $dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
@ -581,10 +700,9 @@ private function aggregateServiceContainerStatuses()
} }
// Use ContainerStatusAggregator service for state machine logic // Use ContainerStatusAggregator service for state machine logic
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator; $aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $restartCount, preserveRestarting: true);
// Update service sub-resource status with aggregated result // Update service sub-resource status with aggregated result
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
@ -627,8 +745,11 @@ private function updateNotFoundApplicationStatus()
// Batch update: mark all not-found applications as exited (excluding already exited ones) // Batch update: mark all not-found applications as exited (excluding already exited ones)
Application::whereIn('id', $notFoundApplicationIds) Application::whereIn('id', $notFoundApplicationIds)
->where('status', 'not like', 'exited%') ->update([
->update(['status' => 'exited']); 'status' => 'exited',
'container_present' => false,
'restart_limit_reached' => false,
]);
} }
private function updateNotFoundApplicationPreviewStatus() private function updateNotFoundApplicationPreviewStatus()
@ -687,7 +808,7 @@ private function updateProxyStatus()
} }
} }
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, mixed $restartCount = null, bool $tcpProxy = false): void
{ {
$database = $this->databasesByUuid->get($databaseUuid); $database = $this->databasesByUuid->get($databaseUuid);
if (! $database) { if (! $database) {
@ -697,6 +818,13 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
$database->status = $containerStatus; $database->status = $containerStatus;
$database->save(); $database->save();
} }
if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if (! $this->isCompleteSnapshot()) { if (! $this->isCompleteSnapshot()) {
return; return;
} }
@ -719,6 +847,30 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
} }
} }
private function storeServiceRestartCount(string $key, string $containerName, mixed $restartCount): void
{
if (! is_numeric($restartCount)) {
return;
}
if (! $this->serviceContainerRestartCounts->has($key)) {
$this->serviceContainerRestartCounts->put($key, collect());
}
$this->serviceContainerRestartCounts->get($key)->put($containerName, (int) $restartCount);
}
private function trackPreviewRestartCounts(): void
{
$this->previewContainerRestartCounts
->groupBy('key')
->each(function (Collection $counts, string $key): void {
$preview = $this->previewsByKey->get($key);
if ($preview?->trackRestartCount((int) $counts->max('count'))) {
StopApplicationPreview::dispatch($preview, false, false);
$preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview));
}
});
}
private function updateNotFoundDatabaseStatus() private function updateNotFoundDatabaseStatus()
{ {
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids); $notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
@ -752,8 +904,9 @@ private function updateNotFoundServiceStatus()
// Batch update service applications // Batch update service applications
if ($notFoundServiceApplicationIds->isNotEmpty()) { if ($notFoundServiceApplicationIds->isNotEmpty()) {
ServiceApplication::whereIn('id', $notFoundServiceApplicationIds) ServiceApplication::whereIn('id', $notFoundServiceApplicationIds)
->where('restart_limit_reached', false)
->where('status', '!=', 'exited') ->where('status', '!=', 'exited')
->update(['status' => 'exited']); ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]);
} }
// Batch update service databases // Batch update service databases

View file

@ -66,7 +66,10 @@ public function handle()
caCert: $caCert->ssl_certificate, caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key, caKey: $caCert->ssl_private_key,
); );
$regenerated->push($certificate); $resource = $certificate->database;
if ($resource) {
$regenerated->push($resource);
}
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Failed to regenerate SSL certificate: '.$e->getMessage()); Log::error('Failed to regenerate SSL certificate: '.$e->getMessage());
} }

View file

@ -166,14 +166,6 @@ private function processServerTasks(Server $server): void
} }
} }
$isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
CheckAndStartSentinelJob::dispatch($server);
}
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
// When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data
if ($sentinelOutOfSync) { if ($sentinelOutOfSync) {
@ -195,7 +187,6 @@ private function processServerTasks(Server $server): void
ServerPatchCheckJob::dispatch($server); ServerPatchCheckJob::dispatch($server);
} }
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
} }

View file

@ -74,7 +74,7 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); // 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}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
} }
Subscription::updateOrCreate( $subscription = Subscription::updateOrCreate(
['team_id' => $teamId], ['team_id' => $teamId],
[ [
'stripe_subscription_id' => $subscriptionId, 'stripe_subscription_id' => $subscriptionId,
@ -83,6 +83,12 @@ public function handle(): void
'stripe_past_due' => false, 'stripe_past_due' => false,
] ]
); );
logger()->info('Stripe subscription checkout completed.', [
'team_id' => $team->id,
'stripe_customer_id' => $customerId,
'stripe_checkout_session_id' => data_get($data, 'id'),
'stripe_subscription_id' => $subscription->stripe_subscription_id,
]);
break; break;
case 'invoice.paid': case 'invoice.paid':
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
@ -218,7 +224,7 @@ public function handle(): void
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); // 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}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
} }
Subscription::updateOrCreate( $subscription = Subscription::firstOrCreate(
['team_id' => $teamId], ['team_id' => $teamId],
[ [
'stripe_subscription_id' => $subscriptionId, 'stripe_subscription_id' => $subscriptionId,
@ -226,6 +232,11 @@ public function handle(): void
'stripe_invoice_paid' => false, 'stripe_invoice_paid' => false,
] ]
); );
if (! $subscription->stripe_subscription_id && $subscription->stripe_customer_id === $customerId) {
$subscription->update(['stripe_subscription_id' => $subscriptionId]);
} elseif ($subscription->stripe_customer_id !== $customerId) {
throw new \RuntimeException("Stripe customer ID mismatch for team {$teamId}: stored {$subscription->stripe_customer_id}, event {$customerId}.");
}
break; break;
case 'customer.subscription.updated': case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id'); $teamId = data_get($data, 'metadata.team_id');

View file

@ -202,6 +202,9 @@ public function handle(): void
// Broadcast events to update UI // Broadcast events to update UI
ServerValidated::dispatch($this->server->team_id, $this->server->uuid); ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
ServerReachabilityChanged::dispatch($this->server); ServerReachabilityChanged::dispatch($this->server);
if ($this->server->isSentinelEnabled()) {
CheckAndStartSentinelJob::dispatch($this->server);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('ValidateAndInstallServer: Exception occurred', [ Log::error('ValidateAndInstallServer: Exception occurred', [

View file

@ -73,6 +73,7 @@ public function handle(): void
$filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
$backupLocation = $backupDirectory.'/'.$filename; $backupLocation = $backupDirectory.'/'.$filename;
$this->execution->update(['filename' => $backupLocation]); $this->execution->update(['filename' => $backupLocation]);
$streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup;
try { try {
$source = $this->backup->sourcePath(); $source = $this->backup->sourcePath();
@ -86,11 +87,17 @@ public function handle(): void
$compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage); $compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage);
$archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume .";
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) if ($streamToS3) {
.' -v '.escapeshellarg($source.':/volume:ro') $this->execution->update(['local_storage_deleted' => true]);
.' '.escapeshellarg($image) $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image);
.' sh -c '.escapeshellarg($archiveScript) $this->execution->update(['s3_cleanup_pending' => true]);
.' > '.escapeshellarg($backupLocation); } else {
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
.' sh -c '.escapeshellarg($archiveScript)
.' > '.escapeshellarg($backupLocation);
}
if ($this->backup->stop_during_backup) { if ($this->backup->stop_during_backup) {
$containers = $this->containersUsingVolume($source, $server); $containers = $this->containersUsingVolume($source, $server);
@ -104,21 +111,23 @@ public function handle(): void
} }
} }
instant_remote_process([ $archiveOutput = instant_remote_process(array_filter([
$verifySourceCommand, $verifySourceCommand,
'mkdir -p '.escapeshellarg($backupDirectory), $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory),
$archiveCommand, $archiveCommand,
], $server, timeout: $this->timeout, disableMultiplexing: true); ]), $server, timeout: $this->timeout, disableMultiplexing: true);
$this->execution->update([ $this->execution->update([
'stop_container_ids' => null, 'stop_container_ids' => null,
'stop_recovery_pending' => false, 'stop_recovery_pending' => false,
]); ]);
$size = (int) instant_remote_process( $size = $streamToS3
['du -b '.escapeshellarg($backupLocation).' | cut -f1'], ? (int) str($archiveOutput)->trim()->afterLast("\n")->toString()
$server, : (int) instant_remote_process(
disableMultiplexing: true, ['du -b '.escapeshellarg($backupLocation).' | cut -f1'],
); $server,
disableMultiplexing: true,
);
if ($size <= 0) { if ($size <= 0) {
throw new \RuntimeException('The storage backup archive is empty or was not created.'); throw new \RuntimeException('The storage backup archive is empty or was not created.');
@ -127,9 +136,12 @@ public function handle(): void
$warning = null; $warning = null;
$s3Uploaded = null; $s3Uploaded = null;
$s3CleanupPending = false; $s3CleanupPending = false;
$localStorageDeleted = false; $localStorageDeleted = $streamToS3;
if ($this->backup->save_s3) { if ($streamToS3) {
$s3Uploaded = true;
$this->execution->update(['s3_cleanup_pending' => false]);
} elseif ($this->backup->save_s3) {
$s3CleanupPending = true; $s3CleanupPending = true;
$this->execution->update(['s3_cleanup_pending' => true]); $this->execution->update(['s3_cleanup_pending' => true]);
@ -181,13 +193,23 @@ public function handle(): void
} }
} catch (Throwable $exception) { } catch (Throwable $exception) {
$recoveryError = $this->recoverIncompleteBackup($this->execution); $recoveryError = $this->recoverIncompleteBackup($this->execution);
$archiveDeleted = false; $archiveDeleted = $streamToS3;
try { if ($streamToS3) {
deleteBackupsLocally($backupLocation, $server, throwError: true); $exception = new \RuntimeException(
$archiveDeleted = true; 'S3-only streaming backup failed: '.$exception->getMessage()
} catch (Throwable $cleanupException) { .'. The S3 destination may not support streaming uploads. Enable local backups to use the local archive upload method.',
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); previous: $exception,
);
}
if (! $streamToS3) {
try {
deleteBackupsLocally($backupLocation, $server, throwError: true);
$archiveDeleted = true;
} catch (Throwable $cleanupException) {
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage();
}
} }
$s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending;
@ -195,7 +217,9 @@ public function handle(): void
$this->execution->update([ $this->execution->update([
'status' => 'failed', 'status' => 'failed',
'message' => $exception->getMessage().$recoveryError, 'message' => $exception->getMessage().$recoveryError,
'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation, 'filename' => $streamToS3
? ($s3CleanupPending ? $backupLocation : null)
: ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation),
'local_storage_deleted' => $archiveDeleted, 'local_storage_deleted' => $archiveDeleted,
]); ]);
@ -338,6 +362,34 @@ private function uploadToS3(string $backupLocation, string $backupDirectory, Ser
} }
} }
private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string
{
$s3 = $this->backup->s3;
if (! $s3) {
$this->backup->update(['save_s3' => false, 's3_storage_id' => null]);
throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.');
}
$s3->testConnection(shouldSave: true);
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts()))
->map(fn (string $option): string => '--resolve '.escapeshellarg($option))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$destination = 'temporary/'.$s3->bucket.$backupLocation;
$streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary '
.escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret)
.' >/dev/null && ('.$archiveScript.' | mc pipe --quiet'.$resolveOptions.' '.escapeshellarg($destination).' >/dev/null)'
.' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination)
.' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p');
return 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
.' sh -c '.escapeshellarg($streamScript);
}
private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void
{ {
if (! isDev()) { if (! isDev()) {

View file

@ -33,7 +33,7 @@ public function back()
if (session('impersonating')) { if (session('impersonating')) {
session()->forget('impersonating'); session()->forget('impersonating');
$user = User::find(0); $user = User::find(0);
$team_to_switch_to = $user->teams->first(); $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first();
Auth::login($user); Auth::login($user);
refreshSession($team_to_switch_to); refreshSession($team_to_switch_to);
@ -69,7 +69,7 @@ public function switchUser(int $user_id)
if (! $user) { if (! $user) {
abort(404); abort(404);
} }
$team_to_switch_to = $user->teams->first(); $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first();
Auth::login($user); Auth::login($user);
refreshSession($team_to_switch_to); refreshSession($team_to_switch_to);

View file

@ -209,19 +209,20 @@ protected function serverIpsForDnsHints(): array
} }
} }
// Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa). if ($this->usesInstanceNetworkAddressesForDnsHints()) {
try { try {
$settings = instanceSettings(); $settings = instanceSettings();
$publicV4 = data_get($settings, 'public_ipv4'); $publicV4 = data_get($settings, 'public_ipv4');
$publicV6 = data_get($settings, 'public_ipv6'); $publicV6 = data_get($settings, 'public_ipv6');
if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipv4 = $publicV4; $ipv4 = $publicV4;
}
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
} }
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
} }
return [$ipv4, $ipv6]; return [$ipv4, $ipv6];
@ -253,5 +254,7 @@ protected function serverIpForDomainConnect(): ?string
return null; return null;
} }
abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool;
abstract protected function authorizeUpdateForDomainConnect(): void; abstract protected function authorizeUpdateForDomainConnect(): void;
} }

View file

@ -43,7 +43,7 @@ public function mount(string $destination_uuid)
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
@ -85,7 +85,7 @@ public function delete()
} }
$this->destination->delete(); $this->destination->delete();
return redirect()->route('destination.index'); return redirectRoute($this, 'destination.index');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -1507,8 +1507,7 @@ public function getServicesProperty()
'type' => 'one-click-service-'.$serviceKey, 'type' => 'one-click-service-'.$serviceKey,
'category' => 'Services', 'category' => 'Services',
'resourceType' => 'service', 'resourceType' => 'service',
'logo' => data_get($service, 'logo'), ] + service_logo_urls(data_get($service, 'logo')) + array_filter([
] + array_filter([
'amd_only' => data_get($service, 'amd_only') ? true : null, 'amd_only' => data_get($service, 'amd_only') ? true : null,
'arm_only' => data_get($service, 'arm_only') ? true : null, 'arm_only' => data_get($service, 'arm_only') ? true : null,
])); ]));

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', 'max:600'])]
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

@ -8,6 +8,7 @@ trait TogglesNotificationEvents
'deploymentSuccess', 'deploymentSuccess',
'deploymentFailure', 'deploymentFailure',
'statusChange', 'statusChange',
'restartLimitReached',
'backupSuccess', 'backupSuccess',
'backupFailure', 'backupFailure',
'scheduledTaskSuccess', 'scheduledTaskSuccess',

View file

@ -34,6 +34,9 @@ class Discord extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $statusChangeDiscordNotifications = false; public bool $statusChangeDiscordNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedDiscordNotifications = true;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $backupSuccessDiscordNotifications = false; public bool $backupSuccessDiscordNotifications = false;
@ -82,17 +85,17 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_enabled = $this->discordEnabled;
$this->settings->discord_webhook_url = $this->discordWebhookUrl; $this->settings->discord_webhook_url = $this->discordWebhookUrl;
$this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications; $this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications;
$this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications; $this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications;
$this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications; $this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications;
$this->settings->restart_limit_reached_discord_notifications = $this->restartLimitReachedDiscordNotifications;
$this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications; $this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications;
$this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications; $this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications;
$this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications; $this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications;
@ -118,6 +121,7 @@ public function syncData(bool $toModel = false)
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications; $this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications; $this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
$this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications; $this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications;
$this->restartLimitReachedDiscordNotifications = $this->settings->restart_limit_reached_discord_notifications;
$this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications; $this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications;
$this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications; $this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications;
$this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications; $this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications;
@ -169,6 +173,7 @@ public function instantSaveDiscordEnabled()
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -179,6 +184,7 @@ public function submit()
{ {
try { try {
$this->resetErrorBag(); $this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
$this->saveModel(); $this->saveModel();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -188,6 +194,8 @@ public function submit()
public function saveModel() public function saveModel()
{ {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
refreshSession(); refreshSession();
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');

View file

@ -79,6 +79,9 @@ class Email extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $statusChangeEmailNotifications = false; public bool $statusChangeEmailNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedEmailNotifications = true;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $backupSuccessEmailNotifications = false; public bool $backupSuccessEmailNotifications = false;
@ -129,12 +132,11 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->authorize('update', $this->settings);
$this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName; $this->settings->smtp_from_name = $this->smtpFromName;
@ -155,6 +157,7 @@ public function syncData(bool $toModel = false)
$this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications;
$this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications;
$this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications;
$this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications;
$this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications;
$this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications; $this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications;
$this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications; $this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications;
@ -193,6 +196,7 @@ public function syncData(bool $toModel = false)
$this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications;
$this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications;
$this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications;
$this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications;
$this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications;
$this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications; $this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications;
$this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications; $this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications;
@ -219,6 +223,8 @@ public function submit()
public function saveModel() public function saveModel()
{ {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Email notifications settings updated.'); $this->dispatch('success', 'Email notifications settings updated.');
} }

View file

@ -41,6 +41,9 @@ class Pushover extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $statusChangePushoverNotifications = false; public bool $statusChangePushoverNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedPushoverNotifications = true;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $backupSuccessPushoverNotifications = false; public bool $backupSuccessPushoverNotifications = false;
@ -86,11 +89,10 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_enabled = $this->pushoverEnabled;
$this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_user_key = $this->pushoverUserKey;
$this->settings->pushover_api_token = $this->pushoverApiToken; $this->settings->pushover_api_token = $this->pushoverApiToken;
@ -98,6 +100,7 @@ public function syncData(bool $toModel = false)
$this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications; $this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications;
$this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications; $this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications;
$this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications; $this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications;
$this->settings->restart_limit_reached_pushover_notifications = $this->restartLimitReachedPushoverNotifications;
$this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications; $this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications;
$this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications; $this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications;
$this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications; $this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications;
@ -125,6 +128,7 @@ public function syncData(bool $toModel = false)
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications; $this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications; $this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
$this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications; $this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications;
$this->restartLimitReachedPushoverNotifications = $this->settings->restart_limit_reached_pushover_notifications;
$this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications; $this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications;
$this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications; $this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications;
$this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications; $this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications;
@ -162,6 +166,7 @@ public function instantSavePushoverEnabled()
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -174,6 +179,7 @@ public function submit()
{ {
try { try {
$this->resetErrorBag(); $this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
$this->saveModel(); $this->saveModel();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -183,6 +189,8 @@ public function submit()
public function saveModel() public function saveModel()
{ {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
refreshSession(); refreshSession();
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');

View file

@ -39,6 +39,9 @@ class Slack extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $statusChangeSlackNotifications = false; public bool $statusChangeSlackNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedSlackNotifications = true;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $backupSuccessSlackNotifications = false; public bool $backupSuccessSlackNotifications = false;
@ -84,17 +87,17 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->slack_enabled = $this->slackEnabled; $this->settings->slack_enabled = $this->slackEnabled;
$this->settings->slack_webhook_url = $this->slackWebhookUrl; $this->settings->slack_webhook_url = $this->slackWebhookUrl;
$this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications; $this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications;
$this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications; $this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications;
$this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications; $this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications;
$this->settings->restart_limit_reached_slack_notifications = $this->restartLimitReachedSlackNotifications;
$this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications; $this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications;
$this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications; $this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications;
$this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications; $this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications;
@ -118,6 +121,7 @@ public function syncData(bool $toModel = false)
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications; $this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications; $this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
$this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications; $this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications;
$this->restartLimitReachedSlackNotifications = $this->settings->restart_limit_reached_slack_notifications;
$this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications; $this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications;
$this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications; $this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications;
$this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications; $this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications;
@ -153,6 +157,7 @@ public function instantSaveSlackEnabled()
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -165,6 +170,7 @@ public function submit()
{ {
try { try {
$this->resetErrorBag(); $this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
$this->saveModel(); $this->saveModel();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -174,6 +180,8 @@ public function submit()
public function saveModel() public function saveModel()
{ {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
refreshSession(); refreshSession();
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');

View file

@ -41,6 +41,9 @@ class Telegram extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $statusChangeTelegramNotifications = false; public bool $statusChangeTelegramNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedTelegramNotifications = true;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $backupSuccessTelegramNotifications = false; public bool $backupSuccessTelegramNotifications = false;
@ -83,6 +86,9 @@ class Telegram extends Component
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsStatusChangeThreadId = null; public ?string $telegramNotificationsStatusChangeThreadId = null;
#[Validate(['nullable', 'string', 'max:255'])]
public ?string $telegramNotificationsRestartLimitReachedThreadId = null;
#[Validate(['nullable', 'string'])] #[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsBackupSuccessThreadId = null; public ?string $telegramNotificationsBackupSuccessThreadId = null;
@ -128,11 +134,10 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->telegram_enabled = $this->telegramEnabled; $this->settings->telegram_enabled = $this->telegramEnabled;
$this->settings->telegram_token = $this->telegramToken; $this->settings->telegram_token = $this->telegramToken;
$this->settings->telegram_chat_id = $this->telegramChatId; $this->settings->telegram_chat_id = $this->telegramChatId;
@ -140,6 +145,7 @@ public function syncData(bool $toModel = false)
$this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications; $this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications;
$this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications; $this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications;
$this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications; $this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications;
$this->settings->restart_limit_reached_telegram_notifications = $this->restartLimitReachedTelegramNotifications;
$this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications; $this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications;
$this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications; $this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications;
$this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications; $this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications;
@ -155,6 +161,7 @@ public function syncData(bool $toModel = false)
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId; $this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId; $this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
$this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId; $this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId;
$this->settings->telegram_notifications_restart_limit_reached_thread_id = $this->telegramNotificationsRestartLimitReachedThreadId;
$this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId; $this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId;
$this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId; $this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId;
$this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId; $this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId;
@ -173,6 +180,21 @@ public function syncData(bool $toModel = false)
if (auth()->user()->can('update', $this->settings)) { if (auth()->user()->can('update', $this->settings)) {
$this->telegramToken = $this->settings->telegram_token; $this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id; $this->telegramChatId = $this->settings->telegram_chat_id;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
$this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id;
$this->telegramNotificationsRestartLimitReachedThreadId = $this->settings->telegram_notifications_restart_limit_reached_thread_id;
$this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id;
$this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id;
$this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id;
$this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id;
$this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id;
$this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id;
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
} else { } else {
$this->telegramToken = null; $this->telegramToken = null;
$this->telegramChatId = null; $this->telegramChatId = null;
@ -181,6 +203,7 @@ public function syncData(bool $toModel = false)
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications; $this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications; $this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
$this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications; $this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications;
$this->restartLimitReachedTelegramNotifications = $this->settings->restart_limit_reached_telegram_notifications;
$this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications; $this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications;
$this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications; $this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications;
$this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications; $this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications;
@ -193,26 +216,13 @@ public function syncData(bool $toModel = false)
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications; $this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications; $this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
$this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id;
$this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id;
$this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id;
$this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id;
$this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id;
$this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id;
$this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id;
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
} }
} }
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -225,6 +235,7 @@ public function submit()
{ {
try { try {
$this->resetErrorBag(); $this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
$this->saveModel(); $this->saveModel();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -254,6 +265,8 @@ public function instantSaveTelegramEnabled()
public function saveModel() public function saveModel()
{ {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
refreshSession(); refreshSession();
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');

View file

@ -34,6 +34,9 @@ class Webhook extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $statusChangeWebhookNotifications = false; public bool $statusChangeWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedWebhookNotifications = true;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $backupSuccessWebhookNotifications = false; public bool $backupSuccessWebhookNotifications = false;
@ -79,17 +82,17 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->authorize('update', $this->settings);
$this->settings->webhook_enabled = $this->webhookEnabled; $this->settings->webhook_enabled = $this->webhookEnabled;
$this->settings->webhook_url = $this->webhookUrl; $this->settings->webhook_url = $this->webhookUrl;
$this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications; $this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications;
$this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications; $this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications;
$this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications; $this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications;
$this->settings->restart_limit_reached_webhook_notifications = $this->restartLimitReachedWebhookNotifications;
$this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications; $this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications;
$this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications; $this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications;
$this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications; $this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications;
@ -113,6 +116,7 @@ public function syncData(bool $toModel = false)
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
$this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications; $this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications;
$this->restartLimitReachedWebhookNotifications = $this->settings->restart_limit_reached_webhook_notifications;
$this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications; $this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications;
$this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications; $this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications;
$this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications; $this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications;
@ -147,6 +151,7 @@ public function instantSaveWebhookEnabled()
public function instantSave() public function instantSave()
{ {
try { try {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -157,6 +162,7 @@ public function submit()
{ {
try { try {
$this->resetErrorBag(); $this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
$this->saveModel(); $this->saveModel();
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -166,6 +172,8 @@ public function submit()
public function saveModel() public function saveModel()
{ {
$this->authorize('update', $this->settings);
$this->syncData(true); $this->syncData(true);
refreshSession(); refreshSession();

View file

@ -47,7 +47,7 @@ public function uploadAvatar(AvatarStorageService $avatarStorage): bool
$avatarStorage->store(Auth::user(), $this->avatar); $avatarStorage->store(Auth::user(), $this->avatar);
$this->reset('avatar'); $this->reset('avatar');
$this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); $this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh()));
$this->dispatch('success', 'Profile picture updated.'); $this->dispatch('success', 'Profile picture updated.');
return true; return true;

View file

@ -27,12 +27,6 @@ class Advanced extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $isGitShallowCloneEnabled = false; public bool $isGitShallowCloneEnabled = false;
#[Validate(['boolean'])]
public bool $isPreviewDeploymentsEnabled = false;
#[Validate(['boolean'])]
public bool $isPrDeploymentsPublicEnabled = false;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $isAutoDeployEnabled = true; public bool $isAutoDeployEnabled = true;
@ -99,7 +93,7 @@ public function mount()
} }
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
@ -107,8 +101,6 @@ public function syncData(bool $toModel = false)
$this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled;
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
$this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled;
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
$this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->settings->is_gpu_enabled = $this->isGpuEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled;
@ -136,8 +128,6 @@ public function syncData(bool $toModel = false)
$this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled;
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
$this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false;
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
$this->gpuDriver = $this->application->settings->gpu_driver; $this->gpuDriver = $this->application->settings->gpu_driver;

View file

@ -8,6 +8,7 @@
use App\Livewire\Project\Shared\ConfigurationChecker; use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application; use App\Models\Application;
use App\Models\Server; use App\Models\Server;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts; use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -57,7 +58,17 @@ class Domains extends Component
public ?string $editingService = null; public ?string $editingService = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */ public string $editingIndexing = 'index';
public string $editingRedirect = 'both';
public string $editingOriginalRedirect = 'both';
public bool $editingDomainWasRegenerated = false;
public ?string $editingGeneratedHost = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
public array $domainRows = []; public array $domainRows = [];
/** When set, the next addSuggestedDomain call for this index skips the DNS block. */ /** When set, the next addSuggestedDomain call for this index skips the DNS block. */
@ -70,6 +81,14 @@ class Domains extends Component
public bool $showDomainConflictModal = false; public bool $showDomainConflictModal = false;
public bool $showPortWarningModal = false;
public bool $forceUseUnknownPort = false;
public ?int $unrecognizedPort = null;
public ?string $pendingPortAction = null;
public bool $forceSaveDomains = false; public bool $forceSaveDomains = false;
public bool $forceSaveDns = false; public bool $forceSaveDns = false;
@ -113,6 +132,8 @@ protected function rules(): array
return [ return [
'newDomain' => ValidationPatterns::applicationDomainRules(), 'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(),
'editingIndexing' => 'string|required|in:index,noindex',
'editingRedirect' => 'string|required|in:both,www,non-www',
'redirect' => 'string|required|in:both,www,non-www', 'redirect' => 'string|required|in:both,www,non-www',
'isForceHttpsEnabled' => 'boolean', 'isForceHttpsEnabled' => 'boolean',
'serviceRedirects' => 'array', 'serviceRedirects' => 'array',
@ -141,7 +162,15 @@ public function mount(): void
public function refreshDomains(): void public function refreshDomains(): void
{ {
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->loadDomainState(); $this->loadDomainState();
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& ($row['service'] ?? null) === ($editingRow['service'] ?? null));
$this->editingIndex = $index === false ? null : (int) $index;
}
} }
public function pollDnsChecks(): void public function pollDnsChecks(): void
@ -218,7 +247,9 @@ public function loadDomainState(): void
$this->isCompose = $this->application->build_pack === 'dockercompose'; $this->isCompose = $this->application->build_pack === 'dockercompose';
$this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false;
$this->redirect = $this->application->redirect ?? 'both'; if ($this->pendingAction !== 'redirect' || $this->isCompose) {
$this->redirect = $this->application->redirect ?? 'both';
}
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
$settings = instanceSettings(); $settings = instanceSettings();
@ -245,6 +276,9 @@ public function loadDomainState(): void
} }
$this->composeServices = []; $this->composeServices = [];
$pendingRedirect = $this->pendingRedirectService !== null
? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null)
: null;
$this->serviceRedirects = []; $this->serviceRedirects = [];
if ($this->isCompose) { if ($this->isCompose) {
try { try {
@ -281,7 +315,9 @@ public function loadDomainState(): void
$serviceEntry = $domains[$serviceName] ?? null; $serviceEntry = $domains[$serviceName] ?? null;
$storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null;
$this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect(
is_string($storedRedirect) ? $storedRedirect : null $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService
? $pendingRedirect
: (is_string($storedRedirect) ? $storedRedirect : null)
); );
} }
} }
@ -485,32 +521,19 @@ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOv
/** /**
* @param array<string, array{status?: string, message?: string, expected_ip?: ?string, checked_at?: ?string}> $stored * @param array<string, array{status?: string, message?: string, expected_ip?: ?string, checked_at?: ?string}> $stored
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool} * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool}
*/ */
protected function domainRowFromStored(string $url, ?string $service, array $stored): array protected function domainRowFromStored(string $url, ?string $service, array $stored): array
{ {
$key = $this->domainDnsStatusKey($url, $service); $key = $this->domainDnsStatusKey($url, $service);
$entry = $stored[$key] ?? null; $entry = $stored[$key] ?? null;
$port = $this->effectiveDomainInternalPort($url, $service);
if (is_array($entry) && filled(data_get($entry, 'status'))) { $row = [
return [
'url' => $url,
'service' => $service,
'dns_status' => (string) data_get($entry, 'status', 'pending'),
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
'needs_force_add' => false,
];
}
return [
'url' => $url, 'url' => $url,
'service' => $service, 'service' => $service,
'internal_port' => $port['internal_port'],
'has_port_override' => $port['has_port_override'],
'dns_status' => 'pending', 'dns_status' => 'pending',
'dns_message' => 'Not checked yet.', 'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp, 'expected_ip' => $this->serverIp,
@ -521,6 +544,119 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
'suggestion_label' => null, 'suggestion_label' => null,
'needs_force_add' => false, 'needs_force_add' => false,
]; ];
if (is_array($entry) && filled(data_get($entry, 'status'))) {
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
$row['checked_at'] = data_get($entry, 'checked_at');
$row['check_id'] = data_get($entry, 'check_id');
}
return $row;
}
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
protected function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->application->domain_port_overrides ?? [];
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
$hasMapEntry = array_key_exists($canonical, $overrides);
if ($hasMapEntry) {
return [
'internal_port' => (int) $overrides[$canonical],
'has_port_override' => true,
];
}
if ($legacyPort !== null) {
return [
'internal_port' => $legacyPort,
'has_port_override' => true,
];
}
$composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
if ($this->isCompose && $service !== null) {
return [
'internal_port' => null,
'has_port_override' => false,
];
}
if ($this->application->settings?->is_static) {
return [
'internal_port' => 80,
'has_port_override' => false,
];
}
$exposed = $this->application->ports_exposes_array;
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
? (int) $exposed[0]
: null;
return [
'internal_port' => $defaultPort,
'has_port_override' => false,
];
}
/**
* @param array{scheme: string, host: string, port: string, path: string} $parts
*/
protected function portFromParts(array $parts): ?int
{
$port = trim((string) ($parts['port'] ?? ''));
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
return null;
}
return (int) $port;
}
protected function currentRowPort(string $url): ?int
{
$canonical = DomainPortOverrides::withoutPort($url);
$override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($override) && (int) $override > 0) {
return (int) $override;
}
$legacy = DomainUrlParts::split($url)['port'] ?? '';
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
}
protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
{
if ($this->forceUseUnknownPort || $port === null) {
return false;
}
if ($currentPort !== null && $port === $currentPort) {
return false;
}
return $this->application->portRequiresConfirmation($port, $serviceName);
}
protected function openPortWarning(?int $port, string $action): void
{
$this->unrecognizedPort = $port;
$this->pendingPortAction = $action;
$this->showPortWarningModal = true;
} }
/** /**
@ -559,45 +695,17 @@ protected function authorizeUpdateForDomainConnect(): void
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
} }
protected function usesInstanceNetworkAddressesForDnsHints(): bool
{
return $this->application->destination?->server?->id === 0;
}
public function checkAllDns(): void public function checkAllDns(): void
{ {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->isCheckingDns = true; foreach ($this->domainRows as $row) {
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
try {
$server = $this->application->destination?->server;
$skipDns = ! $this->dnsValidationEnabled
|| ! $server
|| $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$reason = ! $this->dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: ($this->application->additional_servers->count() > 0
? 'DNS check skipped for multi-server applications.'
: 'No server available for DNS validation.');
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = $reason;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
continue;
}
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistDomainDnsStatuses();
} finally {
$this->isCheckingDns = false;
} }
} }
@ -609,18 +717,8 @@ public function checkDomainDns(int $index): void
return; return;
} }
$server = $this->application->destination?->server; $row = $this->domainRows[$index];
if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) { $this->queueUrlsDns([$row['url']], $row['service'] ?? null);
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->persistDomainDnsStatuses();
return;
}
$this->applyDnsStatus($index, $server);
$this->persistDomainDnsStatuses();
} }
protected function applyDnsStatus(int $index, Server $server): void protected function applyDnsStatus(int $index, Server $server): void
@ -824,6 +922,31 @@ public function confirmDomainUsage(): void
$this->addDomain(); $this->addDomain();
} }
public function confirmUseUnknownPort(): void
{
$this->authorize('update', $this->application);
$this->forceUseUnknownPort = true;
$this->showPortWarningModal = false;
$action = $this->pendingPortAction;
$this->pendingPortAction = null;
if ($action === 'update') {
$this->updateDomain();
return;
}
$this->addDomain();
}
public function cancelUseUnknownPort(): void
{
$this->showPortWarningModal = false;
$this->forceUseUnknownPort = false;
$this->unrecognizedPort = null;
$this->pendingPortAction = null;
}
/** /**
* Clear pending conflict state when the modal is dismissed without confirmation. * Clear pending conflict state when the modal is dismissed without confirmation.
* confirmDomainUsage sets forceSaveDomains before closing the modal. * confirmDomainUsage sets forceSaveDomains before closing the modal.
@ -834,7 +957,13 @@ public function updatedShowDomainConflictModal(bool $value): void
return; return;
} }
$this->authorize('update', $this->application);
$wasRedirect = $this->pendingAction === 'redirect';
$this->pendingAction = null; $this->pendingAction = null;
$this->pendingRedirectService = null;
if ($wasRedirect) {
$this->refreshDomains();
}
} }
public function addDomain(): void public function addDomain(): void
@ -848,7 +977,7 @@ public function addDomain(): void
return; return;
} }
if ($this->newDomainPartsChanged) { if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
} }
$this->validateOnly('newDomain'); $this->validateOnly('newDomain');
@ -867,15 +996,24 @@ public function addDomain(): void
->values() ->values()
->all(); ->all();
$current = $this->currentDomainList($this->newDomainService); $current = $this->currentDomainList($this->newDomainService);
$currentCanonicalDomains = $current->map(
fn (string $url): string => DomainPortOverrides::withoutPort($url)
);
foreach ($newUrls as $url) { foreach ($newUrls as $url) {
if ($current->contains($url)) { if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
$this->addError('newDomain', "Domain {$url} is already configured."); $this->addError('newDomain', "Domain {$url} is already configured.");
return; return;
} }
} }
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
return;
}
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add'; $this->pendingAction = 'add';
if (! $this->saveDomainList($merged, $this->newDomainService)) { if (! $this->saveDomainList($merged, $this->newDomainService)) {
@ -884,6 +1022,7 @@ public function addDomain(): void
$this->forceSaveDomains = false; $this->forceSaveDomains = false;
$this->pendingAction = null; $this->pendingAction = null;
$this->forceUseUnknownPort = false;
$serviceForCheck = $this->newDomainService; $serviceForCheck = $this->newDomainService;
$this->resetAddDomainForm(); $this->resetAddDomainForm();
$this->dispatch('close-modal'); $this->dispatch('close-modal');
@ -1007,6 +1146,7 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
$skipDns = ! $this->dnsValidationEnabled $skipDns = ! $this->dnsValidationEnabled
|| ! $server || ! $server
|| $this->application->additional_servers->count() > 0; || $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) { foreach ($this->domainRows as $index => $row) {
$url = $row['url'] ?? null; $url = $row['url'] ?? null;
@ -1043,6 +1183,34 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
$this->persistDomainDnsStatuses(); $this->persistDomainDnsStatuses();
} }
/**
* @param array<int, string> $urls
*/
protected function queueUrlsDns(array $urls, ?string $service = null): void
{
foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) {
$checkId = new_public_id();
$this->markUrlsAsChecking([$url], $service, $checkId);
$this->persistDomainDnsStatuses();
try {
CheckDomainDnsJob::dispatch(
$this->application,
$statusKey,
$url,
$this->application->destination?->server,
$this->serverIp,
$checkId,
$this->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$this->markUrlsDnsCheckUnavailable([$url], $service, $checkId);
$this->persistDomainDnsStatuses();
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
}
}
}
protected function shouldValidateDnsForAdd(): bool protected function shouldValidateDnsForAdd(): bool
{ {
if (! $this->dnsValidationEnabled) { if (! $this->dnsValidationEnabled) {
@ -1109,8 +1277,18 @@ public function startEdit(int $index): void
$this->editingIndex = $index; $this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain); $this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
$savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($savedPort)) {
$this->editingDomainParts['port'] = (string) $savedPort;
}
$this->editingDomainPartsChanged = false; $this->editingDomainPartsChanged = false;
$this->editingService = $this->domainRows[$index]['service']; $this->editingService = $this->domainRows[$index]['service'];
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
$this->editingRedirect = $this->serviceRedirectFor($this->editingService);
$this->editingOriginalRedirect = $this->editingRedirect;
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->resetEditDomainDnsGate(); $this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain'); $this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true; $this->showEditDomainModal = true;
@ -1196,6 +1374,11 @@ public function cancelEdit(): void
$this->editingDomainParts = DomainUrlParts::empty(); $this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false; $this->editingDomainPartsChanged = false;
$this->editingService = null; $this->editingService = null;
$this->editingIndexing = 'index';
$this->editingRedirect = 'both';
$this->editingOriginalRedirect = 'both';
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->resetEditDomainDnsGate(); $this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain'); $this->resetErrorBag('editingDomain');
if ($this->pendingAction === 'update') { if ($this->pendingAction === 'update') {
@ -1211,6 +1394,39 @@ public function confirmUpdateDomainDespiteDns(): void
$this->updateDomain(); $this->updateDomain();
} }
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->application);
if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$server = data_get($this->application, 'destination.server');
if (! $server) {
$this->dispatch('error', 'No server found for this application.');
return;
}
$generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($generatedHost) || $generatedHost === '') {
$this->dispatch('error', 'Could not generate a domain.');
return;
}
$currentHost = (string) ($this->editingDomainParts['host'] ?? '');
$this->editingGeneratedHost = $generatedHost;
$this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.')
? 'www.'.$generatedHost
: $generatedHost;
$this->editingDomainPartsChanged = true;
$this->editingDomainWasRegenerated = true;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
}
public function updateDomain(): void public function updateDomain(): void
{ {
try { try {
@ -1226,10 +1442,12 @@ public function updateDomain(): void
return; return;
} }
if ($this->editingDomainPartsChanged) { if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
} }
$this->validateOnly('editingDomain'); $this->validateOnly('editingDomain');
$this->validateOnly('editingIndexing');
$this->validateOnly('editingRedirect');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) { if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) {
@ -1241,47 +1459,104 @@ public function updateDomain(): void
$newUrl = $this->splitDomains($normalized)[0]; $newUrl = $this->splitDomains($normalized)[0];
$oldUrl = $this->domainRows[$this->editingIndex]['url']; $oldUrl = $this->domainRows[$this->editingIndex]['url'];
$service = $this->editingService; $service = $this->editingService;
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl); if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
$portOverrides = $this->application->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
$this->application->domain_port_overrides = $portOverrides ?: null;
}
$current = $this->currentDomainList($service); $current = $this->currentDomainList($service);
if ($newUrl !== $oldUrl && $current->contains($newUrl)) { $otherCanonicalDomains = $current
->reject(fn (string $url): bool => $url === $oldUrl)
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
$this->addError('editingDomain', "Domain {$newUrl} is already configured."); $this->addError('editingDomain', "Domain {$newUrl} is already configured.");
return; return;
} }
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) {
$dnsFailure = $this->findDnsFailureMessage([$newUrl]); $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
$this->showEditDomainModal = true;
return;
}
}
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainList($updated, $service)) {
return; return;
} }
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl); $replacements = [$oldUrl => $newUrl];
if ($wasNoindexed) { if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
$noindexDomains->push($newUrl); $oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
if (is_string($oldCounterpart)) {
$counterpartParts = DomainUrlParts::split($oldCounterpart);
$counterpartPort = $this->currentRowPort($oldCounterpart);
if ($counterpartPort !== null) {
$counterpartParts['port'] = (string) $counterpartPort;
}
$counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.')
? 'www.'.$this->editingGeneratedHost
: $this->editingGeneratedHost;
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts);
}
} }
$this->application->setNoindexDomains($noindexDomains);
$this->application->save(); $updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
foreach ($updated->all() as $url) {
$counterpart = $this->wwwCounterpartUrl($url, true);
$counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
$hasCounterpart = filled($counterpartHost) && $updated->contains(
fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost
);
if (filled($counterpart) && ! $hasCounterpart) {
$updated->push($counterpart);
}
}
}
$urlsToCheck = $updated
->reject(fn (string $url): bool => $current->contains(
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->unique()
->values()
->all();
$noindexDomains = $this->application->noindexDomains();
foreach ($replacements as $previousUrl => $replacementUrl) {
$wasNoindexed = $previousUrl === $oldUrl
? $this->editingIndexing === 'noindex'
: $this->application->isDomainNoindexed($previousUrl);
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
if ($wasNoindexed) {
$noindexDomains->push($replacementUrl);
}
}
if ($this->isCompose) {
$allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true);
$existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : [];
$allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]);
$this->application->docker_compose_domains = json_encode($allDomains);
} else {
$this->application->redirect = $this->editingRedirect;
}
$this->pendingAction = 'update';
if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) {
return;
}
$this->resetDefaultLabels(); $this->resetDefaultLabels();
$this->forceSaveDomains = false; $this->forceSaveDomains = false;
$this->pendingAction = null; $this->pendingAction = null;
$this->forceUseUnknownPort = false;
$this->cancelEdit(); $this->cancelEdit();
$this->dispatch('edit-domain-saved'); $this->dispatch('edit-domain-saved');
$this->dispatch('success', 'Domain updated.'); $this->dispatch('success', 'Domain updated.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns([$newUrl], $service); if ($urlsToCheck !== []) {
$this->queueUrlsDns($urlsToCheck, $service);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
} }
@ -1322,6 +1597,28 @@ public function removeDomain(int $index): void
} }
} }
public function removeDomainByKey(string $domainKey): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
&& hash_equals($domainKey, $this->domainRowKey($row))
);
if ($index === false) {
return;
}
$this->removeDomain((int) $index);
}
/**
* @param array{url: string, service?: ?string} $row
*/
private function domainRowKey(array $row): string
{
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
}
public function generateDomain(?string $serviceName = null): void public function generateDomain(?string $serviceName = null): void
{ {
try { try {
@ -1425,7 +1722,7 @@ public function setRedirect(): void
$this->resetDefaultLabels(); $this->resetDefaultLabels();
$this->dispatch('success', 'Redirect updated.'); $this->dispatch('success', 'Redirect updated.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns($addedDomains); $this->queueUrlsDns($addedDomains);
$this->pruneDomainDnsStatusesToCurrentDomains(); $this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
@ -1522,7 +1819,7 @@ public function setServiceRedirect(string $serviceName, mixed ...$modalArgs): vo
$this->dispatch('success', "Redirect updated for {$serviceName}."); $this->dispatch('success', "Redirect updated for {$serviceName}.");
} }
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns($addedDomains, $serviceName); $this->queueUrlsDns($addedDomains, $serviceName);
$this->pruneDomainDnsStatusesToCurrentDomains(); $this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
@ -1787,6 +2084,7 @@ protected function saveDomainList(
Collection $domains, Collection $domains,
?string $serviceName = null, ?string $serviceName = null,
bool $checkConflicts = true, bool $checkConflicts = true,
?Collection $noindexDomains = null,
): bool { ): bool {
$domainString = $domains->filter()->unique()->implode(','); $domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@ -1800,6 +2098,8 @@ protected function saveDomainList(
} }
} }
$intendedComposeOverrides = null;
if ($this->isCompose) { if ($this->isCompose) {
if (blank($serviceName)) { if (blank($serviceName)) {
$this->dispatch('error', 'A service is required for compose domains.'); $this->dispatch('error', 'A service is required for compose domains.');
@ -1815,6 +2115,15 @@ protected function saveDomainList(
$allDomains = []; $allDomains = [];
} }
$previousServiceUrls = $this->currentDomainList($serviceName);
$normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides);
$domainString = $normalizedPorts['fqdn'];
$intendedComposeOverrides = $this->mergeComposeDomainPortOverrides(
$previousServiceUrls,
$domainString,
$normalizedPorts['overrides'] ?? null,
);
$existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : [];
// Preserve stored redirect only — pending Direction dropdown values must not // Preserve stored redirect only — pending Direction dropdown values must not
// persist until setServiceRedirect() runs. // persist until setServiceRedirect() runs.
@ -1823,11 +2132,16 @@ protected function saveDomainList(
]); ]);
$this->application->docker_compose_domains = json_encode($allDomains); $this->application->docker_compose_domains = json_encode($allDomains);
$this->application->domain_port_overrides = $intendedComposeOverrides;
$this->application->fqdn = null; $this->application->fqdn = null;
} else { } else {
$this->application->fqdn = $domainString; $this->application->fqdn = $domainString;
} }
if ($noindexDomains !== null) {
$this->application->setNoindexDomains($noindexDomains);
}
if ($checkConflicts && ! $this->forceSaveDomains) { if ($checkConflicts && ! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application); $result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) { if ($result['hasConflicts']) {
@ -1849,12 +2163,47 @@ protected function saveDomainList(
} }
$this->application->save(); $this->application->save();
if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) {
$this->application->domain_port_overrides = $intendedComposeOverrides;
$this->application->save();
}
$this->resetDefaultLabels(); $this->resetDefaultLabels();
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
return true; return true;
} }
/**
* @param Collection<int, string> $previousServiceUrls
* @param array<string, int>|null $incomingOverrides
* @return array<string, int>|null
*/
protected function mergeComposeDomainPortOverrides(
Collection $previousServiceUrls,
?string $newDomainString,
?array $incomingOverrides,
): ?array {
$merged = $this->application->domain_port_overrides ?? [];
$newCanonical = collect($this->splitDomains($newDomainString))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->all();
foreach ($previousServiceUrls as $url) {
$canonical = DomainPortOverrides::withoutPort($url);
if (! in_array($canonical, $newCanonical, true)) {
unset($merged[$canonical]);
}
}
foreach ($incomingOverrides ?? [] as $url => $port) {
$merged[$url] = (int) $port;
}
return $merged ?: null;
}
protected function resetDefaultLabels(): void protected function resetDefaultLabels(): void
{ {
try { try {

View file

@ -4,6 +4,7 @@
use App\Actions\Application\GenerateConfig; use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob; use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application; use App\Models\Application;
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
@ -144,7 +145,9 @@ protected function rules(): array
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'fqdn' => ValidationPatterns::applicationDomainRules(), 'fqdn' => isset($this->application) && $this->fqdn === $this->application->fqdn
? ['nullable']
: ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required', 'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitBranch' => ['required', 'string', new ValidGitBranch],
@ -320,17 +323,6 @@ public function mount()
} }
} }
$this->initialDockerComposeLocation = $this->application->docker_compose_location; $this->initialDockerComposeLocation = $this->application->docker_compose_location;
if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) {
// Only load compose file if user has update permission
try {
$this->authorize('update', $this->application);
$this->initLoadingCompose = true;
$this->dispatch('info', 'Loading docker compose file.');
} catch (AuthorizationException $e) {
// User doesn't have update permission, skip loading compose file
}
}
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
} }
@ -340,7 +332,7 @@ public function mount()
$this->syncData(); $this->syncData();
} }
public function syncData(bool $toModel = false): void private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
@ -530,7 +522,7 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
$showToast && $this->dispatch('success', 'Docker compose file loaded.'); $showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded'); $this->dispatch('compose_loaded');
$this->dispatch('refreshStorages'); $this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshEnvs'); $this->dispatch('refreshEnvs');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Refresh model to get restored values from Application::loadComposeFile // Refresh model to get restored values from Application::loadComposeFile
@ -607,14 +599,9 @@ public function updatedBuildPack()
$this->resetDefaultLabels(false); $this->resetDefaultLabels(false);
} }
if ($this->buildPack === 'dockercompose') { if ($this->buildPack === 'dockercompose') {
// Only update if user has permission if (blank($this->dockerComposeLocation)) {
try { $this->dockerComposeLocation = '/docker-compose.yaml';
$this->authorize('update', $this->application); $this->application->docker_compose_location = $this->dockerComposeLocation;
$this->fqdn = null;
$this->application->fqdn = null;
$this->application->settings->save();
} catch (AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
} }
} }
if ($this->buildPack === 'static') { if ($this->buildPack === 'static') {
@ -666,6 +653,8 @@ public function generateNginxConfiguration($type = 'static')
public function resetDefaultLabels($manualReset = false) public function resetDefaultLabels($manualReset = false)
{ {
$this->authorize('update', $this->application);
try { try {
if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) { if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
return; return;
@ -770,8 +759,11 @@ public function submit($showToaster = true)
$oldDockerComposeLocation = $this->initialDockerComposeLocation; $oldDockerComposeLocation = $this->initialDockerComposeLocation;
$oldBaseDirectory = $this->application->base_directory; $oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion $fqdnChanged = $this->fqdn !== $this->application->fqdn;
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); if ($fqdnChanged) {
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
}
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -0,0 +1,744 @@
<?php
namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Models\ApplicationPreview;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class PreviewDomains extends Component
{
use AuthorizesRequests;
public ApplicationPreview $preview;
public array $domainRows = [];
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public ?string $newDomainService = null;
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public ?int $editingIndex = null;
public bool $showPortWarningModal = false;
public bool $forceUseUnknownPort = false;
public ?int $unrecognizedPort = null;
public ?string $pendingPortAction = null;
public function mount(): void
{
$this->authorize('view', $this->preview->application);
$this->refreshDomains();
if ($this->preview->application->build_pack === 'dockercompose') {
$this->newDomainService = $this->composeServices()[0] ?? null;
}
}
public function render()
{
return view('livewire.project.application.preview-domains', [
'isCompose' => $this->preview->application->build_pack === 'dockercompose',
'composeServices' => $this->composeServices(),
]);
}
public function addDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->preview->application->build_pack === 'dockercompose'
&& ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) {
$this->addError('newDomainService', 'Select a valid Compose service.');
return;
}
$domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host');
if ($domain === null) {
return;
}
$canonicalDomain = DomainPortOverrides::withoutPort($domain);
if (collect($this->domainRows)->contains(
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain
&& $row['service'] === $this->newDomainService
)) {
$this->addError('newDomainParts.host', 'This domain is already configured.');
return;
}
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
return;
}
$this->domainRows[] = $this->makeRow($domain, $this->newDomainService);
$index = array_key_last($this->domainRows);
$checkId = new_public_id();
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
if (! $this->persistDomains()) {
return;
}
$domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain);
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainService = $this->preview->application->build_pack === 'dockercompose'
? ($this->composeServices()[0] ?? null)
: null;
$this->forceUseUnknownPort = false;
$this->dispatch('close-preview-domain-add', previewId: $this->preview->id);
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($domain, $this->domainRows[$index]['service']),
$domain,
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
$this->dispatch('success', 'Domain added. DNS check started.');
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
$this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.');
}
}
public function generateDomain(): void
{
$this->authorize('update', $this->preview->application);
$this->preview->refresh();
if ($this->preview->application->build_pack === 'dockercompose') {
if ($this->newDomainService === null && $this->domainRows === []) {
$this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true);
} else {
$service = $this->newDomainService ?? data_get($this->domainRows, '0.service');
foreach ($this->generateComposeDomains((string) $service) as $domain) {
$alreadyExists = collect($this->domainRows)->contains(
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain)
&& $row['service'] === $service
);
if (! $alreadyExists) {
$this->domainRows[] = $this->makeRow($domain, $service);
}
}
if (! $this->persistDomains()) {
return;
}
}
} else {
$this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true);
}
$this->refreshDomains();
$this->dispatch('success', 'Domain generated.');
}
public function startEdit(int $index): void
{
$this->authorize('update', $this->preview->application);
if (! isset($this->domainRows[$index])) {
return;
}
$this->editingIndex = $index;
$this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']);
$canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']);
$savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($savedPort)) {
$this->editingDomainParts['port'] = (string) $savedPort;
}
$this->resetErrorBag('editingDomainParts.host');
$this->dispatch('open-preview-domain-edit', previewId: $this->preview->id);
}
public function updateDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host');
if ($domain === null) {
return;
}
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain);
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
return;
}
if (blank(DomainUrlParts::split($domain)['port'] ?? null)) {
$portOverrides = $this->preview->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
unset($portOverrides[DomainPortOverrides::withoutPort($domain)]);
$this->preview->domain_port_overrides = $portOverrides ?: null;
}
$this->domainRows[$this->editingIndex]['url'] = $domain;
$checkId = $dnsRelevantChange ? new_public_id() : null;
if ($dnsRelevantChange) {
$this->domainRows[$this->editingIndex]['dns_status'] = 'checking';
$this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...';
$this->domainRows[$this->editingIndex]['check_id'] = $checkId;
}
$index = $this->editingIndex;
$this->editingIndex = null;
if (! $this->persistDomains()) {
return;
}
$domain = $this->domainRows[$index]['url'];
$this->forceUseUnknownPort = false;
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
if (! $dnsRelevantChange) {
$this->dispatch('success', 'Domain updated.');
return;
}
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($domain, $this->domainRows[$index]['service']),
$domain,
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
$this->dispatch('success', 'Domain updated. DNS check started.');
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
$this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.');
}
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$server = $this->preview->application->destination?->server;
if (! $server) {
$this->dispatch('error', 'No server found for this preview.');
return;
}
$host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return;
}
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
}
public function cancelEdit(): void
{
$this->editingIndex = null;
$this->editingDomainParts = DomainUrlParts::empty();
$this->resetErrorBag('editingDomainParts.host');
}
public function confirmUseUnknownPort(): void
{
$this->authorize('update', $this->preview->application);
$this->forceUseUnknownPort = true;
$this->showPortWarningModal = false;
$action = $this->pendingPortAction;
$this->pendingPortAction = null;
if ($action === 'update') {
$this->updateDomain();
return;
}
$this->addDomain();
}
public function cancelUseUnknownPort(): void
{
$this->showPortWarningModal = false;
$this->forceUseUnknownPort = false;
$this->unrecognizedPort = null;
$this->pendingPortAction = null;
}
public function removeDomain(int $index): void
{
$this->authorize('update', $this->preview->application);
if (! isset($this->domainRows[$index])) {
return;
}
if ($this->editingIndex === $index) {
$this->editingIndex = null;
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
} elseif ($this->editingIndex !== null && $this->editingIndex > $index) {
$this->editingIndex--;
}
unset($this->domainRows[$index]);
$this->domainRows = array_values($this->domainRows);
if (! $this->persistDomains()) {
return;
}
$this->dispatch('success', 'Domain removed.');
}
public function removeDomainByKey(string $domainKey): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service']))
);
if ($index === false) {
return;
}
$this->removeDomain((int) $index);
}
public function checkAllDns(): void
{
$this->authorize('update', $this->preview->application);
foreach (array_keys($this->domainRows) as $index) {
$this->queueDnsCheck($index);
}
}
public function checkDomainDns(int $index): void
{
$this->authorize('update', $this->preview->application);
$this->queueDnsCheck($index);
}
private function queueDnsCheck(int $index): void
{
if (! isset($this->domainRows[$index])) {
return;
}
$row = $this->domainRows[$index];
$checkId = new_public_id();
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
$this->persistDnsStatuses();
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($row['url'], $row['service']),
$row['url'],
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
}
}
public function pollDnsChecks(): void
{
$this->authorize('view', $this->preview->application);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
$this->refreshDomains();
foreach ($checkingRows as $checkingRow) {
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
if (! is_array($row) || $row['dns_status'] === 'checking') {
continue;
}
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
}
}
private function dispatchDnsCheckNotification(string $url, string $status): void
{
$host = parse_url($url, PHP_URL_HOST) ?: $url;
match ($status) {
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
private function applyDnsCheck(int $index): void
{
if (! isset($this->domainRows[$index])) {
return;
}
$result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index);
$this->domainRows[$index]['dns_status'] = $result['status'];
$this->domainRows[$index]['dns_message'] = $result['message'];
}
private function checkUrlDns(string $url, string $key = 'domain'): array
{
$server = $this->preview->application->destination?->server;
return CheckDomainDns::run(
[$key => $url],
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$this->preview->application->additional_servers->count() > 0,
)[$key];
}
private function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->preview->refresh();
$statuses = $this->preview->domain_dns_statuses ?? [];
$rows = [];
if ($this->preview->application->build_pack === 'dockercompose') {
foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) {
foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) {
$rows[] = $this->makeRow($url, (string) $service, $statuses);
}
}
} else {
foreach ($this->splitDomains($this->preview->fqdn) as $url) {
$rows[] = $this->makeRow($url, null, $statuses);
}
}
$this->domainRows = $rows;
if ($editingRow !== null) {
$index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url']
&& $row['service'] === $editingRow['service']);
$this->editingIndex = $index === false ? null : (int) $index;
}
}
private function persistDomains(): bool
{
if ($this->preview->application->build_pack === 'dockercompose') {
try {
$composeServices = $this->composeServices(failOnError: true);
} catch (\Throwable) {
$this->refreshDomains();
$this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.');
return false;
}
$existingDomains = json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [];
$domains = [];
foreach ($composeServices as $service) {
$domains[$service] = is_array($existingDomains[$service] ?? null) ? $existingDomains[$service] : [];
$domains[$service]['domain'] = '';
}
$validRows = collect($this->domainRows)
->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true));
foreach ($validRows->groupBy('service') as $service => $rows) {
$domains[$service]['domain'] = $rows->pluck('url')->implode(',');
}
$this->preview->docker_compose_domains = json_encode($domains);
$this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null;
} else {
$this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null;
}
$normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides);
$this->preview->fqdn = $normalized['fqdn'];
$this->preview->domain_port_overrides = $normalized['overrides'];
if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) {
foreach ($domains as $service => $entry) {
$serviceDomains = $this->splitDomains(composeDomainEntryString($entry));
$domains[$service]['domain'] = collect($serviceDomains)
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->implode(',');
}
$this->preview->docker_compose_domains = json_encode($domains);
}
foreach ($this->domainRows as $index => $row) {
$this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']);
}
$this->preview->save();
$this->persistDnsStatuses();
$this->refreshDomains();
$this->dispatch('update_links');
$this->dispatch('previewDomainsChanged');
return true;
}
private function persistDnsStatuses(): void
{
$statuses = [];
foreach ($this->domainRows as $row) {
$statuses[$this->statusKey($row['url'], $row['service'])] = [
'status' => $row['dns_status'],
'message' => $row['dns_message'],
'check_id' => $row['check_id'] ?? null,
];
}
DB::transaction(function () use (&$statuses): void {
$preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id);
$storedStatuses = $preview->domain_dns_statuses ?? [];
foreach ($statuses as $key => $status) {
$storedStatus = $storedStatuses[$key] ?? null;
if (! is_array($storedStatus)) {
continue;
}
$localCheckId = $status['check_id'] ?? null;
$storedCheckId = $storedStatus['check_id'] ?? null;
if (($storedCheckId !== null && $localCheckId !== $storedCheckId)
|| ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) {
$statuses[$key] = $storedStatus;
}
}
$preview->domain_dns_statuses = $statuses ?: null;
$preview->save();
});
$this->preview->domain_dns_statuses = $statuses ?: null;
}
private function validatedDomain(array $parts, string $errorKey): ?string
{
$domain = DomainUrlParts::compose(...$parts);
$validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]);
if ($validator->fails()) {
$this->addError($errorKey, $validator->errors()->first('domain'));
return null;
}
return ValidationPatterns::normalizeApplicationDomains($domain);
}
private function makeRow(string $url, ?string $service, array $statuses = []): array
{
$status = $statuses[$this->statusKey($url, $service)] ?? [];
$port = $this->effectiveDomainInternalPort($url, $service);
$redirect = 'both';
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) {
$usesPreviewRedirect = (int) $this->preview->application->compose_parsing_version >= 3;
$domains = json_decode(($usesPreviewRedirect
? $this->preview->docker_compose_domains
: $this->preview->application->docker_compose_domains) ?: '[]', true) ?: [];
$storedRedirect = $usesPreviewRedirect
? ($domains[$service]['redirect'] ?? null)
: data_get($domains, "$service.redirect");
$redirect = in_array($storedRedirect, ['www', 'non-www', 'both'], true) ? $storedRedirect : 'both';
}
return [
'url' => $url,
'service' => $service,
'redirect' => $redirect,
'internal_port' => $port['internal_port'],
'has_port_override' => $port['has_port_override'],
'dns_status' => $status['status'] ?? 'pending',
'dns_message' => $status['message'] ?? 'DNS has not been checked yet.',
'check_id' => $status['check_id'] ?? null,
];
}
/**
* @param array{scheme: string, host: string, port: string, path: string} $parts
*/
private function portFromParts(array $parts): ?int
{
$port = trim((string) ($parts['port'] ?? ''));
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
return null;
}
return (int) $port;
}
private function currentRowPort(string $url): ?int
{
$canonical = DomainPortOverrides::withoutPort($url);
$override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($override) && (int) $override > 0) {
return (int) $override;
}
$legacy = DomainUrlParts::split($url)['port'] ?? '';
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
}
private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
{
if ($this->forceUseUnknownPort || $port === null) {
return false;
}
if ($currentPort !== null && $port === $currentPort) {
return false;
}
return $this->preview->application->portRequiresConfirmation($port, $serviceName);
}
private function openPortWarning(?int $port, string $action): void
{
$this->unrecognizedPort = $port;
$this->pendingPortAction = $action;
$this->showPortWarningModal = true;
}
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
private function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->preview->domain_port_overrides ?? [];
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
$hasMapEntry = array_key_exists($canonical, $overrides);
if ($hasMapEntry) {
return [
'internal_port' => (int) $overrides[$canonical],
'has_port_override' => true,
];
}
if ($legacyPort !== null) {
return [
'internal_port' => $legacyPort,
'has_port_override' => true,
];
}
$composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) {
return [
'internal_port' => null,
'has_port_override' => false,
];
}
if ($this->preview->application->settings?->is_static) {
return [
'internal_port' => 80,
'has_port_override' => false,
];
}
$exposed = $this->preview->application->ports_exposes_array;
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
? (int) $exposed[0]
: null;
return [
'internal_port' => $defaultPort,
'has_port_override' => false,
];
}
private function statusKey(string $url, ?string $service): string
{
return hash('sha256', $url.'|'.($service ?? ''));
}
private function splitDomains(?string $domains): array
{
return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all();
}
private function generateComposeDomains(string $service): array
{
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
$domainString = getComposeServiceDomainString($applicationDomains, $service);
if (empty($domainString)) {
$domainString = generateUrl(
server: $this->preview->application->destination->server,
random: str($service)->slug().'-'.$this->preview->application->uuid,
);
}
return collect($this->splitDomains($domainString))->map(function (string $domain): string {
$generated = $this->preview->generatedPreviewDomain($domain);
if (filled($generated['port'])) {
$overrides = $this->preview->domain_port_overrides ?? [];
$overrides[$generated['url']] = $generated['port'];
$this->preview->domain_port_overrides = $overrides;
}
return $generated['url'];
})->all();
}
private function composeServices(bool $failOnError = false): array
{
try {
$parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
$services = data_get($parsedCompose, 'services', []);
if (! is_iterable($services)) {
return [];
}
$previewSuffix = '-pr-'.$this->preview->pull_request_id;
$serviceNames = [];
foreach ($services as $serviceName => $service) {
if (isDatabaseImage(data_get($service, 'image'))) {
continue;
}
$serviceName = (string) $serviceName;
if (str_ends_with($serviceName, $previewSuffix)) {
$serviceName = substr($serviceName, 0, -strlen($previewSuffix));
}
$serviceNames[] = $serviceName;
}
return array_values(array_unique($serviceNames));
} catch (\Throwable $exception) {
if ($failOnError) {
throw $exception;
}
return [];
}
}
}

View file

@ -7,7 +7,6 @@
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
@ -16,8 +15,14 @@ class Previews extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains'];
public Application $application; public Application $application;
public bool $isPreviewDeploymentsEnabled = false;
public bool $isPrDeploymentsPublicEnabled = false;
public string $deployment_uuid; public string $deployment_uuid;
public array $parameters; public array $parameters;
@ -26,16 +31,6 @@ class Previews extends Component
public int $rate_limit_remaining; public int $rate_limit_remaining;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $pendingPreviewId = null;
public array $previewFqdns = [];
public array $previewDockerTags = []; public array $previewDockerTags = [];
public ?int $manualPullRequestId = null; public ?int $manualPullRequestId = null;
@ -43,7 +38,6 @@ class Previews extends Component
public ?string $manualDockerTag = null; public ?string $manualDockerTag = null;
protected $rules = [ protected $rules = [
'previewFqdns.*' => 'string|nullable',
'previewDockerTags.*' => 'string|nullable', 'previewDockerTags.*' => 'string|nullable',
'manualPullRequestId' => 'integer|min:1|nullable', 'manualPullRequestId' => 'integer|min:1|nullable',
'manualDockerTag' => 'string|nullable', 'manualDockerTag' => 'string|nullable',
@ -51,33 +45,51 @@ class Previews extends Component
public function mount() public function mount()
{ {
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
$this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false;
$this->pull_requests = collect(); $this->pull_requests = collect();
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->syncData(false); $this->syncDockerTags();
} }
private function syncData(bool $toModel = false): void public function savePreviewSettings(): void
{ {
if ($toModel) { $this->authorize('update', $this->application);
foreach ($this->previewFqdns as $key => $fqdn) { $this->validate([
$preview = $this->application->previews->get($key); 'isPreviewDeploymentsEnabled' => 'boolean',
if ($preview) { 'isPrDeploymentsPublicEnabled' => 'boolean',
$preview->fqdn = $fqdn; ]);
if ($this->application->build_pack === 'dockerimage') {
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
} $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled;
} $this->application->settings->save();
}
} else { $this->dispatch('success', 'Settings saved.');
$this->previewFqdns = []; $this->dispatch('configurationChanged');
$this->previewDockerTags = []; }
foreach ($this->application->previews as $key => $preview) {
$this->previewFqdns[$key] = $preview->fqdn; public function togglePreviewDeployments(): void
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag; {
} $this->authorize('update', $this->application);
$this->isPreviewDeploymentsEnabled = ! $this->isPreviewDeploymentsEnabled;
$this->savePreviewSettings();
}
private function syncDockerTags(): void
{
$this->previewDockerTags = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
} }
} }
public function refreshPreviewDomains(): void
{
$this->application->refresh();
$this->syncDockerTags();
}
public function load_prs() public function load_prs()
{ {
try { try {
@ -92,103 +104,28 @@ public function load_prs()
} }
} }
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
if ($this->pendingPreviewId) {
$this->save_preview($this->pendingPreviewId);
$this->pendingPreviewId = null;
}
}
public function save_preview($preview_id) public function save_preview($preview_id)
{ {
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$success = true;
$preview = $this->application->previews->find($preview_id); $preview = $this->application->previews->find($preview_id);
if (! $preview) { if (! $preview) {
throw new \Exception('Preview not found'); throw new \Exception('Preview not found');
} }
// Find the key for this preview in the collection
$previewKey = $this->application->previews->search(function ($item) use ($preview_id) { $previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
return $item->id == $preview_id; return $item->id == $preview_id;
}); });
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { if ($previewKey === false) {
$this->validate([ throw new \Exception('Preview not found');
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application, domain: $fqdn);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
$this->pendingPreviewId = $preview_id;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
}
} }
if ($success) { $this->validateOnly("previewDockerTags.{$previewKey}");
$this->syncData(true); $preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null;
$preview->save(); $preview->save();
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.'); $this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function generate_preview($preview_id)
{
try {
$this->authorize('update', $this->application);
$preview = $this->application->previews->find($preview_id);
if (! $preview) {
$this->dispatch('error', 'Preview not found.');
return;
}
if ($this->application->build_pack === 'dockercompose') {
$preview->generate_preview_fqdn_compose();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('success', 'Domain generated.');
return;
}
$preview->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -211,7 +148,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null,
} }
$found->generate_preview_fqdn_compose(); $found->generate_preview_fqdn_compose();
$this->application->refresh(); $this->application->refresh();
$this->syncData(false); $this->syncDockerTags();
} else { } else {
$this->setDeploymentUuid(); $this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
@ -227,9 +164,9 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null,
$found->docker_registry_image_tag = $docker_registry_image_tag; $found->docker_registry_image_tag = $docker_registry_image_tag;
$found->save(); $found->save();
} }
$found->generate_preview_fqdn(); $found->generate_preview_fqdn(generateWithoutApplicationDomain: true);
$this->application->refresh(); $this->application->refresh();
$this->syncData(false); $this->syncDockerTags();
$this->dispatch('update_links'); $this->dispatch('update_links');
$this->dispatch('success', 'Preview added.'); $this->dispatch('success', 'Preview added.');
} }

View file

@ -1,165 +0,0 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
class PreviewsCompose extends Component
{
use AuthorizesRequests;
public $service;
public $serviceName;
public ApplicationPreview $preview;
public ?string $domain = null;
public function mount()
{
$this->domain = data_get($this->service, 'domain');
}
public function render()
{
return view('livewire.project.application.previews-compose');
}
public function save()
{
try {
$this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function generate()
{
try {
$this->authorize('update', $this->preview->application);
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
// If no domain is set in the main application, generate a default domain
if (empty($domain_string)) {
$server = $this->preview->application->destination->server;
$template = $this->preview->application->preview_url_template;
$random = new_public_id();
// Generate a unique domain like main app services do
$generated_fqdn = generateUrl(server: $server, random: $random);
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application
// Handle multiple domains separated by commas
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$random = new_public_id();
foreach ($domain_list as $single_domain) {
$single_domain = trim($single_domain);
if (empty($single_domain)) {
continue;
}
$url = Url::fromString($single_domain);
$host = $url->getHost();
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdns[] = "$schema://$preview_fqdn{$port}";
}
$preview_fqdn = implode(',', $preview_fqdns);
}
$this->domain = $preview_fqdn;
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function persistPreviewDomain(?string $domain): void
{
$docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: [];
$serviceNames = $this->previewServiceNames($docker_compose_domains);
$storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames)
?? (string) $this->serviceName;
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$storageKey,
$domain,
$serviceNames,
);
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
$this->serviceName = $storageKey;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
}
/**
* @param array<string, mixed> $previewDomains
* @return list<string>
*/
private function previewServiceNames(array $previewDomains): array
{
$parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
$fromCompose = collect(data_get($parsedServices, 'services', []))
->keys()
->map(function ($serviceName) {
return str((string) $serviceName)
->replaceLast('-pr-'.$this->preview->pull_request_id, '')
->toString();
})
->all();
$domainKeys = collect(array_keys($previewDomains))
->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []))
->map(fn ($name) => (string) $name);
$unmapped = $domainKeys
->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null)
->all();
return collect($fromCompose)
->merge(preferredComposeServiceNamesFromDomainKeys(
$fromCompose === [] ? $domainKeys->all() : $unmapped
))
->unique()
->values()
->all();
}
}

View file

@ -65,7 +65,7 @@ public function updatedGitCommitSha()
$this->gitCommitSha = trim($this->gitCommitSha); $this->gitCommitSha = trim($this->gitCommitSha);
} }
public function syncData(bool $toModel = false) private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();

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